Kept the reported token usage as one record on the answer

This commit is contained in:
Thorsten Sommer 2026-09-23 18:31:00 +02:00
parent ceb78ff9b7
commit 4918858650
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
4 changed files with 77 additions and 58 deletions

View File

@ -62,38 +62,8 @@ public sealed class ContentText : IContent
/// falls back to the estimate instead of carrying a figure for a conversation which no longer /// 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 /// exists. Null for every answer written before this was recorded, and at every provider which
/// reports nothing. /// reports nothing.
///
/// Two plain numbers rather than a 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> /// </remarks>
public int? ReportedPromptTokens { get; set; } public ReportedTokenUsage? ReportedUsage { get; set; }
/// <summary>
/// What the provider said the answer itself cost, where it said anything.
/// </summary>
/// <remarks>
/// Stored, kept, and dropped together with ReportedPromptTokens, for the reasons given there.
/// </remarks>
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] [JsonIgnore]
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new(); public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
@ -141,6 +111,29 @@ public sealed class ContentText : IContent
/// </remarks> /// </remarks>
public void EndToolRun() => this.PendingToolConversation = []; public void EndToolRun() => this.PendingToolConversation = [];
/// <summary>
/// Keeps what the provider says the request behind this answer cost.
/// </summary>
/// <remarks>
/// The one place where a stream's usage becomes part of the answer, for every path which writes
/// one. It arrives on one line of the stream, usually the last one, and only where the provider
/// reports it at all -- so a usage which states nothing leaves what was reported before alone.
/// </remarks>
/// <param name="usage">What the current chunk of the stream says, which is mostly nothing.</param>
/// <param name="modelId">The model the request went to.</param>
public void RecordReportedUsage(TokenUsage usage, string modelId)
{
if (!usage.IsKnown)
return;
this.ReportedUsage = new()
{
PromptTokens = usage.PromptTokens,
CompletionTokens = usage.CompletionTokens,
ModelId = modelId,
};
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default) public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
{ {
@ -230,17 +223,8 @@ public sealed class ContentText : IContent
// Merge the sources: // Merge the sources:
this.Sources.MergeSources(contentStreamChunk.Sources); this.Sources.MergeSources(contentStreamChunk.Sources);
// // Keep what the provider says the request cost:
// Keep what the provider says the request cost. It arrives on one line of this.RecordReportedUsage(contentStreamChunk.Usage, chatModel.Id);
// 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, // Notify the UI that the content has changed,
// depending on the energy saving mode: // depending on the energy saving mode:

View File

@ -0,0 +1,45 @@
using AIStudio.Provider;
namespace AIStudio.Chat;
/// <summary>
/// What a provider said the request behind one answer cost, as it is stored on that answer.
/// </summary>
/// <remarks>
/// The numbers and the model they were charged for travel as one value. Each of them is meaningless
/// without the others, and loose fields on the answer could be set, cleared, or copied apart.
///
/// Plain numbers rather than a TokenUsage: that type only ever comes out of its own factory, which
/// is what keeps an impossible usage from existing, while a stored value has to be readable back by
/// the serializer. ToTokenUsage is the way back, and it treats a chat file somebody edited by hand
/// the same way the reading side treats a provider's JSON.
/// </remarks>
public sealed record ReportedTokenUsage
{
/// <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>
/// Which model the numbers 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 ModelId { get; init; } = string.Empty;
/// <summary>
/// States the stored numbers as a usage again.
/// </summary>
/// <returns>The usage, or TokenUsage.UNKNOWN when the stored numbers state nothing usable.</returns>
public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens);
}

View File

@ -1650,10 +1650,11 @@ public partial class ChatComponent : MSGComponentBase
{ {
for (var index = thread.Blocks.Count - 1; index >= 0; index--) for (var index = thread.Blocks.Count - 1; index >= 0; index--)
{ {
if (thread.Blocks[index].Content is not ContentText { IsStreaming: false } text) if (thread.Blocks[index].Content is not ContentText { IsStreaming: false, ReportedUsage: { } reported })
continue; continue;
if (!text.ReportedTokens.IsKnown) var usage = reported.ToTokenUsage();
if (!usage.IsKnown)
continue; continue;
// //
@ -1662,8 +1663,8 @@ public partial class ChatComponent : MSGComponentBase
// answers of that same other model, so the search ends here and the estimate takes // answers of that same other model, so the search ends here and the estimate takes
// over until this model has answered once. // over until this model has answered once.
// //
return string.Equals(text.ReportedForModel, model.Id, StringComparison.Ordinal) return string.Equals(reported.ModelId, model.Id, StringComparison.Ordinal)
? text.ReportedTokens ? usage
: TokenUsage.UNKNOWN; : TokenUsage.UNKNOWN;
} }

View File

@ -455,18 +455,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
aiText.IsStreaming = true; aiText.IsStreaming = true;
aiText.Text += contentStreamChunk; aiText.Text += contentStreamChunk;
aiText.Sources.MergeSources(contentStreamChunk.Sources); aiText.Sources.MergeSources(contentStreamChunk.Sources);
aiText.RecordReportedUsage(contentStreamChunk.Usage, state.ChatGenerationRequest.ProviderSettings.Model.Id);
//
// 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) if (state.Snapshot.Status is not AIJobStatus.RUNNING)
{ {