feat: per-message usage/cost tracking with derived session totals - #10172
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42e834cafe
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
0506bec to
5c3f09e
Compare
5c3f09e to
84bb651
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 84bb651d9e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Add optional cost + cost_source (ProviderReported/Estimated) to ProviderUsage
and parse `usage.cost` from OpenRouter and Anthropic-compatible gateways across
the streaming and non-streaming OpenAI and Anthropic paths. Request OpenRouter's
inline cost via `usage: {include: true}`. When no provider cost is present the
cost is filled in downstream from the catalog estimate.
Drop the unused ProviderUsage::combine_with rather than extending it with cost
semantics nothing exercises.
Add MessageUsage (tokens, cost, cost_source, generation timing) on MessageMetadata, populated and serialized to clients as groundwork for the per-message UI, and register it plus CostSource in the OpenAPI schema.
Add an append-only usage_ledger table (migration v15) and a persisted sessions.parent_session_id link. The ledger is decoupled from the messages table so per-call usage survives conversation rewrites (compaction) and cancelled turns, and each row records the model that produced it so estimated costs can be recomputed or broken down per model later. Session totals are derived by summing the ledger across a session and its sub-agent descendants (recursive CTE over parent_session_id), with a per-session fallback to the retained accumulated_* record for sessions that predate the migration. record_usage_metrics runs in one atomic transaction: it first reconciles the ledger with any spend recorded outside it (pre-v15 history, or turns run on a pre-v15 build after switching branches) via a 'carried_forward' adjustment row - both builds only ever grow accumulated_*, so the componentwise difference is exactly the spend the ledger missed - then adds the call's delta to the accumulated columns in SQL (so concurrent writers can't lose updates) and appends the ledger row. The derived total and the legacy fallback therefore converge again after any branch round-trip.
Resolve each turn's best-estimate cost (provider-reported when present, else catalog) once in update_session_metrics and record it to the ledger atomically with the session totals - for normal turns, auto-compaction, and /compact. Link sub-agent sessions to their parent so their usage rolls into the parent's grand total. Attach the turn's usage to its assistant message for display.
Source TokenState and the ACP usage updates from the on-the-fly aggregation (session + sub-agent tree) rather than the stored session record, so clients see the derived per-message totals including delegated sub-agent spend.
84bb651 to
8c8dfa4
Compare
DOsinga
left a comment
There was a problem hiding this comment.
Looks good. Codex feedback has been addressed, CI is green, and I pushed a small cleanup commit to strip redundant comments before approving.
Summary
Records token usage and cost per provider call and derives session totals by aggregation, replacing both the independently accumulated session counter and the sub-agent roll-up approach from #10103.
This follows the direction from @DOsinga's review on #9719: every chunk carries its best cost estimate (provider-reported when available, catalog-derived otherwise), filled in once, and the session total is derived from the per-chunk records so it cannot drift.
What changed
ProviderUsagegainscost/cost_source, parsed fromusage.costacross the OpenAI and Anthropic-compatible paths, with OpenRouter asked to return cost inline viausage: {include: true}.update_session_metrics(provider-reported when present, else catalog estimate), the single point all four usage producers share.The catalog estimate needs a provider name and the
Providertrait has no single wrapper to decorate, so this shared waist is the only place "filled exactly once" is actually enforceable.usage_ledgertable (schema v15) records one row per provider call, kept separate frommessagesso spend survives compaction rewrites and message-less turns.sessions.parent_session_idis persisted for sub-agent sessions, and totals are derived with a recursive CTE so a parent reports the grand total across its tree while each sub-agent stays a distinct record.MessageUsageonMessageMetadataserializes per-message usage to clients as display-only groundwork for the hover UI (follow-up PR).TokenStateand the ACP usage updates are sourced from the derived totals.Migration
v15 is purely additive and
accumulated_*stays dual-written in the same transaction as the ledger row, so a v15 database still works onmainand the two records can't diverge.Spend the ledger missed (pre-v15 history, or turns run on
main) is appended as acarried_forwardrow on the next write, so totals survive branch round-trips.Scope decisions
Orchestrator-spawned sessions are created as
SessionType::Userpeers and intentionally stay unlinked.List rows stay per-session by design: summing each row's tree would double count sub-agents that are also rows.
main./api/v1/generationfollow-up call is not implemented.Follow-ups
elapsedMs.accumulated_*dual-write and reconciliation can be removed, leaving totals purely ledger-derived.