From d258bca2293ee400f3a3dc0a851dcc42f3a3680e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:43:40 -0700 Subject: [PATCH] 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 --- .../exceptions/exception_mapping_utils.py | 6 ++- .../proxy/anthropic_endpoints/endpoints.py | 4 +- .../common_utils/anthropic_error_payload.py | 47 ++++++++++++++----- .../anthropic/test_anthropic_wire.py | 13 ++--- .../test_count_tokens_error_shapes_wire.py | 8 +--- .../test_vertex_partner_count_tokens_wire.py | 5 +- .../test_end_user_spend_without_proxy_user.py | 1 - .../test_exception_mapping_utils.py | 3 +- .../anthropic_endpoints/test_endpoints.py | 1 - .../proxy_server/test_exception_handlers.py | 5 -- tests/unit/proxy/test_proxy_token_counter.py | 1 - ui/litellm-dashboard/src/lib/toast.test.ts | 14 ++++++ ui/litellm-dashboard/src/lib/toast.ts | 5 +- 13 files changed, 67 insertions(+), 46 deletions(-) diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index 9c726b97630..d98110cbea5 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -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( diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index d245984933e..19f320ec5aa 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -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") diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py index 98342e945c2..91d71ee0dd3 100644 --- a/litellm/proxy/common_utils/anthropic_error_payload.py +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -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, diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py index fe6b8efb423..a448a8cd2da 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py @@ -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 diff --git a/tests/integration/providers/test_count_tokens_error_shapes_wire.py b/tests/integration/providers/test_count_tokens_error_shapes_wire.py index a5299547ec3..a6c3a6dc62f 100644 --- a/tests/integration/providers/test_count_tokens_error_shapes_wire.py +++ b/tests/integration/providers/test_count_tokens_error_shapes_wire.py @@ -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 diff --git a/tests/integration/providers/test_vertex_partner_count_tokens_wire.py b/tests/integration/providers/test_vertex_partner_count_tokens_wire.py index 809e8877312..c679311e4cc 100644 --- a/tests/integration/providers/test_vertex_partner_count_tokens_wire.py +++ b/tests/integration/providers/test_vertex_partner_count_tokens_wire.py @@ -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( diff --git a/tests/integration/spend/test_end_user_spend_without_proxy_user.py b/tests/integration/spend/test_end_user_spend_without_proxy_user.py index 3e455f9aa4f..725c809006a 100644 --- a/tests/integration/spend/test_end_user_spend_without_proxy_user.py +++ b/tests/integration/spend/test_end_user_spend_without_proxy_user.py @@ -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 diff --git a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py index 7019e68edf6..ea21e0a1d9b 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -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") diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index 5d18c883db3..c7bb05e5d30 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -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", } diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index 8a8916407d1..4d9054051aa 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -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", diff --git a/tests/unit/proxy/test_proxy_token_counter.py b/tests/unit/proxy/test_proxy_token_counter.py index f8134aed27f..fa0c9eda02d 100644 --- a/tests/unit/proxy/test_proxy_token_counter.py +++ b/tests/unit/proxy/test_proxy_token_counter.py @@ -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", } diff --git a/ui/litellm-dashboard/src/lib/toast.test.ts b/ui/litellm-dashboard/src/lib/toast.test.ts index bb0edf791f3..5d739db2534 100644 --- a/ui/litellm-dashboard/src/lib/toast.test.ts +++ b/ui/litellm-dashboard/src/lib/toast.test.ts @@ -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", { diff --git a/ui/litellm-dashboard/src/lib/toast.ts b/ui/litellm-dashboard/src/lib/toast.ts index 8ef545ba0ef..aa7aa3aa94e 100644 --- a/ui/litellm-dashboard/src/lib/toast.ts +++ b/ui/litellm-dashboard/src/lib/toast.ts @@ -49,11 +49,14 @@ const STATUS_TITLES: Readonly> = { const ANTHROPIC_TYPE_TITLES: Readonly> = { 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 = /(? toStatus(text.slice(0, text.indexOf(embedded)).match(STATUS_BEFORE_JSON)?.[1]);