Fixed reasoning tokens inflating the exact token count

This commit is contained in:
Thorsten Sommer committed 2026-09-23 18:53:01 +02:00
1 parent c23ce9e12a
commit 3277f20f60
12 files changed
+172 -104

No files matched your search

+11 -8
View File
@@ -416,22 +416,25 @@ public sealed record ChatThread
/// is a lot of machinery for a number which corrects itself one answer later.
/// </remarks>
/// <param name="model">The model the next request would go to.</param>
/// <returns>What the provider reported, or unknown when no report describes this conversation.</returns>
public TokenUsage ReportedUsageFor(Model model)
/// <returns>
/// What the provider reported, together with the answer which followed it, or
/// ReportedHistory.UNKNOWN when no report describes this conversation.
/// </returns>
public ReportedHistory ReportedHistoryFor(Model model)
{
if (this.Blocks.Count is 0)
return TokenUsage.UNKNOWN;
return ReportedHistory.UNKNOWN;
if (this.Blocks[^1].Content is not ContentText { IsStreaming: false, ReportedUsage: { } reported })
return TokenUsage.UNKNOWN;
if (this.Blocks[^1].Content is not ContentText { IsStreaming: false, ReportedUsage: { } reported } answer)
return ReportedHistory.UNKNOWN;
if (reported.BlockCount != this.Blocks.Count)
return TokenUsage.UNKNOWN;
return ReportedHistory.UNKNOWN;
if (!string.Equals(reported.ModelId, model.Id, StringComparison.Ordinal))
return TokenUsage.UNKNOWN;
return ReportedHistory.UNKNOWN;
return reported.ToTokenUsage();
return ReportedHistory.Of(reported.ToTokenUsage(), answer.Text);
}
private static void DeleteManagedAttachments(ContentBlock block)
+1 -2
View File
@@ -62,7 +62,7 @@ public sealed class ContentText : IContent
/// answer written before this was recorded, and at every provider which reports nothing.
///
/// Having a report is not the same as the report still being true. Whether it still describes
/// the conversation is decided by ChatThread.ReportedUsageFor, which looks at the thread around
/// the conversation is decided by ChatThread.ReportedHistoryFor, which looks at the thread around
/// the answer, not just at the answer.
/// </remarks>
public ReportedTokenUsage? ReportedUsage { get; set; }
@@ -132,7 +132,6 @@ public sealed class ContentText : IContent
this.ReportedUsage = new()
{
PromptTokens = usage.PromptTokens,
CompletionTokens = usage.CompletionTokens,
ModelId = modelId,
BlockCount = blockCount,
};
@@ -61,8 +61,11 @@ public readonly record struct ConversationTokens
/// </summary>
/// <remarks>
/// True once a provider has stated what a request of this conversation cost, which makes
/// everything but the draft an exact number. It says nothing about the draft, which stays an
/// estimate either way -- nobody has charged for that one yet.
/// everything up to the last answer an exact number. That answer is counted by this app, since
/// the provider's number for it includes reasoning which no request carries; next to the rest
/// of the conversation, its share of the error is small enough to still call the history exact.
/// It says nothing about the draft, which stays an estimate either way -- nobody has charged
/// for that one yet.
/// </remarks>
public bool HistoryIsReported { get; init; }
@@ -0,0 +1,59 @@
using AIStudio.Provider;
namespace AIStudio.Chat;
/// <summary>
/// What a conversation carries into its next request, as far as a provider has stated it.
/// </summary>
/// <remarks>
/// Two parts, and only one of them is the provider's statement. PromptTokens is what the provider
/// counted for the request behind the last answer: the system prompt, the tools, and every message
/// up to the question. The answer travels in the next request as well, though not in the shape the
/// provider charged for: its completion included the model's reasoning and whatever the model wrote
/// between think tags. So the answer comes along as the text it will be sent as, and is counted the
/// same way every other text is.
///
/// That text is the answer alone. Reasoning never belongs to it, whether it was thrown away or kept
/// to be read next to the answer: it is there for a person, and no request carries it. An answer
/// which consists of reasoning only has no text at all, is not sent, and adds nothing to the prompt.
///
/// What is estimated that way is one answer, next to a prompt which holds the whole conversation
/// before it. The error is the tokenizer's error on that one answer, not on the chat.
/// </remarks>
public sealed record ReportedHistory
{
/// <summary>
/// The history of a conversation no report describes.
/// </summary>
public static readonly ReportedHistory UNKNOWN = new();
/// <summary>
/// Whether a report describes the conversation. When false, nothing else here means anything.
/// </summary>
public bool IsKnown { get; private init; }
/// <summary>
/// What the provider counted for the request behind the last answer.
/// </summary>
public int PromptTokens { get; private init; }
/// <summary>
/// The text of the last answer, as the next request will carry it, without any reasoning.
/// </summary>
public string LastAnswer { get; private init; } = string.Empty;
/// <summary>
/// States what a report says about the conversation.
/// </summary>
/// <param name="usage">What the provider reported for the request behind the last answer.</param>
/// <param name="lastAnswer">The text of that answer.</param>
/// <returns>The history, or UNKNOWN when the usage states nothing.</returns>
public static ReportedHistory Of(TokenUsage usage, string lastAnswer) => usage.IsKnown
? new()
{
IsKnown = true,
PromptTokens = usage.PromptTokens,
LastAnswer = lastAnswer,
}
: UNKNOWN;
}
@@ -6,10 +6,11 @@ namespace AIStudio.Chat;
/// What a provider said the request behind one answer cost, as it is stored on that answer.
/// </summary>
/// <remarks>
/// The numbers and the model they were charged for travel as one value. Each of them is meaningless
/// without the others, and loose fields on the answer could be set, cleared, or copied apart.
/// The number, the model it was charged for, and the conversation it was counted on travel as one
/// value. Each of them is meaningless without the others, and loose fields on the answer could be
/// set, cleared, or copied apart.
///
/// Plain numbers rather than a TokenUsage: that type only ever comes out of its own factory, which
/// A plain number rather than a TokenUsage: that type only ever comes out of its own factory, which
/// is what keeps an impossible usage from existing, while a stored value has to be readable back by
/// the serializer. ToTokenUsage is the way back, and it treats a chat file somebody edited by hand
/// the same way the reading side treats a provider's JSON.
@@ -22,12 +23,7 @@ public sealed record ReportedTokenUsage
public int PromptTokens { get; init; }
/// <summary>
/// What the model wrote in answer.
/// </summary>
public int CompletionTokens { get; init; }
/// <summary>
/// Which model the numbers were charged for.
/// Which model the number was charged for.
/// </summary>
/// <remarks>
/// A token count belongs to the tokenizer which produced it. Switch the model of a chat, and
@@ -52,8 +48,8 @@ public sealed record ReportedTokenUsage
public int BlockCount { get; init; }
/// <summary>
/// States the stored numbers as a usage again.
/// States the stored number as a usage again.
/// </summary>
/// <returns>The usage, or TokenUsage.UNKNOWN when the stored numbers state nothing usable.</returns>
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens);
/// <returns>The usage, or TokenUsage.UNKNOWN when the stored number states nothing usable.</returns>
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens);
}