mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 04:18:00 +00:00
Kept the conversation, tool results, and draft apart in the token count
This commit is contained in:
6 files changed
+282
-147
No files matched your search
@@ -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
|
/// 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
|
/// reading at this moment, it is what fills the window while somebody watches, and it is gone
|
||||||
/// again once the answer stands.
|
/// 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>
|
/// </remarks>
|
||||||
public sealed record ConversationParts
|
public sealed record ConversationParts
|
||||||
{
|
{
|
||||||
@@ -28,54 +33,61 @@ public sealed record ConversationParts
|
|||||||
public static readonly ConversationParts NOTHING = new();
|
public static readonly ConversationParts NOTHING = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public IReadOnlyList<string> Texts { get; init; } = [];
|
public IReadOnlyList<string> Texts { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The texts which belong to this moment alone.
|
/// The texts of the conversation which are still being written.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// They cost exactly what the others cost; what sets them apart is that they will never be seen
|
/// 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
|
/// again in this shape. An answer being streamed is a different text three seconds later -- so
|
||||||
/// answer being streamed is a different text three seconds later -- so remembering what they
|
/// remembering what it cost fills memory with answers nobody will ask for again.
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
|
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public IReadOnlyList<FileAttachment> Documents { get; init; } = [];
|
public IReadOnlyList<FileAttachment> Documents { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many images travel along.
|
/// How many images of the conversation travel along.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Images { get; init; }
|
public int Images { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A marker, not a further part: everything named here also stands in GrowingTexts, and
|
/// Changes with the next pause, so it is measured like the texts which are still being written.
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IReadOnlyList<string> DraftTexts { get; init; } = [];
|
public string DraftText { get; init; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <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>
|
/// </remarks>
|
||||||
public IReadOnlyList<FileAttachment> DraftDocuments { get; init; } = [];
|
public int DraftImages { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collects what a conversation would send.
|
/// Collects what a conversation would send.
|
||||||
@@ -104,6 +116,7 @@ public sealed record ConversationParts
|
|||||||
{
|
{
|
||||||
var texts = new List<string>();
|
var texts = new List<string>();
|
||||||
var growing = new List<string>();
|
var growing = new List<string>();
|
||||||
|
var toolConversation = new List<string>();
|
||||||
var documents = new List<FileAttachment>();
|
var documents = new List<FileAttachment>();
|
||||||
var images = 0;
|
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
|
// the tool conversation. A block skipped for having nothing to say is exactly the
|
||||||
// block whose request is growing the fastest.
|
// block whose request is growing the fastest.
|
||||||
//
|
//
|
||||||
growing.AddRange(text.PendingToolConversation);
|
toolConversation.AddRange(text.PendingToolConversation);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(text.Text))
|
if (string.IsNullOrWhiteSpace(text.Text))
|
||||||
continue;
|
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>();
|
var draftDocuments = new List<FileAttachment>();
|
||||||
|
var draftImages = 0;
|
||||||
if (!string.IsNullOrWhiteSpace(draft))
|
|
||||||
{
|
|
||||||
growing.Add(draft);
|
|
||||||
draftTexts.Add(draft);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draftAttachments is not null)
|
if (draftAttachments is not null)
|
||||||
{
|
Sort(draftAttachments, draftDocuments, ref draftImages);
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
Texts = texts,
|
Texts = texts,
|
||||||
GrowingTexts = growing,
|
GrowingTexts = growing,
|
||||||
|
ToolConversation = toolConversation,
|
||||||
Documents = documents,
|
Documents = documents,
|
||||||
Images = imagesAreSent ? images : 0,
|
Images = imagesAreSent ? images : 0,
|
||||||
DraftTexts = draftTexts,
|
DraftText = string.IsNullOrWhiteSpace(draft) ? string.Empty : draft,
|
||||||
DraftDocuments = draftDocuments,
|
DraftDocuments = draftDocuments,
|
||||||
|
DraftImages = imagesAreSent ? draftImages : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,25 @@ public readonly record struct ConversationTokens
|
|||||||
public bool IsKnown { get; init; }
|
public bool IsKnown { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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>
|
/// <summary>
|
||||||
/// Whether the number is an estimate rather than the model's own count.
|
/// 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; }
|
public bool IsEstimate { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How much of Tokens is the message being written right now.
|
/// How many tokens the message being written right now takes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Kept apart from the rest for the same reason the statements above are kept apart: what the
|
/// 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
|
/// 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
|
/// nobody has sent yet can only be estimated.
|
||||||
/// subtracted from Tokens.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public int DraftTokens { get; init; }
|
public int DraftTokens { get; init; }
|
||||||
|
|
||||||
@@ -75,7 +90,17 @@ public readonly record struct ConversationTokens
|
|||||||
public ContextWindow Window { get; init; }
|
public ContextWindow Window { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Every vendor charges images differently -- OpenAI by tiles of the scaled image, Anthropic by
|
/// 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
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
public int UncountedImages { get; init; }
|
public int UncountedImages => this.HistoryIsReported ? this.DraftImages : this.Images;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many images the model takes, where its vendor stated a number.
|
/// 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.
|
/// 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
|
/// 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>
|
/// </remarks>
|
||||||
public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed;
|
public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.Images > allowed;
|
||||||
}
|
}
|
||||||
@@ -158,9 +158,11 @@ public partial class ChatComponent : MSGComponentBase
|
|||||||
/// What the helper text under the input field says about the token budget.
|
/// What the helper text under the input field says about the token budget.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Four sentences rather than one built from pieces, because a translator needs to see the
|
/// The number of the conversation and the window stand in four whole sentences, because a
|
||||||
/// whole thing: which of the two numbers is the limit, and where the word for "about" belongs,
|
/// translator needs to see the whole thing: which of the two numbers is the limit, and where the
|
||||||
/// are decisions no language makes the same way.
|
/// 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
|
/// 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
|
/// 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;
|
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
|
// 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
|
// wrote down about the model; what the tools returned is only there while they work;
|
||||||
// ever be estimated. So the conversation and the draft are named apart, and the word
|
// what is being written has never been sent and can only ever be estimated. So they
|
||||||
// which marks a guess sits where the guess is.
|
// 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 historyIsExact = this.conversationTokens.HistoryIsReported || !this.conversationTokens.IsEstimate;
|
||||||
var budget = this.conversationTokens.Window.IsKnown
|
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} 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);
|
: 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)
|
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));
|
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
|
// 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
|
// 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
|
if (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)
|
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)}";
|
||||||
: string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages);
|
|
||||||
|
|
||||||
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)}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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
|
/// The numbers are estimates and are shown as such, unless a provider reported what the
|
||||||
/// tokenizer for their provider, the built-in one does the counting, and two tokenizers disagree by
|
/// conversation so far cost. Unless somebody configured the model's own tokenizer for their
|
||||||
/// a few percent on prose and by more than that on code.
|
/// 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>
|
/// </remarks>
|
||||||
public sealed class ConversationTokenCounter(RustService rustService, ILogger<ConversationTokenCounter> logger)
|
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.
|
/// What the texts which were still being written cost during the previous count.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// One run's worth, replaced by the next -- so at most the draft and the answer being streamed
|
/// One run's worth, replaced by the next -- so at most the draft, the answer being streamed, and
|
||||||
/// stand in here. It exists for the case where nothing about them changed: a draft somebody left
|
/// 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
|
/// 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.
|
/// call to the tokenizer for an answer we already have.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -83,39 +84,47 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
|
|||||||
var profile = provider.GetModelProfile();
|
var profile = provider.GetModelProfile();
|
||||||
var previouslyGrowing = this.stillGrowing;
|
var previouslyGrowing = this.stillGrowing;
|
||||||
var growing = new Dictionary<string, int>(StringComparer.Ordinal);
|
var growing = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
var tokens = 0;
|
var historyTokens = 0;
|
||||||
var reportedTokens = 0;
|
var toolTokens = 0;
|
||||||
|
var draftTokens = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var text in parts.Texts)
|
if (reported.IsKnown)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var key = Key(provider, text);
|
//
|
||||||
if (!previouslyGrowing.TryGetValue(key, out var known))
|
// Where a provider has said what this conversation cost, that number replaces the
|
||||||
known = await this.MeasureAsync(provider, text, token);
|
// 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
|
||||||
growing[key] = known;
|
// been sent, and their attachments -- which is why none of those has to be read.
|
||||||
tokens += known;
|
|
||||||
}
|
|
||||||
|
|
||||||
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:
|
// 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
|
// 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.
|
// finished text of the conversation, so the cache mostly has it already.
|
||||||
//
|
//
|
||||||
if (reported.IsKnown)
|
historyTokens = reported.PromptTokens + await this.CountTextAsync(provider, reported.LastAnswer, token);
|
||||||
reportedTokens = reported.PromptTokens + await this.CountTextAsync(provider, reported.LastAnswer, token);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var text in parts.Texts)
|
||||||
|
historyTokens += await this.CountTextAsync(provider, text, token);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -128,51 +137,48 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.stillGrowing = growing;
|
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()
|
return new()
|
||||||
{
|
{
|
||||||
IsKnown = true,
|
IsKnown = true,
|
||||||
Tokens = historyIsReported ? reportedTokens + draftTokens : tokens,
|
HistoryTokens = historyTokens,
|
||||||
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
|
ToolTokens = toolTokens,
|
||||||
DraftTokens = draftTokens,
|
DraftTokens = draftTokens,
|
||||||
HistoryIsReported = historyIsReported,
|
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
|
||||||
|
HistoryIsReported = reported.IsKnown,
|
||||||
Window = profile.Context,
|
Window = profile.Context,
|
||||||
UncountedImages = parts.Images,
|
Images = parts.Images + parts.DraftImages,
|
||||||
|
DraftImages = parts.DraftImages,
|
||||||
ImageLimits = profile.Images,
|
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>
|
/// <summary>
|
||||||
/// Forgets everything counted so far.
|
/// Forgets everything counted so far.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ public sealed class ConversationPartsTests
|
|||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris." }));
|
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.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "A question." }));
|
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.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(parts.Texts, Is.Empty);
|
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.Texts, Is.Empty);
|
||||||
Assert.That(parts.GrowingTexts, 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);
|
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]
|
[Test]
|
||||||
@@ -201,7 +208,7 @@ public sealed class ConversationPartsTests
|
|||||||
|
|
||||||
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true, toolDefinitions: null);
|
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]
|
[Test]
|
||||||
@@ -214,8 +221,29 @@ public sealed class ConversationPartsTests
|
|||||||
|
|
||||||
Assert.Multiple(() =>
|
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.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);
|
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]
|
[Test]
|
||||||
@@ -250,7 +278,8 @@ public sealed class ConversationPartsTests
|
|||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(parts.Texts, Is.Empty);
|
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);
|
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]
|
[Test]
|
||||||
@@ -288,7 +317,7 @@ public sealed class ConversationPartsTests
|
|||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "Here is what I found." }));
|
Assert.That(parts.Texts, Is.EqualTo(new[] { "Here is what I found." }));
|
||||||
Assert.That(parts.GrowingTexts, Is.Empty);
|
Assert.That(parts.ToolConversation, Is.Empty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class ConversationTokensTests
|
|||||||
var counted = new ConversationTokens
|
var counted = new ConversationTokens
|
||||||
{
|
{
|
||||||
IsKnown = true,
|
IsKnown = true,
|
||||||
UncountedImages = images,
|
Images = images,
|
||||||
ImageLimits = new ImageLimits(null, allowed),
|
ImageLimits = new ImageLimits(null, allowed),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ public sealed class ConversationTokensTests
|
|||||||
var counted = new ConversationTokens
|
var counted = new ConversationTokens
|
||||||
{
|
{
|
||||||
IsKnown = true,
|
IsKnown = true,
|
||||||
UncountedImages = 500,
|
Images = 500,
|
||||||
ImageLimits = ImageLimits.UNKNOWN,
|
ImageLimits = ImageLimits.UNKNOWN,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ public sealed class ConversationTokensTests
|
|||||||
var counted = new ConversationTokens
|
var counted = new ConversationTokens
|
||||||
{
|
{
|
||||||
IsKnown = true,
|
IsKnown = true,
|
||||||
UncountedImages = 20,
|
Images = 20,
|
||||||
ImageLimits = new ImageLimits(8, 100),
|
ImageLimits = new ImageLimits(8, 100),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ public sealed class ConversationTokensTests
|
|||||||
var counted = new ConversationTokens
|
var counted = new ConversationTokens
|
||||||
{
|
{
|
||||||
IsKnown = true,
|
IsKnown = true,
|
||||||
UncountedImages = 0,
|
Images = 0,
|
||||||
ImageLimits = new ImageLimits(null, 0),
|
ImageLimits = new ImageLimits(null, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,4 +93,64 @@ public sealed class ConversationTokensTests
|
|||||||
//
|
//
|
||||||
Assert.That(ConversationTokens.UNAVAILABLE.TooManyImages, Is.False);
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user