Skip to content

fix(codex): report per-turn tokens instead of the thread-cumulative total - #113

Merged
bai-uipath merged 2 commits into
mainfrom
bai/codex-per-turn-token-usage
Aug 14, 2026
Merged

fix(codex): report per-turn tokens instead of the thread-cumulative total#113
bai-uipath merged 2 commits into
mainfrom
bai/codex-per-turn-token-usage

Conversation

@bai-uipath

@bai-uipath bai-uipath commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

What's wrong

The Codex SDK's thread token total counts the whole thread, not the turn. The Codex thread is created once per task and reused for every turn, so by turn N that total still carries turns 1 through N-1. Each turn was reporting the running total, and the orchestrator sums per-turn usages into the task total. A task's reported tokens therefore became a sum of prefix sums, inflating a task by roughly (N+1)/2 in the number of turns.

Scope: model calls within a turn are fine, simulation turns are not

These are two different levels of nesting, and only the outer one is affected.

Model calls inside a single turn (each tool call leading to the next generation) were always correct. Those are booked from the SDK's per-generation delta, and the thread total at the end of turn 1 is exactly the sum of turn 1's generations. A task that runs 14 model calls in one turn reports all 14 correctly.

Simulation dialog turns (each communicate() call, driven by a task's simulation: block) are where the double-count happens, because the thread persists across them while the orchestrator adds up what each turn claims to have spent.

So single-turn tasks were never affected regardless of how many tool calls they made. On the 2026-08-13 nightly, 490 of the 618 tasks that generated anything ran a single turn and were exactly right. The damage was confined to the 128 multi-turn tasks.

Impact

The affected tasks are a minority, but an expensive one. On the 2026-08-13 nightly the 128 multi-turn tasks were 21% of the tasks that generated anything, yet carried 46% of the reported agent spend, because simulation dialogs are the long tasks to begin with. They were then inflated on top of that.

bucket tasks reported tokens ratio reported cost true cost overstated by
single-turn 490 (79%) 211,816,827 1.02x $96.14 $94.86 $1.28
multi-turn 128 (21%) 166,092,110 2.07x $80.28 $39.11 $41.16
total 618 377,908,937 1.31x $176.41 $133.97 $42.44

Multi-turn tasks reported 2.07x their real tokens on average, and individual tasks ran as high as 5.3x. At the run level that is roughly $41 of a $175.03 nightly, about 23.5%.

The $1.28 in the single-turn row is not part of the defect. It is sub-agent tokens that the per-generation ground truth does not see, since children run on their own threads. The real figure is the multi-turn $41.16.

Token and USD run budgets read the same aggregate, so a multi-turn Codex task could also trip max_total_tokens or max_usd well before it actually should.

The distortion is not a constant

It tracks how much of the suite is multi-turn, which has been moving:

nightly multi-turn share overstated by
07-28 92/1002 (9%) $84.47
08-12 133/650 (20%) $52.63
08-13 128/618 (21%) $41.16

07-28 carries the largest dollar error despite the smallest multi-turn share, because that run was bigger and those 92 tasks were deeper dialogs. So run-over-run Codex cost comparisons have been reading a distortion that changes size each night, which is harder to spot than a fixed bias would be.

Why it went unnoticed

Every task stayed internally consistent. The synthetic reconciliation message absorbed the gap turn by turn, so the message stream still summed to the turn total, just an inflated one. Any audit checking "do the messages sum to the turn total?" passed. Summing every reconciliation row across that run recovers the double-counted amount to within 0.06%, which is the clearest signal that the residual was the bug rather than the expected small prompt slice it is documented to carry.

It is also Codex-only, so in cross-harness comparisons it read as "Codex costs more than Claude" rather than as a defect.

Verification

Live end-to-end run

skill-troubleshoot-no-host-pending run on the eval VM against a real Codex thread and a real Bedrock-backed simulator, once on main and once on this branch. Same task, same command, same model.

Before, 5 simulation turns. Turn 1 is exact; every later turn reports the running total:

sim turn model calls actually spent (cache_read) reported
1 12 338,598 338,598
2 1 43,367 381,965
3 4 196,445 578,410
4 3 179,695 758,105
5 2 132,921 891,026

Task total 2,948,104 reported against 891,026 spent, 3.36x, priced at $1.4503.

After, 3 simulation turns (the simulated dialog ends where it ends, so the two runs are not the same trajectory and the dollar figures are not directly comparable — the invariant is):

sim turn model calls actually spent (cache_read) reported
1 15 447,013 447,013
2 2 83,407 83,407
3 1 42,314 42,314

Every turn now reports exactly what it spent, and the task total matches ground truth exactly: 1.0000x, priced at $0.2585. On this same trajectory the old code would have booked 1,676,555 input tokens against 615,260 real, 2.72x, or about $0.70.

The reconciliation rows corroborate it independently. They ran 382,894 / 425,633 / 635,114 / 824,522 across the turns of the pre-fix run, and collapse to 0 / 977 / 2 after — back to the near-zero residual Codex is documented to produce, since its message stream is already complete.

Worked example from the 2026-08-13 nightly

skill-troubleshoot-export-pdf-com-hang, 4 simulation turns, 21 model calls in total:

sim turn model calls in the turn actually spent reported reconciliation row
1 14 386,026 386,026 0
2 1 37,262 423,288 387,384
3 5 246,294 669,582 423,290
4 1 60,768 730,350 669,584

Every row's reported figure is the previous row's reported figure plus that turn's own spend, and each reconciliation row is almost exactly the previous turn's cumulative total. Turn 1 is correct including all 14 of its model calls. The last row, 730,350, is the correct task total; summing the reported column instead gives 2,209,246 (3.02x), and the task was priced at $1.0660 instead of ~$0.3524.

Replay against production data

All 128 multi-turn tasks from that nightly, replayed through the corrected path:

input tokens error vs ground truth
shipped 166,092,110 +107.1%
with this change 80,033,311 -0.2%
ground truth (per-generation) 80,213,611

123 of the 128 land within 0.5% of ground truth. The remaining 0.2% is sub-agent tokens, which are folded in separately and are not part of the parent thread's total.

Tests

tests/test_codex_token_mapping.py covers the multi-turn delta, the invariant that summed turns equal the thread's final cumulative total, per-turn cost, per-agent baseline isolation, a restarted thread, and the turn following a crash. Four of them were confirmed to fail against the pre-fix behavior rather than passing vacuously.

make format, make check, make typecheck and make lint are clean. make test has 4 failures, all pre-existing and unrelated: 3 in test_reports_stats_nonfinite that reproduce on an unmodified origin/main (a Python 3.13.7 statistics change), and 1 in test_litellm_route that depends on a local .env model pin.

Other harnesses

Unaffected and unchanged. Claude Code and Antigravity already report per-turn figures, confirmed empirically on their own full nightlies rather than by reading the code: both come out at 1.00x against ground truth at every turn count, including tasks running 3, 4 and 6 turns.

…otal

The Codex SDK's ThreadTokenUsage.total counts the whole thread, not the
turn, and the thread is created once per task and reused for every turn.
Each turn reported the running total, and the orchestrator sums per-turn
usages into the task total, so a task's tokens became a sum of prefix
sums — inflating an N-turn task by roughly (N+1)/2.

Track the cumulative snapshot on the agent and report each turn's delta.
The crash/timeout fallback reads per-generation tokens off the flushed
messages, so it advances the same baseline to stay in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bai-uipath
bai-uipath marked this pull request as ready for review August 14, 2026 00:04
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @bai-uipath's task in 2m 4s —— View job


Code Review

I'll review this PR that fixes the token reporting issue in multi-turn Codex tasks.

Todo List:

  • Read .github/code_review.md for review guidelines
  • Read CLAUDE.md for project conventions
  • Review the full changed files for context
  • Run git diff to see exact changes
  • Analyze the token accounting fix for correctness
  • Verify test coverage and quality
  • Check for missing edge cases or side effects
  • Provide comprehensive review feedback

Starting review now...

@uipreliga

Copy link
Copy Markdown
Collaborator

Multi-model code review

Reviewers: gemini-3.1-pro-preview, gpt-5.6-sol, and an Opus sub-agent (4 lenses + mutation testing), reviewing 94e9fc0 against origin/main.

Scope: src/coder_eval/agents/codex_agent.py (+109/−18), tests/test_codex_token_mapping.py (+129/−2).

make verifypassed (exit 0). No fixes applied; this is comment-only.


🟠 High — _ThreadTotals.since() infers "thread restarted" from value-monotonicity, and its fallback amplifies any misread into re-booking the entire thread on one turn

Confirmed by 3/3 reviewers (severity disagreement: gemini-3 🟠, gpt-5 🟠, Opus 🟡).

File: src/coder_eval/agents/codex_agent.py:210-222, consumed at :2210

The guard is all-or-nothing across buckets: if any one bucket decreases, the whole cumulative snapshot is returned as this turn's usage. Concrete: baseline (100000, 5000, 80000), next same-thread snapshot (101000, 5100, 79000) — only cached moved backwards — reports 22k uncached input + 79k cached instead of ~1k, and re-prices the whole thread.

Opus found a reachable trigger rather than a hypothetical one: on_token_usage_updated (:574-579) accepts every thread/tokenUsage/updated notification without checking payload.thread_id / turn_id, even though the SDK's own collector filters on turn_id. A single child-thread notification landing on the parent stream both reports the child's full cumulative as the parent turn and resets the parent baseline downward, so the next turn re-books everything since. Context compaction (:113 is already a known item type) is the other candidate.

Note the coupling with the finding below: any over-advance of the baseline from _advance_usage_baseline makes the next delta negative, which lands in this same branch. Opus verified no over-advance is reachable today (_flush_message pins Codex cache_creation to 0), so this is latent, not live — but the two mechanisms compose badly.

Recommendation: capture notification.payload.thread_id, ignore notifications from a non-parent thread, and key the reset on that id changing. Keep max(0, …) clamping as the numeric backstop instead of "return the whole snapshot".


🟡 Medium — the new production wiring is enforced by comments only; mutation-verified as uncovered

Confirmed by 3/3 reviewers.

Files: src/coder_eval/agents/codex_agent.py:614-627 (finalize), :849-850 (thread_start reset), :940 (stop() reset); tests/test_codex_token_mapping.py

Opus ran two mutations against this branch:

  1. Moving _advance_usage_baseline to after _fold_subagent_tokens — exactly the hazard the new comment at :625-626 warns about.
  2. Deleting both new self._thread_usage_baseline = _ThreadTotals() reset lines.

Both mutations pass all 193 codex tests plus all 5 new test classes. The only thing the new tests pin is _token_usage_from_sdk's arithmetic.

The seam already exists: _started_agent / _FakeThread in tests/test_codex_agent.py:539-556 drives communicate() end-to-end, and there's already a crash-path variant at :827. One caveat — _FakeThread replays the same notification list every turn, so a naive two-turn test would itself assert delta-0 on turn 2; it needs a per-turn list.

Recommendation: one two-turn communicate()-level test asserting each record.token_usage is the delta (catches #2 and the finalize wiring), plus a variant where turn 1 recovers a sub-agent generation (catches #1).


🟡 Medium — _advance_usage_baseline reconstructs the cumulative from the lossy message sum when the exact snapshot is already on the state

File: src/coder_eval/agents/codex_agent.py:2227-2245, called from :621-622

_flush_message (:363-365, :387-389) discards a generation's last breakdown whenever open_blocks is empty — e.g. a generation of only contextCompaction / plan / hookPrompt items, or a text-less reasoning item with reasoning_output_tokens == 0. Turn 1 spends 10k across two generations, one gets dropped, then crashes: the baseline advances to 6k while the SDK thread total sits at 10k, so the retried turn 2 books 4k of turn 1's spend as its own.

The task total stays correct; per-turn attribution (evalboard timeline, reconciliation residual, per-turn cost) is wrong. Direction is always under-advance → next-turn over-report, so nothing is lost — gemini rated this 🟠 High on the assumption tokens are double-counted; they aren't at the task level, which is why it's held at Medium here.

Meanwhile _CodexTurnState.latest_token_usage (:316, updated at :578) holds the exact cumulative as of the last generation before the crash.

Recommendation: in finalize, set the baseline from latest_token_usage.total when present (keeping the message sum as the reported usage for the crashed turn — that's the deliberate "under-report nothing it didn't commit" rule) and drop _advance_usage_baseline entirely.


🟡 Medium — _token_usage_from_sdk became a non-idempotent mutator while keeping a pure-converter name

Confirmed by 3/3 reviewers (gemini 🟡, gpt-5 🟡, Opus 🔵).

File: src/coder_eval/agents/codex_agent.py:2168, mutation at :2211

A second call with the same snapshot returns all-zero buckets and $0.00 — indistinguishable from a legitimately empty turn. Latent today (one call site, guarded by finalized), but tests/test_codex_agent.py:625 already calls this method directly, and the stale docstring still says "Single conversion site for both the TurnEndEvent and the AgentEndEvent" — wording that invites exactly the second call that breaks it.

Recommendation: rename to something that reads as stateful (_consume_turn_usage_from_sdk), or split a pure _ThreadTotals extractor from an explicit baseline commit. Fix the stale docstring either way.


🔵 Low — new test module duplicates a fake-SDK helper and a config idiom that already exist for this exact method

File: tests/test_codex_token_mapping.py:66-83

_sdk_usage is a near-verbatim copy of tests/test_codex_agent.py:617-620, where TestCodexCacheWriteBucketing already unit-tests _token_usage_from_sdk. The new _agent() also builds CodexAgentConfig(...) directly where the sibling file uses parse_agent_config(...). Co-locating would also hand the new tests the fake-stream harness the Medium above needs.


Filtered as a false positive

  • gemini's "crash residue inflates the retry turn" as a standalone 🟠 High: the task total is correct (the residue is unattributed, not double-counted), so it's merged into the Medium above at its real severity.

Positives

  • The core fix is correct, and it was verified through the real communicate() path: two turns on one thread now report (1000, 10, 0) / (1000, 20, 100) instead of (1000, 10, 0) / (2000, 30, 100).
  • It also silently repairs the CLAUDE.md "messages sum to token_usage" reconciliation invariant, which was badly violated for Codex turns 2..N before — the residual now comes out at exactly zero.
  • Fold-after-advance ordering is right, and the parent_tool_use_id filter keeps the parent baseline parent-only.
  • input = uncached + cache_read matches the SDK convention exactly; no reachable over-advance today.
  • The reset in communicate's if self.thread is None: block is the correct single seam — self.thread is assigned in exactly one place, and start() creates no thread.
  • test_summed_turns_equal_the_final_cumulative_total states the invariant rather than three magic numbers; the docstrings quantify the bug and name the downstream consumer.

Suggested next steps

  • The two-turn communicate()-level test is the highest-value follow-up — ~30 lines against an existing harness, and it closes both the 🟡 wiring gap and most of the 🟠 blast radius.
  • Lint-rule candidate: a CE rule flagging a method named _*_from_* (pure-converter naming) that assigns to self.<attr> — the mechanically detectable half of the naming finding.

@uipreliga uipreliga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix what you agree with and 🚢

@bai-uipath
bai-uipath merged commit 80f3523 into main Aug 14, 2026
15 checks passed
@bai-uipath
bai-uipath deleted the bai/codex-per-turn-token-usage branch August 14, 2026 20:14
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