From ceb78ff9b78ea76f82ef3c33742ee1bb9642d20e Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 23 Sep 2026 18:26:18 +0200 Subject: [PATCH] Read the reported token usage in one place --- app/MindWork AI Studio/Provider/BaseProvider.cs | 7 ++++--- .../Provider/Fireworks/ResponseStreamLine.cs | 5 +---- .../Provider/IResponseStreamLine.cs | 12 +++++------- .../Provider/OpenAI/ChatCompletionDeltaStreamLine.cs | 5 +---- .../Provider/OpenAI/ChatCompletionUsage.cs | 11 +++++++---- .../Provider/Perplexity/ResponseStreamLine.cs | 5 +---- app/Tests/Provider/ChatCompletionUsageTests.cs | 9 ++++----- 7 files changed, 23 insertions(+), 31 deletions(-) diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 5fb3e318..0cac137d 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1123,11 +1123,12 @@ public abstract class BaseProvider : IProvider, ISecretId // 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()) + var usage = providerResponse.GetUsage(); + if (usage.IsKnown) { yield return providerResponse.ContainsContent() - ? providerResponse.GetContent() with { Usage = providerResponse.GetUsage() } - : new(string.Empty, [], providerResponse.GetUsage()); + ? providerResponse.GetContent() with { Usage = usage } + : new(string.Empty, [], usage); continue; } diff --git a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs index b8524a4f..7f46c7ba 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ResponseStreamLine.cs @@ -29,10 +29,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint 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 TokenUsage GetUsage() => this.Usage?.ToTokenUsage() ?? TokenUsage.UNKNOWN; #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/IResponseStreamLine.cs b/app/MindWork AI Studio/Provider/IResponseStreamLine.cs index e09e1e71..f26faa9c 100644 --- a/app/MindWork AI Studio/Provider/IResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/IResponseStreamLine.cs @@ -18,18 +18,16 @@ public interface IResponseStreamLine : IAnnotationStreamLine public ContentStreamChunk GetContent(); /// - /// Checks whether the response line states what the request cost. + /// Gets what the provider said 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. + /// + /// Unlike content and sources, there is no separate check for whether a line carries it. This + /// never fails on a line without one, and whether the answer means anything is what IsKnown of + /// the returned usage says. /// - /// 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 TokenUsage.UNKNOWN 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/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs index dc697317..cdd1dfef 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs @@ -34,10 +34,7 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat 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); + public TokenUsage GetUsage() => this.Usage?.ToTokenUsage() ?? TokenUsage.UNKNOWN; #region Implementation of IAnnotationStreamLine diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs index fbfc99df..5108cf7f 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs @@ -22,9 +22,12 @@ public sealed record ChatCompletionUsage 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. + /// States what this block reports, as far as it can be believed. /// - public int? TotalTokens { get; init; } + /// + /// The one way from the wire to a usage, shared by every stream line which carries this block, + /// so that what counts as believable is decided in a single place. + /// + /// The usage, or TokenUsage.UNKNOWN when the block states nothing usable. + public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens); } \ 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 17c96ca2..e31b072b 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ResponseStreamLine.cs @@ -30,10 +30,7 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint 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 TokenUsage GetUsage() => this.Usage?.ToTokenUsage() ?? TokenUsage.UNKNOWN; /// public bool ContainsSources() => this != default && this.SearchResults.Count > 0; diff --git a/app/Tests/Provider/ChatCompletionUsageTests.cs b/app/Tests/Provider/ChatCompletionUsageTests.cs index ff0123fc..46e421ab 100644 --- a/app/Tests/Provider/ChatCompletionUsageTests.cs +++ b/app/Tests/Provider/ChatCompletionUsageTests.cs @@ -41,7 +41,7 @@ public sealed class ChatCompletionUsageTests Assert.Multiple(() => { - Assert.That(line!.ContainsUsage(), Is.True); + Assert.That(line!.GetUsage().IsKnown, 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)); @@ -61,8 +61,7 @@ public sealed class ChatCompletionUsageTests Assert.Multiple(() => { - Assert.That(line!.ContainsUsage(), Is.False); - Assert.That(line.GetUsage().IsKnown, Is.False); + Assert.That(line!.GetUsage().IsKnown, Is.False); Assert.That(line.ContainsContent(), Is.True); }); } @@ -103,7 +102,7 @@ public sealed class ChatCompletionUsageTests {"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); + Assert.That(line!.GetUsage().IsKnown, Is.False); } /// @@ -124,7 +123,7 @@ public sealed class ChatCompletionUsageTests Assert.Multiple(() => { - Assert.That(line!.ContainsUsage(), Is.True); + Assert.That(line!.GetUsage().IsKnown, Is.True); Assert.That(line.GetUsage().TotalTokens, Is.EqualTo(133)); }); }