Skip to content

Attribute provider-reported cost to ingested sessions and fix the divergent rollup #58

Description

@moshloop

Problem

Every session Captain observes (rather than runs itself) reports a list-price reconstruction as its cost, permanently flagged as an estimate, with reasoning uncosted.

Five rollups prefer the provider's own figure when present — migrations/60_view_session_overview.sql:169, 62_view_session_turns.sql:77, 63_view_session_agents.sql:47, 67_view_session_costs.sql:40, and pkg/database/session_list_store.go:266 — all expressing the same rule:

CASE WHEN c.provider_cost_usd > 0 THEN c.provider_cost_usd
     ELSE c.input_cost + c.output_cost + c.reasoning_cost + c.cache_read_cost + c.cache_write_cost END

and Estimated() is defined as ProviderCostUSD == 0 (pkg/claude/cost.go:70).

The only writer of provider_cost_usd is FinishChatModelCall (pkg/database/session_chat_store.go:225), on the live in-process chat path. The monitor ingest path never sets it: unifiedIngestInput populates InputCost, OutputCost, CacheReadCost and CacheWriteCost only (pkg/monitor/ingest.go:301-309), and upsertTurnCalls' DoUpdates list omits both provider_cost_usd and reasoning_cost (pkg/database/session_message_ingest.go:20-26).

This is not cosmetic. pkg/cli/result_costs.go:11 records replay-based pricing measuring roughly 9% below a real billed result.

This needs an attribution policy, not an assignment

There is no per-message cost in the Claude transcript. The session-level total_cost_usd appears in the result record (pkg/claude/reader.go:494); the cost_usd and total_cost fields at pkg/claude/stdin.go:25,29 belong to the streaming stdin protocol, not the JSONL. Per-turn cost is reconstructed from usage and price tables (pkg/session/cost.go:45 CostFromUsage).

So the work is deciding how one billed session total maps onto N turn rows, and doing it in a way that survives re-ingest. Options to weigh explicitly:

  • Attribute the total to a single synthetic session-level row and leave per-turn rows as estimates, keeping the two clearly distinguishable.
  • Distribute proportionally to per-turn reconstructed cost, accepting that individual turn figures become approximations of a real total.
  • Store the billed total on the session rather than on model calls, and teach the rollups to prefer it at the session level only.

Whichever is chosen must be documented in code, because migrations/60_view_session_overview.sql:109-115 already warns that callers "must not present an estimate as billed cost."

The divergent rollup

migrations/69_view_prompt_run_overview.sql:87-93 sums the five list-price buckets and ignores provider_cost_usd entirely, contradicting migrations/75_model_call_provider_cost.sql:3 which asserts rollup views prefer it. The same session therefore reports different costs depending on the surface queried.

View 69 is additionally stale: it omits turn_id, runtime, approval state and the provider checkpoint columns, which is why pkg/database/prompt_run_overview.go:108 queries the base table and joins captain_session_overview instead of using the view.

Latent: currency

captain_model_calls.currency has no field in modelCallRecord (pkg/database/session_ingest_store.go:68-94) and no entry in either upsert, so the column default 'USD' (migrations/30_execution.pg.hcl:202-211) is the only reason WHERE upper(c.currency) = 'USD' matches anything. No current path demonstrates a real non-USD value being dropped, but a future one would silently vanish from every total.

Required change

  • Decide, document and implement the cost-attribution policy.
  • Write reasoning_cost from ingest, and add both it and provider_cost_usd to upsertTurnCalls' DoUpdates.
  • Fix migrations/69_view_prompt_run_overview.sql to use the same rule as the other five surfaces, and bring its column set up to date.
  • Write currency explicitly at both write sites.
  • Preserve every cost bucket independently. migrations/76_model_call_cost_backfill.sql:3-14 records an earlier writer collapsing the whole cost into output_cost, leaving the per-bucket split unrecoverable.

Acceptance Criteria

  • A documented attribution policy maps Claude's session-level total_cost_usd onto persisted rows, with a test covering it.
  • An ingested session whose transcript carries a billed total no longer reports Estimated() == true.
  • reasoning_cost is populated by ingest and included in the upsert's update column list.
  • All six cost surfaces report the same figure for the same session.
  • View 69 uses the provider-preferring rule and exposes turn_id, runtime, approval state and checkpoint columns.
  • Each cost bucket is stored and summed independently; no code path collapses buckets into a total.
  • currency is written explicitly by both the ingest and chat paths.

Verification


cwd: .
timeout: 20m
codeBlocks: [test, lint]
ai: {}
verify:
scope: diff
threshold: 80

Focused tests

paths: [./pkg/session/..., ./pkg/database/..., ./pkg/monitor/..., ./pkg/cli/..., ./migrations/...]
framework: [go, ginkgo]
test-timeout: 12m
show-passed: true

Changed-code lint

changed: true
fix: false
timeout: 5m

Acceptance Criteria

  • A documented, tested policy maps Claude's session-level total_cost_usd onto persisted cost rows.
  • An ingested session whose transcript carries a billed total no longer reports Estimated() == true.
  • reasoning_cost is written by the ingest path and appears in the upsertTurnCalls update column list.
  • provider_cost_usd is written by the ingest path and appears in the same update column list.
  • All six cost surfaces report the same total for the same session, including captain_prompt_run_overview.
  • migrations/69_view_prompt_run_overview.sql exposes turn_id, runtime, approval state and provider checkpoint columns.
  • Every cost bucket is stored and summed independently; a test asserts buckets are preserved and never collapsed into a total.
  • currency is written explicitly by both the ingest and chat write paths.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions