Skip to content

fix(gateway): forward Responses requests intact and replay streams after release - #2399

Merged
hidb4ai merged 3 commits into
oceanbase:masterfrom
jasper-yl:fix/gateway-codex-responses
Nov 2, 2026
Merged

hidb4ai merged 3 commits into
oceanbase:masterfrom
jasper-yl:fix/gateway-codex-responses

Conversation

@jasper-yl

Copy link
Copy Markdown
Contributor

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 without reasoning, include, prompt_cache_key and parallel_tool_calls. Codex cannot work through the gateway in that state.

What changes are included in this PR?

  • Item fidelity. to_canonical_responses keeps only plain user messages in the canonical form, because injection and the recall query need them. Every other input item is wrapped verbatim, and OpenAiResponsesRenderer emits wrapped items unchanged, even when their type is not in RESPONSES_ITEM_TYPES. Developer messages keep their role and type, and Codex's additional_tools item keeps its tools.
  • Unmodeled request fields. OpenAiResponsesRequest collects the top-level fields it does not model (#[serde(flatten)]) into a new CanonicalRequest::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.
  • Streaming. The upstream Responses request is always non-streaming, because the send check supports streaming only for openai-chat. For a client that asked for stream: true, a released 200 response is replayed as Responses SSE events (response.created, response.output_item.added/done per output item, response.completed). Error statuses and other outcomes are returned as JSON, as before. Nothing is released before before_release.
  • gateway/README.md describes the Responses behavior.

Are there any user-facing changes?

Yes, on the Responses route:

  • Requests reach the upstream with their items and fields intact. A field that Core's allowlist does not admit now produces a withhold.
  • A streaming client gets an SSE response that is replayed after the whole response passed the release check, so it does not receive tokens incrementally. Before this change, every streaming Responses request was withheld in enforcing mode.

The Chat route is unchanged.

How was this change tested?

  • cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings and cargo test in gateway/, all clean.

  • New tests in pc-gateway-app (ingress.rs), built from the shape of a real Codex 0.145 request:

    • non-user items reach the rendered upstream body unchanged;
    • reasoning, include, prompt_cache_key and parallel_tool_calls are forwarded and client_metadata is not;
    • passthrough fields never override modeled ones;
    • a streaming client produces a non-streaming upstream request;
    • the SSE replay has the event order and payloads above;
    • an upstream error is not wrapped in a stream.

    Reverting each part of the change makes the matching tests fail.

  • End to end against a master Server in enforcing mode, with an openai-responses target pointing at a local recorder that answers with a completed response. A captured Codex 0.145 first-turn request (47,588 bytes):

    • with stream: true: before this change 409 streaming_unsupported; now 200 text/event-stream;
    • the recorded upstream body equals the client request except stream: false (47,589 bytes; before: 27,504 bytes, with the tool definitions and four fields missing);
    • a second-turn request carrying a reasoning item with encrypted_content is 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

…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 hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +310 to +313
push(
&mut out,
"response.completed",
serde_json::json!({"type": "response.completed", "response": resp}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +303 to +306
push(
&mut out,
"response.output_item.done",
serde_json::json!({"type": "response.output_item.done", "output_index": index, "item": item}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +260 to +265
ManagedOutcome::Released {
status: 200, body, ..
} => serde_json::from_slice::<serde_json::Value>(body)
.ok()
.map(|resp| responses_sse(&resp)),
_ => None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked bbe6548: the three previous reproductions now pass, including native Codex failure handling and degraded streaming. One remaining data-fidelity issue in the replayed text events is detailed inline.

Comment on lines +369 to +375
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@hidb4ai
hidb4ai merged commit b28c00f into oceanbase:master Nov 2, 2026
2 checks passed
@hidb4ai hidb4ai added the 1.5.0 v1.5.0 release tracking label Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.5.0 v1.5.0 release tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants