Repository navigation
fix(oauth): reuse ChatGPT's prompt cache for subagents and resumed chats - #326
Conversation
|
Thanks for picking this up, and for the much more thorough measurement than mine. I read the diff and the guards look right to me. Adding production data in case it helps with the open question in "What we could not verify". Since the issue was filed I have run an installed 2.18.10 with the equivalent one-line patch:
So the size you couldn't measure after the change is large on a fork-heavy workload, as the PR predicts. On fan-out sharing one
Reasons I can't attribute it to the header:
I'll keep the patch in place and post again if the failure rate stays above baseline for a few more days. If you would rather I compare a per-agent value against the session-wide one, I can run that here. |
|
Thank you. The before/after on a fork-heavy session is exactly the measurement we were missing, and I've added it to the description with credit. On the failure signal, here's a control from our side. Our server doesn't send
So at least the 14–16Z cluster looks upstream: we spiked in the same hour without the header. Outside that window you're still about 1.2% against a 0.2–0.4% baseline, which is roughly ten more failures than expected. That's too few to attribute in either direction, as you said. We'd like to take you up on the per-agent comparison. Codex itself sends the agent's own session id for non-root agents. A per-agent value would still cover the agent-summary forks, since they reuse their parent's agent id in our logs, but it would give up some cache sharing between sibling subagents' first turns. If a few days show a failure-rate difference between the two, we'll switch to per-agent in a follow-up. We're merging this now, since the cache win is large and the guards are unchanged by that question. |
|
Following up on the failure-rate question, as promised. Short version: the transport-error excess went back to baseline, while client aborts stayed elevated. Those aborts are concentrated in two very long sessions, so I can't attribute them to the header. Same source as before (
"Error" means Errors returned to baseline. Error-type failures per day were 0.37 / 0.18 / 0.21% before the header, then 0.61 / 0.39 / 0.22 / 0.41% after. Aborts stayed elevated. The baseline had 3 aborts in three days, all at about 600 s. Since the header there have been 60:
51 of the 60 come from two long-running sessions: 25 aborts over 7,706 requests in one, and 26 over 6,115 in the other. Every other session combined had 9 aborts over 6,136 requests (0.15%). The worst hour, 10-15 21Z, was 11 failures in a single session, 7 of them zero-output 120 s aborts. What I can't separate. Volume roughly tripled after 10-13 (3–14 active sessions per day, against 3–5 before). There is no no-header control on this install for the same period, and the abort counts are small and clustered by session. So whether a single session-wide affinity key across thousands of requests contributes to those first-byte stalls is still open. Running the per-agent comparison now. As of 2026-10-16 06:29Z this install sends The cache win held across all sessions: the uncached share went from about 39–41% to about 3%. |
|
Thanks for following up as promised, and for running the per-agent comparison. Here's the same view from our install, since you had no no-header control on yours. Our server moved from a release without the header to 2.21.0 (session-wide
Caveats: our after window is 2.5 days at lower volume (about 5.4k requests a day, against 8.2k before), on a different account. It can't rule out an effect specific to your traffic, so your per-agent run is still the real test. We look forward to your next update. |
|
@bman654, here's the per-agent comparison I promised. Short version: after the switch, aborts and errors fell back to the no-header baseline, and the overall cache share didn't move. Fresh connections cached noticeably worse. I can't credit the drop to the per-agent key, though, because over the same days volume fell about sixfold and the two sessions behind most of the earlier aborts ended or shrank. This is the same install, the same clodex 2.18.10 build and the same definitions as my 10-16 comment (
What I can't separate.
So the per-agent key didn't hurt anything measurable except fresh-connection caching, but my data can't show it helped. Your install ran session-wide 2.21.0 over the same 10-16 → 10-20 window, which makes it the control. If your 120 s idle aborts held near 1.6 per 1k there, the drop here points at the key. If yours fell too, it was upstream. Could you share those numbers? On this evidence I wouldn't make per-agent the default for reliability alone. I'm moving this install to 2.21.x with its built-in session-wide header and dropping my local patch. |
|
Thanks for running this. Our side points to the same conclusion as yours. Our raw idle-abort rate fell too, but that drop came from our own network. Since 10-14 03:01Z our server has run 2.21.0, with the session-wide
So the 1.6 per 1k I gave you on 10-16 was mostly our network, not an upstream baseline. I flagged that it included local failures but didn't remove them, and I should have. With them removed, our idle-abort rate held at about 0.2 per 1k with no header, with the session-wide header, and through your per-agent days. Reading the two installs together:
We agree with your conclusion: session-wide stays the default, and we won't open a per-agent follow-up. If idle stalls come back on 2.21.x in long, wide sessions, please open an issue with the session's agent count and how many requests it had in flight at once, and we'll look again. Thanks for running the comparison so carefully, and for reporting the results that went against the hypothesis as plainly as the ones that supported it. |
When clodex opens a new ChatGPT connection — for a subagent, a Claude Code background-agent summary, or to resume a conversation whose connection was closed — it sends the conversation again, and some ChatGPT models mostly failed to reuse the copy of that text ChatGPT had just processed. This change makes that reuse much more reliable, so repeated conversation history is far more often served from ChatGPT's cache instead of being processed again.
Fixes #324. Thanks to @0xdhx for the report, the root cause and the fix sketch.
What was wrong
ChatGPT's Codex backend derives prompt-cache affinity from the
session-idheader on the Responses WebSocket handshake. Native Codex sends it (openai/codex:codex-rs/codex-api/src/requests/headers.rsbuild_session_headers;codex-rs/core/src/client.rsresponses_session_id, commented "ChatGPT derives cache affinity from the Responses session-id header"); a root Codex session sends its prompt cache key there. clodex sentprompt_cache_keyin the body but never the header, and the body field alone did not keep fresh sockets on a warm cache: on the Responses-Lite models we sampled, requests that open a new socket and send full context — a subagent's first turn, an agent-summary fork, a conversation resent after its socket expired or was replaced — mostly missed the cache; same-socket continuation (previous_response_id) was unaffected.Reachability: every ChatGPT/Codex OAuth Responses request goes through
createResponsesWebSocketFetch(there is no OAuth HTTP path and no WebSocket-to-HTTP fallback), in proxy mode, endpoint mode and the server alike.The change
createResponsesWebSocketFetchnow setssession-idto the request'sprompt_cache_keyon the handshake, unless the caller already supplied asession-id. One value per socket is always correct:prompt_cache_keyis part of the socket partition key, replacement sockets reuse the same headers, and a request with no key gets no header and never reuses a socket. The value is the existing session-derived key, already sent in the body, so nothing new is disclosed.Left out deliberately: Codex's
thread-idandx-codex-routing-hint(not measured;session-idalone reproduced the measured cache improvement), and per-agentsession-idvalues (see below).Tests
tests/responses-websocket-session-header.test.ts— only thewssocket is faked:prompt_cache_keythattranslateRequestderives from the Claude session, driven throughcreateLanguageModeland the real WebSocket fetch;prompt_cache_keyis missing, empty or not a string; a caller-suppliedSession-Idis kept and not duplicated.The fake socket copies its headers at construction, as
wsdoes when it builds the upgrade request, so a header added after the socket exists would not count.Mutations, full-file runs (8 tests): deleting the call reds 4; moving it after socket creation reds 3; sending a constant value reds 3; dropping the caller guard, the string check, or the empty check each reds 1.
pnpm typecheck && pnpm test && pnpm buildon Node v24.14.1 with an isolatedCLODEX_HOMEand a deadHTTPS_PROXY: 3391 passed / 20 skipped, against 3383 / 20 on main.Live evidence (our ChatGPT account, 2026-10-13)
Built product, main vs this branch. Each build ran as its own endpoint server; the same script sent a ~28k-token request through alias
sol(gpt-6.1-sol), then resent it 5 times, each under a new Claude agent id so it opened a new socket (all 12 head decisions per server werenew_partition_head); two rounds per build.Header-only A/B through main's real transport (header injected via request headers; one warm-up then repeats per arm, arms interleaved, each repeat on a new socket):
session-id=prompt_cache_keyOn gpt-6.1-sol, a stable value different from the key also hit 16/16, while a new random value per request hit 1/8 — stability is what matters, and the key is the natural stable value. The non-Lite gpt-5.5 already hit 5/6 without the header (and 3/6 with a random value), so the effect is concentrated on Responses-Lite models. An 8-way concurrent burst sharing one
session-idhit 15/16 warm with no overload errors (1 genericserver_errorin 33 attempts vs 0 in 32 without the header).Smoke test from this build:
clodex claude -- --model luna -p …and--model haiku -p …both answered.How much it helps depends on how you use subagents
The reporter measured 34% of prompt tokens uncached on their traffic, mostly agent-summary forks. On our own 30-day ledger it was 5.7%, with forks a negligible share; requests that opened a new socket held 18.1% of the uncached tokens. After the change, measured by @0xdhx on a background-agent-heavy session running clodex 2.18.10 with the equivalent one-line patch (same version before and after; all traffic on
gpt-6.1-solover the ChatGPT route), 24 hours either side of the patch: uncached share of prompt tokens fell from 44.1% (1,133 requests, 139.9M prompt tokens) to 13.3% (1,207 requests, 158.8M). See their comment below for method and a possible error-rate signal still being watched.On our own traffic the fix had not been deployed, so how much it recovers there was not measured. The more a workload resends full context on new connections — heavy background-agent use is the reporter's case — the more it stands to gain; for ours the recoverable share is around a point at most. Our tests and A/B establish the mechanism; the before/after above is the size measurement for a fork-heavy workload.
What we could not verify
session-idshared by a large fan-out (sessions here reach 200+ agents) concentrates load in a way that raises overload errors. An 8-way burst showed nothing. In @0xdhx's session, failed translations rose from 2/1,139 to 20/1,235 in the day after patching. At least part of that coincides with an upstream spike: our header-less account failed 14/414 (3.4%) in the 15:00Z hour of the same day. The rest is too small to attribute, and they are comparing per-agent against session-wide values. Native Codex uses the agent's own session id for non-root agents. We kept the session-wide key because it is the existing cache key, siblings share the session's prompt prefix, and nothing measured favors per-agent values — but no one has compared shared and per-agent header values under heavy fan-out either. Overload and server-error rates are visible in--ws-diagnosticsif this needs revisiting.session-idbeyond cache routing — no public contract exists; the evidence is Codex's source comment and the measurements above. Affinity raises the chance of a cache hit; it does not guarantee one.Rollback is reverting the one call in
createResponsesWebSocketFetch; nothing is persisted.