Kept the conversation, tool results, and draft apart in the token count

This commit is contained in:
Thorsten Sommer 2026-09-23 19:32:42 +02:00
parent ffc6536ce3
commit f26305336f
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
6 changed files with 283 additions and 148 deletions

View File

@ -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.
/// </remarks>
public sealed record ConversationParts
{
@ -28,54 +33,61 @@ public sealed record ConversationParts
public static readonly ConversationParts NOTHING = new();
/// <summary>
/// The texts which go into the request as they are.
/// The texts of the conversation which go into the request as they are.
/// </summary>
public IReadOnlyList<string> Texts { get; init; } = [];
/// <summary>
/// The texts which belong to this moment alone.
/// The texts of the conversation which are still being written.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public IReadOnlyList<string> ToolConversation { get; init; } = [];
/// <summary>
/// The documents of the conversation whose content is put into the request.
/// </summary>
public IReadOnlyList<FileAttachment> Documents { get; init; } = [];
/// <summary>
/// How many images travel along.
/// How many images of the conversation travel along.
/// </summary>
public int Images { get; init; }
/// <summary>
/// Which of the texts above are the message being written right now.
/// What stands in the composer, or an empty string when nothing does.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public IReadOnlyList<string> DraftTexts { get; init; } = [];
public string DraftText { get; init; } = string.Empty;
/// <summary>
/// Which of the documents above are attached to the message being written right now.
/// The documents attached to the composer.
/// </summary>
public IReadOnlyList<FileAttachment> DraftDocuments { get; init; } = [];
/// <summary>
/// How many images attached to the composer travel along.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public IReadOnlyList<FileAttachment> DraftDocuments { get; init; } = [];
public int DraftImages { get; init; }
/// <summary>
/// Collects what a conversation would send.
@ -104,6 +116,7 @@ public sealed record ConversationParts
{
var texts = new List<string>();
var growing = new List<string>();
var toolConversation = new List<string>();
var documents = new List<FileAttachment>();
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<string>();
//
// Sorted the same way as the attachments of the conversation, into lists of their own.
//
var draftDocuments = new List<FileAttachment>();
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,
};
}

View File

@ -29,9 +29,25 @@ public readonly record struct ConversationTokens
public bool IsKnown { get; init; }
/// <summary>
/// How many tokens the counted parts of the conversation take.
/// How many tokens the counted parts of the conversation take, the draft included.
/// </summary>
public int Tokens { get; init; }
public int Tokens => this.HistoryTokens + this.DraftTokens;
/// <summary>
/// How many tokens the conversation takes without the message being written right now.
/// </summary>
public int HistoryTokens { get; init; }
/// <summary>
/// How much of HistoryTokens is what the tools of the running request returned so far.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public int ToolTokens { get; init; }
/// <summary>
/// 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; }
/// <summary>
/// How much of Tokens is the message being written right now.
/// How many tokens the message being written right now takes.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public int DraftTokens { get; init; }
@ -75,7 +90,17 @@ public readonly record struct ConversationTokens
public ContextWindow Window { get; init; }
/// <summary>
/// How many images travel along which nobody can count.
/// How many images travel along, those of the conversation and those of the draft.
/// </summary>
public int Images { get; init; }
/// <summary>
/// How many of those images are attached to the message being written right now.
/// </summary>
public int DraftImages { get; init; }
/// <summary>
/// How many images travel along which nobody has counted.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public int UncountedImages { get; init; }
public int UncountedImages => this.HistoryIsReported ? this.DraftImages : this.Images;
/// <summary>
/// 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.
/// </remarks>
public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed;
public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.Images > allowed;
}

View File

@ -158,9 +158,11 @@ public partial class ChatComponent : MSGComponentBase
/// What the helper text under the input field says about the token budget.
/// </summary>
/// <remarks>
/// 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)}";
}
}

View File

@ -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.
/// </remarks>
public sealed class ConversationTokenCounter(RustService rustService, ILogger<ConversationTokenCounter> logger)
{
@ -56,8 +57,8 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
/// What the texts which were still being written cost during the previous count.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
@ -83,39 +84,47 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
var profile = provider.GetModelProfile();
var previouslyGrowing = this.stillGrowing;
var growing = new Dictionary<string, int>(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<Co
}
this.stillGrowing = growing;
//
// What the composer adds, out of what was just counted. Every one of these was measured a
// moment ago and is still in the caches, so asking again costs a lookup each -- and asking
// separately is what keeps the two halves of the number apart.
//
var draftTokens = 0;
try
{
foreach (var text in parts.DraftTexts)
draftTokens += growing.TryGetValue(Key(provider, text), out var known) ? known : await this.CountTextAsync(provider, text, token);
foreach (var document in parts.DraftDocuments)
draftTokens += await this.CountDocumentAsync(provider, document, token);
}
catch (Exception e) when (e is not OperationCanceledException)
{
//
// The whole number stands even when its draft share does not: a conversation which
// was counted is worth showing, and a draft of zero only understates what is being
// written.
//
logger.LogWarning(e, "Could not count what the composer adds to this conversation.");
draftTokens = 0;
}
//
// Where a provider has said what this conversation cost, that number replaces everything
// but the last answer and the draft. It is the only exact one of the two, and it covers
// the same ground: the system prompt, the tools, and every message which has been sent.
//
var historyIsReported = reported.IsKnown;
return new()
{
IsKnown = true,
Tokens = historyIsReported ? reportedTokens + draftTokens : tokens,
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
HistoryTokens = historyTokens,
ToolTokens = toolTokens,
DraftTokens = draftTokens,
HistoryIsReported = historyIsReported,
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
HistoryIsReported = reported.IsKnown,
Window = profile.Context,
UncountedImages = parts.Images,
Images = parts.Images + parts.DraftImages,
DraftImages = parts.DraftImages,
ImageLimits = profile.Images,
};
}
/// <summary>
/// Measures a text which is still being written, unless it has not changed since the last count.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="provider">The configured provider, which decides the tokenizer.</param>
/// <param name="text">The text to measure.</param>
/// <param name="previouslyGrowing">What the previous count measured.</param>
/// <param name="growing">What this count measured, for the next one.</param>
/// <param name="token">Ends the counting when nobody needs the answer anymore.</param>
/// <returns>The tokens of the text.</returns>
private async Task<int> MeasureGrowingAsync(Provider provider, string text, IReadOnlyDictionary<string, int> previouslyGrowing, Dictionary<string, int> 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;
}
/// <summary>
/// Forgets everything counted so far.
/// </summary>

View File

@ -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);
});
}

View File

@ -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.");
}
}