feat(codex): capture encrypted reasoning and replay it only to its issuer - #3207
Conversation
…suer Closes the "contained, not delivered" open item #3199 left on ADR 0097. Cross-turn reasoning continuity on `openai-codex` was off because the blob that makes it possible was never captured; this wires the capability and the guard that makes it safe on a shared thread. Capture. langchain-openai's streaming Responses path has no response.output_item.done branch for reasoning (it has one for `compaction`, which carries the same kind of blob), and the terminal response.completed event keeps only parsed/usage/response_metadata — so encrypted_content is visible in exactly one event, which the converter drops. codex_client re-emits that event as a content-block delta that merges onto the reasoning block already in flight, by index. The wrapper sits on the shared module-level converter (there is no instance seam) but is inert unless a contextvar this module's client sets is present, so every other ChatOpenAI in the process goes through the original path unchanged. output_version flipped to responses/v1. "v0" collapses a turn's reasoning into ONE additional_kwargs slot — later items overwrite earlier ones, and streamed fragments of two different items merge into each other — so it structurally cannot carry per-item blobs. The block format keeps each item separate and in order, and langchain replays it that way. The rendering half of the v0 pin was already paid off (every answer site reads AIMessage.text); text_of now skips reasoning blocks rather than writing a _[reasoning]_ placeholder into exports, session memory and chat bundles, which is what ADR 0021 asks for anyway. PROTOAGENT_CODEX_OUTPUT_VERSION=v0 is the escape hatch. Issuer stamping. encrypted_content is sealed to the endpoint AND account that minted it. Each captured item carries a truncated digest of (base_url, account_id) — so a checkpoint never stores a raw account id — and replay drops items stamped with a different issuer. Unstamped items still replay. This is what makes per-slot providers, per-tab model override and the fallback chain safe on one thread; without it the #3199 recovery middleware would fire routinely instead of never. Not verified against a live ChatGPT subscription. If the backend objects, the #3199 recovery valve strips the replay state and retries, so the thread degrades to stateless continuity rather than breaking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reachedNext included review available in 14 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
QA panel review — PASS
code-review-structural · head 66f791bc9473 · formal
⚠️ PR advanced 1 commit(s) during this round (66f791bc9473→5e318bfb2333); 0 finding(s) in the delta were demoted to possibly addressed.
Overall risk is low: the panel surfaced a single nit (a docstring that calls the issuer stamp "salted" when it is a plain truncated SHA-256). Fix-first: align the docstring wording with the ADR already in the same PR ("a truncated digest"). The panel did not disagree on anything — the verifier confirmed the one finding without modification. Verification changed nothing: no findings were refuted or downgraded. Coverage gap: the panel produced only one finding across the entire PR; if other production files were touched in this diff, they went under-read by the finders.
Findings
| Severity | Location | Finding | Verified | |
|---|---|---|---|---|
| ⚪ | nit | graph/providers/codex_client.py:34 |
The module docstring describes the issuer stamp as 'a salted digest', but issuer_fingerprint is an unsalted SHA-256 (no salt is mixed in), so the docstring ove… | confirmed |
findings JSON (machine-readable)
[
{
"file": "graph/providers/codex_client.py",
"line": 34,
"severity": "nit",
"category": "conventions",
"claim": "The module docstring describes the issuer stamp as 'a salted digest', but issuer_fingerprint is an unsalted SHA-256 (no salt is mixed in), so the docstring overstates the implementation.",
"evidence": "Docstring: \"the same design (`_classify_responses_issuer`); the stamp is a salted digest so a\\ncheckpoint never stores a raw account id.\" vs. the code: \"raw = f\\\"{(base_url or '').strip().rstrip('/')}\\\\x00{(account_id or '').strip()}\\\"\\n return hashlib.sha256(raw.encode()).hexdigest()[:16]\" \u2014 no salt. The ADR the same PR writes says only 'a truncated digest', which is accurate.",
"verdict": "confirmed",
"note": "Verified in the diff: the new docstring says 'a salted digest' but issuer_fingerprint is plain SHA-256 of base_url+account_id with no salt; the ADR in the same PR correctly says 'a truncated digest'. Quotes match the diff verbatim."
}
]The contextvar that gates the capture was set in an override of _stream_responses — which ChatOpenAI._stream reaches via `super()._stream_responses(...)`, an explicit super(ChatOpenAI, self) bind that skips the subclass entirely. So the override was dead code and the capture would never have armed in production, while every unit test that set the contextvar by hand still passed. Armed on _stream/_astream instead (dispatched on the instance), and covered by a test that drives the client's OWN stream against a stubbed root_client, so nothing but the client can set the contextvar. Verified to fail against the old seam. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed a correctness fix found while re-verifying, worth calling out because the original tests did not catch it. The contextvar that gates the capture was armed in an override of Armed on |
There was a problem hiding this comment.
QA panel review — PASS
code-review-structural · head 87c13eac77a7 · formal
Clean final pass. The panel's sole finding (the "salted digest" docstring inaccuracy at graph/providers/codex_client.py:34) was a nit adjudicated in round 1 with a PASS verdict and is correctly suppressed here. The verifier confirmed an empty array — no blockers, majors, or minors survive. No prior blockers or majors required disposition. No coverage gaps: the change is small and the single file touched was read by the conventions finder; the structural pass produced no additional findings.
No findings — the review came back clean.
findings JSON (machine-readable)
[]CI installs deps UNPINNED while uv.lock holds langchain-openai 1.3.0, so CI runs 1.6.0 and local runs 1.3.0. 1.6.0 added the reasoning `response.output_item.done` branch this wrapper existed to supply — and the wrapper bailed out whenever the original produced a chunk, so on 1.6.0 the blob was captured but NEVER STAMPED. That is not a test artifact: an unstamped item is treated as legacy and always replayed, i.e. the cross-issuer guard was silently inoperative on exactly the version CI (and any fresh install) uses. CI caught it; the local suite could not. The wrapper now augments instead of bailing: it stamps the issuer whether the installed langchain surfaced the blob or we had to synthesize it. Verified against BOTH dependency sets — 1.3.0 (uv.lock) and 1.6.0 (a CI-equivalent venv): full suite green on each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
QA panel review — PASS
code-review-structural · head 86d67ac9cd20 · formal
Overall risk is low: the panel produced no confirmed defects across its lanes, and the verifier confirmed the findings array is empty. There is nothing to fix first. The panel did not disagree on any point. What verification changed: nothing — the verifier received an empty array and confirmed there was nothing to ground, refute, or mark uncertain. One gap worth flagging: the structural pass (code-review-structural) delivered zero findings to the verifier, which is ambiguous between "clean" and "skipped." Additionally, the verifier noted a coverage concern — every reader of AIMessage.content that expects a plain string may break under the new structured-block shape — but this was never submitted as a concrete finding with a cited file, line, and expected-vs-actual shape, so it cannot gate the merge in this round. If that gap is a real merge concern, it needs to be re-submitted as a grounded finding in a future round.
No findings — the review came back clean.
findings JSON (machine-readable)
[]The seam contracts immediately earned their keep on the merge: with #3207 in the tree, test_responses_chunk_converter_exists_with_its_index_threading_signature was asserting against protoAgent's OWN capture wrapper rather than langchain's function — and only when an earlier test had imported codex_client, so it was order-dependent as well as wrong. Fixed on both sides: - codex_client wraps the converter with functools.wraps, so __wrapped__ points at the original and anything introspecting the installed function sees langchain's contract instead of the shim's *args. That is the honest shape for a transparent delegating wrapper anyway. - the seam test calls inspect.unwrap first, so it pins UPSTREAM's contract whether or not our shim is installed over it. Full suite on the merged tree: 6759 passed / 16 skipped; ruff, import contracts and the A2A live smoke all pass.
#3209) * ci: pin PR gates to uv.lock, add an upstream canary and seam contracts CI installed unpinned while uv.lock held much older versions, so the local suite and CI ran different code. Two costs, both paid this week: a defect that only reproduced on the newer version passed locally, and the resulting failure landed on a PR that had not caused it. Four parts: - PR gates (tests, windows shards, fleet integration) install from uv.lock via `uv sync --frozen`, pinned to the same uv 0.11.29 the lint job uses because uv's lock output is version-dependent. A red PR now means that PR. - The A2A live-smoke job stays deliberately UNPINNED: it installs requirements-core.txt, the production image's dep set, and testing what the image actually resolves is its whole purpose. - upstream-canary.yml runs nightly on main against unpinned latest and files ONE tracking issue when the suite breaks, carrying the version delta and the seam-contract output. Non-blocking; it closes the issue when latest goes green again. - tests/test_upstream_seams.py pins the private upstream APIs protoAgent subclasses: _get_request_payload, the chunk converters, _stream dispatch (which skips any _stream_responses override), Responses replay ordering, merge_dicts string-concat semantics, AIMessage.text, and ChatAnthropic's _client_params cached_property. Each says what breaks when it fails, so upstream drift is named instead of mysterious. Verified to pass against BOTH the locked and the latest dependency sets. Plus scripts/dep_drift.py (lock vs installed, human or markdown) and grouped Dependabot for the AI stack and GitHub Actions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Merge origin/main; keep the capture shim introspectable The seam contracts immediately earned their keep on the merge: with #3207 in the tree, test_responses_chunk_converter_exists_with_its_index_threading_signature was asserting against protoAgent's OWN capture wrapper rather than langchain's function — and only when an earlier test had imported codex_client, so it was order-dependent as well as wrong. Fixed on both sides: - codex_client wraps the converter with functools.wraps, so __wrapped__ points at the original and anything introspecting the installed function sees langchain's contract instead of the shim's *args. That is the honest shape for a transparent delegating wrapper anyway. - the seam test calls inspect.unwrap first, so it pins UPSTREAM's contract whether or not our shim is installed over it. Full suite on the merged tree: 6759 passed / 16 skipped; ruff, import contracts and the A2A live smoke all pass. * test: make the dispatch seam test actually test dispatch The QA panel was right: the probe overrode BOTH _stream and _stream_responses, and its _stream never called super() — so it fully replaced the routing it claimed to pin. It recorded "_stream" because that is the method the test itself called. Vacuous. Rewritten to override ONLY _stream_responses and let the real ChatOpenAI._stream run against a stubbed root_client, then assert both halves: the override was bypassed, AND the base implementation ran instead. Without the second assertion a _stream that silently did nothing would also pass. Confirmed to have teeth: monkeypatching ChatOpenAI._stream to dispatch _stream_responses on the instance (the hypothetical upstream change) makes it fail with the message that tells us codex_client can hook the narrower seam. Also strengthened the inbound-converter test from a signature check to an actual conversion, for the same reason. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to #3199, which closed the bleeding but left cross-turn reasoning continuity on
openai-codexoff. This turns it on, plus the guard that makes it safe on a thread that can move between providers.Capture
langchain-openai's streaming Responses path has no
response.output_item.donebranch for reasoning — it has one forcompaction, which carries the same kind of blob — and the terminalresponse.completedevent rebuilds the full message but keeps onlyparsed/usage/response_metadata. Soencrypted_contentis visible in exactly one event, which the converter drops on the floor:_install_reasoning_capturere-emits that event as a content-block delta that merges onto the reasoning block already in flight, byindex— the way every other streamed block merges. The wrapper sits on the shared module-level converter (there is no instance seam) but is inert unless a contextvar this module's client sets is present, so a gateway client or any other Responses user in the process goes through the original code path unchanged. That's covered by a test.output_version→responses/v1This is the follow-up the
v0pin was explicitly holding open.v0collapses a turn's reasoning into oneadditional_kwargsslot: later items overwrite earlier ones, and streamed fragments of two different items merge into each other — it structurally cannot carry per-item blobs.responses/v1keeps each item as its own block, in order, and langchain replays them that way natively:The rendering half of the pin was already paid off — every answer site reads
AIMessage.text, which yields text blocks only (verified). One gap did surface:text_of(which feeds exports, session memory and chat bundles — all of which persist what they read) rendered a reasoning block as_[reasoning]_. It now skips reasoning outright, which is what ADR 0021 asks for anyway.PROTOAGENT_CODEX_OUTPUT_VERSION=v0is the escape hatch back to the string shape.Issuer stamping
encrypted_contentis sealed to the endpoint and account that minted it, and this repo lets every slot name its own connection, each chat tab override the model per turn, and a failed turn retry against the fallback chain. So each captured item carriesissuer_fingerprint(base_url, account_id)— a truncated digest, so a checkpoint never stores a raw account id — and replay drops items stamped with a different issuer. Unstamped items (checkpointed before this) still replay.This is what makes mixing providers on one thread a continuity cost rather than a
400: without it, #3199's recovery middleware would be firing routinely instead of never.Outbound rules, now complete
idand our private keysVerification
tests/test_codex_reasoning_capture.py— 19 tests driving real OpenAI event types through the real converter and the real chunk merge: blob lands on the right block, two items keep their own blobs in order, wrapper inert without the contextvar, idempotent install, fingerprint separation + no account-id leak, all four replay rules, capture→replay end to end, a foreign thread sending nothing, and a legacyv0message in the same thread still sending no ghost.ruffclean ·lint-imports3/3 ·live_smoke.pyPASSED.configuration.mdtip on mixing providers mid-thread, both env vars inenvironment-variables.md.What is not verified
No turn has been driven against a real ChatGPT subscription with this on — the wire shape is tested exhaustively, the backend's acceptance of it is not. If the backend objects, #3199's
CodexReasoningReplayRecoveryMiddlewarestrips the replay state and retries, so a thread degrades to stateless continuity rather than breaking. That ordering was deliberate.Worth an upstream issue as well: langchain-openai should handle
output_item.donefor reasoning items the way it already does forcompaction, which would let us drop the converter wrapper entirely.🤖 Generated with Claude Code