diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 7550fd65d3..7b88e2f2de 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -1257,12 +1257,18 @@ def _build_error_msg(error: Exception, body: Any) -> str: def _body_message_candidates(body: dict) -> Iterator[Any]: - """Body message fields in priority order (OpenAI, flat, litellm/Bedrock proxy shapes).""" + """Body message fields in priority order (OpenAI, flat, litellm/Bedrock proxy, FastAPI shapes).""" yield _error_obj(body).get("message") yield body.get("message") yield body.get("errorMessage") args = body.get("errorArgs") yield args.get("reason") if isinstance(args, dict) else None + # FastAPI/Starlette relays and the Codex gateway answer {"detail": "..."} (or a nested + # OpenAI-ish object); without it a descriptive rejection reads as a bare 400 and the + # large-session heuristic sends it into compression (#81558). A list here is pydantic's + # validation shape, read by _oversized_message_content_rejection. + detail = body.get("detail") + yield detail.get("message") if isinstance(detail, dict) else detail if isinstance(detail, str) else None def _from_cause_chain(error: Exception, pick: Callable[[Any], Any], default: Any) -> Any: diff --git a/contributors/emails/nagornyy.o@gmail.com b/contributors/emails/nagornyy.o@gmail.com new file mode 100644 index 0000000000..fa64384f79 --- /dev/null +++ b/contributors/emails/nagornyy.o@gmail.com @@ -0,0 +1 @@ +i-Hun diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 047ae48188..2e7ed82670 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -1181,6 +1181,28 @@ class TestClassifyApiError: for r in caplog.records ), "Expected a distinct warning identifying the malformed-body 400" + def test_400_top_level_detail_body_is_not_a_bare_400_on_large_session(self): + """FastAPI-style ``{"detail": "..."}`` bodies (Codex gateway, Starlette relays) → + the descriptive text is read, so the large-session heuristic does not route a + model entitlement/retirement rejection into compression (#81558, #106475). + ``str(error)`` is the SDK's ``Error code: 400 - {...}`` form, exactly as on the wire. + Salvaged from #100783 (@i-Hun).""" + detail = "The 'gpt-5.5-codex' model is not supported when using Codex with a ChatGPT account." + large = dict(provider="openai-codex", model="gpt-5.5-codex", + approx_tokens=109_962, context_length=272_000, num_messages=223) + for body in ({"detail": detail}, {"detail": {"message": detail}}): + e = MockAPIError(f"Error code: 400 - {body!r}", status_code=400, body=body) + result = classify_api_error(e, **large) # type: ignore[arg-type] + assert result.reason is not FailoverReason.context_overflow, body + assert result.should_compress is False + assert result.should_fallback is True + assert result.message == detail + # Control: the genuinely bare body the heuristic exists for still compresses. + bare = classify_api_error( + MockAPIError("Error code: 400 - {'error': {'message': 'Error'}}", status_code=400, + body={"error": {"message": "Error"}}), **large) # type: ignore[arg-type] + assert bare.reason is FailoverReason.context_overflow + # ── Peer closed + large session ──