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)
{