Skip to content

Commit 9aeb942

Browse files
committed
fix(openai-compat): preserve nested response failure codes
Normalize public Responses terminal failures so redacted safe error codes are available under response.error for streaming clients while keeping provider messages generic. Cover transport, runtime relay, collected response, and compatibility matrix behavior.
1 parent 05ffd1b commit 9aeb942

6 files changed

Lines changed: 169 additions & 4 deletions

File tree

lib/codex_pooler/gateway/runtime/streaming/downstream_stream.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
199199
state
200200
|> maybe_put_public_openai_responses_response_id(decoded)
201201
|> maybe_mark_public_openai_responses_data_seen(event)
202-
|> maybe_mark_public_openai_responses_terminal_seen(event)
202+
|> maybe_mark_public_openai_responses_terminal_seen(event_type, decoded)
203203
end
204204

205205
defp normalize_codex_responses_stream_data(data, endpoint, opts, state) when is_binary(data) do
@@ -300,8 +300,8 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
300300
end
301301
end
302302

303-
defp maybe_mark_public_openai_responses_terminal_seen(state, event) do
304-
case StreamProtocol.terminal_outcome_event(event) do
303+
defp maybe_mark_public_openai_responses_terminal_seen(state, event_type, decoded) do
304+
case StreamProtocol.terminal_outcome(event_type, decoded) do
305305
{:ok, %{kind: :failed, failure: failure}} ->
306306
state
307307
|> Map.put(:public_openai_responses_terminal_seen?, true)

lib/codex_pooler/gateway/transports/streaming/stream_protocol/public_responses.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,12 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol.PublicResponse
485485
)
486486
end
487487

488+
defp normalize_response_error(
489+
%{"error" => %{} = public_error, "response" => %{} = response} = decoded
490+
) do
491+
Map.put(decoded, "response", Map.put(response, "error", public_error))
492+
end
493+
488494
defp normalize_response_error(decoded), do: decoded
489495

490496
defp normalize_terminal_error(error) do

test/codex_pooler/gateway/runtime/streaming/downstream_stream_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,42 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStreamTest do
447447
assert failure.event_type == "response.failed"
448448
end
449449

450+
test "copies top-level public Responses terminal error into response failure" do
451+
opts =
452+
RequestOptions.build(
453+
%{public_openai_responses_stream: true},
454+
"/v1/responses",
455+
%{"stream" => true}
456+
)
457+
458+
state = DownstreamStream.initial_state(:relay, opts)
459+
460+
failed =
461+
sse_event("response.failed", %{
462+
"type" => "response.failed",
463+
"response" => %{
464+
"id" => "resp_public_failed_top_level_error",
465+
"status" => "failed"
466+
},
467+
"error" => %{
468+
"type" => "invalid_request_error",
469+
"code" => "context_length_exceeded"
470+
}
471+
})
472+
473+
assert {chunk, state} =
474+
DownstreamStream.normalize_data(failed, "/v1/responses", opts, state)
475+
476+
assert [%{"event" => "response.failed", "data" => data}] = public_sse_events(chunk)
477+
assert data["error"]["code"] == "context_length_exceeded"
478+
479+
assert {:failed, failure} = DownstreamStream.terminal_outcome(state)
480+
assert failure.event_type == "response.failed"
481+
482+
assert {data["response"]["error"]["code"], failure.code} ==
483+
{"context_length_exceeded", "context_length_exceeded"}
484+
end
485+
450486
test "synthesizes a sanitized terminal failure with the observed public response id" do
451487
opts =
452488
RequestOptions.build(

test/codex_pooler/gateway/transports/stream_protocol_test.exs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocolTest do
5151
assert data["type"] == "response.failed"
5252
assert data["response"]["status"] == "failed"
5353
assert data["error"]["code"] == "context_length_exceeded"
54+
assert data["response"]["error"]["code"] == "context_length_exceeded"
5455
end
5556
end
5657

@@ -322,6 +323,32 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocolTest do
322323
refute chunk =~ "event: response.output_text.delta\n"
323324
end
324325

326+
test "adds redacted nested response error for top-level context overflow failures" do
327+
state = StreamProtocol.public_openai_responses_stream_state()
328+
329+
failed =
330+
sse_event("response.failed", %{
331+
"type" => "response.failed",
332+
"error" => %{
333+
"type" => "invalid_request_error",
334+
"code" => "context_length_exceeded",
335+
"message" => "synthetic untrusted overflow detail"
336+
},
337+
"response" => %{"id" => "resp_context_overflow", "status" => "failed"}
338+
})
339+
340+
assert {chunk, _state} =
341+
StreamProtocol.normalize_public_openai_responses_sse_data(failed, state)
342+
343+
assert [%{"event" => "response.failed", "data" => data}] = public_sse_events(chunk)
344+
assert data["error"]["code"] == "context_length_exceeded"
345+
assert data["error"]["message"] == "upstream request failed"
346+
347+
assert %{"error" => response_error} = data["response"]
348+
assert response_error["code"] == "context_length_exceeded"
349+
assert response_error["message"] == "upstream request failed"
350+
end
351+
325352
test "emits early top-level error without synthetic success prefix" do
326353
state = StreamProtocol.public_openai_responses_stream_state()
327354

test/codex_pooler_web/controllers/v1/responses_controller_test.exs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2748,6 +2748,62 @@ defmodule CodexPoolerWeb.V1.ResponsesControllerTest do
27482748
assert FakeUpstream.count(upstream) == 1
27492749
end
27502750

2751+
@tag :provider_invalid_request_redaction
2752+
test "POST /v1/responses collected SSE preserves safe terminal error codes", %{conn: conn} do
2753+
provider_message =
2754+
"provider 400 leaked https://provider.internal.example/context?key=sk-secret and prompt SENTINEL_COLLECT"
2755+
2756+
upstream_error =
2757+
provider_invalid_request_error("context_length_exceeded", provider_message, "input")
2758+
2759+
cases = [
2760+
{"top-level only",
2761+
%{
2762+
"type" => "response.failed",
2763+
"error" => upstream_error,
2764+
"response" => %{
2765+
"id" => "resp_v1_collect_top_level_error",
2766+
"status" => "failed"
2767+
}
2768+
}},
2769+
{"nested",
2770+
%{
2771+
"type" => "response.failed",
2772+
"response" => %{
2773+
"id" => "resp_v1_collect_nested_error",
2774+
"status" => "failed",
2775+
"error" => upstream_error
2776+
}
2777+
}}
2778+
]
2779+
2780+
Enum.each(cases, fn {_label, terminal_event} ->
2781+
upstream = start_upstream(FakeUpstream.sse_stream([{"response.failed", terminal_event}]))
2782+
setup = gateway_setup(upstream)
2783+
2784+
response =
2785+
conn
2786+
|> recycle()
2787+
|> auth(setup)
2788+
|> post("/v1/responses", %{
2789+
"model" => setup.model.exposed_model_id,
2790+
"input" => "synthetic collected provider invalid request"
2791+
})
2792+
2793+
assert %{"error" => error} = json_response(response, 502)
2794+
assert error["message"] == "upstream request failed"
2795+
assert error["type"] == "server_error"
2796+
assert error["code"] == "context_length_exceeded"
2797+
refute Map.has_key?(error, "param")
2798+
refute response.resp_body =~ provider_message
2799+
refute response.resp_body =~ "provider.internal.example"
2800+
refute response.resp_body =~ "sk-secret"
2801+
refute response.resp_body =~ "SENTINEL_COLLECT"
2802+
refute response.resp_body =~ "input"
2803+
assert FakeUpstream.count(upstream) == 1
2804+
end)
2805+
end
2806+
27512807
@tag :server_error_redaction
27522808
test "POST /v1/responses SSE collection redacts safe-looking terminal 502 errors" do
27532809
provider_message =
@@ -2780,6 +2836,38 @@ defmodule CodexPoolerWeb.V1.ResponsesControllerTest do
27802836
refute inspect(error) =~ "provider_stack"
27812837
end
27822838

2839+
@tag :server_error_redaction
2840+
test "POST /v1/responses SSE collection redacts top-level-only terminal errors" do
2841+
provider_message =
2842+
"provider failed at https://upstream.internal.example/internal/context?token=secret"
2843+
2844+
upstream_error =
2845+
provider_invalid_request_error("context_length_exceeded", provider_message, "input")
2846+
2847+
body =
2848+
"event: response.failed\n" <>
2849+
"data: " <>
2850+
Jason.encode!(%{
2851+
"type" => "response.failed",
2852+
"error" => upstream_error,
2853+
"response" => %{
2854+
"id" => "resp_v1_collect_top_level_only_failed",
2855+
"status" => "failed"
2856+
}
2857+
}) <>
2858+
"\n\n"
2859+
2860+
assert {:error, error} = Responses.response_from_sse(body)
2861+
assert error.status == 502
2862+
assert error.message == "upstream request failed"
2863+
assert error.code == "context_length_exceeded"
2864+
assert error.param == nil
2865+
refute inspect(error) =~ "provider failed"
2866+
refute inspect(error) =~ "upstream.internal.example"
2867+
refute inspect(error) =~ "/internal/context"
2868+
refute inspect(error) =~ "input"
2869+
end
2870+
27832871
@tag :server_error_redaction
27842872
test "POST /v1/responses SSE collection rejects failure-coded incomplete" do
27852873
body =

test/support/compatibility_matrix.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ defmodule CodexPooler.CompatibilityMatrix do
292292
future_routes: [],
293293
fixture: :v1_supported_surface,
294294
contract:
295-
"OpenAI-compatible /v1 routes are default-on for pools, require bearer API-key auth, return OpenAI-shaped errors without anonymous local or CIDR bypasses, include narrow GET /v1/responses Responses websocket compatibility only, exclude broad /v1/realtime routes, keep POST /v1/responses/compact routed only to deterministic unsupported_endpoint with no upstream compact dispatch, reject OpenAI Responses remote MCP tool definitions before upstream dispatch in both top-level tools and nested additional_tools.tools locations with OpenAI-shaped invalid_request errors, consume continuity headers using the documented local precedence without forwarding session-id, x-session-id, or x-session-affinity upstream, fail closed for pinned /v1/responses continuations whose upstream account needs revoked-refresh-token reauthentication with the shared restart_with_full_context recovery guidance, allow prompt-cache routing locality only on POST responses and chat completions, accept Codex-native Responses web_search hosted tool shapes with boolean access flags while keeping web_search_preview type-only, accept Responses truncation auto and disabled locally without forwarding it upstream, lift Responses system/developer input-message text into top-level instructions, emit early public streaming terminal errors without synthetic success prefixes, emit sanitized response.failed upstream_stream_error when POST /v1/responses SSE has already exposed public Responses data and upstream closes before a Responses terminal event, keep that synthetic terminal limited to public HTTP SSE and preserve backend raw/websocket stream behavior, redact server-class/internal/upstream public /v1 errors while preserving invalid_request_error validation details, map Responses content_filter/content-filter incomplete reasons to chat finish_reason content_filter while other incomplete reasons remain length, forward structured tool-result/function_call_output payloads unchanged, translate chat-style role=tool continuation messages and Hermes assistant tool-call replays into Responses function_call/function_call_output input items before validation, accept safe Hermes assistant replay status values, drop known OMP function_call replay status fields before validation, translate OpenClaw assistant thinking replays before validation, and keep chat input fallback, Responses additional_tools support narrow and non-executable, and Responses namespace-tool support narrow"
295+
"OpenAI-compatible /v1 routes are default-on for pools, require bearer API-key auth, return OpenAI-shaped errors without anonymous local or CIDR bypasses, include narrow GET /v1/responses Responses websocket compatibility only, exclude broad /v1/realtime routes, keep POST /v1/responses/compact routed only to deterministic unsupported_endpoint with no upstream compact dispatch, reject OpenAI Responses remote MCP tool definitions before upstream dispatch in both top-level tools and nested additional_tools.tools locations with OpenAI-shaped invalid_request errors, consume continuity headers using the documented local precedence without forwarding session-id, x-session-id, or x-session-affinity upstream, fail closed for pinned /v1/responses continuations whose upstream account needs revoked-refresh-token reauthentication with the shared restart_with_full_context recovery guidance, allow prompt-cache routing locality only on POST responses and chat completions, accept Codex-native Responses web_search hosted tool shapes with boolean access flags while keeping web_search_preview type-only, accept Responses truncation auto and disabled locally without forwarding it upstream, lift Responses system/developer input-message text into top-level instructions, emit early public streaming terminal errors without synthetic success prefixes, emit sanitized response.failed upstream_stream_error when POST /v1/responses SSE has already exposed public Responses data and upstream closes before a Responses terminal event, keep that synthetic terminal limited to public HTTP SSE and preserve backend raw/websocket stream behavior, redact server-class/internal/upstream public /v1 errors while preserving invalid_request_error validation details, preserve safe machine-readable codes for redacted public OpenAI-compatible Responses terminal failures in nested response.error through low-level public SSE normalization and the runtime streaming relay, keep top-level error code-aligned when Pooler emits one, map Responses content_filter/content-filter incomplete reasons to chat finish_reason content_filter while other incomplete reasons remain length, forward structured tool-result/function_call_output payloads unchanged, translate chat-style role=tool continuation messages and Hermes assistant tool-call replays into Responses function_call/function_call_output input items before validation, accept safe Hermes assistant replay status values, drop known OMP function_call replay status fields before validation, translate OpenClaw assistant thinking replays before validation, and keep chat input fallback, Responses additional_tools support narrow and non-executable, and Responses namespace-tool support narrow"
296296
},
297297
%{
298298
slug: :v1_unsupported_public_surface,
@@ -781,6 +781,14 @@ defmodule CodexPooler.CompatibilityMatrix do
781781
server_class_message: "upstream request failed",
782782
server_class_type: "server_error",
783783
server_class_code: ["safe_upstream_code", "upstream_error"],
784+
responses_terminal_code_locations: [
785+
"response.error.code",
786+
"top_level_error.code_when_emitted"
787+
],
788+
responses_terminal_stream_paths: [
789+
"low_level_public_sse_normalization",
790+
"runtime_streaming_relay"
791+
],
784792
preserves_invalid_request_error_details: true
785793
},
786794
chat_finish_reasons: %{

0 commit comments

Comments
 (0)