Count the whole conversation against the model's window

This commit is contained in:
Thorsten Sommer 2026-09-12 17:22:29 +02:00
parent e8ec2c1bef
commit 5ee4685f6f
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
15 changed files with 844 additions and 153 deletions

View File

@ -3571,6 +3571,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Type your
-- Your Prompt (use selected instance '{0}', provider '{1}')
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1967611328"] = "Your Prompt (use selected instance '{0}', provider '{1}')"
-- approx. {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "approx. {0} of {1} tokens"
-- Code
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code"
@ -3595,15 +3598,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2991985411"] = "Delete th
-- Move Chat to Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat to Workspace"
-- {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} tokens"
-- plus {0} image(s), which cannot be counted
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0} image(s), which cannot be counted"
-- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
-- Estimated amount of tokens:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T377990776"] = "Estimated amount of tokens:"
-- Start new chat in workspace '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'"
-- {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3996190985"] = "{0} of {1} tokens"
-- Start temporary chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Start temporary chat"
@ -3619,6 +3628,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Move the c
-- Show your workspaces
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T733672375"] = "Show your workspaces"
-- approx. {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T857715435"] = "approx. {0} tokens"
-- Create template from current chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1112722156"] = "Create template from current chat"

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,19 +212,13 @@ 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}
@ -253,8 +271,6 @@ public sealed record ChatThread
}
}
LOGGER.LogInformation(logMessage);
var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []);
if (!string.IsNullOrWhiteSpace(toolPolicy))
{
@ -265,8 +281,9 @@ 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 $"""
var withDateTime = $"""
{currentDateTime}
{systemPromptText}
""";
return new(withDateTime, systemPromptTextWithChatTemplate, allowProfile, explanation);
}
/// <summary>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
using System.Globalization;
using AIStudio.Chat;
using AIStudio.Dialogs;
using AIStudio.Provider;
@ -56,7 +58,8 @@ public partial class ChatComponent : MSGComponentBase
private IDialogService DialogService { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
private ConversationTokenCounter ConversationTokenCounter { get; init; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
@ -93,11 +96,57 @@ public partial class ChatComponent : MSGComponentBase
private Guid loadedParameterWorkspaceId = Guid.Empty;
private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion;
private string tokenCount = "0";
private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath);
private string TokenCountMessage => this.HasCustomTokenizer
? $"{this.T("Estimated amount of tokens:")} {this.tokenCount}"
: string.Empty;
private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
/// <summary>
/// The culture the token numbers are written in.
/// </summary>
/// <remarks>
/// Taken from the language plugin the user chose, not from the machine. AI Studio's language is
/// a setting of its own, and a German who set German would otherwise read English separators
/// inside a German sentence -- where "1,234" means something a thousand times smaller.
/// </remarks>
private CultureInfo currentCulture = CultureInfo.InvariantCulture;
/// <summary>
/// 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 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
/// how many of them the number does not include.
/// </remarks>
private string TokenCountMessage
{
get
{
if (!this.conversationTokens.IsKnown)
return string.Empty;
var used = TokenAmount.Format(this.conversationTokens.Tokens, this.currentCulture);
var budget = this.conversationTokens.Window.IsKnown
? string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} of {1} tokens") : this.T("{0} of {1} tokens"), used, TokenAmount.Format(this.conversationTokens.Window.DefaultTokens, this.currentCulture))
: string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} tokens") : this.T("{0} tokens"), used);
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)}";
}
}
/// <summary>
/// Takes over the culture of the language the user chose for AI Studio.
/// </summary>
private async Task RefreshCulture()
{
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
}
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId);
@ -125,6 +174,7 @@ public partial class ChatComponent : MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
await this.RefreshCulture();
// Apply the filters for the message bus:
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@ -380,20 +430,21 @@ public partial class ChatComponent : MSGComponentBase
protected override async Task OnParametersSetAsync()
{
var incomingChatId = this.ChatThread?.ChatId ?? Guid.Empty;
var providerChanged = this.Provider != this.lastSeenProvider;
if (incomingChatId != this.lastSeenChatId || this.Provider != this.lastSeenProvider)
{
this.lastSeenChatId = incomingChatId;
this.lastSeenProvider = this.Provider;
if (providerChanged)
this.tokenCount = "0";
this.previousInputForbidden = true;
}
await this.ApplyLoadedChatParameterAsync();
await this.SyncForegroundChatAsync();
if (providerChanged && this.HasCustomTokenizer)
//
// Both of these change the answer, and the chat is the reason the count is no longer about
// the draft alone: opening another conversation, or loading one, changes what the next
// message would carry along with it.
//
await this.CalculateTokenCount();
await this.ConsumeMediaOutcomeAsync();
@ -603,9 +654,14 @@ public partial class ChatComponent : MSGComponentBase
private async Task ProfileWasChanged(Profile profile)
{
this.currentProfile = this.SettingsManager.GetProfileById(profile.Id);
if(this.ChatThread is null)
return;
//
// A thread which already exists has to carry the choice. Before the first message there is
// none, and the choice then travels in the thread a new chat is started with -- so the
// count below has to happen either way, which is what the early return here used to skip.
//
if (this.ChatThread is not null)
{
this.ChatThread = this.ChatThread with
{
SelectedProfile = this.currentProfile.Id,
@ -614,6 +670,10 @@ public partial class ChatComponent : MSGComponentBase
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
// A profile is a paragraph of the system prompt, so choosing another one changes the count:
await this.CalculateTokenCount();
}
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
{
this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatTemplate.Id);
@ -623,10 +683,15 @@ public partial class ChatComponent : MSGComponentBase
// Apply template's file attachments (replaces existing):
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
if(this.ChatThread is null)
return;
if (this.ChatThread is not null)
await this.StartNewChat(true);
//
// Counted in both cases. Without a thread nothing is started anew, but the template already
// decides the system prompt, the attachments and possibly an example conversation of the
// first message, and all of that costs before anything is sent.
//
await this.CalculateTokenCount();
}
private void RefreshCurrentProfileAndChatTemplate()
@ -664,6 +729,9 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange();
// The provider, the profile and the template may all have moved, and each of them counts:
await this.CalculateTokenCount();
}
private IReadOnlyList<DataSourceAgentSelected> GetAgentSelectedDataSources()
@ -757,13 +825,20 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
}
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
private async Task ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
{
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft();
this.hasUnsavedChanges = true;
//
// A document is usually the largest thing a person attaches, so this is the moment the
// number matters most. It is also the expensive one: the file is read and measured here,
// once, and remembered afterwards.
//
await this.CalculateTokenCount();
}
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
@ -774,21 +849,13 @@ public partial class ChatComponent : MSGComponentBase
this.RefreshCurrentProfileAndChatTemplate();
var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
this.ChatThread = new()
{
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions,
Name = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
: promptName,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
: promptName;
this.ChatThread = this.NewChatThread(threadName) with
{
DataSourceOptions = this.earlyDataSourceOptions,
};
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
@ -819,19 +886,9 @@ public partial class ChatComponent : MSGComponentBase
// Create a new chat thread if necessary:
if (this.ChatThread is null)
{
this.ChatThread = new()
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)) with
{
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions,
Name = this.ExtractThreadName(this.ComposerState.UserInput),
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
};
this.MarkCurrentChatAsLoadedParameter();
@ -916,7 +973,13 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.Clear();
await this.inputField.BlurAsync();
this.tokenCount = "0";
//
// The draft just became part of the conversation, so the number does not drop back: what
// was being typed a moment ago now travels with every further message.
//
await this.CalculateTokenCount();
// Enable the stream state for the chat component:
this.hasUnsavedChanges = true;
@ -962,7 +1025,7 @@ public partial class ChatComponent : MSGComponentBase
private void ApplyToolSelectionOfLoadedChat() =>
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
private Task SelectedToolIdsChanged(HashSet<string> updatedToolIds)
private async Task SelectedToolIdsChanged(HashSet<string> updatedToolIds)
{
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
@ -978,7 +1041,11 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
}
return Task.CompletedTask;
//
// Every tool the model may run describes itself in the system prompt, so picking tools costs
// tokens before a single one of them is called.
//
await this.CalculateTokenCount();
}
private async Task SaveThread()
@ -1087,19 +1154,7 @@ public partial class ChatComponent : MSGComponentBase
// reset the chat thread only. The workspace id and the workspace name remain
// the same:
//
this.ChatThread = new()
{
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
Name = string.Empty,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
};
this.ChatThread = this.NewChatThread(string.Empty);
}
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
@ -1111,6 +1166,7 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncForegroundChatAsync();
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.CalculateTokenCount();
}
private async Task MoveChatToWorkspace()
@ -1214,6 +1270,7 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.CalculateTokenCount();
}
private async Task SelectProviderWhenLoadingChat()
@ -1249,6 +1306,9 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
await this.SaveThread();
this.StateHasChanged();
// One message less in the conversation is one message less in every request from here on:
await this.CalculateTokenCount();
}
private async Task RegenerateBlock(IContent aiBlock)
@ -1266,18 +1326,18 @@ public partial class ChatComponent : MSGComponentBase
await this.SendMessage(reuseLastUserPrompt: true);
}
private Task EditLastUserBlock(IContent block)
private async Task EditLastUserBlock(IContent block)
{
if(this.ChatThread is null)
return Task.CompletedTask;
return;
if (block is not ContentText textBlock)
return Task.CompletedTask;
return;
var lastBlock = this.ChatThread.Blocks.Last();
var lastBlockContent = lastBlock.Content;
if(lastBlockContent is null)
return Task.CompletedTask;
return;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block);
@ -1285,23 +1345,27 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
//
// The message moved out of the conversation and back into the composer, attachments and
// all. It costs the same either way, but nothing says so unless it is counted again.
//
await this.CalculateTokenCount();
}
private Task EditLastBlock(IContent block)
private async Task EditLastBlock(IContent block)
{
if(this.ChatThread is null)
return Task.CompletedTask;
return;
if (block is not ContentText textBlock)
return Task.CompletedTask;
return;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block);
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
await this.CalculateTokenCount();
}
private void RestoreComposerFromTextBlock(ContentText textBlock)
@ -1309,42 +1373,92 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.RestoreFromTextBlock(textBlock);
}
/// <summary>
/// Works out what the next request would take out of the model's context window.
/// </summary>
/// <remarks>
/// The whole conversation, not only what is being typed. A number counting the draft alone
/// answers a question nobody asks: what decides whether the next message fits is everything
/// which travels with it, and in a chat of any age the draft is the smallest part of that.
///
/// This used to run only for providers with a tokenizer of their own, which is almost nobody,
/// so almost nobody ever saw a number. The runtime falls back to the tokenizer shipped with AI
/// Studio when a provider names none, so the count is available everywhere -- it is then an
/// estimate, and it says so.
///
/// Read the text from the bound property rather than from the input field: the field is a
/// component reference, which is only set once the component has rendered, while counting is
/// also triggered while parameters are set.
/// </remarks>
private async Task CalculateTokenCount()
{
if (!this.HasCustomTokenizer)
{
if (this.tokenCount != "0")
{
this.tokenCount = "0";
//
// Before the first message there is no thread yet, so what is measured is the one a new
// chat would start with. A preselected profile or a chat template is already part of that,
// and it may even bring an example conversation along -- reporting nothing for all of it
// would tell a person their window is empty while their first message already is not.
//
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
var counted = await this.ConversationTokenCounter.CountAsync(this.Provider, thread, this.BuildSystemPromptFor(thread), this.UserInput, this.ComposerState.FileAttachments);
if (counted == this.conversationTokens)
return;
this.conversationTokens = counted;
this.StateHasChanged();
}
return;
/// <summary>
/// Works out the system prompt a thread would send.
/// </summary>
/// <remarks>
/// Not the prompt a person typed: a chat template may replace it, the retrieved data of a data
/// source is appended to it, the selected profile adds a paragraph, and the policy of the
/// selected tools adds another. Switching a profile while writing therefore moves the number,
/// which is the whole reason this is asked rather than read off the thread.
///
/// The tools are filtered for the provider the same way they are before sending, so that a tool
/// the provider is not trusted enough to receive does not count either.
/// </remarks>
/// <param name="thread">The thread to build the prompt for.</param>
/// <returns>The system prompt as it would be sent.</returns>
private string BuildSystemPromptFor(ChatThread thread)
{
var toolDefinitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
.ToList();
return thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
}
//
// Read the text from the bound property rather than from the input field: the field is a
// component reference, which is only set once the component has rendered. Counting is also
// triggered while parameters are set, which happens before that.
//
var currentInput = this.UserInput;
if (string.IsNullOrEmpty(currentInput))
/// <summary>
/// The thread a new chat starts with, as the selections made so far decide it.
/// </summary>
/// <remarks>
/// In one place because three code paths used to write it out, and because the token count has
/// to measure the same thing they build. A count against a thread assembled differently from
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before
/// they send their first message.
///
/// The data source options are left out on purpose: two of the three callers set them and the
/// third replaces them right afterwards, so this stays the part they agree on.
/// </remarks>
/// <param name="name">The name of the thread.</param>
/// <returns>The new thread.</returns>
private ChatThread NewChatThread(string name) => new()
{
this.tokenCount = "0";
return;
}
var response = await this.RustService.GetTokenCount(this.Provider, currentInput);
if (response is null)
return;
if (!response.Value.Success)
{
this.Logger.LogWarning("Failed to calculate token count: reason='{Reason}'", response.Value.Message);
return;
}
this.tokenCount = response.Value.TokenCount.ToString();
this.StateHasChanged();
}
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
Name = name,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
};
#region Overrides of MSGComponentBase
@ -1363,6 +1477,9 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
if(this.autoSaveEnabled)
await this.SaveThread();
// The answer just became part of what every further message carries:
await this.CalculateTokenCount();
break;
case Event.WORKSPACE_RENAMED:
@ -1372,6 +1489,7 @@ public partial class ChatComponent : MSGComponentBase
case Event.CONFIGURATION_CHANGED:
case Event.PLUGINS_RELOADED:
await this.RefreshCulture();
await this.RefreshChatSelectionsAfterConfigurationChange();
this.StateHasChanged();
break;

View File

@ -3573,6 +3573,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Geben Sie
-- Your Prompt (use selected instance '{0}', provider '{1}')
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1967611328"] = "Ihr Prompt (verwendete Instanz: '{0}', Anbieter: '{1}')"
-- approx. {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "ca. {0} von {1} Token"
-- Code
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code"
@ -3597,15 +3600,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2991985411"] = "Diesen Ch
-- Move Chat to Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Chat in den Arbeitsbereich verschieben"
-- {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} Token"
-- plus {0} image(s), which cannot be counted
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "zuzüglich {0} Bild(er), die nicht gezählt werden können"
-- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Wähle zuerst einen Anbieter aus"
-- Estimated amount of tokens:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T377990776"] = "Geschätzte Anzahl an Token:"
-- Start new chat in workspace "{0}"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Neuen Chat im Arbeitsbereich '{0}' starten"
-- {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3996190985"] = "{0} von {1} Tokens"
-- New disappearing chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Neuen selbstlöschenden Chat starten"
@ -3621,6 +3630,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Verschiebe
-- Show your workspaces
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T733672375"] = "Ihre Arbeitsbereiche anzeigen"
-- approx. {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T857715435"] = "ca. {0} Token"
-- Create template from current chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1112722156"] = "Vorlage aus aktuellem Chat erstellen"
@ -11424,6 +11436,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4112586014"] =
-- The field LANG_NAME does not exist or is not a valid string.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "Das Feld LANG_NAME existiert nicht oder ist keine gültige Zeichenkette."
-- The table MODELS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINMODELS::T976664425"] = "Die Tabelle MODELS existiert nicht oder verwendet eine ungültige Syntax."
-- Artists
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Künstler"
@ -11466,6 +11481,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62
-- Software developers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Softwareentwickler"
-- Model plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1507522553"] = "Modell-Plugin"
-- Theme plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme-Plugin"

View File

@ -3573,6 +3573,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Type your
-- Your Prompt (use selected instance '{0}', provider '{1}')
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1967611328"] = "Your Prompt (use selected instance '{0}', provider '{1}')"
-- approx. {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "approx. {0} of {1} tokens"
-- Code
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code"
@ -3597,15 +3600,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2991985411"] = "Delete th
-- Move Chat to Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat to Workspace"
-- {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} tokens"
-- plus {0} image(s), which cannot be counted
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0} image(s), which cannot be counted"
-- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
-- Estimated amount of tokens:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T377990776"] = "Estimated amount of tokens:"
-- Start new chat in workspace "{0}"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace \"{0}\""
-- {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3996190985"] = "{0} of {1} tokens"
-- New disappearing chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "New disappearing chat"
@ -3621,6 +3630,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Move the c
-- Show your workspaces
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T733672375"] = "Show your workspaces"
-- approx. {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T857715435"] = "approx. {0} tokens"
-- Create template from current chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1112722156"] = "Create template from current chat"
@ -11424,6 +11436,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4112586014"] =
-- The field LANG_NAME does not exist or is not a valid string.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "The field LANG_NAME does not exist or is not a valid string."
-- The table MODELS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINMODELS::T976664425"] = "The table MODELS does not exist or is using an invalid syntax."
-- Artists
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists"
@ -11466,6 +11481,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62
-- Software developers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Software developers"
-- Model plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1507522553"] = "Model plugin"
-- Theme plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin"

View File

@ -189,6 +189,7 @@ internal sealed class Program
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
builder.Services.AddSingleton<GlobalShortcutService>();
builder.Services.AddSingleton<MediaTranscriptionService>();
builder.Services.AddSingleton<ConversationTokenCounter>();
builder.Services.AddSingleton<VisualBriefingArtifactService>();
builder.Services.AddSingleton<VisualBriefingStore>();
builder.Services.AddSingleton<VisualBriefingBuildProgressService>();

View File

@ -31,7 +31,13 @@ public sealed partial class RustService
/// already gone.
/// </param>
/// <returns>The result of reading the file.</returns>
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false, CancellationToken token = default)
/// <param name="reportPromptInjections">
/// Whether to tell the user about passages which were filtered out of the file. Pass false only
/// where the content is measured and thrown away again, such as counting the tokens of an
/// attachment: nothing leaves the app on that path, so there is nothing to warn about, and
/// reporting it there would warn a second time when the file is actually sent.
/// </param>
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false, bool reportPromptInjections = true, CancellationToken token = default)
{
//
// The runtime filters prompt injections while it streams the file. Doing it there rather
@ -238,8 +244,11 @@ public sealed partial class RustService
//
// Reported from here rather than from the callers: every way of reading a file passes
// through this method, so this is the one place where no caller can forget it.
// through this method, so this is the one place where no caller can forget it. The
// filtering itself has already happened either way -- only the telling is skipped, and only
// where the content never leaves the app.
//
if (reportPromptInjections)
await guardService.ReportAsync(new(PromptInjectionSource.FileContent(path), promptInjectionFindings, promptInjectionRedactedCount));
//

View File

@ -0,0 +1,194 @@
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks what a conversation is counted as costing before it is sent.
/// </summary>
/// <remarks>
/// The number under the input field used to count the sentence being typed and nothing else, which
/// answers a question nobody asks: what decides whether the next message fits is everything that
/// travels with it. So what is collected here has to be what the message builder actually sends --
/// no more, because a number which counts something that stays behind is wrong in the direction
/// that makes a person stop writing.
/// </remarks>
[TestFixture]
public sealed class ConversationPartsTests
{
private string directory = string.Empty;
[SetUp]
public void CreateFiles()
{
this.directory = Path.Combine(Path.GetTempPath(), $"ai-studio-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.directory);
}
[TearDown]
public void RemoveFiles()
{
if (Directory.Exists(this.directory))
Directory.Delete(this.directory, true);
}
[Test]
public void TheWholeConversationCountsAndNotOnlyWhatIsBeingTyped()
{
var thread = new ChatThread
{
SystemPrompt = "You are helpful.",
Blocks =
[
Block("What is the capital of France?"),
Block("Paris."),
],
};
var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true);
Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris.", "And of Italy?" }));
}
[Test]
public void TheSystemPromptCountedIsTheOneWhichWouldBeSent()
{
//
// Not the one standing in the thread. A chat template may replace it, retrieved data is
// appended to it, a profile adds a paragraph and the tool policy adds another -- and
// switching a profile while writing has to move the number, which it cannot do if the
// thread's own field is what gets counted.
//
var thread = new ChatThread { SystemPrompt = "What the person typed." };
var parts = ConversationParts.Of(thread, "What the request carries.", string.Empty, null, imagesAreSent: true);
Assert.That(parts.Texts, Is.EqualTo(new[] { "What the request carries." }));
}
[Test]
public void ABlockHiddenFromTheUserStillCosts()
{
//
// Hidden on the screen, not in the request: the message builder sends it like any other
// block, so its tokens are gone whether or not anybody can see where they went.
//
var hidden = Block("An instruction the user does not see.");
var thread = new ChatThread { Blocks = [new() { ContentType = hidden.ContentType, Role = hidden.Role, Content = hidden.Content, HideFromUser = true }] };
var parts = ConversationParts.Of(thread, string.Empty, string.Empty, null, imagesAreSent: true);
Assert.That(parts.Texts, Is.EqualTo(new[] { "An instruction the user does not see." }));
}
[Test]
public void WithoutAConversationOnlyTheDraftCounts()
{
var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true);
Assert.That(parts.Texts, Is.EqualTo(new[] { "Hello" }));
}
[TestCase("")]
[TestCase(" ")]
public void NothingWrittenIsNothingToCount(string draft)
{
var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true);
Assert.That(parts.Texts, Is.Empty);
}
[Test]
public void ABlockWithoutTextIsSkippedBecauseItIsNeverSent()
{
//
// The message builder drops a block whose text is empty, whatever else hangs off it. A
// count which added that block's attachments would report tokens for a message which is
// never built.
//
var document = this.WriteFile("notes.txt", "some content");
var empty = Block(string.Empty);
((ContentText)empty.Content!).FileAttachments.Add(FileAttachment.FromPath(document));
var parts = ConversationParts.Of(new() { Blocks = [empty] }, string.Empty, string.Empty, null, imagesAreSent: true);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty);
Assert.That(parts.Documents, Is.Empty);
});
}
[Test]
public void AttachmentsOfTheConversationAndOfTheComposerBothCount()
{
//
// A document attached three messages ago is sent again with every further message, so it
// costs its tokens again every time. That is exactly the thing a person cannot see and
// which this number is for.
//
var older = this.WriteFile("older.txt", "older content");
var draft = this.WriteFile("draft.txt", "draft content");
var block = Block("Please read this.");
((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(older));
var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true);
Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" }));
}
[Test]
public void AnAttachmentWhoseFileIsGoneCountsForNothing()
{
//
// It is not sent either: the message builder reports it as unavailable and leaves it out.
//
var attachment = FileAttachment.FromPath(Path.Combine(this.directory, "never-existed.txt"));
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true);
Assert.That(parts.Documents, Is.Empty);
}
[Test]
public void ImagesAreCountedSeparatelyFromDocuments()
{
var document = this.WriteFile("notes.txt", "content");
var image = this.WriteFile("photo.png", "not really a png");
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(document), FileAttachment.FromPath(image)], imagesAreSent: true);
Assert.Multiple(() =>
{
Assert.That(parts.Documents.Select(entry => entry.FileName), Is.EqualTo(new[] { "notes.txt" }));
Assert.That(parts.Images, Is.EqualTo(1));
});
}
[Test]
public void AModelWhichTakesNoImagesIsSentNoneAndIsToldAboutNone()
{
//
// The message builder leaves the pictures out entirely for such a model, so reporting them
// as uncounted would tell a person about a cost which is not there.
//
var image = this.WriteFile("photo.png", "not really a png");
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false);
Assert.That(parts.Images, Is.Zero);
}
private static ContentBlock Block(string text) => new()
{
ContentType = ContentType.TEXT,
Role = ChatRole.USER,
Content = new ContentText { Text = text },
};
private string WriteFile(string name, string content)
{
var path = Path.Combine(this.directory, name);
File.WriteAllText(path, content);
return path;
}
}

View File

@ -0,0 +1,54 @@
using System.Globalization;
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks how a number of tokens is written under the input field.
/// </summary>
/// <remarks>
/// The culture is an argument rather than something taken from the machine, and that is the point
/// being checked as much as the digits are: AI Studio's language is chosen in its own settings, so
/// the thread's culture says nothing about which separators a person expects to read.
/// </remarks>
[TestFixture]
public sealed class TokenAmountTests
{
private static readonly CultureInfo AMERICAN = CultureInfo.GetCultureInfo("en-US");
private static readonly CultureInfo GERMAN = CultureInfo.GetCultureInfo("de-DE");
[TestCase(0, "0")]
[TestCase(7, "7")]
[TestCase(847, "847")]
[TestCase(999, "999", Description = "The last number written out in full.")]
[TestCase(1_000, "1.00k")]
[TestCase(1_234, "1.23k")]
[TestCase(12_347, "12.35k")]
[TestCase(128_000, "128.00k")]
[TestCase(400_000, "400.00k")]
[TestCase(999_499, "999.50k")]
[TestCase(999_999, "1.00M", Description = "Rounded before the unit is chosen, so it does not read as 1,000.00k.")]
[TestCase(1_000_000, "1.00M")]
[TestCase(1_048_576, "1.05M")]
[TestCase(1_050_000, "1.05M", Description = "Which is how OpenAI writes it themselves.")]
[TestCase(2_000_000, "2.00M")]
public void ANumberOfTokensIsWrittenTheWayItIsRead(int tokens, string wanted)
{
Assert.That(TokenAmount.Format(tokens, AMERICAN), Is.EqualTo(wanted));
}
[TestCase(999, "999")]
[TestCase(1_234, "1,23k")]
[TestCase(400_000, "400,00k")]
[TestCase(1_048_576, "1,05M")]
public void TheSeparatorsAreTheOnesTheUserKnows(int tokens, string wanted)
{
//
// A German reads 1,23k where an American reads 1.23k. Writing either of them the other way
// around reads as a number a thousand times off.
//
Assert.That(TokenAmount.Format(tokens, GERMAN), Is.EqualTo(wanted));
}
}

View File

@ -1,4 +1,3 @@
using AIStudio.Models;
using AIStudio.Models.Registry;
using AIStudio.Provider;
using AIStudio.Settings;