Added the exact token count where the provider reports it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
j-erlerandClaude Opus 5 committed 2026-09-20 17:42:29 +02:00
1 parent c95cc5bacc
commit 0aa3b23b81
21 files changed
+598 -7

No files matched your search

@@ -456,6 +456,18 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
aiText.Text += contentStreamChunk;
aiText.Sources.MergeSources(contentStreamChunk.Sources);
//
// Keep what the provider says the request cost. It arrives on one line of the stream,
// usually the last one, and only where a provider reports it at all -- so a chunk
// without it leaves what was reported before alone.
//
if (contentStreamChunk.Usage.IsKnown)
{
aiText.ReportedPromptTokens = contentStreamChunk.Usage.PromptTokens;
aiText.ReportedCompletionTokens = contentStreamChunk.Usage.CompletionTokens;
aiText.ReportedForModel = state.ChatGenerationRequest.ProviderSettings.Model.Id;
}
if (state.Snapshot.Status is not AIJobStatus.RUNNING)
{
state.Snapshot = state.Snapshot with
@@ -68,9 +68,13 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
/// </summary>
/// <param name="provider">The configured provider, which decides both the tokenizer and the window.</param>
/// <param name="parts">What the conversation would send, collected beforehand.</param>
/// <param name="reported">
/// What a provider said the last request of this conversation cost, where one did. It replaces
/// everything the app would otherwise estimate about the conversation so far.
/// </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, ConversationParts parts, CancellationToken token = default)
public async Task<ConversationTokens> CountAsync(Provider provider, ConversationParts parts, TokenUsage reported = default, CancellationToken token = default)
{
if (provider.UsedLLMProvider is LLMProviders.NONE)
return ConversationTokens.UNAVAILABLE;
@@ -115,11 +119,44 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger<Co
this.stillGrowing = growing;
//
// What the composer adds, out of what was just counted. Every one of these was measured a
// moment ago and is still in the caches, so asking again costs a lookup each -- and asking
// separately is what keeps the two halves of the number apart.
//
var draftTokens = 0;
try
{
foreach (var text in parts.DraftTexts)
draftTokens += growing.TryGetValue(Key(provider, text), out var known) ? known : await this.CountTextAsync(provider, text, token);
foreach (var document in parts.DraftDocuments)
draftTokens += await this.CountDocumentAsync(provider, document, token);
}
catch (Exception e) when (e is not OperationCanceledException)
{
//
// The whole number stands even when its draft share does not: a conversation which
// was counted is worth showing, and a draft of zero only understates what is being
// written.
//
logger.LogWarning(e, "Could not count what the composer adds to this conversation.");
draftTokens = 0;
}
//
// Where a provider has said what this conversation cost, that number replaces everything
// but the draft. It is the only exact one of the two, and it covers the same ground: the
// system prompt, the tools, and every message which has been sent.
//
var historyIsReported = reported.IsKnown;
return new()
{
IsKnown = true,
Tokens = tokens,
Tokens = historyIsReported ? reported.TotalTokens + draftTokens : tokens,
IsEstimate = string.IsNullOrWhiteSpace(provider.TokenizerPath),
DraftTokens = draftTokens,
HistoryIsReported = historyIsReported,
Window = profile.Context,
UncountedImages = parts.Images,
ImageLimits = profile.Images,