mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 02:33:38 +00:00
198 lines
8.8 KiB
C#
198 lines
8.8 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
using AIStudio.Chat;
|
||
using AIStudio.Provider;
|
||
using AIStudio.Settings;
|
||
|
||
namespace AIStudio.Tools.Services;
|
||
|
||
//
|
||
// Inside the namespace on purpose. A "Provider" written here would otherwise be the namespace
|
||
// AIStudio.Provider, which every namespace below AIStudio sees before it sees a file's aliases.
|
||
//
|
||
using Provider = AIStudio.Settings.Provider;
|
||
|
||
/// <summary>
|
||
/// Counts what a conversation takes out of a model's context window.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Asked from the chat while somebody types, so what it must not do is as important as what it
|
||
/// does. Every document is read and measured once and then remembered, because extracting a
|
||
/// thousand-page PDF on each keystroke would be unusable. The conversation so far is remembered the
|
||
/// same way, so typing measures the sentence being typed rather than the whole chat again.
|
||
///
|
||
/// The numbers are estimates and are shown as such. Unless somebody configured the model's own
|
||
/// tokenizer for their provider, the built-in one does the counting, and two tokenizers disagree by
|
||
/// a few percent on prose and by more than that on code.
|
||
/// </remarks>
|
||
public sealed class ConversationTokenCounter(RustService rustService, ILogger<ConversationTokenCounter> logger)
|
||
{
|
||
/// <summary>
|
||
/// How much text goes into one counting request.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The same bound the rest of the app uses when it hands text to the tokenizer. Longer
|
||
/// conversations are counted in several pieces and added up, which costs a handful of special
|
||
/// tokens per piece -- a rounding error against a window of hundreds of thousands.
|
||
/// </remarks>
|
||
private const int CHUNK_SIZE = RustService.MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH;
|
||
|
||
private readonly ConcurrentDictionary<string, int> counted = new(StringComparer.Ordinal);
|
||
|
||
/// <summary>
|
||
/// Counts what the next request would carry.
|
||
/// </summary>
|
||
/// <param name="provider">The configured provider, which decides both the tokenizer and the window.</param>
|
||
/// <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.</param>
|
||
/// <param name="draft">What stands in the composer.</param>
|
||
/// <param name="draftAttachments">What is attached to the composer.</param>
|
||
/// <param name="token">Ends the counting when nobody needs the answer anymore.</param>
|
||
/// <returns>What the conversation costs, or that nothing could be counted.</returns>
|
||
public async Task<ConversationTokens> CountAsync(Provider provider, ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, CancellationToken token = default)
|
||
{
|
||
if (provider.UsedLLMProvider is LLMProviders.NONE)
|
||
return ConversationTokens.UNAVAILABLE;
|
||
|
||
var profile = provider.GetModelProfile();
|
||
var parts = ConversationParts.Of(thread, systemPrompt, draft, draftAttachments, provider.SupportsImageInput());
|
||
var tokens = 0;
|
||
|
||
try
|
||
{
|
||
foreach (var text in parts.Texts)
|
||
tokens += await this.CountTextAsync(provider, text, token);
|
||
|
||
foreach (var document in parts.Documents)
|
||
tokens += await this.CountDocumentAsync(provider, document, token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
return ConversationTokens.UNAVAILABLE;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
logger.LogWarning(e, "Could not count the tokens of this conversation.");
|
||
return ConversationTokens.UNAVAILABLE;
|
||
}
|
||
|
||
return new()
|
||
{
|
||
IsKnown = true,
|
||
Tokens = tokens,
|
||
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
|
||
Window = profile.Context,
|
||
UncountedImages = parts.Images,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Forgets everything counted so far.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Needed when a file changed behind our back in a way its size and time do not show, which is
|
||
/// rare enough that nothing calls this today. It exists so that the cache has a way out other
|
||
/// than restarting the app.
|
||
/// </remarks>
|
||
public void Forget() => this.counted.Clear();
|
||
|
||
/// <summary>
|
||
/// Counts one text, remembering the answer under a fingerprint of it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This is what makes typing affordable. A message which was sent an hour ago says exactly what
|
||
/// it said then, and its tokens are the same number every time -- so the whole conversation is
|
||
/// measured once and every keystroke afterwards measures the sentence being written.
|
||
///
|
||
/// Keyed by a hash rather than by the text, because the key of the cache would otherwise be a
|
||
/// second copy of the whole conversation in memory. Hashing is not free, but it is two orders of
|
||
/// magnitude cheaper than tokenizing the same bytes, so the trade pays for itself on the first
|
||
/// repeat.
|
||
/// </remarks>
|
||
private async Task<int> CountTextAsync(Provider provider, string text, CancellationToken token)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return 0;
|
||
|
||
var key = $"{provider.TokenizerPath} {Fingerprint(text)}";
|
||
if (this.counted.TryGetValue(key, out var known))
|
||
return known;
|
||
|
||
var tokens = 0;
|
||
|
||
//
|
||
// A text longer than one request is split. Cutting between characters rather than between
|
||
// words costs a token or two where the cut falls, which is the cheapest way to stay inside
|
||
// the bound without pretending to know the language.
|
||
//
|
||
for (var start = 0; start < text.Length; start += CHUNK_SIZE)
|
||
tokens += await this.AskTokenizerAsync(provider, text.Substring(start, Math.Min(CHUNK_SIZE, text.Length - start)), token);
|
||
|
||
this.counted[key] = tokens;
|
||
return tokens;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A short, stable name for a piece of text.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The length travels along with the hash. Two texts colliding on the hash and agreeing on
|
||
/// their length as well is not something which happens by accident, and nothing here is a
|
||
/// security decision: the worst a collision could do is show a number which is a few tokens off.
|
||
/// </remarks>
|
||
private static string Fingerprint(string text) => $"{text.Length}:{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)))}";
|
||
|
||
/// <summary>
|
||
/// Counts one document, reading it the first time and remembering it afterwards.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The key carries the tokenizer as well as the file: the same document counted for two
|
||
/// providers is two different numbers, and handing one of them to the other would be wrong in
|
||
/// exactly the case somebody switches providers to see whether their chat fits.
|
||
/// </remarks>
|
||
private async Task<int> CountDocumentAsync(Provider provider, FileAttachment document, CancellationToken token)
|
||
{
|
||
var file = new FileInfo(document.FilePath);
|
||
if (!file.Exists)
|
||
return 0;
|
||
|
||
var key = $"{provider.TokenizerPath} |