From ffc6536ce368aaf7364e59e111ee9173dfee15a0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 23 Sep 2026 18:58:23 +0200 Subject: [PATCH] Added the exact token count to chats which offer tools --- app/MindWork AI Studio/Chat/ChatThread.cs | 3 +- .../OpenAI/ChatCompletionStreamPart.cs | 9 +- .../ChatCompletionToolCallAccumulator.cs | 20 +-- .../ChatCompletionToolCallingAdapter.cs | 15 ++- .../OpenAI/ChatCompletionToolStreamLine.cs | 13 +- .../Chat/ChatThreadReportedHistoryTests.cs | 4 +- .../ChatCompletionToolCallAccumulatorTests.cs | 33 ++++- .../ChatCompletionToolCallingAdapterTests.cs | 118 ++++++++++++++++++ 8 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs index 4f7f05cf..0a10b2c9 100644 --- a/app/MindWork AI Studio/Chat/ChatThread.cs +++ b/app/MindWork AI Studio/Chat/ChatThread.cs @@ -401,8 +401,7 @@ public sealed record ChatThread /// asked, and no earlier answer ever stands in for it. Whatever came after an older report -- a /// message whose request was turned down, an answer which is still being written, an answer /// without a report of its own -- is missing from that report's number, and the estimate is - /// closer to the truth than a figure which leaves it out. Answers written while tools were - /// offered carry no report yet, so for them the estimate always takes over.

+ /// closer to the truth than a figure which leaves it out.

/// /// A report also stops counting when the thread holds a different number of blocks than the /// request did, which means an earlier message was deleted, and when the next request goes to diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs index 6b152b93..c13b72aa 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs @@ -5,15 +5,20 @@ namespace AIStudio.Provider.OpenAI; /// /// The text this line carried, empty when it carried none. /// The sources this line announced, empty when it announced none. -public readonly record struct ChatCompletionStreamPart(string TextDelta, IList Sources) +/// What the provider said the request cost, unknown on every line but the one which carries it. +public readonly record struct ChatCompletionStreamPart(string TextDelta, IList Sources, TokenUsage Usage = default) { /// /// The part of a line which says nothing to the user, such as a fragment of a tool call. /// public static ChatCompletionStreamPart Nothing => new(string.Empty, []); - + /// /// Whether this part has anything to show at all. /// + /// + /// The usage is not part of that: it is nothing to show, and whether it is passed on at all is + /// the adapter's decision, which knows which round this is. + /// public bool HasContent => this.TextDelta.Length > 0 || this.Sources.Count > 0; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs index 91efbb9a..4b455bc1 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs @@ -60,10 +60,16 @@ public sealed class ChatCompletionToolCallAccumulator(Func @@ -183,10 +189,10 @@ public sealed class ChatCompletionToolCallAccumulator(Func string.IsNullOrWhiteSpace(value) ? null : value; /// - /// A part for a line which brought sources but no text, or nothing at all. + /// A part for a line which brought sources or a usage but no text, or nothing at all. /// - private static ChatCompletionStreamPart WithSources(string text, IList sources) - => sources.Count is 0 ? ChatCompletionStreamPart.Nothing : new ChatCompletionStreamPart(text, sources); + private static ChatCompletionStreamPart WithSources(string text, IList sources, TokenUsage usage) + => sources.Count is 0 && !usage.IsKnown ? ChatCompletionStreamPart.Nothing : new ChatCompletionStreamPart(text, sources, usage); /// /// One tool call while its fragments are still arriving. diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs index 43bdfef4..1925b1bd 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs @@ -54,6 +54,16 @@ public sealed class ChatCompletionToolCallingAdapter( ParallelToolCalls = requestDtoBase.Tools is null ? null : false, }; + // + // Only the first round passes on what its request cost. Its prompt is the conversation up + // to the question, which is exactly what the next question will be sent after. Every later + // round carries the tool calls and their results on top, and none of that is sent again + // once the answer stands -- a report of such a round would count a chat far larger than + // the one the next request carries. What the answer adds, all rounds of text together, is + // counted from its text afterwards, cf. ReportedHistory. + // + var passesOnUsage = this.internalMessages.Count is 0; + // // The text goes out while it is being written; the tool calls are put back together // behind it, fragment by fragment. @@ -62,8 +72,9 @@ public sealed class ChatCompletionToolCallingAdapter( await foreach (var serverSentEvent in streamRequestAsync(requestDto, token)) { var part = accumulator.Process(serverSentEvent); - if (part.HasContent) - yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources)); + var usage = passesOnUsage ? part.Usage : TokenUsage.UNKNOWN; + if (part.HasContent || usage.IsKnown) + yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources, Usage: usage)); } var message = accumulator.Build(); diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolStreamLine.cs index 064b585c..a0f0bbf8 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolStreamLine.cs @@ -11,4 +11,15 @@ namespace AIStudio.Provider.OpenAI; /// /// The ID of the answer. /// The choices this line adds to. -public sealed record ChatCompletionToolStreamLine(string? Id, IList? Choices); \ No newline at end of file +public sealed record ChatCompletionToolStreamLine(string? Id, IList? Choices) +{ + /// + /// What the provider says the request cost, on the one line which carries it. + /// + /// + /// The same block the plain text path reads, and asked for the same way: every streamed + /// ChatCompletionAPIRequest asks for it, the requests of the tool rounds included. Not a + /// positional parameter, because nobody but the serializer ever builds this line. + /// + public ChatCompletionUsage? Usage { get; init; } +} \ No newline at end of file diff --git a/app/Tests/Chat/ChatThreadReportedHistoryTests.cs b/app/Tests/Chat/ChatThreadReportedHistoryTests.cs index ecdecbe8..f65df07d 100644 --- a/app/Tests/Chat/ChatThreadReportedHistoryTests.cs +++ b/app/Tests/Chat/ChatThreadReportedHistoryTests.cs @@ -88,8 +88,8 @@ public sealed class ChatThreadReportedHistoryTests public void AnAnswerWithoutAReportDoesNotBorrowTheReportBeforeIt() { // - // What an answer written while tools were offered looks like today: finished, but without - // a report of its own. + // What an answer looks like whose provider reports nothing, or whose API is not read for a + // report yet: finished, but without a report of its own. // 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); diff --git a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallAccumulatorTests.cs b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallAccumulatorTests.cs index 294c9ec2..9722ae01 100644 --- a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallAccumulatorTests.cs +++ b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallAccumulatorTests.cs @@ -183,7 +183,38 @@ public sealed class ChatCompletionToolCallAccumulatorTests Assert.That(part.Sources.Select(x => x.URL), Is.EqualTo(new[] { "https://example.org/" }), "Whatever the provider announced on that line reaches the user with it."); } - + + [Test] + public void TheLineWithoutChoicesStatesWhatTheRequestCost() + { + var accumulator = new ChatCompletionToolCallAccumulator(); + var part = accumulator.Process(Event("""{"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":345,"total_tokens":1545}}""")); + + Assert.Multiple(() => + { + Assert.That(part.Usage.IsKnown, Is.True, "The last line of the stream has no choices, and it must not be dropped for that."); + Assert.That(part.Usage.PromptTokens, Is.EqualTo(1200)); + Assert.That(part.HasContent, Is.False, "It has nothing to show, though."); + }); + } + + [Test] + public void AUsageNextToTheLastTextIsReadAsWell() + { + // + // Some providers put the usage on the line which carries the last piece of the answer + // rather than on a line of its own. + // + var accumulator = new ChatCompletionToolCallAccumulator(); + var part = accumulator.Process(Event("""{"choices":[{"index":0,"delta":{"content":"Bye"}}],"usage":{"prompt_tokens":1200,"completion_tokens":2}}""")); + + Assert.Multiple(() => + { + Assert.That(part.TextDelta, Is.EqualTo("Bye")); + Assert.That(part.Usage.PromptTokens, Is.EqualTo(1200)); + }); + } + private static ChatCompletionResponseMessage? Read(params string[] data) { var accumulator = new ChatCompletionToolCallAccumulator(); diff --git a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs new file mode 100644 index 00000000..140093bb --- /dev/null +++ b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs @@ -0,0 +1,118 @@ +using System.Runtime.CompilerServices; + +using AIStudio.Provider; +using AIStudio.Provider.OpenAI; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.Provider.ToolCalling; + +/// +/// Checks which round of a tool calling conversation passes on what its request cost. +/// +/// +/// Every round of a tool conversation is a request of its own, and every one of them reports what +/// it cost. Only the first one describes what the next question will be sent after: every later +/// round carries the tool calls and their results on top, none of which is sent again once the +/// answer stands. Passing on the last report instead would put the chat at the size of everything +/// the tools returned, which is the one number a person watching their context window must not +/// see as exact. +/// +[TestFixture] +public sealed class ChatCompletionToolCallingAdapterTests +{ + private const string FIRST_ROUND_USAGE = """{"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":20}}"""; + + private const string SECOND_ROUND_USAGE = """{"choices":[],"usage":{"prompt_tokens":9800,"completion_tokens":150}}"""; + + [Test] + public async Task OnlyTheFirstRoundPassesOnWhatItsRequestCost() + { + var adapter = Adapter( + [ + """{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\"query\":\"weather\"}"}}]}}]}""", + FIRST_ROUND_USAGE, + "[DONE]", + ], + [ + """{"choices":[{"index":0,"delta":{"content":"It is sunny."}}]}""", + SECOND_ROUND_USAGE, + "[DONE]", + ]); + + var firstRound = await Usages(adapter); + + // + // What the loop does between two rounds: the model's turn and the tool's result become part + // of the next request. + // + adapter.RecordAssistantTurn(); + adapter.RecordToolResult("call_1", "Sunny, 24 degrees."); + + var secondRound = await Usages(adapter); + + Assert.Multiple(() => + { + Assert.That(firstRound, Is.EqualTo(new[] { 1200 }), "The first round's prompt is the conversation up to the question."); + Assert.That(secondRound, Is.Empty, "The second round's prompt holds the tool result as well, which the next question is not sent with."); + }); + } + + [Test] + public async Task ARoundWithoutToolCallsPassesItOnAsWell() + { + // + // Offering tools does not mean the model uses them. Then the first round is the only one, + // and its report is as good as the one of a request which offered none. + // + var adapter = Adapter( + [ + """{"choices":[{"index":0,"delta":{"content":"Hello."}}]}""", + FIRST_ROUND_USAGE, + "[DONE]", + ]); + + Assert.That(await Usages(adapter), Is.EqualTo(new[] { 1200 })); + } + + /// + /// Runs the next round and returns the prompt of every usage it passed on. + /// + private static async Task> Usages(ChatCompletionToolCallingAdapter adapter) + { + var usages = new List(); + await foreach (var streamEvent in adapter.ExecuteRoundAsync(null, true)) + if (streamEvent.Delta is { Usage.IsKnown: true } delta) + usages.Add(delta.Usage.PromptTokens); + + return usages; + } + + /// + /// Builds an adapter whose requests are answered by the given rounds, one after another. + /// + private static ChatCompletionToolCallingAdapter Adapter(params string[][] rounds) + { + var nextRound = 0; + return new( + (_, _, _) => Task.FromResult(new ChatCompletionAPIRequest("model-a", [], true)), + new TextMessage("You are a helpful assistant.", "system"), + new Dictionary(), + [], + [], + (_, token) => Lines(rounds[nextRound++], token), + _ => [], + NullLogger.Instance); + } + + private static async IAsyncEnumerable Lines(string[] data, [EnumeratorCancellation] CancellationToken token = default) + { + foreach (var line in data) + { + token.ThrowIfCancellationRequested(); + yield return new ServerSentEvent($"data: {line}", line); + } + + await Task.CompletedTask; + } +} \ No newline at end of file