response.completed usage that fails serde parse is treated as a retryable stream error and re-issues the whole sampling request
What version of Codex CLI is running?
main @ fa5d5ae047d1891a2f816c22d9ed926a0728ba47 (codex-rs; codex-api/src/sse/responses.rs:437-451, protocol/src/error.rs:362-397)
What subscription do you have?
n/a (self-hosted)
Which model were you using?
Any (mechanism is model-independent; reachable with OpenAI-compatible providers/proxies that omit a usage subfield)
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?
When the usage block of a response.completed event fails serde deserialization, codex classifies the failure as a retryable stream error and re-issues the entire sampling request. Output items from the first (successful) generation were already persisted to session history and tool executions were already queued before the parse failure. Each retry issues a fresh billed request; with the default retry limit the turn performs 6 attempts (1 initial + DEFAULT_STREAM_MAX_RETRIES = 5), and up to 12 when WebSocket transport is in use and the one-time WebSocket-to-HTTPS fallback resets the counter, before failing with a retry-limit error. The first call's usage is never recorded (no completed event), so the metered cost under-counts while the billed cost multiplies. Based on the code path below, each retry's items are recorded into history with no dedup and emitted to the UI as completed turn items, so the expected user-visible result is repeated items followed by a retry-limit error.
This is distinct from #37138 (usage-less response.completed is accepted with token_usage=None and silently skips totals). Here the usage object is present but incomplete, so the whole event parse fails and the stream is treated as a transient transport error.
Mechanism, verified on main @ fa5d5ae0:
codex-api/src/sse/responses.rs:123-161:ResponseCompletedUsagefieldsinput_tokens,output_tokens,total_tokenshave no#[serde(default)];ResponseCompletedInputTokensDetails.cached_tokens(line 153) has no#[serde(default)](onlycache_write_tokensat 154-155 does);ResponseCompletedOutputTokensDetails.reasoning_tokens(line 160) has no#[serde(default)]. A missing or null nested field fails the whole parse. (usage: null/ absent at the top level is handled byOption+#[serde(default)]onResponseCompleted.usageat lines 116-117; a partial usage object with a missing subfield is not.)codex-api/src/sse/responses.rs:437-451: on parse error:return Err(ResponsesEventError::Api(ApiError::Stream(error)))with messagefailed to parse ResponseCompleted: ....codex-api/src/api_bridge.rs:31:ApiError::Stream(msg) => CodexErr::Stream(msg).protocol/src/error.rs:362-397:is_retryable()putsCodexErrorDetails::Stream(..)in the true branch (line 387).core/src/session/turn.rs:1403-1416: retryable error ->handle_retryable_response_stream_error-> loop continues -> full request re-issued. DefaultDEFAULT_STREAM_MAX_RETRIES = 5(model-provider-info/src/lib.rs:26); on exhausting retries the one-time WebSocket-to-HTTPS fallback can reset the counter (core/src/responses_retry.rs:31-46).core/src/stream_events_utils.rs:288-323and 351-357 : output items (tool calls, messages, reasoning) are persisted immediately atoutput_item.doneviarecord_completed_response_item, and tool execution is queued, BEFOREresponse.completedarrives.
Because the provider deterministically sends the same malformed usage, every retry fails identically, so the turn bills multiple full generations and then fails with a non-retryable retry-limit error.
Note on tools: after the stream loop breaks with Err, drain_in_flight at turn.rs:2708 still runs, so in-flight tool futures complete and their outputs are recorded into history before the retry. Already-started tools are therefore not re-queued by the runtime; the main residual risk is history pollution (each retry records a fresh generation's items with no dedup) and a model that re-issues a call after seeing prior state. Side effects of tools that already ran are not rolled back.
What steps can reproduce the bug?
Minimal unit-level repro against the real SSE parser (temporary test in codex-api tests, or equivalent):
// After an assistant output_item.done, a completed event whose usage
// omits input_tokens_details.cached_tokens fails the whole stream as ApiError::Stream.
let item = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello"}]
}
});
let completed = json!({
"type": "response.completed",
"response": {
"id": "resp_cx2",
"usage": {
"input_tokens": 42,
"output_tokens": 7,
"total_tokens": 49,
"input_tokens_details": {
// cached_tokens intentionally omitted
"cache_write_tokens": 0
}
}
}
});
// process_sse / collect_events yields:
// Ok(OutputItemDone(Message))
// Err(ApiError::Stream(
// "failed to parse ResponseCompleted: missing field `cached_tokens`"
// ))
Also fails for:
output_tokens_details: {}(missingreasoning_tokens)"cached_tokens": null(null not accepted without#[serde(default)])
End-to-end with a live provider/proxy:
- Use an OpenAI-compatible provider/proxy whose
response.completedincludesinput_tokens_details(oroutput_tokens_details) but omits one inner field, e.g.cached_tokensorreasoning_tokens. - Run a turn. Expected from the code path:
- the turn performs multiple billed LLM calls (6, or up to 12 with WebSocket transport fallback) before failing,
- session token totals do not include the first call's usage (no completed event was parsed),
- history contains multiple near-duplicate assistant messages (each retry records a fresh generation's items with no dedup),
- the UI shows the items repeated, then a retry-limit error.
Contrast (correct path today): a usage-less completed event (response: { "id": "resp1" }) is accepted with token_usage=None (existing test parses_items_and_completed). Only a present-but-incomplete usage object triggers the retry storm.
What is the expected behavior?
A response.completed whose usage block is incomplete should still be treated as a completed turn: the usage should be parsed leniently (missing subfields default to 0, or usage recorded as unknown) rather than failing the whole event. The turn should complete normally with the output already generated, and should not re-issue a billed sampling request for a deterministic schema mismatch.
Additional information
Suggested fix, in preference order:
- Add
#[serde(default)]tocached_tokens,reasoning_tokens, and the top-level usage counters so a missing subfield defaults to 0 instead of failing the parse. (Note the existing asymmetry:cache_write_tokensalready has#[serde(default)].) - More robustly: on
ResponseCompletedparse failure that concerns usage only, still emitCompleted { token_usage: None }(or a best-effort partial usage) instead ofApiError::Stream, so a metering-shape difference never re-issues a billed request. - Do not make all
Streamerrors non-retryable as a blanket fix, since that would abort turns after side effects; the lenient-usage fix addresses the root cause.
Related: #37138 covers the usage-absent / totals-skip path. This issue is the complementary case where partial usage fails the event and amplifies spend via retries.
1 Comment
I reproduced this on current
main(bfb6a6e) through the real SSE parser. Before the fix, aresponse.completedevent withcached_tokens: nullfails with:I prepared a scoped patch here:
The patch treats missing or explicit-null usage counters as zero at the wire-deserialization boundary. It covers top-level counters plus
cached_tokens,cache_write_tokens, andreasoning_tokens. Other invalid types still fail normally, and the patch does not change retry classification or any public protocol type.The regression test drives a full
response.completedSSE event and verifies both nested partial details and sparse top-level usage. Validation:mainwith the stream parse error above.just test -p codex-api: 158 passed.just fix -p codex-api: passed.just fmt: passed.git diff --check: passed.The change is limited to
codex-rs/codex-api/src/sse/responses.rs(67 additions, 1 deletion; most additions are test coverage), with no dependency or schema changes.Per the invitation-only contribution policy, I have not opened an upstream PR. If this compatibility behavior matches the intended fix, please invite me to submit the focused PR.