diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index e8e7f96b..48e54c14 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -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 /// exists. Null for every answer written before this was recorded, and at every provider which /// 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. /// - public int? ReportedPromptTokens { get; set; } - - /// - /// What the provider said the answer itself cost, where it said anything. - /// - /// - /// Stored, kept, and dropped together with ReportedPromptTokens, for the reasons given there. - /// - public int? ReportedCompletionTokens { get; set; } - - /// - /// Which model the numbers above were charged for. - /// - /// - /// 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. - /// - public string? ReportedForModel { get; set; } - - /// - /// What the provider said this exchange cost, which is what the next request carries as its - /// history. - /// - [JsonIgnore] - public TokenUsage ReportedTokens => TokenUsage.OfReported(this.ReportedPromptTokens, this.ReportedCompletionTokens); + public ReportedTokenUsage? ReportedUsage { get; set; } [JsonIgnore] public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new(); @@ -141,6 +111,29 @@ public sealed class ContentText : IContent /// public void EndToolRun() => this.PendingToolConversation = []; + /// + /// Keeps what the provider says the request behind this answer cost. + /// + /// + /// 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. + /// + /// What the current chunk of the stream says, which is mostly nothing. + /// The model the request went to. + public void RecordReportedUsage(TokenUsage usage, string modelId) + { + if (!usage.IsKnown) + return; + + this.ReportedUsage = new() + { + PromptTokens = usage.PromptTokens, + CompletionTokens = usage.CompletionTokens, + ModelId = modelId, + }; + } + /// public async Task CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default) { @@ -230,17 +223,8 @@ 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; - } + // Keep what the provider says the request cost: + this.RecordReportedUsage(contentStreamChunk.Usage, chatModel.Id); // Notify the UI that the content has changed, // depending on the energy saving mode: diff --git a/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs b/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs new file mode 100644 index 00000000..90f7c5dd --- /dev/null +++ b/app/MindWork AI Studio/Chat/ReportedTokenUsage.cs @@ -0,0 +1,45 @@ +using AIStudio.Provider; + +namespace AIStudio.Chat; + +/// +/// What a provider said the request behind one answer cost, as it is stored on that answer. +/// +/// +/// 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. +/// +public sealed record ReportedTokenUsage +{ + /// + /// What everything sent to the model cost. + /// + public int PromptTokens { get; init; } + + /// + /// What the model wrote in answer. + /// + public int CompletionTokens { get; init; } + + /// + /// Which model the numbers were charged for. + /// + /// + /// 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. + /// + public string ModelId { get; init; } = string.Empty; + + /// + /// States the stored numbers as a usage again. + /// + /// The usage, or TokenUsage.UNKNOWN when the stored numbers state nothing usable. + public TokenUsage ToTokenUsage() => TokenUsage.OfReported(this.PromptTokens, this.CompletionTokens); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index a706ab8b..05ec1864 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -1650,10 +1650,11 @@ public partial class ChatComponent : MSGComponentBase { 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; - if (!text.ReportedTokens.IsKnown) + var usage = reported.ToTokenUsage(); + if (!usage.IsKnown) 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 // over until this model has answered once. // - return string.Equals(text.ReportedForModel, model.Id, StringComparison.Ordinal) - ? text.ReportedTokens + return string.Equals(reported.ModelId, model.Id, StringComparison.Ordinal) + ? usage : TokenUsage.UNKNOWN; } diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index 49c3fab7..58b3644d 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -455,18 +455,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes aiText.IsStreaming = true; 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; - } + aiText.RecordReportedUsage(contentStreamChunk.Usage, state.ChatGenerationRequest.ProviderSettings.Model.Id); if (state.Snapshot.Status is not AIJobStatus.RUNNING) {