A response.completed without a usage block silently skips all token totals and the rollout budget

Open 💬 3 comments Opened Aug 5, 2026 by EvolveAegis

What version of Codex CLI is running?

main (record_token_usage_info at codex-rs/core/src/session/mod.rs:3781)

What subscription do you have?

n/a (self-hosted)

Which model were you using?

Any (mechanism is model-independent; reachable with OpenAI-compatible providers that omit usage)

What platform is your computer?

macOS arm64

What terminal emulator and version are you using (if applicable)?

n/a

Codex doctor report

not available

What issue are you seeing?

Token usage is recorded into session accounting in exactly one place in the turn path: ResponseEvent::Completed { token_usage, .. } at core/src/session/turn.rs:2529 calls record_token_usage_info (core/src/session/mod.rs:3781-3815), which does:

if let Some(token_usage) = token_usage {
    let token_info = { ... update_token_info_from_usage ... };
    let budget_result = self.record_rollout_budget_usage(token_usage);
    ... contributor notifications ...
    budget_result?;
}

When a response.completed event carries no usage block, token_usage is None (parsed from usage: Option<ResponseCompletedUsage> with #[serde(default)] at codex-api/src/sse/responses.rs:112-120), and the if let Some skips all three:

  1. session token totals (update_token_info_from_usage) are not updated;
  2. the rollout budget check (record_rollout_budget_usage) is not run - and this is the ONLY call site in the turn path (core/src/session/rollout_budget.rs:26), so a usage-less completion fully escapes budget accounting;
  3. token-usage contributor notifications are skipped.

The skip is silent: no warning, no fallback, no conservative treatment. A usage-less response.completed is an explicitly tolerated input (there is a test asserting token_usage.is_none() for a usage-less completed event, responses.rs:745-810), so this is not an error path - it is a designed input that produces zero accounting.

Related: response.incomplete events (max_output_tokens stops etc.) may carry usage server-side, but the parser at responses.rs:426-436 extracts only incomplete_details.reason and discards the rest, so a token-limited stop also contributes nothing.

What steps can reproduce the bug?

Static demonstration (no live run required):

  1. Configure codex with an OpenAI-compatible provider that may omit usage on response.completed (or intercept the SSE stream and strip the usage block).
  2. Run a turn; observe the completed event has no usage.
  3. Observe session totals unchanged and rollout budget not decremented for that turn.
  4. There is no log line indicating the skip.

What is the expected behavior?

A usage-less completion should at minimum (a) log that usage was absent, (b) keep budget accounting conservative (e.g. treat unknown usage as not exceeding the budget, or as consuming the remaining budget), rather than silently skipping both totals and the budget gate.

Additional information

Suggested fix options:

  1. In record_token_usage_info, when token_usage is None, log a warning and record a "usage unknown" marker instead of silently returning.
  2. Keep record_rollout_budget_usage reachable on the None path with a conservative assumption (e.g. count the turn against the budget with an estimated/unknown weight).
  3. For response.incomplete, extract and record the usage block when present (incomplete_details is parsed but the response's usage is discarded).

View original on GitHub ↗

3 Comments

ded-furby · 22 days ago

Thanks for the detailed repro and code pointers. I confirmed this path: when ResponseEvent::Completed has token_usage: None, record_token_usage_info returns early and skips record_rollout_budget_usage, so rollout accounting sees a silent zero delta. Could we treat missing usage as tracked unknown-usage, emit a warning, and still run a conservative budget gate instead of bypassing checks? That keeps enforcement intact for providers that omit usage on complete events.

EvolveAegis · 22 days ago

Dynamic reproduction added: a cargo test (zz_c5_usage_none_skips_totals_and_budget) drives the real Session::record_token_usage_info:

  1. Record a measured usage (100 input, 50 output, total 150) -> totals update as expected.
  2. Call record_token_usage_info(&turn_context, None) (a response.completed without usage).
  3. Assert: total_token_usage and last_token_usage are byte-identical before and after the None call.

Result: the None call is a silent no-op on totals, and record_rollout_budget_usage (single call site at session/mod.rs:3789, inside the if let Some) is never invoked, so a usage-less completion escapes both totals and budget accounting with no warning. Test: 1 passed.

xiehuanyi · 9 days ago

Confirmed on current main: ResponseEvent::Completed { token_usage: None } reaches Session::record_token_usage_info, and the if let Some(token_usage) skips totals, record_rollout_budget_usage, and contributor notifications with no log.

I am not opening a PR. docs/contributing.md says Codex does not accept external code PRs.

Suggested scope if the team picks this up:

  1. Keep response.completed without usage as a first-class input (the parser already allows usage: Option<_>).
  2. On token_usage == None, log that usage is unknown. Do not invent token counts.
  3. Leave rollout-budget policy for unknown usage as an explicit product choice. Counting it as zero is the current skip; treating it as "consume remaining budget" is the conservative gate. Either should be named, not implicit.
  4. response.incomplete is a separate bug: the SSE handler maps it to ApiError::Stream and drops usage. That is #38831 (retryable stream failure) as well as lost accounting.

A regression test can stay fully synthetic: record a measured usage, then call record_token_usage_info(..., None) and assert totals are unchanged while a warning/unknown branch is taken.