Claude API Cost Tracking#
Decant computes session cost at ingest time and reconciles it against current pricing on every sync, writing the result to the estimated_cost_usd column on the session row. Four project-specific mechanics govern accurate cost accounting for Claude sessions: per-block usage deduplication, TTL-split cache write pricing, per-request model and tier pricing, and additive schema migrations that carry new token dimensions without breaking existing archives.
Per-Block Usage Deduplication#
Claude Code journals emit one JSONL line per content block in an assistant turn — thinking, text, tool-use — and repeat the full usage object on each line. Without deduplication, a three-block turn would triple the billed token count.
The fix lives in billAssistant() in src/sources/claude.ts. It maintains a billed map keyed by the billable turn identifier: the top-level requestId field (also request_id), with fallback to message.id when requestId is absent . The newer Claude Code journal format omits requestId entirely, making the message.id fallback the common path.
When a duplicate key is seen, billAssistant applies a max-output supersede strategy :
- If the new record's output token count is not greater than the previously recorded one, the new record gets
usage: null— it's preserved in the transcript but excluded from billing. - If the new record has more output tokens (e.g., the text block after a thinking block), the earlier record is nullified and the new one takes over.
All content blocks are kept in the message list regardless; only the usage field is suppressed on duplicates. The deduplication was introduced in commit 997d961 alongside INGEST_PIPELINE_REVISION = 2 , which triggers a one-time re-ingest of previously-processed Claude source files so historical costs are corrected automatically on the next decant sync.
Cache Write Pricing#
Claude bills cache creation tokens at two TTL tiers — 5-minute (1.25× input rate) and 1-hour (2× input rate) — which must be priced separately.
estimateCostParts() in src/cost.ts handles this by:
- Reading
cacheCreation(total) andcacheCreation1h(1-hour portion) from theTokenUsageobject. - Clamping the 1-hour figure so it cannot exceed the total .
- Deriving the 5-minute portion as
creationTotal − creation1h. - Multiplying each portion by its respective per-Mtok rate (
cacheWritePerMtokandcacheWrite1hPerMtok).
The 1-hour cache creation tokens are stored separately in session.total_cache_creation_1h_tokens (added in migration 12, described below) so that sync can reprice correctly from archived session data without re-reading source files.
If a model slug is unrecognized or explicitly unpriceable (e.g., codex-auto-review), estimateCostParts returns all-zero cost parts rather than guessing a neighboring model's rate.
Per-Request Model and Tier Pricing#
Session cost is not computed from session totals at the session's dominant model. Each billed request is priced at the model that served it and at its price tier, so a session that switches models is priced per model.
Usage is split by model and tier in the session_model_usage table (schema v25), with one row per (session_id, model, tier) holding the request count and token totals. The rows are derived from the per-request usage already stored on each message, so no transcript is reread and sessions whose source files have been pruned are covered.
- Ingest writes the split for each session and prices
estimated_cost_usdfrom it. - Sync backfills missing or outdated rows from the archive (
materializeMissingModelUsage()) before repricing. - Repricing of session costs, activity costs, and the model breakdown all use the split at current rates.
stats --by modeland/api/stats/by-dimension?dim=modelstill count each session once under its label, but tokens and cost follow the model behind each request, with each session's cost divided among its models in proportion to their cost at current rates.
Tiers applied per request:
- Claude Haiku 5.5 long prompts. Prompts over 100,000 tokens use a long-prompt rate five times the short-prompt rate.
- Fast mode. Requests with fast-mode speed cost 2× on Opus 5.5, Opus 5, and Opus 4.8.
Claude Sonnet 5.5 cache reads are priced at $0.10 per million tokens, down from $0.20; see docs/pricing.md for the current rates.
Sources that log no per-request model (such as Codex) and sessions whose per-request usage does not add up to their totals are still priced from session totals at the session's model.
Additive Schema Migrations#
New token and cost dimensions are added via ALTER TABLE … ADD COLUMN migrations (or, for the per-model split, a new table) in src/db.ts, never by dropping or rewriting columns. Each migration is gated by a hasColumn check for idempotent replay.
Relevant migrations:
| Migration | Column(s) Added | Purpose |
|---|---|---|
| v11 | context_window_tokens, peak_context_tokens | Context-window occupancy tracking |
| v12 | total_cache_creation_1h_tokens INTEGER NOT NULL DEFAULT 0 | Separate 1-hour TTL cache write tokens for accurate repricing |
| v13 | reasoning_effort, reasoning_effort_checked; cache_write_1h_per_mtok on model_pricing | Reasoning effort tracking; per-model 1-hour cache write rate |
| v25 | New session_model_usage table (no columns added to session) | Per-model, per-tier usage split so each request is priced at the model and tier that served it; sync backfills the rows |
Archives at schema v8–v24 migrate forward automatically on open; the next decant sync backfills the new columns for unchanged sessions. Archives older than v8 must be rebuilt from source logs.
Cost Reconciliation and Repricing#
Sync reconciles stored session and activity costs against the current pricing table on every run, including unchanged and archived sessions whose source files are no longer available. refreshSessionCosts() reads each session's per-model usage rows from session_model_usage (falling back to session totals at the session's model when a session has no rows), applies estimateCostParts() with the live pricing table, and updates estimated_cost_usd and cached activity cost components (session_economics.vector_json) in one transaction.
Stale session costs and activity cost components update atomically. Recommendations and server economics caches refresh after repricing, even when ingested is zero. Matching costs are left untouched, and transcripts and user metadata (archive/delete state, recommendation status) are preserved. This eliminates the rebuild requirement when rates or pricing logic change.
The sync report includes repriced when stored cost estimates changed without requiring re-ingest.
Key Files#
| File | Role |
|---|---|
src/sources/claude.ts | billAssistant() — per-block usage deduplication |
src/cost.ts | estimateCostParts(), defaultPricing(), canonicalModel() |
src/ingest.ts | INGEST_PIPELINE_REVISION; writes the session row and per-model usage rows and prices the session from them; invokes refreshSessionCosts on every sync |
src/token-economics.ts | refreshSessionCosts() — reprices stored usage against current rates |
src/model-usage.ts | Derives, stores, and prices the per-model, per-tier usage split (session_model_usage) |
src/db.ts | Schema migrations v11–v13 for token/cost columns; v25 for session_model_usage |
src/schema.sql | Canonical session table definition including estimated_cost_usd |
docs/pricing.md | Human-readable rate tables and source-verification notes |