mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
4dc2b7be0f
commit
78b9d98d76
2 changed files with 157 additions and 3 deletions
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue