feat: record and show the provider-reported cost of assistant messages #341

Merged
rwachters merged 6 commits from feat/assistant-message-cost-stats into master 2026-10-11 13:56:33 +00:00
rwachters commented 2026-10-11 13:55:32 +00:00 (Migrated from github.com)

Summary

Records the charge a provider reports for a generation on the assistant message that produced it, sums it per conversation branch, and shows it to the user as a per-message amount in the message usage dialog and as a branch total in the thread stats dialog. Until now the token counters were displayed while the amount actually billed was discarded.

Changes

  • Added a nullable cost to UsageStats and a nullable cost total to ThreadStats, both defaulted to null so older payloads keep decoding and a cost-free value stays absent on the wire.
  • Read the provider extension usage.cost in the Chat Completions and Responses API dialects, streaming and non-streaming, through one internal reader that treats an unusable, negative or implausibly large value as "no cost" instead of failing the generation.
  • Summed the reported costs of a branch, skipping messages that report none and keeping a sum of reported zeros as a zero.
  • Added a USD formatter that widens below a cent so a charged request is never rendered as free, and rendered a charge-only block in the message usage dialog next to the token block.
  • Added a cost section to the thread stats dialog showing the branch total or the existing not-available label.
  • Added English and Spanish labels for the message-level strings and English labels for the thread-stats strings.

Implementation notes

  • The charge is a nullable Double named cost, not the Long nano-USD amount the architecture report planned; that report's D1 section is obsolete and the report is not part of this change. Amounts are stored exactly as the provider stated them, with no rounding, rescaling or unit conversion, because only the provider knows what its own figure means.
  • No Flyway migration, no new column, no DAO, entity or collector signature change and no new client-facing stream event were needed: the charge rides the existing nullable usage_stats JSON, and it shares the fate of the UsageStats object it rides on (a truncated or abnormal ending still records nothing).
  • null means "the provider reported no charge" and 0 means "the provider reported a free request"; both the model and the UI keep them distinct, matching the existing token discipline.
  • Parsing is deliberately non-fatal: the extension is decoded as an untyped JSON element and filtered, so a provider that changes the shape of its extension cannot fail an otherwise good generation.
  • A branch total may add amounts that different providers expressed in their own currencies; the total carries no currency and this is documented rather than gated.

Acceptance criteria coverage

  • A charge reported by the provider is persisted with the assistant message and survives a storage round trip through the existing usage_stats column.
  • The charge appears per message in MessageUsageDialog, formatted as a dollar amount, and a reported zero is shown as a zero amount rather than as "not available".
  • A message whose provider reported no charge shows an explicit statement and no amount.
  • The branch total appears in ThreadStatsDialog, and a branch where no message reported a charge shows the not-available label.
  • Malformed, negative or implausibly large provider values read as "no cost" and never fail the generation.

Validation

  • ./gradlew :common:desktopTest
  • ./gradlew :server:test
  • ./gradlew :app:desktopTest
  • ./gradlew :server:classes, ./gradlew :app:desktopMainClasses, ./gradlew :app:compileKotlinWasmJs, ./gradlew :worker:testClasses
  • Manual end-to-end check against OpenRouter by the author, confirming the charge is displayed for a real generation.

Reported by the implementer: :common:desktopTest 203 tests / 0 failures, :server:test 2342 tests / 0 failures / 1 skipped, :app:desktopTest 1032 tests / 0 failures / 2 skipped. The validation runs on the final working tree; individual commits are not claimed to build or pass tests in isolation. The remediation of the review findings was not independently re-verified after it was applied.

Risks and follow-up

  • The review was closed as APPROVE WITH LOW-PRIORITY FOLLOW-UP; the findings were remediated in the same working tree, but no independent re-review of the remediation was performed.
  • Deliberate follow-ups: provider/currency gating of the displayed amount (the figure is currently assumed to be USD), model price tables or budgets that would turn a reported charge into an estimate when a provider reports none, parsing of prompt_tokens_details and completion_tokens_details, and the Spanish translation of the thread_stats_* strings, which is missing for the whole section and predates this change.
  • A branch total can mix currencies because the provider's unit is not recorded; treat it as informational until currency gating exists.
## Summary Records the charge a provider reports for a generation on the assistant message that produced it, sums it per conversation branch, and shows it to the user as a per-message amount in the message usage dialog and as a branch total in the thread stats dialog. Until now the token counters were displayed while the amount actually billed was discarded. ## Changes - Added a nullable `cost` to `UsageStats` and a nullable `cost` total to `ThreadStats`, both defaulted to `null` so older payloads keep decoding and a cost-free value stays absent on the wire. - Read the provider extension `usage.cost` in the Chat Completions and Responses API dialects, streaming and non-streaming, through one internal reader that treats an unusable, negative or implausibly large value as "no cost" instead of failing the generation. - Summed the reported costs of a branch, skipping messages that report none and keeping a sum of reported zeros as a zero. - Added a USD formatter that widens below a cent so a charged request is never rendered as free, and rendered a charge-only block in the message usage dialog next to the token block. - Added a cost section to the thread stats dialog showing the branch total or the existing not-available label. - Added English and Spanish labels for the message-level strings and English labels for the thread-stats strings. ## Implementation notes - The charge is a nullable `Double` named `cost`, not the `Long` nano-USD amount the architecture report planned; that report's D1 section is obsolete and the report is not part of this change. Amounts are stored exactly as the provider stated them, with no rounding, rescaling or unit conversion, because only the provider knows what its own figure means. - No Flyway migration, no new column, no DAO, entity or collector signature change and no new client-facing stream event were needed: the charge rides the existing nullable `usage_stats` JSON, and it shares the fate of the `UsageStats` object it rides on (a truncated or abnormal ending still records nothing). - `null` means "the provider reported no charge" and `0` means "the provider reported a free request"; both the model and the UI keep them distinct, matching the existing token discipline. - Parsing is deliberately non-fatal: the extension is decoded as an untyped JSON element and filtered, so a provider that changes the shape of its extension cannot fail an otherwise good generation. - A branch total may add amounts that different providers expressed in their own currencies; the total carries no currency and this is documented rather than gated. ## Acceptance criteria coverage - A charge reported by the provider is persisted with the assistant message and survives a storage round trip through the existing `usage_stats` column. - The charge appears per message in `MessageUsageDialog`, formatted as a dollar amount, and a reported zero is shown as a zero amount rather than as "not available". - A message whose provider reported no charge shows an explicit statement and no amount. - The branch total appears in `ThreadStatsDialog`, and a branch where no message reported a charge shows the not-available label. - Malformed, negative or implausibly large provider values read as "no cost" and never fail the generation. ## Validation - `./gradlew :common:desktopTest` - `./gradlew :server:test` - `./gradlew :app:desktopTest` - `./gradlew :server:classes`, `./gradlew :app:desktopMainClasses`, `./gradlew :app:compileKotlinWasmJs`, `./gradlew :worker:testClasses` - Manual end-to-end check against OpenRouter by the author, confirming the charge is displayed for a real generation. Reported by the implementer: `:common:desktopTest` 203 tests / 0 failures, `:server:test` 2342 tests / 0 failures / 1 skipped, `:app:desktopTest` 1032 tests / 0 failures / 2 skipped. The validation runs on the final working tree; individual commits are not claimed to build or pass tests in isolation. The remediation of the review findings was not independently re-verified after it was applied. ## Risks and follow-up - The review was closed as APPROVE WITH LOW-PRIORITY FOLLOW-UP; the findings were remediated in the same working tree, but no independent re-review of the remediation was performed. - Deliberate follow-ups: provider/currency gating of the displayed amount (the figure is currently assumed to be USD), model price tables or budgets that would turn a reported charge into an estimate when a provider reports none, parsing of `prompt_tokens_details` and `completion_tokens_details`, and the Spanish translation of the `thread_stats_*` strings, which is missing for the whole section and predates this change. - A branch total can mix currencies because the provider's unit is not recorded; treat it as informational until currency gating exists.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Torvian.eu/chatbot!341
No description provided.