fix(proxy): keep handler error bodies unchanged and timeouts as api_error

Errors the Messages and count_tokens handlers already returned in the Anthropic
shape keep their original bodies, so litellm_error_type now appears only on
errors the proxy raises before the handler. A 408 stays api_error as before,
and the playground toast titles the new Anthropic types and ignores long
numbers in front of an embedded error
This commit is contained in:
mateo-berri 2026-10-28 14:43:40 -07:00
parent a5e86700c2
commit d258bca229
13 changed files with 67 additions and 46 deletions

View file

@ -31,6 +31,8 @@ ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = {
529: "overloaded_error",
}
REQUEST_TIMEOUT_STATUS: Final = 408
class AnthropicExceptionMapping:
"""
@ -45,7 +47,9 @@ class AnthropicExceptionMapping:
named: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code)
if named is not None:
return named
return "invalid_request_error" if 400 <= status_code < 500 else "api_error"
if status_code == REQUEST_TIMEOUT_STATUS or not 400 <= status_code < 500:
return "api_error"
return "invalid_request_error"
@staticmethod
def create_error_response(

View file

@ -36,7 +36,7 @@ from litellm.proxy.common_request_processing import (
resolve_litellm_call_id,
)
from litellm.proxy.common_utils.anthropic_error_payload import (
anthropic_error_json_response,
anthropic_handler_error_json_response,
)
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
@ -56,7 +56,7 @@ router: Final = APIRouter()
def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
return anthropic_error_json_response(exc, request)
return anthropic_handler_error_json_response(exc, request)
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")

View file

@ -1,7 +1,7 @@
"""Auth, budget and model-access failures are raised by ``user_api_key_auth`` before the Messages
handler runs, so the central handlers pick the Anthropic envelope from the request route."""
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from typing import Final
from fastapi import Request, status
@ -74,17 +74,14 @@ def _with_litellm_error_type(
return with_type
def _anthropic_error_detail(
exc: ProxyException, status_code: int, detail: AnthropicErrorDetail, call_id: str | None
) -> AnthropicErrorDetail:
with_proxy_fields: Final = _with_litellm_error_type(exc, status_code, _with_provider_specific_fields(exc, detail))
def _with_call_id(detail: AnthropicErrorDetail, call_id: str | None) -> AnthropicErrorDetail:
if call_id is None:
return with_proxy_fields
with_call_id: Final[AnthropicErrorDetail] = {**with_proxy_fields, "litellm_call_id": call_id}
return detail
with_call_id: Final[AnthropicErrorDetail] = {**detail, "litellm_call_id": call_id}
return with_call_id
def anthropic_error_content(
def anthropic_handler_error_content(
exc: ProxyException,
status_code: int,
request_id: str | None,
@ -98,12 +95,30 @@ def anthropic_error_content(
body_call_id: Final = error_body_call_id(general_settings, exc.headers.get(LITELLM_CALL_ID_HEADER))
content: Final[AnthropicErrorResponse] = {
**envelope,
"error": _anthropic_error_detail(exc, status_code, envelope["error"], body_call_id),
"error": _with_call_id(_with_provider_specific_fields(exc, envelope["error"]), body_call_id),
}
return content
def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
def anthropic_error_content(
exc: ProxyException,
status_code: int,
request_id: str | None,
general_settings: Mapping[str, object],
) -> AnthropicErrorResponse:
content: Final = anthropic_handler_error_content(exc, status_code, request_id, general_settings)
with_type: Final[AnthropicErrorResponse] = {
**content,
"error": _with_litellm_error_type(exc, status_code, content["error"]),
}
return with_type
def _anthropic_json_response(
exc: ProxyException,
request: Request,
build_content: Callable[[ProxyException, int, str | None, Mapping[str, object]], AnthropicErrorResponse],
) -> JSONResponse:
from litellm.proxy.proxy_server import (
_close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; a JSONResponse returned instead of a raised error must stamp the OTel server span like the global ProxyException handler does
general_settings_view,
@ -111,12 +126,18 @@ def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSON
status_code: Final = anthropic_error_status_code(exc)
_close_dangling_otel_server_span(request, status_code, exc=exc)
content: Final = anthropic_error_content(
exc, status_code, request.headers.get("x-request-id"), general_settings_view()
)
content: Final = build_content(exc, status_code, request.headers.get("x-request-id"), general_settings_view())
return JSONResponse(status_code=status_code, content=content, headers=exc.headers)
def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
return _anthropic_json_response(exc, request, anthropic_error_content)
def anthropic_handler_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
return _anthropic_json_response(exc, request, anthropic_handler_error_content)
def anthropic_internal_server_error_response(request: Request) -> JSONResponse:
content: Final = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View file

@ -357,14 +357,10 @@ def test_anthropic_messages_preserves_cli_beta_safeguards_and_result(gateway: Ga
@pytest.mark.parametrize(
("status", "error_type", "error_class", "litellm_error", "litellm_error_type"),
("status", "error_type", "error_class", "litellm_error"),
[
pytest.param(
429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", "throttling_error", id="rate_limit"
),
pytest.param(
400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", None, id="invalid_request"
),
pytest.param(429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", id="rate_limit"),
pytest.param(400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", id="invalid_request"),
],
)
def test_anthropic_messages_preserves_upstream_error_status_and_body(
@ -373,7 +369,6 @@ def test_anthropic_messages_preserves_upstream_error_status_and_body(
error_type: str,
error_class: type[anthropic.APIStatusError],
litellm_error: str,
litellm_error_type: str | None,
) -> None:
message: Final = f"synthetic {error_type}"
expected_error: Final = {"type": "error", "error": {"type": error_type, "message": message}}
@ -421,7 +416,6 @@ def test_anthropic_messages_preserves_upstream_error_status_and_body(
f"litellm.{litellm_error}: AnthropicException - {json.dumps(expected_error)}\n\n"
f"LiteLLM: model group '{model}' failed with the error above. No fallback was attempted."
),
**({"litellm_error_type": litellm_error_type} if litellm_error_type else {}),
},
}, caught.value.response.text
assert len(wire.drain()) == 1
@ -476,7 +470,6 @@ def test_anthropic_messages_preserves_upstream_rate_limit_retry_after(gateway: G
f"litellm.RateLimitError: AnthropicException - {json.dumps(upstream_error)}\n\n"
f"LiteLLM: model group '{model}' failed with the error above. No fallback was attempted."
),
"litellm_error_type": "throttling_error",
},
}, caught.value.response.text
assert len(wire.drain()) == 1

View file

@ -140,11 +140,7 @@ def test_anthropic_count_without_a_provider_credential_is_an_anthropic_401(
_anthropic(gateway).messages.count_tokens(model=model, messages=_MESSAGES)
assert raised.value.body == {
"type": "error",
"error": {
"type": "authentication_error",
"message": raised.value.body["error"]["message"],
"litellm_error_type": "token_counting_error",
},
"error": {"type": "authentication_error", "message": raised.value.body["error"]["message"]},
}
assert missing in raised.value.message, raised.value.message
assert wire.drain() == ()
@ -214,7 +210,7 @@ def test_anthropic_count_request_errors_use_the_anthropic_envelope(keyless_proxy
assert rejected.status_code == 400, rejected.text
body: Final = _JSON_OBJECT.validate_json(rejected.content)
assert set(body) <= {"type", "error", "request_id"}, body
assert body["error"] == {**upstream_error["error"], "litellm_error_type": "token_counting_error"}, body
assert body["error"] == upstream_error["error"], body
assert len(wire.drain()) == 1

View file

@ -258,10 +258,7 @@ class _CountRoute:
_MESSAGES_ROUTE: Final = _CountRoute(
path=lambda _model: "/v1/messages/count_tokens",
body=_bare,
rejection=lambda message: {
"type": "error",
"error": {"type": "invalid_request_error", "message": message, "litellm_error_type": "token_counting_error"},
},
rejection=lambda message: {"type": "error", "error": {"type": "invalid_request_error", "message": message}},
count_field="input_tokens",
)
_RESPONSES_ROUTE: Final = _CountRoute(

View file

@ -121,7 +121,6 @@ def _assert_budget_refusal(response: httpx.Response, end_user: str) -> None:
def _assert_messages_budget_refusal(response: httpx.Response, end_user: str) -> None:
"""A budget refusal on /v1/messages keeps its 422 but answers the Anthropic envelope."""
assert response.status_code == 422, response.text
body: Final = _json_object(response.content)
assert body["type"] == "error", response.text

View file

@ -73,12 +73,13 @@ class TestCreateErrorResponse:
(418, "invalid_request_error"),
(422, "invalid_request_error"),
(499, "invalid_request_error"),
(408, "api_error"),
(502, "api_error"),
(503, "api_error"),
(599, "api_error"),
],
)
def test_a_status_the_docs_do_not_name_is_a_request_error_below_500_and_an_api_error_from_500(
def test_a_status_the_docs_do_not_name_is_a_request_error_below_500_except_a_timeout_and_an_api_error_from_500(
self, status_code: int, expected_error_type: str
):
response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status")

View file

@ -172,7 +172,6 @@ class TestProxyExceptionAnthropicEnvelope:
"error": {
"type": "invalid_request_error",
"message": "Invalid type for 'metadata': expected an object, but got a string instead.",
"litellm_error_type": "bad_request_error",
},
"request_id": "req_test_6468",
}

View file

@ -168,11 +168,6 @@ async def test_openai_exception_handler_never_fabricates_a_call_id(monkeypatch):
assert "litellm_call_id" not in body["error"]
# ---------------------------------------------------------------------------
# openai_exception_handler: Anthropic envelope on the Messages routes
# ---------------------------------------------------------------------------
def _model_access_denied_exception() -> ProxyException:
return ProxyException(
message="Team not allowed to access model. Tried to access gpt-5.6",

View file

@ -1525,7 +1525,6 @@ async def test_count_tokens_without_a_count_api_rejects_a_non_string_tool_result
assert json.loads(bytes(response.body))["error"] == {
"type": "invalid_request_error",
"message": "tool_result toolu_log content.1.text: Input should be a valid string",
"litellm_error_type": "token_counting_error",
}

View file

@ -173,6 +173,20 @@ describe("toast", () => {
expect(sonner.error).toHaveBeenCalledWith("Access Denied", { description: "denied", duration: 6000 });
});
it.each([
["billing_error", "Request Error"],
["conflict_error", "Already Exists"],
["timeout_error", "Server Error"],
])("titles an Anthropic %s body with no status as %s", (type, title) => {
toast.fromError(JSON.stringify({ type: "error", error: { type, message: "boom" } }));
expect(sonner.error).toHaveBeenCalledWith(title, { description: "boom", duration: 6000 });
});
it("reads no status from a number longer than three digits before the envelope", () => {
toast.fromError(`request 98765 ${JSON.stringify({ error: { message: "boom" } })}`);
expect(sonner.error).toHaveBeenCalledWith("Error", { description: "request 98765 boom", duration: 6000 });
});
it("leaves a string alone when its braces are not a JSON envelope", () => {
toast.fromError("Template {name} is invalid");
expect(sonner.error).toHaveBeenCalledWith("Error", {

View file

@ -49,11 +49,14 @@ const STATUS_TITLES: Readonly<Record<number, string>> = {
const ANTHROPIC_TYPE_TITLES: Readonly<Record<string, string>> = {
invalid_request_error: "Request Error",
authentication_error: "Authentication Error",
billing_error: "Request Error",
permission_error: "Access Denied",
not_found_error: "Not Found",
conflict_error: "Already Exists",
request_too_large: "Request Error",
rate_limit_error: "Rate Limit Exceeded",
api_error: "Server Error",
timeout_error: "Server Error",
overloaded_error: "Service Unavailable",
};
@ -88,7 +91,7 @@ const proxyTypeOf = (payload: unknown): string | undefined => {
};
const EMBEDDED_JSON = /\{[\s\S]*\}/;
const STATUS_BEFORE_JSON = /(\d{3})\s*$/;
const STATUS_BEFORE_JSON = /(?<!\d)([1-5]\d{2})\s*$/;
const statusBefore = (text: string, embedded: string): number | undefined =>
toStatus(text.slice(0, text.indexOf(embedded)).match(STATUS_BEFORE_JSON)?.[1]);