diff --git a/agent/error_classifier.py b/agent/error_classifier.py index fa6a985041..fcdb8ba676 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -290,7 +290,7 @@ def classify_api_error( if isinstance(body, dict): _err_obj = body.get("error", {}) if isinstance(_err_obj, dict): - _body_msg = (_err_obj.get("message") or "").lower() + _body_msg = str(_err_obj.get("message") or "").lower() # Parse metadata.raw for wrapped provider errors _metadata = _err_obj.get("metadata", {}) if isinstance(_metadata, dict): @@ -302,11 +302,11 @@ def classify_api_error( if isinstance(_inner, dict): _inner_err = _inner.get("error", {}) if isinstance(_inner_err, dict): - _metadata_msg = (_inner_err.get("message") or "").lower() + _metadata_msg = str(_inner_err.get("message") or "").lower() except (json.JSONDecodeError, TypeError): pass if not _body_msg: - _body_msg = (body.get("message") or "").lower() + _body_msg = str(body.get("message") or "").lower() # Combine all message sources for pattern matching parts = [_raw_msg] if _body_msg and _body_msg not in _raw_msg: @@ -606,10 +606,10 @@ def _classify_400( if isinstance(body, dict): err_obj = body.get("error", {}) if isinstance(err_obj, dict): - err_body_msg = (err_obj.get("message") or "").strip().lower() + err_body_msg = str(err_obj.get("message") or "").strip().lower() # Responses API (and some providers) use flat body: {"message": "..."} if not err_body_msg: - err_body_msg = (body.get("message") or "").strip().lower() + err_body_msg = str(body.get("message") or "").strip().lower() is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "") is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80 diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 766c5475f8..dd74249b14 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -849,3 +849,73 @@ class TestAdversarialEdgeCases: ) result = classify_api_error(e, provider="openrouter") assert result.reason == FailoverReason.model_not_found + + # ── Regression: dict-typed message field (Issue #11233) ── + + def test_pydantic_dict_message_no_crash(self): + """Pydantic validation errors return message as dict, not string. + + Regression: classify_api_error must not crash when body['message'] + is a dict (e.g. {"detail": [...]} from FastAPI/Pydantic). The + 'or ""' fallback only handles None/falsy values — a non-empty + dict is truthy and passed to .lower(), causing AttributeError. + """ + e = MockAPIError( + "Unprocessable Entity", + status_code=422, + body={ + "object": "error", + "message": { + "detail": [ + { + "type": "extra_forbidden", + "loc": ["body", "think"], + "msg": "Extra inputs are not permitted", + } + ] + }, + }, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.format_error + assert result.status_code == 422 + assert result.retryable is False + + def test_nested_error_dict_message_no_crash(self): + """Nested body['error']['message'] as dict must not crash. + + Some providers wrap Pydantic errors in an 'error' object. + """ + e = MockAPIError( + "Validation error", + status_code=400, + body={ + "error": { + "message": { + "detail": [ + {"type": "missing", "loc": ["body", "required"]} + ] + } + } + }, + ) + result = classify_api_error(e, approx_tokens=1000) + assert result.reason == FailoverReason.format_error + assert result.status_code == 400 + + def test_metadata_raw_dict_message_no_crash(self): + """OpenRouter metadata.raw with dict message must not crash.""" + e = MockAPIError( + "Provider error", + status_code=400, + body={ + "error": { + "message": "Provider error", + "metadata": { + "raw": '{"error":{"message":{"detail":[{"type":"invalid"}]}}}' + } + } + }, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.format_error