Skip to content

Count Codex usage in the ledger, billed per turn - #89

Draft
ndr-ds wants to merge 4 commits into
mainfrom
ndr-ds/codex-usage-ledger
Draft

Count Codex usage in the ledger, billed per turn#89
ndr-ds wants to merge 4 commits into
mainfrom
ndr-ds/codex-usage-ledger

Conversation

@ndr-ds

@ndr-ds ndr-ds commented Aug 29, 2026

Copy link
Copy Markdown

Why

The ledger only ever read ~/.claude/projects. Codex sessions contributed nothing to today/week/month, and nothing to session cost — has_usage_metrics() is false for them, so #79 correctly renders the total as a floor. Honest, but it means half the fleet is invisible.

What changed

Codex rollouts under $CODEX_HOME/sessions (default ~/.codex/sessions) are now scanned into the same ledger, so the windows cover both products.

Codex reports a running total, not per-message usage. total_token_usage is cumulative for the session, and a row per token_count event would re-bill everything spent so far on every turn — the same over-counting that made the Claude ledger read 11x high. So turns are the differences between successive snapshots, which sum exactly to the session total.

last_token_usage looks like it would do the job, but it is only present on some events and nothing guarantees the series partitions the total. Differencing the cumulative figure needs no such assumption.

Details worth knowing:

  • Rows key on codex:<session>:<ordinal>. The rollout line's ordinal is stable across re-reads, so re-scanning is idempotent — the same property that makes the Claude side safe.
  • Rollouts are re-read in full when they change, not tailed. A tail read starting mid-file has no previous snapshot to subtract, so that turn would be billed for the whole session to date. Re-reading is affordable (rollouts are per-session and few next to the Claude tree) and harmless, because the rows are keyed.
  • Each turn bills at the model in force when it ran. turn_context is re-emitted per turn and a session can switch models mid-flight; attributing the whole session to whatever it ended on would misprice it.

Verified

New tests: cumulative totals become per-turn deltas and sum back to the session total; per-turn model attribution across a mid-session switch (gpt-5-codexgpt-5.4); ordinals stable across reads; a truncated (still-being-written) rollout yields its complete turns; rollouts reach the ledger; a re-scan changes no total; Claude and Codex usage share one ledger (1 Claude message + 3 Codex turns = 4, tokens summed across both); and Codex turns priced from the feed rather than the fallback.

A rollout-multi-turn.jsonl fixture is checked in beside the existing one — the shipped fixture has a single token_count event and cannot exercise differencing at all.

cargo clippy --all-targets and cargo fmt --check clean; full suite green on 3 consecutive runs.

Notes for review

  • Stacked: needs Discover transcripts added to nested session directories #88 and Refresh model prices from a maintained feed instead of a static table #87. Discover transcripts added to nested session directories #88 fixes a transcript-discovery bug that makes CI red, and Refresh model prices from a maintained feed instead of a static table #87 supplies GPT prices — without it gpt-5-codex has no static entry and falls back to the flagship-Opus tier, ~4x its real $1.25/$10. Both are merged into this branch, so the diff to review here is the last commit.
  • Cached input tokens are not broken out. Codex reports only input_tokens/output_tokens, so cached input bills at the full input rate. LiteLLM does carry cache_read_input_token_cost for gpt-5-codex, so if the rollout ever grows a cached-token field this gets more accurate for free. Today it slightly over-reports a heavily-cached Codex session.
  • One drive-by fix, in a path this PR touches. find_jsonl_files_cached ended with cache.retain(|k, _| seen_subdirs.contains(k)), but seen_subdirs only describes the root just scanned while the cache is global — so scanning a second root evicted the first root's entries. Harmless in production (one root) and invisible until a test scanned two, which is how it surfaced. Now scoped to children of root.
  • Codex rollouts are never marked drained. A live session's rollout keeps growing and there is no writer-liveness signal here, so they are gated on mtime+size instead. That is one stat per rollout per scan — cheap at Codex's file counts, but it is a real difference from the Claude path if the rollout count ever grows to Claude's scale.

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.

1 participant