mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-09 21:33:48 +00:00
Fixed reasoning tokens inflating the exact token count
This commit is contained in:
12 files changed
+172
-104
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1595,7 +1595,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
{
|
||||
var provider = AIStudio.Settings.Provider.NONE;
|
||||
var parts = ConversationParts.NOTHING;
|
||||
var reported = TokenUsage.UNKNOWN;
|
||||
var reported = ReportedHistory.UNKNOWN;
|
||||
|
||||
//
|
||||
// Collected on the render thread, counted off it. Counting may take an IPC call per text,
|
||||
@@ -1614,7 +1614,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
var toolDefinitions = this.GetRunnableToolDefinitions();
|
||||
provider = this.Provider;
|
||||
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
|
||||
reported = thread.ReportedUsageFor(provider.Model);
|
||||
reported = thread.ReportedHistoryFor(provider.Model);
|
||||
});
|
||||
|
||||
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, reported, token);
|
||||
|
||||
@@ -1128,7 +1128,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
{
|
||||
yield return providerResponse.ContainsContent()
|
||||
? providerResponse.GetContent() with { Usage = usage }
|
||||
: new(string.Empty, [], usage);
|
||||
: new(string.Empty, [], Usage: usage);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace AIStudio.Provider.OpenAI;
|
||||
/// What an OpenAI-compatible provider reports a chat completion cost.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every number is optional because this is somebody else's JSON: the block arrives only when the
|
||||
/// The number is optional because this is somebody else's JSON: the block arrives only when the
|
||||
/// request asked for it, and the providers which follow the shape loosely leave fields out. Reading
|
||||
/// it is one thing, believing it another -- TokenUsage.OfReported decides that.
|
||||
///
|
||||
/// The block states more than this, the completion and its reasoning share among it. Those are left
|
||||
/// unread on purpose, for the reason given at TokenUsage: no later request carries them.
|
||||
/// </remarks>
|
||||
public sealed record ChatCompletionUsage
|
||||
{
|
||||
@@ -16,11 +19,6 @@ public sealed record ChatCompletionUsage
|
||||
/// </summary>
|
||||
public int? PromptTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the model wrote in answer.
|
||||
/// </summary>
|
||||
public int? CompletionTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// States what this block reports, as far as it can be believed.
|
||||
/// </summary>
|
||||
@@ -29,5 +27,5 @@ public sealed record ChatCompletionUsage
|
||||
/// so that what counts as believable is decided in a single place.
|
||||
/// </remarks>
|
||||
/// <returns>The usage, or TokenUsage.UNKNOWN when the block states nothing usable.</returns>
|
||||
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens);
|
||||
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
namespace AIStudio.Provider;
|
||||
|
||||
/// <summary>
|
||||
/// What a provider said one request actually cost, in tokens.
|
||||
/// What a provider said one request actually carried, in tokens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The counterpart to what the app counts for itself: the app estimates what the next request will
|
||||
/// cost, while this is what the provider charged for the last one. Two different statements, and
|
||||
/// cost, while this is what the provider counted for the last one. Two different statements, and
|
||||
/// this one is the only exact one of the two.
|
||||
///
|
||||
/// Only the prompt is kept. Providers state what the answer cost as well, but that number includes
|
||||
/// the model's reasoning and whatever the model wrote between think tags, and neither of them ever
|
||||
/// becomes part of the answer's text. No later request carries them, so the number has no place in
|
||||
/// a statement about those requests.
|
||||
///
|
||||
/// Nothing here says "unknown" with a zero. The default value of this type is unknown, which is the
|
||||
/// right answer for every provider which reports nothing, and a counted request can never cost zero
|
||||
/// prompt tokens because the factory below refuses to build one.
|
||||
@@ -20,7 +25,7 @@ public readonly record struct TokenUsage
|
||||
public static readonly TokenUsage UNKNOWN = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether a provider reported anything at all. When false, the numbers are meaningless.
|
||||
/// Whether a provider reported anything at all. When false, the number is meaningless.
|
||||
/// </summary>
|
||||
public bool IsKnown { get; private init; }
|
||||
|
||||
@@ -30,37 +35,23 @@ public readonly record struct TokenUsage
|
||||
/// </summary>
|
||||
public int PromptTokens { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the model wrote in answer.
|
||||
/// </summary>
|
||||
public int CompletionTokens { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the whole exchange cost, which is what the next request carries as its history.
|
||||
/// </summary>
|
||||
public int TotalTokens => this.PromptTokens + this.CompletionTokens;
|
||||
|
||||
/// <summary>
|
||||
/// States what a provider reported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A completion of zero tokens is a real answer: a model which was cut off before writing
|
||||
/// anything still cost its prompt. A prompt of zero is not, because there is no request
|
||||
/// without one, and a provider sending it means we read the wrong field.
|
||||
/// A prompt of zero is not a report, because there is no request without one, and a provider
|
||||
/// sending it means we read the wrong field.
|
||||
/// </remarks>
|
||||
/// <param name="promptTokens">What the request carried. Has to be greater than zero.</param>
|
||||
/// <param name="completionTokens">What the answer cost. Zero or more.</param>
|
||||
/// <returns>The usage.</returns>
|
||||
public static TokenUsage Of(int promptTokens, int completionTokens)
|
||||
public static TokenUsage Of(int promptTokens)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(promptTokens);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(completionTokens);
|
||||
|
||||
return new()
|
||||
{
|
||||
IsKnown = true,
|
||||
PromptTokens = promptTokens,
|
||||
CompletionTokens = completionTokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,14 +59,10 @@ public readonly record struct TokenUsage
|
||||
/// States what a provider reported, or unknown when it reported nothing usable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For the reading side, where the numbers come out of somebody else's JSON: a missing field, a
|
||||
/// null, or a zero prompt all mean the same thing there, and none of them is worth an exception.
|
||||
/// For the reading side, where the number comes out of somebody else's JSON: a missing field, a
|
||||
/// null, or a zero all mean the same thing there, and none of them is worth an exception.
|
||||
/// </remarks>
|
||||
/// <param name="promptTokens">What the request carried, as the provider stated it.</param>
|
||||
/// <param name="completionTokens">What the answer cost, as the provider stated it.</param>
|
||||
/// <returns>The usage, or UNKNOWN.</returns>
|
||||
public static TokenUsage OfReported(int? promptTokens, int? completionTokens) =>
|
||||
promptTokens is > 0
|
||||
? Of(promptTokens.Value, completionTokens is > 0 ? completionTokens.Value : 0)
|
||||
: UNKNOWN;
|
||||
public static TokenUsage OfReported(int? promptTokens) => promptTokens is > 0 ? Of(promptTokens.Value) : UNKNOWN;
|
||||
}
|
||||
@@ -69,12 +69,13 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
|
||||
/// <param name="provider">The configured provider, which decides both the tokenizer and the window.</param>
|
||||
/// <param name="parts">What the conversation would send, collected beforehand.</param>
|
||||
/// <param name="reported">
|
||||
/// What a provider said the last request of this conversation cost, where one did. It replaces
|
||||
/// everything the app would otherwise estimate about the conversation so far.
|
||||
/// What a provider counted for the request behind the last answer, where its report still
|
||||
/// describes this conversation. Together with that answer, it replaces everything the app would
|
||||
/// otherwise estimate about the conversation so far.
|
||||
/// </param>
|
||||
/// <param name="token">Ends the counting when nobody needs the answer anymore.</param>
|
||||
/// <returns>What the conversation costs, or that nothing could be counted.</returns>
|
||||
public async Task<ConversationTokens> CountAsync(Provider provider, ConversationParts parts, TokenUsage reported = default, CancellationToken token = default)
|
||||
public async Task<ConversationTokens> CountAsync(Provider provider, ConversationParts parts, ReportedHistory reported, CancellationToken token = default)
|
||||
{
|
||||
if (provider.UsedLLMProvider is LLMProviders.NONE)
|
||||
return ConversationTokens.UNAVAILABLE;
|
||||
@@ -83,6 +84,7 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
|
||||
var previouslyGrowing = this.stillGrowing;
|
||||
var growing = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var tokens = 0;
|
||||
var reportedTokens = 0;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -106,6 +108,14 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
|
||||
|
||||
foreach (var document in parts.Documents)
|
||||
tokens += await this.CountDocumentAsync(provider, document, token);
|
||||
|
||||
//
|
||||
// The last answer is counted as the text it is sent as, not taken from the report:
|
||||
// ReportedHistory says why the provider's number for it is the wrong one. It is a
|
||||
// finished text of the conversation, so the cache has it already.
|
||||
//
|
||||
if (reported.IsKnown)
|
||||
reportedTokens = reported.PromptTokens + await this.CountTextAsync(provider, reported.LastAnswer, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -146,14 +156,14 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
|
||||
|
||||
//
|
||||
// Where a provider has said what this conversation cost, that number replaces everything
|
||||
// but the draft. It is the only exact one of the two, and it covers the same ground: the
|
||||
// system prompt, the tools, and every message which has been sent.
|
||||
// but the last answer and the draft. It is the only exact one of the two, and it covers
|
||||
// the same ground: the system prompt, the tools, and every message which has been sent.
|
||||
//
|
||||
var historyIsReported = reported.IsKnown;
|
||||
return new()
|
||||
{
|
||||
IsKnown = true,
|
||||
Tokens = historyIsReported ? reported.TotalTokens + draftTokens : tokens,
|
||||
Tokens = historyIsReported ? reportedTokens + draftTokens : tokens,
|
||||
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
|
||||
DraftTokens = draftTokens,
|
||||
HistoryIsReported = historyIsReported,
|
||||
|
||||
Reference in new issue
Block a user