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-erler 2026-09-20 17:42:29 +02:00
parent c95cc5bacc
commit 0aa3b23b81
21 changed files with 598 additions and 7 deletions

View File

@ -3556,6 +3556,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Add fil
-- Changelog
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Changelog"
-- {0}, plus approx. {1} for your message
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus approx. {1} for your message"
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Move chat"

View File

@ -52,6 +52,44 @@ public sealed class ContentText : IContent
public List<ToolInvocationTrace> ToolInvocations { get; set; } = [];
/// <summary>
/// What the provider said everything sent along with this answer cost, where it said anything.
/// </summary>
/// <remarks>
/// Kept on the answer rather than beside the chat, so that it is stored, loaded, and exported
/// with the message it belongs to -- and so that it goes away when the message does. An edited
/// or regenerated answer removes its block, which removes these numbers with it, and the chat
/// falls back to the estimate instead of carrying a figure for a conversation which no longer
/// exists. Null for every answer written before this was recorded, and at every provider which
/// reports nothing.
///
/// Two plain numbers rather than a <see cref="TokenUsage"/>: that type only ever comes out of
/// its own factory, which is what keeps an impossible usage from existing, and a stored field
/// has to be readable back by the serializer.
/// </remarks>
public int? ReportedPromptTokens { get; set; }
/// <inheritdoc cref="ReportedPromptTokens"/>
public int? ReportedCompletionTokens { get; set; }
/// <summary>
/// Which model the numbers above were charged for.
/// </summary>
/// <remarks>
/// A token count belongs to the tokenizer which produced it. Switch the model of a chat, and
/// the same conversation is worth a different number of tokens -- so the reported one stops
/// being an answer about the request which is about to be sent, and the estimate, wrong as it
/// is, is at least wrong about the right model.
/// </remarks>
public string? ReportedForModel { get; set; }
/// <summary>
/// What the provider said this exchange cost, which is what the next request carries as its
/// history.
/// </summary>
[JsonIgnore]
public TokenUsage ReportedTokens => TokenUsage.OfReported(this.ReportedPromptTokens, this.ReportedCompletionTokens);
[JsonIgnore]
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
@ -187,6 +225,18 @@ public sealed class ContentText : IContent
// Merge the sources:
this.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 the provider reports it
// at all -- so the previous value is kept rather than cleared:
//
if (contentStreamChunk.Usage.IsKnown)
{
this.ReportedPromptTokens = contentStreamChunk.Usage.PromptTokens;
this.ReportedCompletionTokens = contentStreamChunk.Usage.CompletionTokens;
this.ReportedForModel = chatModel.Id;
}
// Notify the UI that the content has changed,
// depending on the energy saving mode:
var now = DateTimeOffset.Now;

View File

@ -57,6 +57,21 @@ public sealed record ConversationParts
/// </summary>
public int Images { get; init; }
/// <summary>
/// Which of the texts above are the message being written right now.
/// </summary>
/// <remarks>
/// A marker, not a further part: everything named here also stands in <see cref="GrowingTexts"/>,
/// and 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>
public IReadOnlyList<string> DraftTexts { get; init; } = [];
/// <inheritdoc cref="DraftTexts"/>
public IReadOnlyList<FileAttachment> DraftDocuments { get; init; } = [];
/// <summary>
/// Collects what a conversation would send.
/// </summary>
@ -131,18 +146,36 @@ public sealed record ConversationParts
}
}
var draftTexts = new List<string>();
var draftDocuments = new List<FileAttachment>();
if (!string.IsNullOrWhiteSpace(draft))
{
growing.Add(draft);
draftTexts.Add(draft);
}
if (draftAttachments is not null)
{
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()
{
Texts = texts,
GrowingTexts = growing,
Documents = documents,
Images = imagesAreSent ? images : 0,
DraftTexts = draftTexts,
DraftDocuments = draftDocuments,
};
}

View File

@ -45,6 +45,27 @@ public readonly record struct ConversationTokens
/// </remarks>
public bool IsEstimate { get; init; }
/// <summary>
/// How much of <see cref="Tokens"/> is the message being written right now.
/// </summary>
/// <remarks>
/// 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
/// nobody has sent yet can only be estimated. What the conversation costs without it is this
/// subtracted from <see cref="Tokens"/>.
/// </remarks>
public int DraftTokens { get; init; }
/// <summary>
/// Whether the conversation so far was counted by the provider rather than by this app.
/// </summary>
/// <remarks>
/// True once a provider has stated what a request of this conversation cost, which makes
/// everything but the draft an exact number. It says nothing about the draft, which stays an
/// estimate either way -- nobody has charged for that one yet.
/// </remarks>
public bool HistoryIsReported { get; init; }
/// <summary>
/// How much the model reads, where anybody has stated it.
/// </summary>

View File

@ -173,10 +173,21 @@ public partial class ChatComponent : MSGComponentBase
if (!this.conversationTokens.IsKnown)
return string.Empty;
var used = TokenAmount.Format(this.conversationTokens.Tokens, this.currentCulture);
//
// Three 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
// wrote down about the model; what is being written has never been sent and can only
// ever be estimated. So the conversation and the draft 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 historyIsExact = this.conversationTokens.HistoryIsReported || !this.conversationTokens.IsEstimate;
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);
? 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);
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));
if (this.conversationTokens.UncountedImages is 0)
return budget;
@ -1460,6 +1471,7 @@ public partial class ChatComponent : MSGComponentBase
{
var provider = AIStudio.Settings.Provider.NONE;
var parts = ConversationParts.NOTHING;
var reported = TokenUsage.UNKNOWN;
//
// Collected on the render thread, counted off it. Counting may take an IPC call per text,
@ -1478,9 +1490,10 @@ public partial class ChatComponent : MSGComponentBase
var toolDefinitions = this.GetRunnableToolDefinitions();
provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
reported = LastReportedTokensOf(thread, provider.Model);
});
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, reported, token);
if (token.IsCancellationRequested)
return;
@ -1494,6 +1507,45 @@ public partial class ChatComponent : MSGComponentBase
});
}
/// <summary>
/// Finds what a provider last said a request of this conversation cost.
/// </summary>
/// <remarks>
/// The last answer which carries such a number decides, and nothing else has to be remembered
/// for it: the number lives on the answer, so editing, regenerating, or deleting a message
/// takes it along and an earlier answer -- or none at all -- becomes the one which counts.
///
/// An answer still being written is passed over. Its number arrives with the last line of the
/// stream, and until then it states what the request before it cost, which is a conversation
/// shorter than the one on the screen.
/// </remarks>
/// <param name="thread">The conversation to look through.</param>
/// <param name="model">The model the next request would go to.</param>
/// <returns>What the provider reported, or unknown when none of them did.</returns>
private static TokenUsage LastReportedTokensOf(ChatThread thread, Model model)
{
for (var index = thread.Blocks.Count - 1; index >= 0; index--)
{
if (thread.Blocks[index].Content is not ContentText { IsStreaming: false } text)
continue;
if (!text.ReportedTokens.IsKnown)
continue;
//
// A number charged for another model says nothing about this one: another model counts
// the same conversation with another tokenizer. Reading on would only find older
// answers of that same other model, so the search ends here and the estimate takes
// over until this model has answered once.
//
return string.Equals(text.ReportedForModel, model.Id, StringComparison.Ordinal)
? text.ReportedTokens
: TokenUsage.UNKNOWN;
}
return TokenUsage.UNKNOWN;
}
/// <summary>
/// Works out the system prompt a thread would send.
/// </summary>

View File

@ -3558,6 +3558,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Datei h
-- Changelog
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Änderungsprotokoll"
-- {0}, plus approx. {1} for your message
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus ca. {1} für Ihre Nachricht"
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Chat verschieben"

View File

@ -3558,6 +3558,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Add fil
-- Changelog
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Changelog"
-- {0}, plus approx. {1} for your message
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus approx. {1} for your message"
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Move chat"

View File

@ -1037,6 +1037,20 @@ public abstract class BaseProvider : IProvider, ISecretId
continue;
}
//
// The line stating what the request cost carries no content of its own: providers
// send it as the last line of the stream, with no choices at all. It is handled
// before the check below, which would otherwise drop it as an empty response.
//
if (providerResponse.ContainsUsage())
{
yield return providerResponse.ContainsContent()
? providerResponse.GetContent() with { Usage = providerResponse.GetUsage() }
: new(string.Empty, [], providerResponse.GetUsage());
continue;
}
// Skip empty responses:
if (!providerResponse.ContainsContent())
continue;

View File

@ -3,9 +3,15 @@ namespace AIStudio.Provider;
/// <summary>
/// A chunk of content from a content stream, along with its associated sources.
/// </summary>
/// <remarks>
/// The usage rides along on the chunk rather than being reported next to the stream, because a
/// provider states it as one more line of that same stream. It is unknown on every chunk but the
/// one which carries it, and unknown on all of them at the providers which report nothing.
/// </remarks>
/// <param name="Content">The text content of the chunk.</param>
/// <param name="Sources">The list of sources associated with the chunk.</param>
public sealed record ContentStreamChunk(string Content, IList<ISource> Sources)
/// <param name="Usage">What the provider said the request cost, where it said anything.</param>
public sealed record ContentStreamChunk(string Content, IList<ISource> Sources, TokenUsage Usage = default)
{
/// <summary>
/// Implicit conversion to string.

View File

@ -1,3 +1,5 @@
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Fireworks;
/// <summary>
@ -16,6 +18,22 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
/// <inheritdoc />
public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []);
/// <summary>
/// What Fireworks says the request cost, on the one line which carries it.
/// </summary>
/// <remarks>
/// The same block the OpenAI chat completion API sends, because this is that wire format.
/// Not a positional parameter: the struct is built from JSON, and a further parameter would
/// only be a value nobody passes.
/// </remarks>
public ChatCompletionUsage? Usage { get; init; }
/// <inheritdoc />
public bool ContainsUsage() => this.GetUsage().IsKnown;
/// <inheritdoc />
public TokenUsage GetUsage() => this.Usage is null ? TokenUsage.UNKNOWN : TokenUsage.OfReported(this.Usage.PromptTokens, this.Usage.CompletionTokens);
#region Implementation of IAnnotationStreamLine
//

View File

@ -16,4 +16,20 @@ public interface IResponseStreamLine : IAnnotationStreamLine
/// </summary>
/// <returns>The content of the response line.</returns>
public ContentStreamChunk GetContent();
/// <summary>
/// Checks whether the response line states what the request cost.
/// </summary>
/// <remarks>
/// Answered here for every wire format which says nothing about it, which is most of them: a
/// provider who reports no usage is the normal case, not a gap somebody has to fill in.
/// </remarks>
/// <returns>True when the response line carries a token usage, false otherwise.</returns>
public bool ContainsUsage() => false;
/// <summary>
/// Gets what the provider said the request cost.
/// </summary>
/// <returns>The usage, or <see cref="TokenUsage.UNKNOWN"/> when the line carries none.</returns>
public TokenUsage GetUsage() => TokenUsage.UNKNOWN;
}

View File

@ -23,6 +23,17 @@ public record ChatCompletionAPIRequest(
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ParallelToolCalls { get; init; }
/// <summary>
/// Asks a streamed request to end with what it cost.
/// </summary>
/// <remarks>
/// Derived rather than set, so that every provider which builds one of these asks for it
/// without having to know that it exists. A request which is not streamed carries no such
/// line, and then the block would only be a field the provider has to ignore.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChatCompletionStreamOptions? StreamOptions => this.Stream ? ChatCompletionStreamOptions.INCLUDE_USAGE : null;
// Attention: The "required" modifier is not supported for [JsonExtensionData].
[JsonExtensionData]

View File

@ -15,12 +15,30 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat
{
}
/// <summary>
/// What the provider says the request cost, on the one line which carries it.
/// </summary>
/// <remarks>
/// Not a positional parameter: every provider builds an empty line through the constructor
/// above, and a further parameter would change all of those call sites for a value none of
/// them has. Providers send this block only when the request asked for it, and then on a final
/// line of its own which carries no choices -- which is why the usage is read apart from the
/// content rather than next to it.
/// </remarks>
public ChatCompletionUsage? Usage { get; init; }
/// <inheritdoc />
public bool ContainsContent() => this.Choices.Count > 0;
/// <inheritdoc />
public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, []);
/// <inheritdoc />
public bool ContainsUsage() => this.GetUsage().IsKnown;
/// <inheritdoc />
public TokenUsage GetUsage() => this.Usage is null ? TokenUsage.UNKNOWN : TokenUsage.OfReported(this.Usage.PromptTokens, this.Usage.CompletionTokens);
#region Implementation of IAnnotationStreamLine
//

View File

@ -0,0 +1,18 @@
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// What a streamed chat completion should report beyond its content.
/// </summary>
/// <remarks>
/// An OpenAI-compatible provider says nothing about what a streamed request cost unless it is asked
/// to. Without this block, the stream simply ends and the only token number anybody ever sees is
/// the one AI Studio estimated for itself.
/// </remarks>
/// <param name="IncludeUsage">Whether the stream should end with a line stating the token usage.</param>
public sealed record ChatCompletionStreamOptions(bool IncludeUsage)
{
/// <summary>
/// Asks for the usage line.
/// </summary>
public static readonly ChatCompletionStreamOptions INCLUDE_USAGE = new(true);
}

View File

@ -0,0 +1,30 @@
// ReSharper disable ClassNeverInstantiated.Global
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// What an OpenAI-compatible provider reports a chat completion cost.
/// </summary>
/// <remarks>
/// Every number is optional because this is somebody else's JSON: the block arrives only when the
/// request asked for it, and the providers which follow the shape loosely leave fields out. Reading
/// it is one thing, believing it another -- <see cref="TokenUsage.OfReported"/> decides that.
/// </remarks>
public sealed record ChatCompletionUsage
{
/// <summary>
/// What everything sent to the model cost.
/// </summary>
public int? PromptTokens { get; init; }
/// <summary>
/// What the model wrote in answer.
/// </summary>
public int? CompletionTokens { get; init; }
/// <summary>
/// What the provider says both of them add up to. Read but not relied upon: it is the sum of
/// the other two wherever a provider fills all three, and this way a provider which sends only
/// this one is not a reason to throw the other numbers away.
/// </summary>
public int? TotalTokens { get; init; }
}

View File

@ -1,3 +1,5 @@
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Perplexity;
/// <summary>
@ -16,6 +18,22 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
/// <inheritdoc />
public ContentStreamChunk GetContent() => new(this.Choices[0].Delta.Content, this.GetSources());
/// <summary>
/// What Perplexity says the request cost, on the one line which carries it.
/// </summary>
/// <remarks>
/// The same block the OpenAI chat completion API sends, because this is that wire format.
/// Not a positional parameter: the struct is built from JSON, and a further parameter would
/// only be a value nobody passes.
/// </remarks>
public ChatCompletionUsage? Usage { get; init; }
/// <inheritdoc />
public bool ContainsUsage() => this.GetUsage().IsKnown;
/// <inheritdoc />
public TokenUsage GetUsage() => this.Usage is null ? TokenUsage.UNKNOWN : TokenUsage.OfReported(this.Usage.PromptTokens, this.Usage.CompletionTokens);
/// <inheritdoc />
public bool ContainsSources() => this != default && this.SearchResults.Count > 0;

View File

@ -0,0 +1,81 @@
namespace AIStudio.Provider;
/// <summary>
/// What a provider said one request actually cost, in tokens.
/// </summary>
/// <remarks>
/// The counterpart to what the app counts for itself: the app estimates what the next request will
/// cost, while this is what the provider charged for the last one. Two different statements, and
/// this one is the only exact one of the two.
///
/// Nothing here says "unknown" with a zero. The default value of this type is unknown, which is the
/// right answer for every provider which reports nothing, and a counted request can never cost zero
/// prompt tokens because the factory below refuses to build one.
/// </remarks>
public readonly record struct TokenUsage
{
/// <summary>
/// The usage of a request nobody reported anything about.
/// </summary>
public static readonly TokenUsage UNKNOWN = new();
/// <summary>
/// Whether a provider reported anything at all. When false, the numbers are meaningless.
/// </summary>
public bool IsKnown { get; private init; }
/// <summary>
/// What everything sent to the model cost: the conversation so far, its attachments, the system
/// prompt, and whatever tools were offered.
/// </summary>
public int PromptTokens { get; private init; }
/// <summary>
/// What the model wrote in answer.
/// </summary>
public int CompletionTokens { get; private init; }
/// <summary>
/// What the whole exchange cost, which is what the next request carries as its history.
/// </summary>
public int TotalTokens => this.PromptTokens + this.CompletionTokens;
/// <summary>
/// States what a provider reported.
/// </summary>
/// <remarks>
/// A completion of zero tokens is a real answer: a model which was cut off before writing
/// anything still cost its prompt. A prompt of zero is not, because there is no request
/// without one, and a provider sending it means we read the wrong field.
/// </remarks>
/// <param name="promptTokens">What the request carried. Has to be greater than zero.</param>
/// <param name="completionTokens">What the answer cost. Zero or more.</param>
/// <returns>The usage.</returns>
public static TokenUsage Of(int promptTokens, int completionTokens)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(promptTokens);
ArgumentOutOfRangeException.ThrowIfNegative(completionTokens);
return new()
{
IsKnown = true,
PromptTokens = promptTokens,
CompletionTokens = completionTokens,
};
}
/// <summary>
/// States what a provider reported, or unknown when it reported nothing usable.
/// </summary>
/// <remarks>
/// For the reading side, where the numbers come out of somebody else's JSON: a missing field, a
/// null, or a zero prompt all mean the same thing there, and none of them is worth an exception.
/// </remarks>
/// <param name="promptTokens">What the request carried, as the provider stated it.</param>
/// <param name="completionTokens">What the answer cost, as the provider stated it.</param>
/// <returns>The usage, or <see cref="UNKNOWN"/>.</returns>
public static TokenUsage OfReported(int? promptTokens, int? completionTokens) =>
promptTokens is > 0
? Of(promptTokens.Value, completionTokens is > 0 ? completionTokens.Value : 0)
: UNKNOWN;
}

View File

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

View File

@ -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,

View File

@ -13,6 +13,7 @@
- Added the context window to what AI Studio knows about a model, wherever its metadata states one.
- Added a live read of that context window at the providers which report it, among them Mistral, Groq, OpenRouter, and self-hosted vLLM servers. You then get the window your own server was started with, not the one the model card advertises.
- Added a token count below the message field, so you always see how much of the conversation you have used. It counts everything that travels along: your messages, the files you attached, what your data sources contributed, and the tools you offered the AI. It can be an estimate when a provider does not give AI Studio everything it needs to count exactly.
- Added the exact token count to that number, wherever your provider reports one. What the conversation has cost so far is then no longer estimated but taken from the provider which charged for it, and only the message you are still writing is estimated — shown as a number of its own, so you can tell the two apart. Editing or regenerating an answer drops the reported number along with it, and so does switching to another model, because another model counts the same conversation differently. Providers which report nothing keep the estimate exactly as before, and so do Anthropic and OpenAI's Responses API for the time being.
- Added a warning when your conversation holds more images than the model accepts, wherever we know that limit. The Visual Briefing assistant stops before anything is uploaded, instead of letting the provider refuse it afterward.
- Added the context window and the image limits to the expert provider settings, next to the abilities you could already state there. Leave a field empty, and AI Studio keeps its own answer, which you see as the placeholder. IT departments can state the same numbers for the providers they roll out.
- Added model plugins, so IT departments can describe the models their organization runs itself.

View File

@ -0,0 +1,146 @@
using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Tests.Provider;
/// <summary>
/// Checks that what a provider says a request cost is read off the stream, and asked for.
/// </summary>
/// <remarks>
/// Both halves matter and neither is visible from the other: an OpenAI-compatible provider says
/// nothing about the cost of a streamed request unless the request asks for it, and the line it
/// then sends carries no content, so the reading side has to look for it apart from the text.
/// Get either half wrong and the app silently keeps estimating, which looks exactly like a
/// provider which reports nothing.
/// </remarks>
[TestFixture]
public sealed class ChatCompletionUsageTests
{
/// <summary>
/// The last line of a streamed answer at a provider which was asked for the usage.
/// </summary>
private const string USAGE_LINE =
"""
{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":345,"total_tokens":1545}}
""";
/// <summary>
/// An ordinary line carrying a piece of the answer.
/// </summary>
private const string CONTENT_LINE =
"""
{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[{"index":0,"delta":{"content":"Hi"}}]}
""";
[Test]
public void TheFinalLineStatesWhatTheRequestCost()
{
var line = JsonSerializer.Deserialize<ChatCompletionDeltaStreamLine>(USAGE_LINE, ProviderJsonOptions.OPTIONS);
Assert.Multiple(() =>
{
Assert.That(line!.ContainsUsage(), Is.True);
Assert.That(line.GetUsage().PromptTokens, Is.EqualTo(1200));
Assert.That(line.GetUsage().CompletionTokens, Is.EqualTo(345));
Assert.That(line.GetUsage().TotalTokens, Is.EqualTo(1545));
//
// The line which carries the usage carries no answer, which is why it has to be read
// before the content check drops it:
//
Assert.That(line.ContainsContent(), Is.False);
});
}
[Test]
public void ALineOfTheAnswerStatesNoCost()
{
var line = JsonSerializer.Deserialize<ChatCompletionDeltaStreamLine>(CONTENT_LINE, ProviderJsonOptions.OPTIONS);
Assert.Multiple(() =>
{
Assert.That(line!.ContainsUsage(), Is.False);
Assert.That(line.GetUsage().IsKnown, Is.False);
Assert.That(line.ContainsContent(), Is.True);
});
}
[Test]
public void AStreamedRequestAsksForTheUsage()
{
var request = new ChatCompletionAPIRequest("gpt-5", [], true);
var json = JsonSerializer.Serialize(request, ProviderJsonOptions.OPTIONS);
Assert.That(json, Does.Contain("""
"stream_options":{"include_usage":true}
"""));
}
[Test]
public void ARequestWhichIsNotStreamedDoesNot()
{
var request = new ChatCompletionAPIRequest("gpt-5", [], false);
var json = JsonSerializer.Serialize(request, ProviderJsonOptions.OPTIONS);
Assert.That(json, Does.Not.Contain("stream_options"));
}
/// <summary>
/// A provider which sends the block but fills in nothing usable states nothing.
/// </summary>
/// <remarks>
/// Several OpenAI-compatible servers send an empty or zeroed usage block on every line while
/// streaming and the real numbers only at the end. Reading a zero as a fact would replace an
/// estimate with a statement that the conversation costs nothing.
/// </remarks>
[Test]
public void AnEmptyUsageBlockStatesNothing()
{
var line = JsonSerializer.Deserialize<ChatCompletionDeltaStreamLine>(
"""
{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[],"usage":{"prompt_tokens":0,"completion_tokens":0}}
""", ProviderJsonOptions.OPTIONS);
Assert.That(line!.ContainsUsage(), Is.False);
}
/// <summary>
/// The line a real LM Studio server sends, taken off the wire.
/// </summary>
/// <remarks>
/// It carries fields the shape above does not name -- the reasoning share of the completion,
/// among them -- and a server which sends more than we read must not stop us from reading what
/// we came for.
/// </remarks>
[Test]
public void ARealServerLineIsRead()
{
var line = JsonSerializer.Deserialize<ChatCompletionDeltaStreamLine>(
"""
{"id":"chatcmpl-xb8mn282eff3tiu46xz8t3","object":"chat.completion.chunk","created":1789917744,"model":"google/gemma-4-12b-qat","system_fingerprint":"google/gemma-4-12b-qat","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":116,"total_tokens":133,"completion_tokens_details":{"reasoning_tokens":102}}}
""", ProviderJsonOptions.OPTIONS);
Assert.Multiple(() =>
{
Assert.That(line!.ContainsUsage(), Is.True);
Assert.That(line.GetUsage().TotalTokens, Is.EqualTo(133));
});
}
/// <summary>
/// An answer cut off before it wrote anything still cost its prompt.
/// </summary>
[Test]
public void AnAnswerOfNoTokensIsStillACost()
{
var usage = TokenUsage.OfReported(900, 0);
Assert.Multiple(() =>
{
Assert.That(usage.IsKnown, Is.True);
Assert.That(usage.TotalTokens, Is.EqualTo(900));
});
}
}