From 0aa3b23b81779a931cb6d99af3cc0cb95d11c75e Mon Sep 17 00:00:00 2001 From: j-erler Date: Sun, 20 Sep 2026 17:42:29 +0200 Subject: [PATCH] Added the exact token count where the provider reports it Co-Authored-By: Claude Opus 5 --- .../Assistants/I18N/allTexts.lua | 3 + app/MindWork AI Studio/Chat/ContentText.cs | 50 ++++++ .../Chat/ConversationParts.cs | 33 ++++ .../Chat/ConversationTokens.cs | 21 +++ .../Components/ChatComponent.razor.cs | 60 ++++++- .../plugin.lua | 3 + .../plugin.lua | 3 + .../Provider/BaseProvider.cs | 14 ++ .../Provider/ContentStreamChunk.cs | 8 +- .../Provider/Fireworks/ResponseStreamLine.cs | 18 +++ .../Provider/IResponseStreamLine.cs | 16 ++ .../OpenAI/ChatCompletionAPIRequest.cs | 11 ++ .../OpenAI/ChatCompletionDeltaStreamLine.cs | 18 +++ .../OpenAI/ChatCompletionStreamOptions.cs | 18 +++ .../Provider/OpenAI/ChatCompletionUsage.cs | 30 ++++ .../Provider/Perplexity/ResponseStreamLine.cs | 18 +++ app/MindWork AI Studio/Provider/TokenUsage.cs | 81 ++++++++++ .../Tools/AIJobs/AIJobService.cs | 12 ++ .../Services/ConversationTokenCounter.cs | 41 ++++- .../wwwroot/changelog/v26.9.1.md | 1 + .../Provider/ChatCompletionUsageTests.cs | 146 ++++++++++++++++++ 21 files changed, 598 insertions(+), 7 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamOptions.cs create mode 100644 app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs create mode 100644 app/MindWork AI Studio/Provider/TokenUsage.cs create mode 100644 app/Tests/Provider/ChatCompletionUsageTests.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index d3ea7596..8447d9f6 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3556,6 +3556,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Add fil -- Changelog UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Changelog" +-- {0}, plus approx. {1} for your message +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus approx. {1} for your message" + -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Move chat" diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 661dcc95..c931121d 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -52,6 +52,44 @@ public sealed class ContentText : IContent public List ToolInvocations { get; set; } = []; + /// + /// What the provider said everything sent along with this answer cost, where it said anything. + /// + /// + /// Kept on the answer rather than beside the chat, so that it is stored, loaded, and exported + /// with the message it belongs to -- and so that it goes away when the message does. An edited + /// or regenerated answer removes its block, which removes these numbers with it, and the chat + /// falls back to the estimate instead of carrying a figure for a conversation which no longer + /// exists. Null for every answer written before this was recorded, and at every provider which + /// reports nothing. + /// + /// Two plain numbers rather than a : that type only ever comes out of + /// its own factory, which is what keeps an impossible usage from existing, and a stored field + /// has to be readable back by the serializer. + /// + public int? ReportedPromptTokens { get; set; } + + /// + public int? ReportedCompletionTokens { get; set; } + + /// + /// Which model the numbers above were charged for. + /// + /// + /// A token count belongs to the tokenizer which produced it. Switch the model of a chat, and + /// the same conversation is worth a different number of tokens -- so the reported one stops + /// being an answer about the request which is about to be sent, and the estimate, wrong as it + /// is, is at least wrong about the right model. + /// + public string? ReportedForModel { get; set; } + + /// + /// What the provider said this exchange cost, which is what the next request carries as its + /// history. + /// + [JsonIgnore] + public TokenUsage ReportedTokens => TokenUsage.OfReported(this.ReportedPromptTokens, this.ReportedCompletionTokens); + [JsonIgnore] public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new(); @@ -187,6 +225,18 @@ public sealed class ContentText : IContent // Merge the sources: this.Sources.MergeSources(contentStreamChunk.Sources); + // + // Keep what the provider says the request cost. It arrives on one line of + // the stream, usually the last one, and only where the provider reports it + // at all -- so the previous value is kept rather than cleared: + // + if (contentStreamChunk.Usage.IsKnown) + { + this.ReportedPromptTokens = contentStreamChunk.Usage.PromptTokens; + this.ReportedCompletionTokens = contentStreamChunk.Usage.CompletionTokens; + this.ReportedForModel = chatModel.Id; + } + // Notify the UI that the content has changed, // depending on the energy saving mode: var now = DateTimeOffset.Now; diff --git a/app/MindWork AI Studio/Chat/ConversationParts.cs b/app/MindWork AI Studio/Chat/ConversationParts.cs index f3683d26..6db25fb3 100644 --- a/app/MindWork AI Studio/Chat/ConversationParts.cs +++ b/app/MindWork AI Studio/Chat/ConversationParts.cs @@ -57,6 +57,21 @@ public sealed record ConversationParts /// public int Images { get; init; } + /// + /// Which of the texts above are the message being written right now. + /// + /// + /// A marker, not a further part: everything named here also stands in , + /// and counting the parts counts each of them exactly once. It exists because the two halves of + /// the number answer different questions. What the conversation has cost so far can be had + /// exactly, from the provider which charged for it; what is about to be added to it can only be + /// estimated. Told as one number, nobody can see which half they are looking at. + /// + public IReadOnlyList DraftTexts { get; init; } = []; + + /// + public IReadOnlyList DraftDocuments { get; init; } = []; + /// /// Collects what a conversation would send. /// @@ -131,18 +146,36 @@ public sealed record ConversationParts } } + var draftTexts = new List(); + var draftDocuments = new List(); + if (!string.IsNullOrWhiteSpace(draft)) + { growing.Add(draft); + draftTexts.Add(draft); + } if (draftAttachments is not null) + { + var documentsBefore = documents.Count; Sort(draftAttachments, documents, ref images); + // + // Whatever sorting just appended is what the composer carries. Read off the list + // rather than sorted a second time, so that a change to what counts as a document + // cannot start meaning two different things in one method. + // + draftDocuments.AddRange(documents.Skip(documentsBefore)); + } + return new() { Texts = texts, GrowingTexts = growing, Documents = documents, Images = imagesAreSent ? images : 0, + DraftTexts = draftTexts, + DraftDocuments = draftDocuments, }; } diff --git a/app/MindWork AI Studio/Chat/ConversationTokens.cs b/app/MindWork AI Studio/Chat/ConversationTokens.cs index 79836cfa..abb2da1d 100644 --- a/app/MindWork AI Studio/Chat/ConversationTokens.cs +++ b/app/MindWork AI Studio/Chat/ConversationTokens.cs @@ -45,6 +45,27 @@ public readonly record struct ConversationTokens /// public bool IsEstimate { get; init; } + /// + /// How much of is the message being written right now. + /// + /// + /// Kept apart from the rest for the same reason the statements above are kept apart: what the + /// conversation has already cost is something a provider can be asked about, while a sentence + /// nobody has sent yet can only be estimated. What the conversation costs without it is this + /// subtracted from . + /// + public int DraftTokens { get; init; } + + /// + /// Whether the conversation so far was counted by the provider rather than by this app. + /// + /// + /// 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. + /// + public bool HistoryIsReported { get; init; } + /// /// How much the model reads, where anybody has stated it. /// diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index a11730ba..05a2d6a3 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -173,10 +173,21 @@ public partial class ChatComponent : MSGComponentBase if (!this.conversationTokens.IsKnown) return string.Empty; - var used = TokenAmount.Format(this.conversationTokens.Tokens, this.currentCulture); + // + // Three statements, and which of them can be trusted differs. What the conversation + // has cost is exact wherever the provider said it; the window is whatever somebody + // wrote down about the model; what is being written has never been sent and can only + // ever be estimated. So the conversation and the draft are named apart, and the word + // which marks a guess sits where the guess is. + // + var history = TokenAmount.Format(this.conversationTokens.Tokens - this.conversationTokens.DraftTokens, this.currentCulture); + var historyIsExact = this.conversationTokens.HistoryIsReported || !this.conversationTokens.IsEstimate; var budget = this.conversationTokens.Window.IsKnown - ? string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} of {1} tokens") : this.T("{0} of {1} tokens"), used, TokenAmount.Format(this.conversationTokens.Window.DefaultTokens, this.currentCulture)) - : string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} tokens") : this.T("{0} tokens"), used); + ? string.Format(historyIsExact ? this.T("{0} of {1} tokens") : this.T("approx. {0} of {1} tokens"), history, TokenAmount.Format(this.conversationTokens.Window.DefaultTokens, this.currentCulture)) + : string.Format(historyIsExact ? this.T("{0} tokens") : this.T("approx. {0} tokens"), history); + + if (this.conversationTokens.DraftTokens > 0) + budget = string.Format(this.T("{0}, plus approx. {1} for your message"), budget, TokenAmount.Format(this.conversationTokens.DraftTokens, this.currentCulture)); if (this.conversationTokens.UncountedImages is 0) return budget; @@ -1460,6 +1471,7 @@ public partial class ChatComponent : MSGComponentBase { var provider = AIStudio.Settings.Provider.NONE; var parts = ConversationParts.NOTHING; + var reported = TokenUsage.UNKNOWN; // // Collected on the render thread, counted off it. Counting may take an IPC call per text, @@ -1478,9 +1490,10 @@ 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 = LastReportedTokensOf(thread, provider.Model); }); - var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token); + var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, reported, token); if (token.IsCancellationRequested) return; @@ -1494,6 +1507,45 @@ public partial class ChatComponent : MSGComponentBase }); } + /// + /// Finds what a provider last said a request of this conversation cost. + /// + /// + /// The last answer which carries such a number decides, and nothing else has to be remembered + /// for it: the number lives on the answer, so editing, regenerating, or deleting a message + /// takes it along and an earlier answer -- or none at all -- becomes the one which counts. + /// + /// An answer still being written is passed over. Its number arrives with the last line of the + /// stream, and until then it states what the request before it cost, which is a conversation + /// shorter than the one on the screen. + /// + /// The conversation to look through. + /// The model the next request would go to. + /// What the provider reported, or unknown when none of them did. + private static TokenUsage LastReportedTokensOf(ChatThread thread, Model model) + { + for (var index = thread.Blocks.Count - 1; index >= 0; index--) + { + if (thread.Blocks[index].Content is not ContentText { IsStreaming: false } text) + continue; + + if (!text.ReportedTokens.IsKnown) + continue; + + // + // A number charged for another model says nothing about this one: another model counts + // the same conversation with another tokenizer. Reading on would only find older + // answers of that same other model, so the search ends here and the estimate takes + // over until this model has answered once. + // + return string.Equals(text.ReportedForModel, model.Id, StringComparison.Ordinal) + ? text.ReportedTokens + : TokenUsage.UNKNOWN; + } + + return TokenUsage.UNKNOWN; + } + /// /// Works out the system prompt a thread would send. /// diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index c0e2d70d..133149bd 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3558,6 +3558,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Datei h -- Changelog UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Änderungsprotokoll" +-- {0}, plus approx. {1} for your message +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus ca. {1} für Ihre Nachricht" + -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Chat verschieben" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 4725c524..cb0095bb 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3558,6 +3558,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Add fil -- Changelog UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Changelog" +-- {0}, plus approx. {1} for your message +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus approx. {1} for your message" + -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Move chat" diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index c35ff5ac..b180a475 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1037,6 +1037,20 @@ public abstract class BaseProvider : IProvider, ISecretId continue; } + // + // The line stating what the request cost carries no content of its own: providers + // send it as the last line of the stream, with no choices at all. It is handled + // before the check below, which would otherwise drop it as an empty response. + // + if (providerResponse.ContainsUsage()) + { + yield return providerResponse.ContainsContent() + ? providerResponse.GetContent() with { Usage = providerResponse.GetUsage() } + : new(string.Empty, [], providerResponse.GetUsage()); + + continue; + } + // Skip empty responses: if (!providerResponse.ContainsContent()) continue; diff --git a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs index c6b2e205..2ecc62ae 100644 --- a/app/MindWork AI Studio/Provider/ContentStreamChunk.cs +++ b/app/MindWork AI Studio/Provider/ContentStreamChunk.cs @@ -3,9 +3,15 @@ namespace AIStudio.Provider; /// /// A chunk of content from a content stream, along with its associated sources. /// +/// +/// The usage rides along on the chunk rather than being reported next to the stream, because a +/// provider states it as one more line of that same stream. It is unknown on every chunk but the +/// one which carries it, and unknown on all of them at the providers which report nothing. +/// /// The text content of the chunk. /// The list of sources associated with the chunk. -public sealed record ContentStreamChunk(string Content, IList Sources) +/// What the provider said the request cost, where it said anything. +public sealed record ContentStreamChunk(string Content, IList Sources, TokenUsage Usage = default) { /// /// Implicit conversion to string. diff --git a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs index 25e35f82..b8524a4f 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs @@ -1,3 +1,5 @@ +using AIStudio.Provider.OpenAI; + namespace AIStudio.Provider.Fireworks; /// @@ -16,6 +18,22 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint /// public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []); + /// + /// What Fireworks says the request cost, on the one line which carries it. + /// + /// + /// The same block the OpenAI chat completion API sends, because this is that wire format. + /// Not a positional parameter: the struct is built from JSON, and a further parameter would + /// only be a value nobody passes. + /// + public ChatCompletionUsage? Usage { get; init; } + + /// + public bool ContainsUsage() => this.GetUsage().IsKnown; + + /// + public TokenUsage GetUsage() => this.Usage is null ? TokenUsage.UNKNOWN : TokenUsage.OfReported(this.Usage.PromptTokens, this.Usage.CompletionTokens); + #region Implementation of IAnnotationStreamLine // diff --git a/app/MindWork AI Studio/Provider/IResponseStreamLine.cs b/app/MindWork AI Studio/Provider/IResponseStreamLine.cs index 76ae56fe..f5b31528 100644 --- a/app/MindWork AI Studio/Provider/IResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/IResponseStreamLine.cs @@ -16,4 +16,20 @@ public interface IResponseStreamLine : IAnnotationStreamLine /// /// The content of the response line. public ContentStreamChunk GetContent(); + + /// + /// Checks whether the response line states what the request cost. + /// + /// + /// Answered here for every wire format which says nothing about it, which is most of them: a + /// provider who reports no usage is the normal case, not a gap somebody has to fill in. + /// + /// True when the response line carries a token usage, false otherwise. + public bool ContainsUsage() => false; + + /// + /// Gets what the provider said the request cost. + /// + /// The usage, or when the line carries none. + public TokenUsage GetUsage() => TokenUsage.UNKNOWN; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs index b7ebd6e0..5c64ce44 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs @@ -23,6 +23,17 @@ public record ChatCompletionAPIRequest( [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? ParallelToolCalls { get; init; } + + /// + /// Asks a streamed request to end with what it cost. + /// + /// + /// Derived rather than set, so that every provider which builds one of these asks for it + /// without having to know that it exists. A request which is not streamed carries no such + /// line, and then the block would only be a field the provider has to ignore. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChatCompletionStreamOptions? StreamOptions => this.Stream ? ChatCompletionStreamOptions.INCLUDE_USAGE : null; // Attention: The "required" modifier is not supported for [JsonExtensionData]. [JsonExtensionData] diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs index 1db13ba9..dc697317 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs @@ -15,12 +15,30 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat { } + /// + /// What the provider says the request cost, on the one line which carries it. + /// + /// + /// Not a positional parameter: every provider builds an empty line through the constructor + /// above, and a further parameter would change all of those call sites for a value none of + /// them has. Providers send this block only when the request asked for it, and then on a final + /// line of its own which carries no choices -- which is why the usage is read apart from the + /// content rather than next to it. + /// + public ChatCompletionUsage? Usage { get; init; } + /// public bool ContainsContent() => this.Choices.Count > 0; /// public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []); + /// + public bool ContainsUsage() => this.GetUsage().IsKnown; + + /// + public TokenUsage GetUsage() => this.Usage is null ? TokenUsage.UNKNOWN : TokenUsage.OfReported(this.Usage.PromptTokens, this.Usage.CompletionTokens); + #region Implementation of IAnnotationStreamLine // diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamOptions.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamOptions.cs new file mode 100644 index 00000000..c7c8af54 --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamOptions.cs @@ -0,0 +1,18 @@ +namespace AIStudio.Provider.OpenAI; + +/// +/// What a streamed chat completion should report beyond its content. +/// +/// +/// An OpenAI-compatible provider says nothing about what a streamed request cost unless it is asked +/// to. Without this block, the stream simply ends and the only token number anybody ever sees is +/// the one AI Studio estimated for itself. +/// +/// Whether the stream should end with a line stating the token usage. +public sealed record ChatCompletionStreamOptions(bool IncludeUsage) +{ + /// + /// Asks for the usage line. + /// + public static readonly ChatCompletionStreamOptions INCLUDE_USAGE = new(true); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs new file mode 100644 index 00000000..ed9550c8 --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs @@ -0,0 +1,30 @@ +// ReSharper disable ClassNeverInstantiated.Global +namespace AIStudio.Provider.OpenAI; + +/// +/// What an OpenAI-compatible provider reports a chat completion cost. +/// +/// +/// Every 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 -- decides that. +/// +public sealed record ChatCompletionUsage +{ + /// + /// What everything sent to the model cost. + /// + public int? PromptTokens { get; init; } + + /// + /// What the model wrote in answer. + /// + public int? CompletionTokens { get; init; } + + /// + /// What the provider says both of them add up to. Read but not relied upon: it is the sum of + /// the other two wherever a provider fills all three, and this way a provider which sends only + /// this one is not a reason to throw the other numbers away. + /// + public int? TotalTokens { get; init; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs index 5ef74083..17c96ca2 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs @@ -1,3 +1,5 @@ +using AIStudio.Provider.OpenAI; + namespace AIStudio.Provider.Perplexity; /// @@ -16,6 +18,22 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint /// public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.GetSources()); + + /// + /// What Perplexity says the request cost, on the one line which carries it. + /// + /// + /// The same block the OpenAI chat completion API sends, because this is that wire format. + /// Not a positional parameter: the struct is built from JSON, and a further parameter would + /// only be a value nobody passes. + /// + public ChatCompletionUsage? Usage { get; init; } + + /// + public bool ContainsUsage() => this.GetUsage().IsKnown; + + /// + public TokenUsage GetUsage() => this.Usage is null ? TokenUsage.UNKNOWN : TokenUsage.OfReported(this.Usage.PromptTokens, this.Usage.CompletionTokens); /// public bool ContainsSources() => this != default && this.SearchResults.Count > 0; diff --git a/app/MindWork AI Studio/Provider/TokenUsage.cs b/app/MindWork AI Studio/Provider/TokenUsage.cs new file mode 100644 index 00000000..750cfc44 --- /dev/null +++ b/app/MindWork AI Studio/Provider/TokenUsage.cs @@ -0,0 +1,81 @@ +namespace AIStudio.Provider; + +/// +/// What a provider said one request actually cost, in tokens. +/// +/// +/// 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 +/// this one is the only exact one of the two. +/// +/// 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. +/// +public readonly record struct TokenUsage +{ + /// + /// The usage of a request nobody reported anything about. + /// + public static readonly TokenUsage UNKNOWN = new(); + + /// + /// Whether a provider reported anything at all. When false, the numbers are meaningless. + /// + public bool IsKnown { get; private init; } + + /// + /// What everything sent to the model cost: the conversation so far, its attachments, the system + /// prompt, and whatever tools were offered. + /// + public int PromptTokens { get; private init; } + + /// + /// What the model wrote in answer. + /// + public int CompletionTokens { get; private init; } + + /// + /// What the whole exchange cost, which is what the next request carries as its history. + /// + public int TotalTokens => this.PromptTokens + this.CompletionTokens; + + /// + /// States what a provider reported. + /// + /// + /// 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. + /// + /// What the request carried. Has to be greater than zero. + /// What the answer cost. Zero or more. + /// The usage. + public static TokenUsage Of(int promptTokens, int completionTokens) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(promptTokens); + ArgumentOutOfRangeException.ThrowIfNegative(completionTokens); + + return new() + { + IsKnown = true, + PromptTokens = promptTokens, + CompletionTokens = completionTokens, + }; + } + + /// + /// States what a provider reported, or unknown when it reported nothing usable. + /// + /// + /// 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. + /// + /// What the request carried, as the provider stated it. + /// What the answer cost, as the provider stated it. + /// The usage, or . + public static TokenUsage OfReported(int? promptTokens, int? completionTokens) => + promptTokens is > 0 + ? Of(promptTokens.Value, completionTokens is > 0 ? completionTokens.Value : 0) + : UNKNOWN; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index de20d7ec..49c3fab7 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -456,6 +456,18 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes aiText.Text += contentStreamChunk; aiText.Sources.MergeSources(contentStreamChunk.Sources); + // + // Keep what the provider says the request cost. It arrives on one line of the stream, + // usually the last one, and only where a provider reports it at all -- so a chunk + // without it leaves what was reported before alone. + // + if (contentStreamChunk.Usage.IsKnown) + { + aiText.ReportedPromptTokens = contentStreamChunk.Usage.PromptTokens; + aiText.ReportedCompletionTokens = contentStreamChunk.Usage.CompletionTokens; + aiText.ReportedForModel = state.ChatGenerationRequest.ProviderSettings.Model.Id; + } + if (state.Snapshot.Status is not AIJobStatus.RUNNING) { state.Snapshot = state.Snapshot with diff --git a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs index 675d1ab1..b9bf8f56 100644 --- a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs +++ b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs @@ -68,9 +68,13 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger /// The configured provider, which decides both the tokenizer and the window. /// What the conversation would send, collected beforehand. + /// + /// 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. + /// /// Ends the counting when nobody needs the answer anymore. /// What the conversation costs, or that nothing could be counted. - public async Task CountAsync(Provider provider, ConversationParts parts, CancellationToken token = default) + public async Task CountAsync(Provider provider, ConversationParts parts, TokenUsage reported = default, CancellationToken token = default) { if (provider.UsedLLMProvider is LLMProviders.NONE) return ConversationTokens.UNAVAILABLE; @@ -115,11 +119,44 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger +/// Checks that what a provider says a request cost is read off the stream, and asked for. +/// +/// +/// Both halves matter and neither is visible from the other: an OpenAI-compatible provider says +/// nothing about the cost of a streamed request unless the request asks for it, and the line it +/// then sends carries no content, so the reading side has to look for it apart from the text. +/// Get either half wrong and the app silently keeps estimating, which looks exactly like a +/// provider which reports nothing. +/// +[TestFixture] +public sealed class ChatCompletionUsageTests +{ + /// + /// The last line of a streamed answer at a provider which was asked for the usage. + /// + private const string USAGE_LINE = + """ + {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":345,"total_tokens":1545}} + """; + + /// + /// An ordinary line carrying a piece of the answer. + /// + private const string CONTENT_LINE = + """ + {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[{"index":0,"delta":{"content":"Hi"}}]} + """; + + [Test] + public void TheFinalLineStatesWhatTheRequestCost() + { + var line = JsonSerializer.Deserialize(USAGE_LINE, ProviderJsonOptions.OPTIONS); + + Assert.Multiple(() => + { + Assert.That(line!.ContainsUsage(), Is.True); + Assert.That(line.GetUsage().PromptTokens, Is.EqualTo(1200)); + Assert.That(line.GetUsage().CompletionTokens, Is.EqualTo(345)); + Assert.That(line.GetUsage().TotalTokens, Is.EqualTo(1545)); + + // + // The line which carries the usage carries no answer, which is why it has to be read + // before the content check drops it: + // + Assert.That(line.ContainsContent(), Is.False); + }); + } + + [Test] + public void ALineOfTheAnswerStatesNoCost() + { + var line = JsonSerializer.Deserialize(CONTENT_LINE, ProviderJsonOptions.OPTIONS); + + Assert.Multiple(() => + { + Assert.That(line!.ContainsUsage(), Is.False); + Assert.That(line.GetUsage().IsKnown, Is.False); + Assert.That(line.ContainsContent(), Is.True); + }); + } + + [Test] + public void AStreamedRequestAsksForTheUsage() + { + var request = new ChatCompletionAPIRequest("gpt-5", [], true); + var json = JsonSerializer.Serialize(request, ProviderJsonOptions.OPTIONS); + + Assert.That(json, Does.Contain(""" + "stream_options":{"include_usage":true} + """)); + } + + [Test] + public void ARequestWhichIsNotStreamedDoesNot() + { + var request = new ChatCompletionAPIRequest("gpt-5", [], false); + var json = JsonSerializer.Serialize(request, ProviderJsonOptions.OPTIONS); + + Assert.That(json, Does.Not.Contain("stream_options")); + } + + /// + /// A provider which sends the block but fills in nothing usable states nothing. + /// + /// + /// Several OpenAI-compatible servers send an empty or zeroed usage block on every line while + /// streaming and the real numbers only at the end. Reading a zero as a fact would replace an + /// estimate with a statement that the conversation costs nothing. + /// + [Test] + public void AnEmptyUsageBlockStatesNothing() + { + var line = JsonSerializer.Deserialize( + """ + {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[],"usage":{"prompt_tokens":0,"completion_tokens":0}} + """, ProviderJsonOptions.OPTIONS); + + Assert.That(line!.ContainsUsage(), Is.False); + } + + /// + /// The line a real LM Studio server sends, taken off the wire. + /// + /// + /// It carries fields the shape above does not name -- the reasoning share of the completion, + /// among them -- and a server which sends more than we read must not stop us from reading what + /// we came for. + /// + [Test] + public void ARealServerLineIsRead() + { + var line = JsonSerializer.Deserialize( + """ + {"id":"chatcmpl-xb8mn282eff3tiu46xz8t3","object":"chat.completion.chunk","created":1789917744,"model":"google/gemma-4-12b-qat","system_fingerprint":"google/gemma-4-12b-qat","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":116,"total_tokens":133,"completion_tokens_details":{"reasoning_tokens":102}}} + """, ProviderJsonOptions.OPTIONS); + + Assert.Multiple(() => + { + Assert.That(line!.ContainsUsage(), Is.True); + Assert.That(line.GetUsage().TotalTokens, Is.EqualTo(133)); + }); + } + + /// + /// An answer cut off before it wrote anything still cost its prompt. + /// + [Test] + public void AnAnswerOfNoTokensIsStillACost() + { + var usage = TokenUsage.OfReported(900, 0); + + Assert.Multiple(() => + { + Assert.That(usage.IsKnown, Is.True); + Assert.That(usage.TotalTokens, Is.EqualTo(900)); + }); + } +} \ No newline at end of file