From 3277f20f6024859585e99b517955ede3aaa5ceec Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 23 Sep 2026 18:47:05 +0200 Subject: [PATCH] Fixed reasoning tokens inflating the exact token count --- app/MindWork AI Studio/Chat/ChatThread.cs | 19 +++--- app/MindWork AI Studio/Chat/ContentText.cs | 3 +- .../Chat/ConversationTokens.cs | 7 ++- .../Chat/ReportedHistory.cs | 59 ++++++++++++++++++ .../Chat/ReportedTokenUsage.cs | 20 +++--- .../Components/ChatComponent.razor.cs | 4 +- .../Provider/BaseProvider.cs | 2 +- .../Provider/OpenAI/ChatCompletionUsage.cs | 12 ++-- app/MindWork AI Studio/Provider/TokenUsage.cs | 41 +++++-------- .../Services/ConversationTokenCounter.cs | 22 +++++-- ...s.cs => ChatThreadReportedHistoryTests.cs} | 61 ++++++++++++++----- .../Provider/ChatCompletionUsageTests.cs | 26 ++------ 12 files changed, 172 insertions(+), 104 deletions(-) create mode 100644 app/MindWork AI Studio/Chat/ReportedHistory.cs rename app/Tests/Chat/{ChatThreadReportedUsageTests.cs => ChatThreadReportedHistoryTests.cs} (66%) diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs index 7dbbe536..4f7f05cf 100644 --- a/app/MindWork AI Studio/Chat/ChatThread.cs +++ b/app/MindWork AI Studio/Chat/ChatThread.cs @@ -416,22 +416,25 @@ public sealed record ChatThread /// is a lot of machinery for a number which corrects itself one answer later. /// /// The model the next request would go to. - /// What the provider reported, or unknown when no report describes this conversation. - public TokenUsage ReportedUsageFor(Model model) + /// + /// What the provider reported, together with the answer which followed it, or + /// ReportedHistory.UNKNOWN when no report describes this conversation. + /// + 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) diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index f74f3e9b..c35918c3 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -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. /// 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, }; diff --git a/app/MindWork AI Studio/Chat/ConversationTokens.cs b/app/MindWork AI Studio/Chat/ConversationTokens.cs index 17b0277c..b33ea015 100644 --- a/app/MindWork AI Studio/Chat/ConversationTokens.cs +++ b/app/MindWork AI Studio/Chat/ConversationTokens.cs @@ -61,8 +61,11 @@ public readonly record struct ConversationTokens /// /// /// 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. /// public bool HistoryIsReported { get; init; } diff --git a/app/MindWork AI Studio/Chat/ReportedHistory.cs b/app/MindWork AI Studio/Chat/ReportedHistory.cs new file mode 100644 index 00000000..e6997748 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ReportedHistory.cs @@ -0,0 +1,59 @@ +using AIStudio.Provider; + +namespace AIStudio.Chat; + +/// +/// What a conversation carries into its next request, as far as a provider has stated it. +/// +/// +/// 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. +/// +public sealed record ReportedHistory +{ + /// + /// The history of a conversation no report describes. + /// + public static readonly ReportedHistory UNKNOWN = new(); + + /// + /// Whether a report describes the conversation. When false, nothing else here means anything. + /// + public bool IsKnown { get; private init; } + + /// + /// What the provider counted for the request behind the last answer. + /// + public int PromptTokens { get; private init; } + + /// + /// The text of the last answer, as the next request will carry it, without any reasoning. + /// + public string LastAnswer { get; private init; } = string.Empty; + + /// + /// States what a report says about the conversation. + /// + /// What the provider reported for the request behind the last answer. + /// The text of that answer. + /// The history, or UNKNOWN when the usage states nothing. + public static ReportedHistory Of(TokenUsage usage, string lastAnswer) => usage.IsKnown + ? new() + { + IsKnown = true, + PromptTokens = usage.PromptTokens, + LastAnswer = lastAnswer, + } + : UNKNOWN; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs b/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs index 1654ded6..b005241f 100644 --- a/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs +++ b/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs @@ -6,10 +6,11 @@ namespace AIStudio.Chat; /// What a provider said the request behind one answer cost, as it is stored on that answer. /// /// -/// 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; } /// - /// What the model wrote in answer. - /// - public int CompletionTokens { get; init; } - - /// - /// Which model the numbers were charged for. + /// Which model the number was charged for. /// /// /// 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; } /// - /// States the stored numbers as a usage again. + /// States the stored number as a usage again. /// - /// The usage, or TokenUsage.UNKNOWN when the stored numbers state nothing usable. - public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens); + /// The usage, or TokenUsage.UNKNOWN when the stored number states nothing usable. + public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 750e4e45..fbef15b4 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -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); diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 0cac137d..88d8d949 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -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; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs index 5108cf7f..186cee76 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs @@ -5,9 +5,12 @@ 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 +/// 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. /// public sealed record ChatCompletionUsage { @@ -16,11 +19,6 @@ public sealed record ChatCompletionUsage /// public int? PromptTokens { get; init; } - /// - /// What the model wrote in answer. - /// - public int? CompletionTokens { get; init; } - /// /// States what this block reports, as far as it can be believed. /// @@ -29,5 +27,5 @@ public sealed record ChatCompletionUsage /// 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); + public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/TokenUsage.cs b/app/MindWork AI Studio/Provider/TokenUsage.cs index 21986d67..76c1d0fe 100644 --- a/app/MindWork AI Studio/Provider/TokenUsage.cs +++ b/app/MindWork AI Studio/Provider/TokenUsage.cs @@ -1,13 +1,18 @@ namespace AIStudio.Provider; /// -/// What a provider said one request actually cost, in tokens. +/// What a provider said one request actually carried, 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 +/// 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(); /// - /// 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. /// public bool IsKnown { get; private init; } @@ -30,37 +35,23 @@ public readonly record struct TokenUsage /// 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. + /// 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. /// /// 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) + 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. /// /// - /// 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. /// /// What the request carried, as the provider stated it. - /// What the answer cost, as the provider stated it. /// The usage, or UNKNOWN. - 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; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs index b9bf8f56..55437c18 100644 --- a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs +++ b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs @@ -69,12 +69,13 @@ public sealed class ConversationTokenCounter(RustService rustService, ILoggerThe 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. + /// 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. /// /// 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, TokenUsage reported = default, CancellationToken token = default) + public async Task 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(StringComparer.Ordinal); var tokens = 0; + var reportedTokens = 0; try { @@ -106,6 +108,14 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger [TestFixture] -public sealed class ChatThreadReportedUsageTests +public sealed class ChatThreadReportedHistoryTests { private static readonly DateTimeOffset START = new(2026, 9, 23, 10, 0, 0, TimeSpan.Zero); @@ -26,12 +26,39 @@ public sealed class ChatThreadReportedUsageTests { var thread = Thread(Question(1), Answer(2, promptTokens: 1200, blockCount: 2)); - var usage = thread.ReportedUsageFor(MODEL); + var history = thread.ReportedHistoryFor(MODEL); Assert.Multiple(() => { - Assert.That(usage.IsKnown, Is.True); - Assert.That(usage.PromptTokens, Is.EqualTo(1200)); + Assert.That(history.IsKnown, Is.True); + Assert.That(history.PromptTokens, Is.EqualTo(1200)); + + // + // The answer comes along as text rather than as the provider's number for it, which + // would include the reasoning the next request never carries: + // + Assert.That(history.LastAnswer, Is.EqualTo("Answer 2")); + }); + } + + [Test] + public void AnAnswerWithoutTextAddsNothingToThePrompt() + { + // + // An answer which consists of reasoning only. It may be kept to be read, but without any + // text it is never sent, so what the provider counted is all the next request carries of + // the conversation so far. + // + var reasoningOnly = new ContentText { Text = string.Empty }; + reasoningOnly.RecordReportedUsage(TokenUsage.Of(1200), MODEL.Id, 2); + var thread = Thread(Question(1), Block(ChatRole.AI, reasoningOnly, 2)); + + var history = thread.ReportedHistoryFor(MODEL); + Assert.Multiple(() => + { + Assert.That(history.IsKnown, Is.True); + Assert.That(history.PromptTokens, Is.EqualTo(1200)); + Assert.That(history.LastAnswer, Is.Empty); }); } @@ -45,7 +72,7 @@ public sealed class ChatThreadReportedUsageTests // var thread = Thread(Question(1), Answer(2, promptTokens: 1200, blockCount: 2), Question(3)); - Assert.That(thread.ReportedUsageFor(MODEL).IsKnown, Is.False); + Assert.That(thread.ReportedHistoryFor(MODEL).IsKnown, Is.False); } [Test] @@ -54,7 +81,7 @@ public sealed class ChatThreadReportedUsageTests var streaming = Block(ChatRole.AI, new ContentText { Text = "Half an ans", IsStreaming = true }, 4); var thread = Thread(Question(1), Answer(2, promptTokens: 1200, blockCount: 2), Question(3), streaming); - Assert.That(thread.ReportedUsageFor(MODEL).IsKnown, Is.False); + Assert.That(thread.ReportedHistoryFor(MODEL).IsKnown, Is.False); } [Test] @@ -67,7 +94,7 @@ public sealed class ChatThreadReportedUsageTests var withoutReport = Block(ChatRole.AI, new ContentText { Text = "Second answer" }, 4); var thread = Thread(Question(1), Answer(2, promptTokens: 1200, blockCount: 2), Question(3), withoutReport); - Assert.That(thread.ReportedUsageFor(MODEL).IsKnown, Is.False); + Assert.That(thread.ReportedHistoryFor(MODEL).IsKnown, Is.False); } [Test] @@ -82,7 +109,7 @@ public sealed class ChatThreadReportedUsageTests thread.Remove(firstQuestion.Content!); - Assert.That(thread.ReportedUsageFor(MODEL).IsKnown, Is.False); + Assert.That(thread.ReportedHistoryFor(MODEL).IsKnown, Is.False); } [Test] @@ -90,7 +117,7 @@ public sealed class ChatThreadReportedUsageTests { var thread = Thread(Question(1), Answer(2, promptTokens: 1200, blockCount: 2)); - Assert.That(thread.ReportedUsageFor(OTHER_MODEL).IsKnown, Is.False); + Assert.That(thread.ReportedHistoryFor(OTHER_MODEL).IsKnown, Is.False); } [Test] @@ -107,11 +134,12 @@ public sealed class ChatThreadReportedUsageTests thread.Remove(lastQuestion.Content!); thread.Remove(lastAnswer.Content!); - var usage = thread.ReportedUsageFor(MODEL); + var history = thread.ReportedHistoryFor(MODEL); Assert.Multiple(() => { - Assert.That(usage.IsKnown, Is.True, "The first answer is the last block again, with exactly the blocks it was reported for."); - Assert.That(usage.PromptTokens, Is.EqualTo(1200)); + Assert.That(history.IsKnown, Is.True, "The first answer is the last block again, with exactly the blocks it was reported for."); + Assert.That(history.PromptTokens, Is.EqualTo(1200)); + Assert.That(history.LastAnswer, Is.EqualTo("Answer 2")); }); } @@ -123,11 +151,12 @@ public sealed class ChatThreadReportedUsageTests thread.RollBackTo(firstAnswer.Content!); - var usage = thread.ReportedUsageFor(MODEL); + var history = thread.ReportedHistoryFor(MODEL); Assert.Multiple(() => { - Assert.That(usage.IsKnown, Is.True); - Assert.That(usage.PromptTokens, Is.EqualTo(1200)); + Assert.That(history.IsKnown, Is.True); + Assert.That(history.PromptTokens, Is.EqualTo(1200)); + Assert.That(history.LastAnswer, Is.EqualTo("Answer 2")); }); } @@ -141,7 +170,7 @@ public sealed class ChatThreadReportedUsageTests private static ContentBlock Answer(int minute, int promptTokens, int blockCount) { var answer = new ContentText { Text = $"Answer {minute}" }; - answer.RecordReportedUsage(TokenUsage.Of(promptTokens, 100), MODEL.Id, blockCount); + answer.RecordReportedUsage(TokenUsage.Of(promptTokens), MODEL.Id, blockCount); return Block(ChatRole.AI, answer, minute); } diff --git a/app/Tests/Provider/ChatCompletionUsageTests.cs b/app/Tests/Provider/ChatCompletionUsageTests.cs index 46e421ab..3aa995a3 100644 --- a/app/Tests/Provider/ChatCompletionUsageTests.cs +++ b/app/Tests/Provider/ChatCompletionUsageTests.cs @@ -43,8 +43,6 @@ public sealed class ChatCompletionUsageTests { 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)); // // The line which carries the usage carries no answer, which is why it has to be read @@ -109,9 +107,10 @@ public sealed class ChatCompletionUsageTests /// 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. + /// It carries far more than we read, and it shows why the prompt is all we take: 102 of the 116 + /// completion tokens are the model's reasoning, which the next request never carries. Counting + /// the completion would have put the history at 133 tokens, when what travels on is the prompt + /// and an answer of a handful of tokens. /// [Test] public void ARealServerLineIsRead() @@ -124,22 +123,7 @@ public sealed class ChatCompletionUsageTests Assert.Multiple(() => { Assert.That(line!.GetUsage().IsKnown, 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)); + Assert.That(line.GetUsage().PromptTokens, Is.EqualTo(17)); }); } } \ No newline at end of file