From 78b9d98d76d7fe67c831ff4bbb8f515d3365c00b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:10:06 -0700 Subject: [PATCH] fix(codex): surface nested error envelope in Responses type=error SSE frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#36130: the Responses spec carries streaming error details at the top level of the error frame, but the official OpenAI SDK and several OpenAI-compatible proxies wrap them in an HTTP-style nested envelope ({"type": "error", "error": {code, message, param}}). _raise_stream_error only read top-level fields, so nested-envelope frames collapsed to the generic 'stream emitted error event' placeholder with code=None — the error classifier never saw the provider's real failure reason, misrouting rate-limit / context-overflow / entitlement errors into the generic retry path. Top-level fields keep precedence; the envelope is a fallback. Null-tolerant for spec-compliant frames with explicit nulls. --- agent/codex_runtime.py | 28 +++- .../test_codex_xai_oauth_recovery.py | 132 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 9c8362b8a39b..8312cdb53aaa 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -823,15 +823,37 @@ def _item_field(item: Any, name: str, default: Any = None) -> Any: def _raise_stream_error(event: Any) -> None: """Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame. + The Responses spec puts the failure details at the top level of the + frame (``{"type": "error", "code": ..., "message": ..., "param": ...}``), + but the official OpenAI SDK and several OpenAI-compatible proxies wrap + them in an HTTP-style nested envelope instead + (``{"type": "error", "error": {"code": ..., "message": ..., "param": ...}}``). + Read the top-level fields first, then fall back to the nested envelope so + the error classifier sees the provider's real code/message (rate-limit vs + context-overflow vs entitlement) rather than the generic placeholder. + Port of anomalyco/opencode#36130. + Imported lazily so this module stays importable from places that don't pull in ``run_agent`` (e.g. plugin code, doc tools). """ from run_agent import _StreamErrorEvent - message = (_event_field(event, "message", "") or "stream emitted error event").strip() + + nested = _event_field(event, "error") + + def _error_field(name: str) -> Any: + value = _event_field(event, name) + if value is None and nested is not None: + value = _item_field(nested, name) + return value + + raw_message = _error_field("message") + if raw_message is not None and not isinstance(raw_message, str): + raw_message = str(raw_message) + message = (raw_message or "stream emitted error event").strip() or "stream emitted error event" raise _StreamErrorEvent( message, - code=_event_field(event, "code"), - param=_event_field(event, "param"), + code=_error_field("code"), + param=_error_field("param"), ) diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 33d287c2b2fa..cb926a09affe 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -102,6 +102,138 @@ def test_codex_stream_wire_error_event_surfaces_stream_error_event(provider_mess assert excinfo.value.body["error"]["message"] == provider_message +# --------------------------------------------------------------------------- +# Nested error envelope on ``type=error`` SSE frames (opencode#36130 port) +# +# The Responses spec carries error details at the top level of the frame, +# but the official OpenAI SDK and several OpenAI-compatible proxies wrap +# them in an HTTP-style nested envelope: +# {"type": "error", "error": {"code": ..., "message": ..., "param": ...}} +# Before the fix, _raise_stream_error only read top-level fields, so these +# frames collapsed to the generic "stream emitted error event" placeholder +# and the error classifier never saw the provider's real code/message. +# --------------------------------------------------------------------------- + + +def test_codex_stream_wire_error_event_nested_envelope_dict(): + """Details nested under ``error`` (dict shape) are surfaced.""" + from run_agent import _StreamErrorEvent + + agent = _make_codex_agent() + + class _ErrorCreateStream: + def __iter__(self_inner): + yield { + "type": "error", + "sequence_number": 2, + "error": { + "type": "invalid_request_error", + "code": "context_length_exceeded", + "message": "prompt too long", + "param": "input", + }, + } + + def close(self_inner): + pass + + mock_client = MagicMock() + mock_client.responses.create.return_value = _ErrorCreateStream() + + with pytest.raises(_StreamErrorEvent) as excinfo: + agent._run_codex_stream({}, client=mock_client) + + assert "prompt too long" in str(excinfo.value) + assert excinfo.value.code == "context_length_exceeded" + assert excinfo.value.param == "input" + assert excinfo.value.body["error"]["message"] == "prompt too long" + + +def test_codex_stream_wire_error_event_nested_envelope_attr_style(): + """Details nested under ``error`` (SDK attr-object shape) are surfaced.""" + from run_agent import _StreamErrorEvent + + agent = _make_codex_agent() + + class _ErrorCreateStream: + def __iter__(self_inner): + yield SimpleNamespace( + type="error", + message=None, + code=None, + param=None, + error=SimpleNamespace( + type="rate_limit_error", + code="rate_limit_exceeded", + message="Slow down", + param=None, + ), + ) + + def close(self_inner): + pass + + mock_client = MagicMock() + mock_client.responses.create.return_value = _ErrorCreateStream() + + with pytest.raises(_StreamErrorEvent) as excinfo: + agent._run_codex_stream({}, client=mock_client) + + assert "Slow down" in str(excinfo.value) + assert excinfo.value.code == "rate_limit_exceeded" + + +def test_codex_stream_wire_error_event_top_level_wins_over_envelope(): + """Top-level fields keep precedence when both shapes are present.""" + from run_agent import _StreamErrorEvent + + agent = _make_codex_agent() + + class _ErrorCreateStream: + def __iter__(self_inner): + yield { + "type": "error", + "message": "top-level message", + "code": "top_level_code", + "error": {"message": "nested message", "code": "nested_code"}, + } + + def close(self_inner): + pass + + mock_client = MagicMock() + mock_client.responses.create.return_value = _ErrorCreateStream() + + with pytest.raises(_StreamErrorEvent) as excinfo: + agent._run_codex_stream({}, client=mock_client) + + assert "top-level message" in str(excinfo.value) + assert excinfo.value.code == "top_level_code" + + +def test_codex_stream_wire_error_event_null_fields_fall_back_to_placeholder(): + """Spec-compliant frames with null fields keep the stable placeholder.""" + from run_agent import _StreamErrorEvent + + agent = _make_codex_agent() + + class _ErrorCreateStream: + def __iter__(self_inner): + yield {"type": "error", "code": None, "message": None, "param": None, "error": None} + + def close(self_inner): + pass + + mock_client = MagicMock() + mock_client.responses.create.return_value = _ErrorCreateStream() + + with pytest.raises(_StreamErrorEvent) as excinfo: + agent._run_codex_stream({}, client=mock_client) + + assert "stream emitted error event" in str(excinfo.value) + assert excinfo.value.code is None + + def test_codex_stream_retries_remote_protocol_error_once(): """Transport errors (``httpx.RemoteProtocolError``) trigger a single retry.