fix(codex): surface nested error envelope in Responses type=error SSE frames

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.
This commit is contained in:
Teknium 2026-07-16 17:10:06 -07:00
parent 4dc2b7be0f
commit 78b9d98d76
2 changed files with 157 additions and 3 deletions

View file

@ -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"),
)

View file

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