From f26305336f723168d3a0f6d0d481c7b9f5149e28 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 23 Sep 2026 19:32:42 +0200 Subject: [PATCH] Kept the conversation, tool results, and draft apart in the token count --- .../Chat/ConversationParts.cs | 85 +++++------ .../Chat/ConversationTokens.cs | 47 ++++-- .../Components/ChatComponent.razor.cs | 42 ++++-- .../Services/ConversationTokenCounter.cs | 140 +++++++++--------- app/Tests/Chat/ConversationPartsTests.cs | 49 ++++-- app/Tests/Chat/ConversationTokensTests.cs | 68 ++++++++- 6 files changed, 283 insertions(+), 148 deletions(-) diff --git a/app/MindWork AI Studio/Chat/ConversationParts.cs b/app/MindWork AI Studio/Chat/ConversationParts.cs index f2010b55..bcb8e297 100644 --- a/app/MindWork AI Studio/Chat/ConversationParts.cs +++ b/app/MindWork AI Studio/Chat/ConversationParts.cs @@ -19,6 +19,11 @@ namespace AIStudio.Chat; /// which is not about the next request but about the one in flight: it is what the model is /// reading at this moment, it is what fills the window while somebody watches, and it is gone /// again once the answer stands. +/// +/// The three are kept apart, and nothing stands in two of them. The conversation so far is what a +/// provider may already have counted exactly; the draft is what nobody has counted yet; and the +/// tool conversation is what the number will lose again once the answer is there. Each of them is +/// counted once and named on its own, so that a person can tell which part they are looking at. /// public sealed record ConversationParts { @@ -28,54 +33,61 @@ public sealed record ConversationParts public static readonly ConversationParts NOTHING = new(); /// - /// The texts which go into the request as they are. + /// The texts of the conversation which go into the request as they are. /// public IReadOnlyList Texts { get; init; } = []; /// - /// The texts which belong to this moment alone. + /// The texts of the conversation which are still being written. /// /// /// They cost exactly what the others cost; what sets them apart is that they will never be seen - /// again in this shape. The sentence somebody is typing changes with the next pause, and an - /// answer being streamed is a different text three seconds later -- so remembering what they - /// cost fills memory with answers nobody will ask for again. - /// - /// What a model's tools have returned so far belongs here for the same reason, although nobody - /// is writing it: it travels with every further round of one request and with nothing after - /// that, so it is measured while it matters and forgotten when the answer is there. + /// again in this shape. An answer being streamed is a different text three seconds later -- so + /// remembering what it cost fills memory with answers nobody will ask for again. /// public IReadOnlyList GrowingTexts { get; init; } = []; /// - /// The documents whose content is put into the request. + /// What the tools of the running request have returned so far, along with the calls to them. + /// + /// + /// Growing in the same way as the answer being streamed, and measured the same way. It travels + /// with every further round of one request and with nothing after that, so it is measured while + /// it matters and forgotten when the answer is there. + /// + public IReadOnlyList ToolConversation { get; init; } = []; + + /// + /// The documents of the conversation whose content is put into the request. /// public IReadOnlyList Documents { get; init; } = []; /// - /// How many images travel along. + /// How many images of the conversation travel along. /// public int Images { get; init; } /// - /// Which of the texts above are the message being written right now. + /// What stands in the composer, or an empty string when nothing does. /// /// - /// A marker, not a further part: everything named here also stands in GrowingTexts, 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. + /// Changes with the next pause, so it is measured like the texts which are still being written. /// - public IReadOnlyList DraftTexts { get; init; } = []; + public string DraftText { get; init; } = string.Empty; /// - /// Which of the documents above are attached to the message being written right now. + /// The documents attached to the composer. + /// + public IReadOnlyList DraftDocuments { get; init; } = []; + + /// + /// How many images attached to the composer travel along. /// /// - /// A marker in the same way as DraftTexts: everything named here also stands in Documents. + /// Apart from the images of the conversation, because only those can be part of what a + /// provider has already counted. /// - public IReadOnlyList DraftDocuments { get; init; } = []; + public int DraftImages { get; init; } /// /// Collects what a conversation would send. @@ -104,6 +116,7 @@ public sealed record ConversationParts { var texts = new List(); var growing = new List(); + var toolConversation = new List(); var documents = new List(); var images = 0; @@ -137,7 +150,7 @@ public sealed record ConversationParts // the tool conversation. A block skipped for having nothing to say is exactly the // block whose request is growing the fastest. // - growing.AddRange(text.PendingToolConversation); + toolConversation.AddRange(text.PendingToolConversation); if (string.IsNullOrWhiteSpace(text.Text)) continue; @@ -151,36 +164,24 @@ public sealed record ConversationParts } } - var draftTexts = new List(); + // + // Sorted the same way as the attachments of the conversation, into lists of their own. + // var draftDocuments = new List(); - - if (!string.IsNullOrWhiteSpace(draft)) - { - growing.Add(draft); - draftTexts.Add(draft); - } - + var draftImages = 0; 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)); - } + Sort(draftAttachments, draftDocuments, ref draftImages); return new() { Texts = texts, GrowingTexts = growing, + ToolConversation = toolConversation, Documents = documents, Images = imagesAreSent ? images : 0, - DraftTexts = draftTexts, + DraftText = string.IsNullOrWhiteSpace(draft) ? string.Empty : draft, DraftDocuments = draftDocuments, + DraftImages = imagesAreSent ? draftImages : 0, }; } diff --git a/app/MindWork AI Studio/Chat/ConversationTokens.cs b/app/MindWork AI Studio/Chat/ConversationTokens.cs index b33ea015..a915d991 100644 --- a/app/MindWork AI Studio/Chat/ConversationTokens.cs +++ b/app/MindWork AI Studio/Chat/ConversationTokens.cs @@ -29,9 +29,25 @@ public readonly record struct ConversationTokens public bool IsKnown { get; init; } /// - /// How many tokens the counted parts of the conversation take. + /// How many tokens the counted parts of the conversation take, the draft included. /// - public int Tokens { get; init; } + public int Tokens => this.HistoryTokens + this.DraftTokens; + + /// + /// How many tokens the conversation takes without the message being written right now. + /// + public int HistoryTokens { get; init; } + + /// + /// How much of HistoryTokens is what the tools of the running request returned so far. + /// + /// + /// Named on its own because it is the one share which goes away again. The model reads it in + /// every round of the request, so it fills the window while the tools work -- and none of it is + /// sent with the next message. Without saying so, the number would climb by tens of thousands + /// and then drop back once the answer stands, and nobody could tell why. + /// + public int ToolTokens { get; init; } /// /// Whether the number is an estimate rather than the model's own count. @@ -46,13 +62,12 @@ public readonly record struct ConversationTokens public bool IsEstimate { get; init; } /// - /// How much of Tokens is the message being written right now. + /// How many tokens the message being written right now takes. /// /// /// 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 Tokens. + /// nobody has sent yet can only be estimated. /// public int DraftTokens { get; init; } @@ -75,7 +90,17 @@ public readonly record struct ConversationTokens public ContextWindow Window { get; init; } /// - /// How many images travel along which nobody can count. + /// How many images travel along, those of the conversation and those of the draft. + /// + public int Images { get; init; } + + /// + /// How many of those images are attached to the message being written right now. + /// + public int DraftImages { get; init; } + + /// + /// How many images travel along which nobody has counted. /// /// /// Every vendor charges images differently -- OpenAI by tiles of the scaled image, Anthropic by @@ -83,8 +108,11 @@ public readonly record struct ConversationTokens /// file without decoding it first. So they are reported as a number of images instead of being /// guessed at, or worse, counted as the base64 text they are sent as: that text is two to three /// orders of magnitude longer than what any vendor charges for the picture. + /// + /// Where the provider reported the conversation so far, it counted the pictures in it as well, + /// however it charges them. Then only those of the draft are left uncounted. /// - public int UncountedImages { get; init; } + public int UncountedImages => this.HistoryIsReported ? this.DraftImages : this.Images; /// /// How many images the model takes, where its vendor stated a number. @@ -101,7 +129,8 @@ public readonly record struct ConversationTokens /// the person who crosses it has usually forgotten that the pictures are still there. /// /// False whenever nobody stated a limit, which is most models. An invented ceiling would refuse - /// something that works. + /// something that works. Whether anybody counted the pictures plays no part: the limit is on + /// how many travel, not on what they cost. /// - public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed; + public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.Images > allowed; } \ 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 fbef15b4..8fa53013 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -158,9 +158,11 @@ public partial class ChatComponent : MSGComponentBase /// What the helper text under the input field says about the token budget. /// /// - /// Four sentences rather than one built from pieces, because a translator needs to see the - /// whole thing: which of the two numbers is the limit, and where the word for "about" belongs, - /// are decisions no language makes the same way. + /// The number of the conversation and the window stand in four whole sentences, because a + /// translator needs to see the whole thing: which of the two numbers is the limit, and where the + /// word for "about" belongs, are decisions no language makes the same way. What follows -- the + /// tools' share, the draft, the pictures -- is added as clauses which are whole phrases in turn, + /// each with the one number it is about. /// /// The images are named rather than counted. Every vendor charges a picture differently, and /// none of those rules can be applied without decoding the file, so the honest answer is to say @@ -174,34 +176,42 @@ public partial class ChatComponent : MSGComponentBase return string.Empty; // - // Three statements, and which of them can be trusted differs. What the conversation + // Several 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. + // wrote down about the model; what the tools returned is only there while they work; + // what is being written has never been sent and can only ever be estimated. So they + // 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 history = TokenAmount.Format(this.conversationTokens.HistoryTokens, this.currentCulture); var historyIsExact = this.conversationTokens.HistoryIsReported || !this.conversationTokens.IsEstimate; var budget = this.conversationTokens.Window.IsKnown ? 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); + // + // Said while the tools work, so that the number does not climb and fall back without a + // reason anybody could see: all of it is sent with every round of this request, and none + // of it with the next message. + // + if (this.conversationTokens.ToolTokens > 0) + budget = string.Format(this.T("{0}, of which approx. {1} from tool results"), budget, TokenAmount.Format(this.conversationTokens.ToolTokens, this.currentCulture)); + 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; - // // The pictures of the whole conversation, not of the message being written: every one // of them is sent again with every further message, so a chat runs past the model's - // limit long after anybody last thought about images. + // limit long after anybody last thought about images. That is worth saying even when the + // provider counted all of them. // - var images = this.conversationTokens.TooManyImages - ? string.Format(this.T("plus {0} image(s), which is more than the {1} this model accepts"), this.conversationTokens.UncountedImages, this.conversationTokens.ImageLimits.MaxInOneMessage) - : string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages); + if (this.conversationTokens.TooManyImages) + return $"{budget} {string.Format(this.T("plus {0} image(s), which is more than the {1} this model accepts"), this.conversationTokens.Images, this.conversationTokens.ImageLimits.MaxInOneMessage)}"; - return $"{budget} {images}"; + if (this.conversationTokens.UncountedImages is 0) + return budget; + + return $"{budget} {string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages)}"; } } diff --git a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs index 55437c18..e56ea12f 100644 --- a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs +++ b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs @@ -23,9 +23,10 @@ using Provider = AIStudio.Settings.Provider; /// thousand-page PDF on each keystroke would be unusable. The conversation so far is remembered the /// same way, so typing measures the sentence being typed rather than the whole chat again. /// -/// The numbers are estimates and are shown as such. Unless somebody configured the model's own -/// tokenizer for their provider, the built-in one does the counting, and two tokenizers disagree by -/// a few percent on prose and by more than that on code. +/// The numbers are estimates and are shown as such, unless a provider reported what the +/// conversation so far cost. Unless somebody configured the model's own tokenizer for their +/// provider, the built-in one does the counting, and two tokenizers disagree by a few percent on +/// prose and by more than that on code. /// public sealed class ConversationTokenCounter(RustService rustService, ILogger logger) { @@ -56,8 +57,8 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger /// - /// One run's worth, replaced by the next -- so at most the draft and the answer being streamed - /// stand in here. It exists for the case where nothing about them changed: a draft somebody left + /// One run's worth, replaced by the next -- so at most the draft, the answer being streamed, and + /// the tool conversation of the running request stand in here. It exists for the case where nothing about them changed: a draft somebody left /// standing while they think would otherwise be measured again on every heartbeat, and that is a /// call to the tokenizer for an answer we already have. /// @@ -83,39 +84,47 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger(StringComparer.Ordinal); - var tokens = 0; - var reportedTokens = 0; + var historyTokens = 0; + var toolTokens = 0; + var draftTokens = 0; try { - foreach (var text in parts.Texts) - tokens += await this.CountTextAsync(provider, text, token); - - // - // A text which is still being written is measured whole every time rather than by its - // increment. Two counts meet at a token boundary, and adding up the pieces drifts - // further from the truth with every three seconds an answer goes on. - // - foreach (var text in parts.GrowingTexts) + if (reported.IsKnown) { - var key = Key(provider, text); - if (!previouslyGrowing.TryGetValue(key, out var known)) - known = await this.MeasureAsync(provider, text, token); + // + // Where a provider has said what this conversation cost, that number replaces the + // estimate of everything but the last answer. It is the exact one of the two, and + // it covers the same ground: the system prompt, the tools, every message which has + // been sent, and their attachments -- which is why none of those has to be read. + // + // 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 mostly has it already. + // + historyTokens = reported.PromptTokens + await this.CountTextAsync(provider, reported.LastAnswer, token); + } + else + { + foreach (var text in parts.Texts) + historyTokens += await this.CountTextAsync(provider, text, token); - growing[key] = known; - tokens += known; + foreach (var text in parts.GrowingTexts) + historyTokens += await this.MeasureGrowingAsync(provider, text, previouslyGrowing, growing, token); + + foreach (var text in parts.ToolConversation) + toolTokens += await this.MeasureGrowingAsync(provider, text, previouslyGrowing, growing, token); + + foreach (var document in parts.Documents) + historyTokens += await this.CountDocumentAsync(provider, document, token); + + // The tools' share is part of the conversation, and it is named as a share of it: + historyTokens += toolTokens; } - 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); + draftTokens = await this.MeasureGrowingAsync(provider, parts.DraftText, previouslyGrowing, growing, token); + foreach (var document in parts.DraftDocuments) + draftTokens += await this.CountDocumentAsync(provider, document, token); } catch (OperationCanceledException) { @@ -128,51 +137,48 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger + /// Measures a text which is still being written, unless it has not changed since the last count. + /// + /// + /// A text which is still being written is measured whole every time rather than by its + /// increment. Two counts meet at a token boundary, and adding up the pieces drifts further from + /// the truth with every three seconds an answer goes on. + /// + /// The configured provider, which decides the tokenizer. + /// The text to measure. + /// What the previous count measured. + /// What this count measured, for the next one. + /// Ends the counting when nobody needs the answer anymore. + /// The tokens of the text. + private async Task MeasureGrowingAsync(Provider provider, string text, IReadOnlyDictionary previouslyGrowing, Dictionary growing, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(text)) + return 0; + + var key = Key(provider, text); + if (!previouslyGrowing.TryGetValue(key, out var known)) + known = await this.MeasureAsync(provider, text, token); + + growing[key] = known; + return known; + } + /// /// Forgets everything counted so far. /// diff --git a/app/Tests/Chat/ConversationPartsTests.cs b/app/Tests/Chat/ConversationPartsTests.cs index 8972be9a..132c21af 100644 --- a/app/Tests/Chat/ConversationPartsTests.cs +++ b/app/Tests/Chat/ConversationPartsTests.cs @@ -56,7 +56,8 @@ public sealed class ConversationPartsTests Assert.Multiple(() => { Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris." })); - Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "And of Italy?" })); + Assert.That(parts.DraftText, Is.EqualTo("And of Italy?")); + Assert.That(parts.GrowingTexts, Is.Empty, "The draft is a part of its own, and it stands nowhere else."); }); } @@ -77,7 +78,8 @@ public sealed class ConversationPartsTests Assert.Multiple(() => { Assert.That(parts.Texts, Is.EqualTo(new[] { "A question." })); - Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The answer so far", "a draft" })); + Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The answer so far" })); + Assert.That(parts.DraftText, Is.EqualTo("a draft")); }); } @@ -135,7 +137,7 @@ public sealed class ConversationPartsTests Assert.Multiple(() => { Assert.That(parts.Texts, Is.Empty); - Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "Hello" })); + Assert.That(parts.DraftText, Is.EqualTo("Hello")); }); } @@ -149,6 +151,7 @@ public sealed class ConversationPartsTests { Assert.That(parts.Texts, Is.Empty); Assert.That(parts.GrowingTexts, Is.Empty); + Assert.That(parts.DraftText, Is.Empty); }); } @@ -188,7 +191,11 @@ public sealed class ConversationPartsTests var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true, toolDefinitions: null); - Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" })); + Assert.Multiple(() => + { + Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt" })); + Assert.That(parts.DraftDocuments.Select(document => document.FileName), Is.EqualTo(new[] { "draft.txt" }), "Both count, each in the part it belongs to."); + }); } [Test] @@ -201,7 +208,7 @@ public sealed class ConversationPartsTests var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true, toolDefinitions: null); - Assert.That(parts.Documents, Is.Empty); + Assert.That(parts.DraftDocuments, Is.Empty); } [Test] @@ -214,8 +221,29 @@ public sealed class ConversationPartsTests Assert.Multiple(() => { - Assert.That(parts.Documents.Select(entry => entry.FileName), Is.EqualTo(new[] { "notes.txt" })); + Assert.That(parts.DraftDocuments.Select(entry => entry.FileName), Is.EqualTo(new[] { "notes.txt" })); + Assert.That(parts.DraftImages, Is.EqualTo(1)); + }); + } + + [Test] + public void ImagesOfTheConversationAndOfTheDraftAreKeptApart() + { + // + // A provider which reported the conversation so far counted the pictures in it as well, + // but never the ones which are still waiting in the composer. + // + var sent = this.WriteFile("sent.png", "not really a png"); + var waiting = this.WriteFile("waiting.png", "not really a png either"); + var block = Block("Look at this."); + ((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(sent)); + + var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And at this.", [FileAttachment.FromPath(waiting)], imagesAreSent: true, toolDefinitions: null); + + Assert.Multiple(() => + { Assert.That(parts.Images, Is.EqualTo(1)); + Assert.That(parts.DraftImages, Is.EqualTo(1)); }); } @@ -230,7 +258,7 @@ public sealed class ConversationPartsTests var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false, toolDefinitions: null); - Assert.That(parts.Images, Is.Zero); + Assert.That(parts.DraftImages, Is.Zero); } [Test] @@ -250,7 +278,8 @@ public sealed class ConversationPartsTests Assert.Multiple(() => { Assert.That(parts.Texts, Is.Empty); - Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "What the web search found.", "What the page said." })); + Assert.That(parts.ToolConversation, Is.EqualTo(new[] { "What the web search found.", "What the page said." })); + Assert.That(parts.GrowingTexts, Is.Empty, "The tool conversation is a part of its own, so that its share can be named."); }); } @@ -267,7 +296,7 @@ public sealed class ConversationPartsTests var parts = ConversationParts.Of(new() { Blocks = [running] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null); - Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The same page.", "The same page." })); + Assert.That(parts.ToolConversation, Is.EqualTo(new[] { "The same page.", "The same page." })); } [Test] @@ -288,7 +317,7 @@ public sealed class ConversationPartsTests Assert.Multiple(() => { Assert.That(parts.Texts, Is.EqualTo(new[] { "Here is what I found." })); - Assert.That(parts.GrowingTexts, Is.Empty); + Assert.That(parts.ToolConversation, Is.Empty); }); } diff --git a/app/Tests/Chat/ConversationTokensTests.cs b/app/Tests/Chat/ConversationTokensTests.cs index 9cff6dde..4f1997ac 100644 --- a/app/Tests/Chat/ConversationTokensTests.cs +++ b/app/Tests/Chat/ConversationTokensTests.cs @@ -25,7 +25,7 @@ public sealed class ConversationTokensTests var counted = new ConversationTokens { IsKnown = true, - UncountedImages = images, + Images = images, ImageLimits = new ImageLimits(null, allowed), }; @@ -42,7 +42,7 @@ public sealed class ConversationTokensTests var counted = new ConversationTokens { IsKnown = true, - UncountedImages = 500, + Images = 500, ImageLimits = ImageLimits.UNKNOWN, }; @@ -60,7 +60,7 @@ public sealed class ConversationTokensTests var counted = new ConversationTokens { IsKnown = true, - UncountedImages = 20, + Images = 20, ImageLimits = new ImageLimits(8, 100), }; @@ -77,7 +77,7 @@ public sealed class ConversationTokensTests var counted = new ConversationTokens { IsKnown = true, - UncountedImages = 0, + Images = 0, ImageLimits = new ImageLimits(null, 0), }; @@ -93,4 +93,64 @@ public sealed class ConversationTokensTests // Assert.That(ConversationTokens.UNAVAILABLE.TooManyImages, Is.False); } + + [Test] + public void PicturesAProviderCountedAreNotCalledUncounted() + { + // + // What the provider reported for the conversation so far includes its pictures, however + // it charges them. Only those still waiting in the composer are left for nobody to count. + // + var reported = new ConversationTokens + { + IsKnown = true, + HistoryIsReported = true, + Images = 3, + DraftImages = 1, + }; + + var estimated = reported with { HistoryIsReported = false }; + + Assert.Multiple(() => + { + Assert.That(reported.UncountedImages, Is.EqualTo(1), "The provider counted the two which were sent."); + Assert.That(estimated.UncountedImages, Is.EqualTo(3), "Without a report, nobody counted any of them."); + }); + } + + [Test] + public void TooManyPicturesStaysTooManyWhenTheProviderCountedThem() + { + // + // The limit is on how many pictures travel, not on what they cost. A provider which has + // counted them still refuses the request which carries one too many. + // + var counted = new ConversationTokens + { + IsKnown = true, + HistoryIsReported = true, + Images = 10, + ImageLimits = new ImageLimits(8, null), + }; + + Assert.Multiple(() => + { + Assert.That(counted.UncountedImages, Is.Zero); + Assert.That(counted.TooManyImages, Is.True); + }); + } + + [Test] + public void TheWholeNumberIsTheConversationAndTheDraft() + { + var counted = new ConversationTokens + { + IsKnown = true, + HistoryTokens = 12_400, + ToolTokens = 9_000, + DraftTokens = 340, + }; + + Assert.That(counted.Tokens, Is.EqualTo(12_740), "The tools' share is part of the conversation, not added on top of it."); + } } \ No newline at end of file