Repository navigation
fix(gateway): forward Responses requests intact and replay streams after release - #2399
Conversation
…ter release Codex could not run through the Responses ingress: - Every input item with a role was rebuilt from role and content, so Codex's additional_tools item lost its tool definitions and developer messages became system messages without their type. - Unmodeled top-level fields (reasoning, include, prompt_cache_key, parallel_tool_calls) were silently dropped although Core admits them. - Streaming requests were forwarded as streaming and withheld by the send check, which only supports openai-chat streaming. Forward every item that is not a plain user message verbatim, pass the unmodeled fields through for Core's request allowlist to decide (dropping only the client-local client_metadata), always request a non-streaming response upstream, and replay a released response to streaming clients as Responses SSE events. Nothing is released before the release check. Refs oceanbase#2398 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hidb4ai
left a comment
There was a problem hiding this comment.
Three correctness issues remain in the Responses SSE replay: text-stream consumers receive no text events, failed responses are announced as completed, and degraded streaming requests receive JSON. Details and reproductions are inline.
| push( | ||
| &mut out, | ||
| "response.completed", | ||
| serde_json::json!({"type": "response.completed", "response": resp}), |
There was a problem hiding this comment.
[P2] Preserve failed Responses in the terminal event
HTTP 200 does not imply response.status == "completed". A response with status: "failed", a nonempty error object, and output: [] passes enforcing Core in a real Server + Gateway probe: complete_response() explicitly accepts inspected error carriers before checking the status. With the actual gateway and Codex CLI 0.162.1 against local HTTP fixtures, this replay produced exit code 0 and turn.completed, with no answer or provider error, for an upstream server_error. The unconditional response.completed event hides the failure from the client. Select the terminal event from the approved response status and preserve its failure semantics after the release check.
There was a problem hiding this comment.
Fixed in bbe6548. The terminal event now follows the approved response: status failed/cancelled or a non-null error yields response.failed, incomplete yields response.incomplete, otherwise response.completed; the full response (including error) is carried in it. response.created reports error: null. Covered by replay_terminal_event_follows_response_status.
| push( | ||
| &mut out, | ||
| "response.output_item.done", | ||
| serde_json::json!({"type": "response.output_item.done", "output_index": index, "item": item}), |
There was a problem hiding this comment.
[P2] Replay text events for Responses streaming consumers
The replay emits item snapshots but no response.output_text.delta or response.output_text.done. With a completed upstream answer containing fixture-ok, the actual gateway plus OpenAI Python SDK 2.46.0 returns that text from get_final_response(), but the standard delta-event loop receives an empty string; the same loop receives fixture-ok from a normal upstream SSE stream. Clients using the documented streaming pattern therefore display no answer. After the release check, replay the corresponding text/content events as well; one buffered text delta is sufficient to preserve the no-early-release behavior.
There was a problem hiding this comment.
Fixed in bbe6548. After the release check, each message content part is replayed as response.content_part.added → one buffered response.output_text.delta → response.output_text.done → response.content_part.done (refusals use response.refusal.delta/done), with item_id/output_index/content_index. Message items are announced empty in output_item.added, as upstream streams do, so accumulating clients do not duplicate text; events carry sequence_number. Covered by replayed_text_reaches_delta_stream_consumers and the updated released_responses_are_replayed_as_sse_events.
| ManagedOutcome::Released { | ||
| status: 200, body, .. | ||
| } => serde_json::from_slice::<serde_json::Value>(body) | ||
| .ok() | ||
| .map(|resp| responses_sse(&resp)), | ||
| _ => None, |
There was a problem hiding this comment.
[P2] Preserve the client stream contract during allowed degradation
Every Responses request now forces upstream stream: false, but this match only replays ManagedOutcome::Released. With PC_GATEWAY_DEGRADE=passthrough, non-enforcing capabilities at startup, and Core becoming unavailable before the send check, the executor returns Degraded and this falls through to a 200 application/json response despite client stream: true. Reproduced with the actual gateway and OpenAI Python SDK 2.46.0 against local HTTP fixtures: the SDK receives no events and get_final_response() raises Didn't receive a response.completed event. Replay successful degraded Responses too while retaining x-pc-degraded and the existing degradation eligibility checks.
There was a problem hiding this comment.
Fixed in bbe6548. respond_responses_sse now also replays ManagedOutcome::Degraded with status 200 and keeps x-pc-degraded: passthrough_without_memory; degradation eligibility is unchanged (still decided before the executor runs). Non-200 degraded results are returned as before. Covered by degraded_responses_are_replayed_as_a_marked_stream.
…ed streams The SSE replay for streaming Responses clients emitted only item snapshots, always ended with response.completed, and covered only released responses: - Replay each message content part as content_part.added/done around one buffered output_text (or refusal) delta/done, so delta-loop consumers see the answer; message items are announced empty as upstream streams do. - Choose the terminal event from the response status: failed/cancelled or a non-null error yield response.failed, incomplete yields response.incomplete. - Replay a permitted degraded passthrough 200 response as well, keeping the x-pc-degraded header. - Number events with sequence_number. Refs oceanbase#2398 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| let logprobs = json!({"logprobs": []}); | ||
| let is_text = field == "text"; | ||
| let mut delta = with(at.clone(), json!({"delta": text})); | ||
| let mut done = with(at.clone(), json!({ field: text })); | ||
| if is_text { | ||
| delta = with(delta, logprobs.clone()); | ||
| done = with(done, logprobs); |
There was a problem hiding this comment.
[P2] Preserve requested logprobs in replayed text events
With include: ["message.output_text.logprobs"], the buffered upstream text part can contain nonempty token probabilities, but this writes logprobs: [] into both response.output_text.delta and response.output_text.done. A real enforcing Core + Gateway probe accepts that request and preserves the probabilities in the content-part/final response while clearing these two events. The official Python SDK event consumer likewise receives [] through the gateway versus the original probabilities from a normal upstream SSE stream. Clients using the text events for token confidence/scoring therefore lose explicitly requested data. Replay the part's logprobs alongside its buffered text instead of replacing them with an empty array.
Which issue or RFC does this PR close?
Refs #2398. This PR fixes the gateway-side request problems listed there (items 1 and 3 of the issue). Two parts stay open: prepared-context injection for Responses requests (item 4), which I'll send as a separate PR, and the Core policy for reasoning ciphertext (item 2), which needs a maintainer decision.
Rationale for this change
Codex reaches the gateway through
/gateway/v1/responses. On master, a real Codex 0.145 request either is withheld (Codex always streams) or reaches the upstream without its tool definitions, with its developer messages rewritten, and withoutreasoning,include,prompt_cache_keyandparallel_tool_calls. Codex cannot work through the gateway in that state.What changes are included in this PR?
to_canonical_responseskeeps only plain user messages in the canonical form, because injection and the recall query need them. Every other input item is wrapped verbatim, andOpenAiResponsesRendereremits wrapped items unchanged, even when their type is not inRESPONSES_ITEM_TYPES. Developer messages keep their role andtype, and Codex'sadditional_toolsitem keeps itstools.OpenAiResponsesRequestcollects the top-level fields it does not model (#[serde(flatten)]) into a newCanonicalRequest::passthrough_fields, and the Responses renderer adds them to the body without overriding modeled fields. Core's request allowlist decides whether they may be sent, so an unsupported field now leads to an explicit withhold instead of a silent drop.client_metadata, Codex's client-local turn metadata, is removed.openai-chat. For a client that asked forstream: true, a released 200 response is replayed as Responses SSE events (response.created,response.output_item.added/doneper output item,response.completed). Error statuses and other outcomes are returned as JSON, as before. Nothing is released beforebefore_release.gateway/README.mddescribes the Responses behavior.Are there any user-facing changes?
Yes, on the Responses route:
The Chat route is unchanged.
How was this change tested?
cargo fmt --all -- --check,cargo clippy --all-targets -- -D warningsandcargo testingateway/, all clean.New tests in
pc-gateway-app(ingress.rs), built from the shape of a real Codex 0.145 request:reasoning,include,prompt_cache_keyandparallel_tool_callsare forwarded andclient_metadatais not;Reverting each part of the change makes the matching tests fail.
End to end against a master Server in enforcing mode, with an
openai-responsestarget pointing at a local recorder that answers with a completed response. A captured Codex 0.145 first-turn request (47,588 bytes):stream: true: before this change 409streaming_unsupported; now 200text/event-stream;stream: false(47,589 bytes; before: 27,504 bytes, with the tool definitions and four fields missing);reasoningitem withencrypted_contentis still withheld (content_unsupported), which is the open Core question in bug(gateway): Codex requests fail or lose their tools through the gateway's Responses ingress #2398.A port of this approach was used for about 150 Codex SWE-bench Pro sessions through an earlier revision of feat(gateway): add Rust PowerContext Gateway executor #2307.
AI usage statement
Built with Claude Code (Claude Opus 5.5), which investigated the issue, wrote the change and the tests, and ran the checks above.
🤖 Generated with Claude Code