Skip to content

Add per-session cost readout for API billing - #127

Merged
omartelo merged 2 commits into
mainfrom
claude/issue-121-4qvxxd
Jul 29, 2026
Merged

omartelo merged 2 commits into
mainfrom
claude/issue-121-4qvxxd

Conversation

@omartelo

Copy link
Copy Markdown
Collaborator

Adds an optional cost readout to the footer showing what the current session has spent in USD. The feature is off by default (subscription plans don't need it) and only appears when enabled in settings and the provider's pricing is known.

Summary

This implements a complete cost-tracking pipeline from provider transcripts through to the UI:

  • Backend pricing: New internal/pricing package maintains a two-layer price table (baked Claude models + remote refresh for newer releases). Fetches from LiteLLM's public table once per unknown model per run, caches the result under the config dir.
  • Cost ledger: New internal/store/cost.go tracks per-transcript accounting (byte offset, last message ID, cumulative cost) so rescans only process appended lines.
  • Transcript scanning: New internal/terminal/usage_cost.go walks provider transcripts, sums token costs, deduplicates repeated messages, and stops cleanly when a model has no price yet (rather than guessing zero).
  • Session cost API: Extended usageEvent with optional CostUSD field; new sessionCost() method aggregates all conversations in a session.
  • Frontend display: New cost-readout-store manages the on/off setting (stored in backend, loaded once per page). New formatCost() utility renders the number with appropriate precision (cents for dollars, three decimals below). Footer displays cost alongside context usage when enabled.
  • Settings toggle: Added to Provider settings UI under Claude, wired to persist the choice.

Key implementation details

  • Money invariant: The scan stops (returns complete=false) when it hits an unpriced model, never skipping it. This prevents silent undercounting. The miss schedules a price refresh that may unblock it on the next turn.
  • Incremental scanning: Ledger stores the file offset and last message ID, so a second scan resumes from where it stopped. Critical for performance—emitUsage runs on every non-idle hook.
  • Deduplication: Providers may write the same assistant message multiple times (cumulative updates). The ledger remembers the last billed message ID to skip repeats, even across scan boundaries.
  • Sidechain billing: Unlike the context gauge (which excludes Task sub-agent sidechains), cost includes them—they're billed to the same account.
  • Synthetic lines: Lines with model <synthetic> (provider error entries) are skipped without blocking the count.
  • Dated model IDs: Strips date suffixes (-20251101) to resolve to the undated family entry, so a model released after this build still prices correctly once the remote table is fetched.

Testing

  • internal/pricing/pricing_test.go: Baked table, remote refresh, caching, deduplication, zero-priced entries.
  • internal/terminal/usage_cost_test.go: Transcript scanning, resumption, partial lines, message dedup across boundaries, unpriced models, synthetic lines, sidechain billing.
  • internal/store/cost_test.go: Ledger round-trip, session aggregation, session deletion, race conditions.
  • frontend/src/lib/cost-readout-store.test.ts: Store load, toggle persistence, error handling.
  • frontend/src/lib/session/session-cost.test.ts: Cost formatting (cents vs. decimals, sub-cent floor).
  • frontend/src/lib/session/session-usage-store.test.ts: Cost field in usage events.

All tests follow the contract-first pattern: they name the invariant being tested and assert the behavior, never the implementation.

CHANGELOG

Added entry under [Unreleased] documenting the feature for users.

https://claude.ai/code/session_01NW5SGwooH8xCCvnrc3PhZY

claude added 2 commits July 28, 2026 23:08
The footer knew the model and how full the context window was, but never
what the session cost. This adds that readout beside the context ring —
off by default, and absent rather than zeroed when it is off, because on
a subscription plan the number is noise and nobody on one should have to
read a running total that does not apply to them.

The setting lives in the backend rather than the page's localStorage:
it gates the work, not just the paint. Off, no transcript is summed and
no price is ever fetched.

Three pieces sit behind it:

- internal/pricing bills token counts against a table baked into the
  binary, which refreshes itself from LiteLLM's published one the first
  time a session runs a model this build never heard of, and caches that
  under the config dir. Keyed by model id and nothing else, so a second
  provider's reader prices off the same table.
- The transcript read gains output_tokens and a second traversal. The
  context gauge answers "how full is the window now" from the tail; cost
  is a sum over every assistant line, sub-agents included — they are
  billed to the same account — resumed from a stored byte offset so a
  turn re-reads only what was appended.
- session_costs keeps one ledger per (session, transcript), so a /clear
  starts a new conversation without losing what the old one cost, the
  total survives a restart, and a session parked with its worktree keeps
  it across the reopen.

A model no table can price stops the count at that line instead of
skipping it, and the readout goes absent until a price for it lands: a
total that silently misses a turn is worse than no total, and pricing an
unknown model at zero is the one answer this must never give.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW5SGwooH8xCCvnrc3PhZY
Both sides added an entry to CHANGELOG's [Unreleased] and a bullet to
Known Ceilings, in the same spots. Kept both, main's first.

The code merged cleanly and stays orthogonal: main's LICH_WORKTREE_PORT
rides in sessionEnv, this branch's cost readout in the Store interface
and the usage event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW5SGwooH8xCCvnrc3PhZY
@omartelo
omartelo merged commit 1aaa3cd into main Jul 29, 2026
3 checks passed
@omartelo
omartelo deleted the claude/issue-121-4qvxxd branch July 29, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants