fix(error_classifier): handle dict-typed message fields without crashing

When API providers return Pydantic-style validation errors where
body['message'] or body['error']['message'] is a dict (e.g.
{"detail": [...]}), the error classifier was crashing with
AttributeError: 'dict' object has no attribute 'lower'.

The 'or ""' fallback only handles None/falsy values. A non-empty
dict is truthy and passes through to .lower(), which fails.

Fix: Wrap all 5 call sites with str() before calling .lower().
This is a no-op for strings and safely converts dicts to their
repr for pattern matching (no false positives on classification
patterns like 'rate limit', 'context length', etc.).

Closes #11233
This commit is contained in:
Linux2010 2026-04-17 08:40:30 +00:00 committed by Teknium
parent acca428c81
commit b869bf206c
2 changed files with 75 additions and 5 deletions

View file

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