Count the whole conversation against the model's window

This commit is contained in:
Thorsten Sommer committed 2026-09-12 17:56:01 +02:00
1 parent e8ec2c1bef
commit 5ee4685f6f
15 files changed
+844 -153

No files matched your search

+47 -28
View File
@@ -146,10 +146,8 @@ public sealed record ChatThread
/// </summary>
public bool MayRunTools(SettingsManager settingsManager) => this.RuntimeToolsAreAssistantManaged || settingsManager.IsToolSelectionVisible(this.RuntimeComponent);
private bool allowProfile = true;
/// <summary>
/// Prepares the system prompt for the chat thread.
/// Prepares the system prompt for the chat thread, and remembers what it was built from.
/// </summary>
/// <remarks>
/// The actual system prompt depends on the selected profile. If no profile is selected,
@@ -161,7 +159,35 @@ public sealed record ChatThread
/// <returns>The prepared system prompt.</returns>
public string PrepareSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
{
this.allowProfile = true;
var prepared = this.BuildSystemPrompt(settingsManager, runnableToolDefinitions);
// We need a way to save the changed system prompt in our chat thread.
// Otherwise, the chat thread will always tell us that it is using the
// default system prompt:
this.SystemPrompt = prepared.BasePrompt;
LOGGER.LogInformation(prepared.Explanation);
return prepared.Text;
}
/// <summary>
/// Works out the system prompt without changing anything about the thread.
/// </summary>
/// <remarks>
/// Split off from the preparation above so that somebody can ask how long the next request
/// would be. Counting the tokens of a conversation has to ask the same question the request
/// asks -- a count against the prompt a person typed, rather than against the one a chat
/// template, a data source, a profile and the tool policy make of it, is a number about a
/// request which is never sent.
///
/// Nothing here writes to the thread and nothing logs, because this runs while somebody types.
/// </remarks>
/// <param name="settingsManager">The settings manager instance to use.</param>
/// <param name="runnableToolDefinitions">The tools which may run in this thread. Null when the thread runs without tools.</param>
/// <returns>The system prompt and what building it decided.</returns>
public PreparedSystemPrompt BuildSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
{
var allowProfile = true;
//
// Use the information from the chat template, if provided. Otherwise, use the default system prompt
@@ -186,18 +212,12 @@ public sealed record ChatThread
else
{
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'.";
this.allowProfile = chatTemplate.AllowProfileUsage;
allowProfile = chatTemplate.AllowProfileUsage;
systemPromptTextWithChatTemplate = chatTemplate.ToSystemPrompt();
}
}
}
}
// We need a way to save the changed system prompt in our chat thread.
// Otherwise, the chat thread will always tell us that it is using the
// default system prompt:
this.SystemPrompt = systemPromptTextWithChatTemplate;
LOGGER.LogInformation(logMessage);
//
// Add augmented data, if available:
@@ -214,18 +234,16 @@ public sealed record ChatThread
false => systemPromptTextWithChatTemplate,
};
if(isAugmentedDataAvailable)
LOGGER.LogInformation("Augmented data is available for the chat thread.");
else
LOGGER.LogInformation("No augmented data is available for the chat thread.");
logMessage = isAugmentedDataAvailable
? $"{logMessage} Augmented data is available for the chat thread."
: $"{logMessage} No augmented data is available for the chat thread.";
//
// Add information from the profile if available and allowed:
//
string systemPromptText;
logMessage = $"Using no profile for chat thread '{this.Name}'.";
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !this.allowProfile)
var profileNote = $"Using no profile for chat thread '{this.Name}'.";
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !allowProfile)
systemPromptText = systemPromptWithAugmentedData;
else
{
@@ -242,7 +260,7 @@ public sealed record ChatThread
systemPromptText = systemPromptWithAugmentedData;
else
{
logMessage = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
profileNote = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
systemPromptText = $"""
{systemPromptWithAugmentedData}
@@ -252,8 +270,6 @@ public sealed record ChatThread
}
}
}
LOGGER.LogInformation(logMessage);
var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []);
if (!string.IsNullOrWhiteSpace(toolPolicy))
@@ -265,9 +281,10 @@ public sealed record ChatThread
""";
}
var explanation = $"{logMessage} {profileNote}";
if(!this.IncludeDateTime)
return systemPromptText;
return new(systemPromptText, systemPromptTextWithChatTemplate, allowProfile, explanation);
//
// Prepend the current date and time to the system prompt:
//
@@ -278,11 +295,13 @@ public sealed record ChatThread
$"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)."
);
return $"""
{currentDateTime}
var withDateTime = $"""
{currentDateTime}
{systemPromptText}
""";
{systemPromptText}
""";
return new(withDateTime, systemPromptTextWithChatTemplate, allowProfile, explanation);
}
/// <summary>
@@ -0,0 +1,119 @@
namespace AIStudio.Chat;
/// <summary>
/// Everything a conversation would put into the next request, sorted by how it can be counted.
/// </summary>
/// <remarks>
/// Collected here rather than while counting, so that what counts towards a token budget is one
/// question with one answer which a test can ask. It follows what the message builder actually
/// sends: the system prompt, the text of every block, and the attachments hanging off those
/// blocks -- plus whatever is standing in the composer but has not been sent yet, because that is
/// the part a person is deciding about while they look at the number.
/// </remarks>
public sealed record ConversationParts
{
/// <summary>
/// A conversation with nothing in it.
/// </summary>
public static readonly ConversationParts NOTHING = new();
/// <summary>
/// The texts which go into the request as they are.
/// </summary>
public IReadOnlyList<string> Texts { get; init; } = [];
/// <summary>
/// The documents whose content is put into the request.
/// </summary>
public IReadOnlyList<FileAttachment> Documents { get; init; } = [];
/// <summary>
/// How many images travel along.
/// </summary>
public int Images { get; init; }
/// <summary>
/// Collects what a conversation would send.
/// </summary>
/// <remarks>
/// Blocks without text are skipped, because the message builder skips them too: a block whose
/// text is empty never becomes a message, whatever else hangs off it.
/// </remarks>
/// <param name="thread">The conversation so far, or null when there is none yet.</param>
/// <param name="systemPrompt">
/// The system prompt as it would be sent, which is not the one a person typed: a chat template
/// may replace it, the retrieved data of a data source is appended to it, a profile adds a
/// paragraph, and the tool policy adds another.
/// </param>
/// <param name="draft">What stands in the composer.</param>
/// <param name="draftAttachments">What is attached to the composer.</param>
/// <param name="imagesAreSent">Whether the model takes images at all. When it does not, none are sent.</param>
/// <returns>The parts of the conversation.</returns>
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent)
{
var texts = new List<string>();
var documents = new List<FileAttachment>();
var images = 0;
if (!string.IsNullOrWhiteSpace(systemPrompt))
texts.Add(systemPrompt);
if (thread is not null)
{
//
// Blocks hidden from the user are counted like any other. They are hidden on the screen,
// not in the request: the message builder sends them, so they take their tokens whether
// or not anybody can see them.
//
foreach (var block in thread.Blocks)
{
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text || string.IsNullOrWhiteSpace(text.Text))
continue;
texts.Add(text.Text);
Sort(text.FileAttachments, documents, ref images);
}
}
if (!string.IsNullOrWhiteSpace(draft))
texts.Add(draft);
if (draftAttachments is not null)
Sort(draftAttachments, documents, ref images);
return new()
{
Texts = texts,
Documents = documents,
Images = imagesAreSent ? images : 0,
};
}
/// <summary>
/// Puts attachments into the two groups they are counted in.
/// </summary>
/// <remarks>
/// An attachment whose file is gone is left out of both. It is not sent either: the message
/// builder drops it and tells the person about it, so counting it would promise a request which
/// is never made.
/// </remarks>
private static void Sort(IEnumerable<FileAttachment> attachments, List<FileAttachment> documents, ref int images)
{
foreach (var attachment in attachments)
{
if (!attachment.Exists)
continue;
switch (attachment.Type)
{
case FileAttachmentType.DOCUMENT:
documents.Add(attachment);
break;
case FileAttachmentType.IMAGE:
images++;
break;
}
}
}
}
@@ -0,0 +1,64 @@
using AIStudio.Models;
namespace AIStudio.Chat;
/// <summary>
/// What a conversation costs, as far as the app can count it.
/// </summary>
/// <remarks>
/// Three separate statements, and keeping them apart is the point. How many tokens were counted is
/// one; what the model's window is, if anybody has written it down, is the second; and how much of
/// the conversation could not be counted at all is the third. Folding any of them into the others
/// would turn a gap into a number somebody reads as a fact.
/// </remarks>
public readonly record struct ConversationTokens
{
/// <summary>
/// The answer when nothing could be counted, which is what a broken tokenizer leaves behind.
/// </summary>
/// <remarks>
/// Deliberately not a zero. A conversation of no tokens and a conversation nobody could measure
/// look the same as a number and are not the same thing, so the display shows nothing at all
/// rather than claiming an empty chat.
/// </remarks>
public static readonly ConversationTokens UNAVAILABLE = new();
/// <summary>
/// Whether anything could be counted.
/// </summary>
public bool IsKnown { get; init; }
/// <summary>
/// How many tokens the counted parts of the conversation take.
/// </summary>
public int Tokens { get; init; }
/// <summary>
/// Whether the number is an estimate rather than the model's own count.
/// </summary>
/// <remarks>
/// True whenever the built-in tokenizer did the counting, which is the normal case: a model's
/// own tokenizer is only used where somebody configured one for their provider. Two tokenizers
/// disagree by a few percent on ordinary prose and by a lot more on code or a language they were
/// not trained on, so the number is shown as an approximation unless we counted with the
/// tokenizer the model itself uses.
/// </remarks>
public bool IsEstimate { get; init; }
/// <summary>
/// How much the model reads, where anybody has stated it.
/// </summary>
public ContextWindow Window { get; init; }
/// <summary>
/// How many images travel along which nobody can count.
/// </summary>
/// <remarks>
/// Every vendor charges images differently -- OpenAI by tiles of the scaled image, Anthropic by
/// its area, Google by tiles of another size -- and none of those numbers can be had from the
/// 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.
/// </remarks>
public int UncountedImages { get; init; }
}
@@ -0,0 +1,19 @@
namespace AIStudio.Chat;
/// <summary>
/// The system prompt of a chat thread as it would be sent, together with what building it decided.
/// </summary>
/// <remarks>
/// The system prompt is not the text a person typed into it. A chat template may replace it, the
/// retrieved data of a data source is appended to it, a profile adds its own paragraph, the tool
/// policy adds another, and the current date goes in front of everything. Whoever wants to know how
/// long the next request is has to ask the same question the request does.
/// </remarks>
/// <param name="Text">The whole system prompt, as the provider receives it.</param>
/// <param name="BasePrompt">
/// The prompt without any of the parts added around it. The thread keeps this one, so that it can
/// still say which prompt it was configured with rather than the assembled result.
/// </param>
/// <param name="ProfileIsAllowed">Whether the chat template let a profile take part.</param>
/// <param name="Explanation">What was used, in one sentence, for the log.</param>
public sealed record PreparedSystemPrompt(string Text, string BasePrompt, bool ProfileIsAllowed, string Explanation);
@@ -0,0 +1,47 @@
using System.Globalization;
namespace AIStudio.Chat;
/// <summary>
/// Writes a number of tokens the way a person reads it next to their input field.
/// </summary>
/// <remarks>
/// A context window of a million tokens written out in full is eight characters of noise under a
/// text field, and nobody reads the last five of them. So everything from a thousand on is
/// shortened, and two decimals keep the resolution a person acts on: the difference between 1.20k
/// and 1.80k is one they can see, while the last three digits of 1,234 are not.
///
/// The culture is passed in rather than taken from the thread. AI Studio's language is chosen in
/// its settings and does not move the thread's culture along with it, so a German who picked German
/// would otherwise read English separators inside a German sentence.
/// </remarks>
public static class TokenAmount
{
/// <summary>
/// Below this, the exact number is shown.
/// </summary>
private const int EXACT_BELOW = 1_000;
/// <summary>
/// Writes a number of tokens.
/// </summary>
/// <param name="tokens">The number of tokens.</param>
/// <param name="culture">The culture whose separators the number is written with.</param>
/// <returns>The number, shortened from a thousand on.</returns>
public static string Format(int tokens, CultureInfo culture)
{
if (tokens < EXACT_BELOW)
return tokens.ToString("N0", culture);
//
// Rounded before the unit is chosen, not after. Otherwise the few hundred tokens just below
// a million round up inside their own unit and read as "1,000.00k", which is a number
// nobody writes.
//
var thousands = tokens / 1_000d;
if (Math.Round(thousands, 2) < 1_000d)
return $"{thousands.ToString("N2", culture)}k";
return $"{(tokens / 1_000_000d).ToString("N2", culture)}M";
}
}