From a0d8f147248d189bb17a0f498351e175d627c590 Mon Sep 17 00:00:00 2001 From: Alberto Perdomo Date: Mon, 26 Oct 2026 12:26:44 +0000 Subject: [PATCH 1/9] fix: Return Anthropic error envelopes on Messages auth failures Signed-off-by: Alberto Perdomo --- .../exceptions/exception_mapping_utils.py | 12 +- .../proxy/anthropic_endpoints/endpoints.py | 48 +----- .../common_utils/anthropic_error_payload.py | 110 +++++++++++++ litellm/proxy/proxy_server.py | 7 + .../test_exception_mapping_utils.py | 13 +- .../proxy_server/test_exception_handlers.py | 153 ++++++++++++++++++ 6 files changed, 297 insertions(+), 46 deletions(-) create mode 100644 litellm/proxy/common_utils/anthropic_error_payload.py diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index eb3ec8aaee2..8a868b3aff4 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -34,8 +34,16 @@ class AnthropicExceptionMapping: @staticmethod def get_error_type(status_code: int) -> AnthropicErrorType: - """Map HTTP status code to Anthropic error type.""" - return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error") + """Map HTTP status code to Anthropic error type. + + A 4xx the table does not name is the caller's mistake, so it answers + ``invalid_request_error`` rather than ``api_error``, which the Anthropic taxonomy + reserves for server-side failures. + """ + mapped: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code) + if mapped is not None: + return mapped + return "invalid_request_error" if 400 <= status_code < 500 else "api_error" @staticmethod def create_error_response( diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index a11268ad432..3a7ac509aef 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -13,8 +13,6 @@ from typing_extensions import ReadOnly, TypedDict import litellm from litellm.anthropic_interface.exceptions import ( - AnthropicErrorDetail, - AnthropicErrorResponse, AnthropicExceptionMapping, ) from litellm.integrations.custom_guardrail import ModifyResponseException @@ -34,13 +32,14 @@ from litellm.proxy.common_request_processing import ( proxy_exception_from_route_error, resolve_litellm_call_id, ) -from litellm.proxy.common_utils.error_body_call_id import error_body_call_id +from litellm.proxy.common_utils.anthropic_error_payload import ( + anthropic_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 read_request_body, ) from litellm.proxy.common_utils.openai_error_payload import ( - LITELLM_CALL_ID_HEADER, error_status_code, openai_error_param, openai_error_type, @@ -51,44 +50,13 @@ from litellm.proxy.litellm_pre_call_utils import should_auto_drop_params_for_age router: Final = APIRouter() -def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDetail) -> AnthropicErrorDetail: - if not exc.provider_specific_fields: - return detail - with_fields: Final[AnthropicErrorDetail] = {**detail, "provider_specific_fields": exc.provider_specific_fields} - return with_fields - - -def _anthropic_error_detail( - exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None -) -> AnthropicErrorDetail: - if call_id is None: - return _with_provider_specific_fields(exc, detail) - with_call_id: Final[AnthropicErrorDetail] = { - **_with_provider_specific_fields(exc, detail), - "litellm_call_id": call_id, - } - return with_call_id - - def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse: - from litellm.proxy.proxy_server import ( - _close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; error JSONResponses returned by the route must stamp the OTel server span like the global ProxyException handler does - general_settings_view, - ) + """The Anthropic error envelope for a failure raised inside a Messages handler. - status_code: Final = int(exc.code) if exc.code is not None and exc.code.isdigit() else 500 - _close_dangling_otel_server_span(request, status_code, exc=exc) - envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error( - status_code=status_code, - raw_message=exc.message, - request_id=request.headers.get("x-request-id"), - ) - body_call_id: Final = error_body_call_id(general_settings_view(), exc.headers.get(LITELLM_CALL_ID_HEADER)) - content: Final[AnthropicErrorResponse] = { - **envelope, - "error": _anthropic_error_detail(exc, envelope["error"], body_call_id), - } - return JSONResponse(status_code=status_code, content=content, headers=exc.headers) + Delegates to the shared renderer so a failure raised here and one raised by the auth + layer before the handler (see the central ProxyException handler) answer identically. + """ + return anthropic_error_json_response(exc, request) def _strip_total_tokens_from_anthropic_response(response: object) -> None: diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py new file mode 100644 index 00000000000..d303e96427d --- /dev/null +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -0,0 +1,110 @@ +"""Renders the Anthropic error envelope for the Messages routes, wherever the failure is raised. + +The central ``ProxyException`` handler answers every route with OpenAI's ``{"error": {...}}`` +body, which an Anthropic SDK cannot read. Auth, key, budget and model-access failures are +raised by ``user_api_key_auth`` before the Messages handler runs, so the envelope is picked +from the request route rather than inside the handler. +""" + +from collections.abc import Mapping +from typing import Final + +from fastapi import Request, status +from fastapi.responses import JSONResponse + +from litellm.anthropic_interface.exceptions import ( + AnthropicErrorDetail, + AnthropicErrorResponse, + AnthropicExceptionMapping, +) +from litellm.proxy._types import ProxyException +from litellm.proxy.common_utils.error_body_call_id import error_body_call_id +from litellm.proxy.common_utils.openai_error_payload import LITELLM_CALL_ID_HEADER + +ANTHROPIC_MESSAGES_ROUTES: Final[frozenset[str]] = frozenset( + { + "/v1/messages", + "/v1/messages/count_tokens", + "/claude_code_gateway/v1/messages", + "/claude_code_gateway/v1/messages/count_tokens", + } +) + + +def is_anthropic_messages_route(route: str) -> bool: + """True for the Messages routes, whose callers read the Anthropic error envelope. + + Narrower than ``LiteLLMRoutes.anthropic_routes``: the Skills and marketplace routes there + answer their own body shape, and ``/anthropic/v1/messages`` relays the upstream response. + """ + return route in ANTHROPIC_MESSAGES_ROUTES + + +def anthropic_error_status_code(exc: ProxyException) -> int: + """The exception's stringified ``code`` as a status, else 500. + + ``ProxyException.__init__`` stringifies ``code``, so a code-less exception holds the + literal ``"None"``, which must not be parsed as a status. + """ + code: Final = exc.code + if code.isdigit(): + return int(code) + return status.HTTP_500_INTERNAL_SERVER_ERROR + + +def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDetail) -> AnthropicErrorDetail: + if not exc.provider_specific_fields: + return detail + with_fields: Final[AnthropicErrorDetail] = {**detail, "provider_specific_fields": exc.provider_specific_fields} + return with_fields + + +def _anthropic_error_detail( + exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None +) -> AnthropicErrorDetail: + if call_id is None: + return _with_provider_specific_fields(exc, detail) + with_call_id: Final[AnthropicErrorDetail] = { + **_with_provider_specific_fields(exc, detail), + "litellm_call_id": call_id, + } + return with_call_id + + +def anthropic_error_content( + exc: ProxyException, + status_code: int, + request_id: str | None, + general_settings: Mapping[str, object], +) -> AnthropicErrorResponse: + """The Anthropic ``{"type": "error", "error": {...}}`` body for ``exc``, typed by status.""" + envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=status_code, + raw_message=exc.message, + request_id=request_id, + ) + 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, envelope["error"], body_call_id), + } + return content + + +def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse: + """The Anthropic error response for a Messages request, span close included. + + Shared by the Messages handlers and the central handler, so a failure raised before the + handler and one raised inside it answer identically. + """ + 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, + ) + + 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() + ) + return JSONResponse(status_code=status_code, content=content, headers=exc.headers) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3de3dccc532..584bcbc9d20 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -371,6 +371,7 @@ from litellm.proxy.auth.auth_checks import ( from litellm.proxy.auth.auth_object_prefetch import AUTH_OBJECTS_TARGET from litellm.proxy.auth.auth_utils import ( check_response_size_is_safe, + get_request_route, is_request_body_safe, log_model_access_denial, log_once_if_budget_reservation_disabled, @@ -433,6 +434,10 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l should_return_raw_model_name, ttft_keepalive_interval, ) +from litellm.proxy.common_utils.anthropic_error_payload import ( + anthropic_error_json_response, + is_anthropic_messages_route, +) from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( AuthCacheInvalidationSubscriber, ) @@ -2066,6 +2071,8 @@ async def openai_exception_handler(request: Request, exc: ProxyException): # NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions log_model_access_denial(exc) headers: Final = exc.headers + if is_anthropic_messages_route(get_request_route(request)): + return anthropic_error_json_response(exc, request) error_dict: Final = with_call_id( JSON_OBJECT.validate_python(exc.to_dict()), error_body_call_id(general_settings_view(), headers.get(LITELLM_CALL_ID_HEADER)), 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 0d8e7674e7d..6d13b91aab5 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -63,10 +63,15 @@ class TestCreateErrorResponse: ) assert response["request_id"] == "req_123" - def test_unknown_status_defaults_to_api_error(self): - """Test unknown status code defaults to api_error.""" - response = AnthropicExceptionMapping.create_error_response(418, "I'm a teapot") - assert response["error"]["type"] == "api_error" + @pytest.mark.parametrize( + ("status_code", "expected_error_type"), + [(418, "invalid_request_error"), (422, "invalid_request_error"), (502, "api_error"), (599, "api_error")], + ) + def test_an_unmapped_status_is_the_callers_fault_when_4xx(self, status_code: int, expected_error_type: str): + """An unmapped 4xx is the client's mistake, so it must not answer api_error, which + the Anthropic taxonomy reserves for server-side failures; 5xx keeps api_error.""" + response = AnthropicExceptionMapping.create_error_response(status_code, "mapped only by range") + assert response["error"]["type"] == expected_error_type class TestExtractErrorMessage: diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index f6efdecc884..6b994e326ca 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -168,6 +168,159 @@ 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(): + """The 403 can_team_access_model raises: OpenAI type team_model_access_denied, param model.""" + return ProxyException( + message="Team not allowed to access model. Tried to access gpt-5.6", + type="team_model_access_denied", + param="model", + code=403, + ) + + +_MESSAGES_ROUTES: Final = ( + "/v1/messages", + "/v1/messages/count_tokens", + "/claude_code_gateway/v1/messages", + "/claude_code_gateway/v1/messages/count_tokens", +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _MESSAGES_ROUTES) +async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_messages_routes(path): + """An auth failure raised before the Messages handler (user_api_key_auth) must reach an + Anthropic SDK as {"type": "error", "error": {...}}, not the OpenAI {"error": {...}} body.""" + response = await openai_exception_handler(request=_make_request(path=path), exc=_model_access_denied_exception()) + body = json.loads(response.body) + + assert response.status_code == 403 + assert body == { + "type": "error", + "error": { + "type": "permission_error", + "message": "Team not allowed to access model. Tried to access gpt-5.6", + }, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code,anthropic_type", + [ + (401, "authentication_error"), + (403, "permission_error"), + (404, "not_found_error"), + (429, "rate_limit_error"), + ], +) +async def test_openai_exception_handler_maps_messages_failures_to_anthropic_error_types(status_code, anthropic_type): + """The Anthropic error type follows the HTTP status, so a bad key, a denied model, an + unknown model and a rate limit each read the way an Anthropic client expects.""" + exc = ProxyException(message="denied", type="key_model_access_denied", param="model", code=status_code) + + response = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc) + + assert response.status_code == status_code + assert json.loads(response.body)["error"]["type"] == anthropic_type + + +@pytest.mark.asyncio +async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_messages(): + """A budget failure carries the proxy's own budget status; the Messages route must keep + it and answer the Anthropic envelope rather than the OpenAI body.""" + exc = ProxyException(message="Budget exceeded", type="budget_exceeded", param=None, code=422) + + response = await openai_exception_handler(request=_make_request(path="/v1/messages/count_tokens"), exc=exc) + body = json.loads(response.body) + + assert response.status_code == 422 + assert body["type"] == "error" + assert body["error"]["type"] == "invalid_request_error" + assert body["error"]["message"] == "Budget exceeded" + assert "param" not in body["error"] and "code" not in body["error"] + + +@pytest.mark.asyncio +async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messages_routes(): + """The envelope is chosen by route, so OpenAI routes are untouched.""" + response = await openai_exception_handler( + request=_make_request(path="/v1/chat/completions"), exc=_model_access_denied_exception() + ) + body = json.loads(response.body) + + assert response.status_code == 403 + assert body == { + "error": { + "message": "Team not allowed to access model. Tried to access gpt-5.6", + "type": "team_model_access_denied", + "param": "model", + "code": "403", + } + } + + +@pytest.mark.asyncio +async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthropic_routes(): + """The Skills and marketplace routes are not Messages routes and answer their own body.""" + response = await openai_exception_handler( + request=_make_request(path="/v1/skills"), exc=_model_access_denied_exception() + ) + body = json.loads(response.body) + + assert response.status_code == 403 + assert "type" not in body + assert body["error"]["type"] == "team_model_access_denied" + + +@pytest.mark.asyncio +async def test_openai_exception_handler_messages_route_copies_the_call_id_when_opted_in(monkeypatch): + """The Messages envelope carries error.litellm_call_id under the same opt-in as the + OpenAI envelope, so an Anthropic SDK's e.body still names the request to look up.""" + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"include_call_id_in_error_body": True}) + exc = ProxyException( + message="denied", + type="team_model_access_denied", + param="model", + code=403, + headers={"x-litellm-call-id": "call-8302"}, + provider_specific_fields={"guardrail": "keyword-block"}, + ) + + response = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc) + body = json.loads(response.body) + + assert response.headers["x-litellm-call-id"] == "call-8302" + assert body["error"] == { + "type": "permission_error", + "message": "denied", + "provider_specific_fields": {"guardrail": "keyword-block"}, + "litellm_call_id": "call-8302", + } + + +@pytest.mark.asyncio +async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error(client, monkeypatch): + """End to end through the real user_api_key_auth: a /v1/messages request with no + credential is rejected before the handler, and the SDK must see Anthropic's + authentication_error envelope, not OpenAI's {"error": {...}} body.""" + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key") + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + + response = client.post("/v1/messages", json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}) + body = response.json() + + assert response.status_code == 401 + assert body["type"] == "error" + assert body["error"]["type"] == "authentication_error" + assert "param" not in body["error"] + + # --------------------------------------------------------------------------- # _close_dangling_otel_server_span # --------------------------------------------------------------------------- From e6a0fcc027c7234792897f42fa6e9a5edacfbd46 Mon Sep 17 00:00:00 2001 From: Alberto Perdomo Date: Mon, 26 Oct 2026 14:09:57 +0000 Subject: [PATCH 2/9] fix: Answer Anthropic envelopes on Messages auth failures without relabelling unmapped statuses Signed-off-by: Alberto Perdomo --- .../exceptions/exception_mapping_utils.py | 13 +--- .../authorization/_hidden_alias_budget.py | 7 +++ .../test_alias_chain_budget_waiver.py | 6 +- .../test_claude_code_gateway_oauth.py | 7 +-- .../test_cli_sso_login_ui_disabled.py | 8 ++- .../test_hidden_alias_budget_bypass.py | 11 ++-- .../test_unknown_key_generic_401.py | 62 +++++++++++++++---- .../anthropic/test_anthropic_gateway_wire.py | 14 ++--- .../spend/test_end_user_budget_enforcement.py | 18 ++++-- .../test_end_user_spend_without_proxy_user.py | 15 ++++- .../test_exception_mapping_utils.py | 12 ++-- 11 files changed, 118 insertions(+), 55 deletions(-) diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index 8a868b3aff4..50813500813 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -19,6 +19,7 @@ ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = { 403: "permission_error", 404: "not_found_error", 413: "request_too_large", + 422: "invalid_request_error", 429: "rate_limit_error", 500: "api_error", 529: "overloaded_error", @@ -34,16 +35,8 @@ class AnthropicExceptionMapping: @staticmethod def get_error_type(status_code: int) -> AnthropicErrorType: - """Map HTTP status code to Anthropic error type. - - A 4xx the table does not name is the caller's mistake, so it answers - ``invalid_request_error`` rather than ``api_error``, which the Anthropic taxonomy - reserves for server-side failures. - """ - mapped: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code) - if mapped is not None: - return mapped - return "invalid_request_error" if 400 <= status_code < 500 else "api_error" + """Map HTTP status code to Anthropic error type.""" + return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error") @staticmethod def create_error_response( diff --git a/tests/integration/authorization/_hidden_alias_budget.py b/tests/integration/authorization/_hidden_alias_budget.py index eb099e56026..fb3cd51131c 100644 --- a/tests/integration/authorization/_hidden_alias_budget.py +++ b/tests/integration/authorization/_hidden_alias_budget.py @@ -150,6 +150,13 @@ def error_type(response: httpx.Response) -> str: return str(object_value(JSON_OBJECT.validate_json(response.content)["error"])["type"]) +def budget_refusal_error_type(route: str) -> str: + """The ``error.type`` a budget refusal carries on a route label from ``ROUTES``: the + Anthropic envelope on Messages (its budget refusal is raised in + ``user_api_key_auth``), the OpenAI ``budget_exceeded`` type on the other routes.""" + return "invalid_request_error" if route == "messages" else "budget_exceeded" + + def settle( send: Callable[[str], httpx.Response], status: int, *, burst: int = GATEWAY_BURST, seconds: float = 60 ) -> None: diff --git a/tests/integration/authorization/test_alias_chain_budget_waiver.py b/tests/integration/authorization/test_alias_chain_budget_waiver.py index 0d64415d445..4dbcc361bc3 100644 --- a/tests/integration/authorization/test_alias_chain_budget_waiver.py +++ b/tests/integration/authorization/test_alias_chain_budget_waiver.py @@ -44,6 +44,7 @@ from integration.authorization._hidden_alias_budget import ( async_anthropic_client, async_openai_client, base_url, + budget_refusal_error_type, error_type, fresh_chat, fresh_message, @@ -191,17 +192,18 @@ def _assert_refused( key: str, model: str, prefix: str, + route: str = "chat", send: Callable[[Gateway, str, str, str], httpx.Response] = fresh_chat, ) -> None: marker: Final = f"{prefix}-" + uuid.uuid4().hex refused: Final = send(candidate, model, key, marker) - assert _refusal(refused) == (BUDGET_EXCEEDED, "budget_exceeded"), refused.text + assert _refusal(refused) == (BUDGET_EXCEEDED, budget_refusal_error_type(route)), refused.text assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0 def _assert_refused_on_every_route(rig: ChainRig, candidate: Gateway, key: str, model: str) -> None: for route, send in ROUTES: - _assert_refused(rig, candidate, key, model, f"refused-{route}", send) + _assert_refused(rig, candidate, key, model, f"refused-{route}", route, send) def _assert_provider_failure(response: httpx.Response) -> None: diff --git a/tests/integration/authorization/test_claude_code_gateway_oauth.py b/tests/integration/authorization/test_claude_code_gateway_oauth.py index acab042bfc9..5be4379ab2e 100644 --- a/tests/integration/authorization/test_claude_code_gateway_oauth.py +++ b/tests/integration/authorization/test_claude_code_gateway_oauth.py @@ -447,15 +447,14 @@ def test_device_authorization_polls_complete_sso_and_mints_a_model_restricted_be ) assert model_b_response.status_code == 403, model_b_response.text assert _JSON_OBJECT.validate_json(model_b_response.content) == { + "type": "error", "error": { + "type": "permission_error", "message": ( f"The requested model '{model_b}' is not available for this API key, or the model name is " "invalid. Check the models available to you and try again." ), - "type": "team_model_access_denied", - "param": "model", - "code": "403", - } + }, }, model_b_response.text assert upstream.drain() == () diff --git a/tests/integration/authorization/test_cli_sso_login_ui_disabled.py b/tests/integration/authorization/test_cli_sso_login_ui_disabled.py index 315a0b20334..c908b9896e8 100644 --- a/tests/integration/authorization/test_cli_sso_login_ui_disabled.py +++ b/tests/integration/authorization/test_cli_sso_login_ui_disabled.py @@ -695,9 +695,11 @@ def test_cli_session_token_is_denied_once_its_team_budget_is_exhausted( key=key, ) assert refused.status_code == 422, f"{refused.status_code} {refused.text}" - error: Final = object_value(JSON_OBJECT.validate_json(refused.content)["error"]) - assert error["type"] == "budget_exceeded", refused.text - assert error["code"] == "422", refused.text + body: Final = JSON_OBJECT.validate_json(refused.content) + assert body["type"] == "error", refused.text + error: Final = object_value(body["error"]) + assert error["type"] == "invalid_request_error", refused.text + assert "param" not in error and "code" not in error, refused.text message: Final = string_value(error["message"]) assert "Budget has been exceeded!" in message, refused.text assert f"Team={team}" in message, refused.text diff --git a/tests/integration/authorization/test_hidden_alias_budget_bypass.py b/tests/integration/authorization/test_hidden_alias_budget_bypass.py index 493db4c3e62..f624aa92048 100644 --- a/tests/integration/authorization/test_hidden_alias_budget_bypass.py +++ b/tests/integration/authorization/test_hidden_alias_budget_bypass.py @@ -38,6 +38,7 @@ from integration.authorization._hidden_alias_budget import ( async_anthropic_client, async_openai_client, base_url, + budget_refusal_error_type, chat_statuses, clear_provider_script, error_type, @@ -599,7 +600,7 @@ def _assert_refused_like_target(rig: AliasRig, candidate: Gateway, key: str, sha target_marker: Final = f"target-{route}-" + uuid.uuid4().hex via_shadow: Final = send(candidate, shadow, key, shadow_marker) by_name: Final = send(candidate, target, key, target_marker) - assert _refusal(by_name) == (BUDGET_EXCEEDED, "budget_exceeded"), by_name.text + assert _refusal(by_name) == (BUDGET_EXCEEDED, budget_refusal_error_type(route)), by_name.text assert via_shadow.status_code == by_name.status_code, via_shadow.text assert error_type(via_shadow) == error_type(by_name), via_shadow.text observed: Final = upstream_requests(rig.gateway.upstream_url) @@ -621,7 +622,7 @@ def _assert_refused_on_every_route(rig: AliasRig, candidate: Gateway, key: str, marker: Final = f"chain-{route}-" + uuid.uuid4().hex refused: Final = send(candidate, model, key, marker) assert refused.status_code == BUDGET_EXCEEDED, refused.text - assert error_type(refused) == "budget_exceeded", refused.text + assert error_type(refused) == budget_refusal_error_type(route), refused.text assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0 @@ -828,9 +829,9 @@ def _deployment_id(rig: AliasRig, name: str) -> str: return identities[0] -def _assert_budget_refusal(status_code: int, body: str) -> None: +def _assert_budget_refusal(status_code: int, body: str, route: str = "chat") -> None: assert status_code == BUDGET_EXCEEDED, body - assert "budget_exceeded" in body, body + assert budget_refusal_error_type(route) in body, body def _assert_no_served_row(key: str, marker: str) -> None: @@ -881,7 +882,7 @@ async def test_exhausted_key_is_refused_on_shadowing_alias_through_streamed_anth extra_headers=spend_marker(marker), ) as stream: await stream.get_final_message() - _assert_budget_refusal(refused.value.status_code, str(refused.value.body)) + _assert_budget_refusal(refused.value.status_code, str(refused.value.body), route="messages") assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0 diff --git a/tests/integration/authorization/test_unknown_key_generic_401.py b/tests/integration/authorization/test_unknown_key_generic_401.py index 8e6ff3fa3ab..7826c11bec8 100644 --- a/tests/integration/authorization/test_unknown_key_generic_401.py +++ b/tests/integration/authorization/test_unknown_key_generic_401.py @@ -50,7 +50,23 @@ class _ErrorResponse(BaseModel): error: _ProxyError +class _AnthropicErrorDetail(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + type: str + message: str + + +class _AnthropicErrorResponse(BaseModel): + model_config = ConfigDict(frozen=True) + + type: str + error: _AnthropicErrorDetail + + GENERIC_ERROR: Final = _ProxyError(message=GENERIC_MESSAGE, type="token_not_found_in_db", param="key", code="401") +MESSAGES_PATH: Final = "/v1/messages" +ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail(type="authentication_error", message=GENERIC_MESSAGE) @dataclass(frozen=True, slots=True) @@ -119,6 +135,23 @@ def _assert_generic_refusal(response: httpx.Response, *secrets: str) -> None: assert secret not in response.text, response.text +def _assert_messages_refusal(response: httpx.Response, *secrets: str) -> None: + assert response.status_code == 401, response.text + body: Final = _AnthropicErrorResponse.model_validate_json(response.content) + assert body.type == "error", response.text + assert body.error == ANTHROPIC_GENERIC_ERROR, response.text + for secret in secrets: + assert secret not in response.text, response.text + + +def _assert_refusal(path: str, response: httpx.Response, *secrets: str) -> None: + """The Messages routes answer the Anthropic envelope, every other route the OpenAI one.""" + if path == MESSAGES_PATH: + _assert_messages_refusal(response, *secrets) + else: + _assert_generic_refusal(response, *secrets) + + def _side(gateway: Gateway, peer: Gateway, side: Side) -> Gateway: return gateway if side == "gateway" else peer @@ -129,7 +162,10 @@ def test_an_unknown_key_gets_the_generic_401_without_reaching_the_provider( gateway: Gateway, peer: Gateway, provider: SharedProvider, route: str, side: Side ) -> None: key: Final = _unknown_key() - _assert_generic_refusal(_send(_side(gateway, peer, side), ROUTES[route], key), key, key[-4:], _digest(key)) + request_route: Final = ROUTES[route] + _assert_refusal( + request_route.path, _send(_side(gateway, peer, side), request_route, key), key, key[-4:], _digest(key) + ) assert provider.received() == () @@ -183,8 +219,9 @@ def test_two_different_unknown_keys_in_both_headers_get_the_generic_401_naming_n bearer: Final = _unknown_key() api_key: Final = _unknown_key() headers: Final = {**_headers("authorization", bearer), **_headers("x-api-key", api_key)} - for path, body in (("/v1/chat/completions", CHAT), ("/v1/messages", MESSAGES)): - _assert_generic_refusal( + for path, body in (("/v1/chat/completions", CHAT), (MESSAGES_PATH, MESSAGES)): + _assert_refusal( + path, gateway.client.post(path, json=dict(body), headers=headers), bearer, api_key, @@ -359,16 +396,15 @@ async def _send_async(client: httpx.AsyncClient, route: _Route, key: str) -> htt ) -async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[httpx.Response, ...]: +async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[tuple[_Route, httpx.Response], ...]: + """Each key's response paired with the route it was sent on, so the caller asserts the + envelope the route actually answered with.""" + routes: Final = tuple(ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]] for index in range(len(keys))) async with httpx.AsyncClient(base_url=str(owned.gateway.client.base_url), timeout=60, trust_env=False) as client: - return tuple( - await asyncio.gather( - *( - _send_async(client, ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]], key) - for index, key in enumerate(keys) - ) - ) + responses: Final = await asyncio.gather( + *(_send_async(client, route, key) for route, key in zip(routes, keys, strict=True)) ) + return tuple(zip(routes, responses, strict=True)) def _log_after(owned: OwnedProxy, offset: int) -> str: @@ -397,7 +433,7 @@ async def test_a_burst_across_a_worker_kill_answers_every_unknown_key_with_the_g lambda text: all(_console_line(key) in text for key in keys), 30, ) - for response, key in zip((*during, *after), keys, strict=True): - _assert_generic_refusal(response, key, _digest(key)) + for (route, response), key in zip((*during, *after), keys, strict=True): + _assert_refusal(route.path, response, key, _digest(key)) assert [window.count(_console_line(key)) for key in keys] == [1] * len(keys) assert [key for key in keys if key in window] == [] diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py index 8a79d5c6a7a..91748187876 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py @@ -263,15 +263,14 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en ) assert restricted_response.status_code == 403, restricted_response.text assert _JSON_OBJECT.validate_json(restricted_response.content) == { + "type": "error", "error": { + "type": "permission_error", "message": ( f"The requested model '{restricted_model}' is not available for this API key, or the model name " "is invalid. Check the models available to you and try again." ), - "type": "key_model_access_denied", - "param": "model", - "code": "403", - } + }, }, restricted_response.text assert wire.drain() == () @@ -280,15 +279,14 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en def test_enabled_gateway_refuses_model_outside_virtual_key(enabled_gateway: Gateway) -> None: result: Final = _restricted_model_responses(enabled_gateway) expected_body: Final = { + "type": "error", "error": { + "type": "permission_error", "message": ( f"The requested model '{result[4]}' is not available for this API key, or the model name is invalid. " "Check the models available to you and try again." ), - "type": "key_model_access_denied", - "param": "model", - "code": "403", - } + }, } assert result[0] == result[2] == 403, (result[1], result[3]) assert _JSON_OBJECT.validate_json(result[1]) == expected_body, result[1] diff --git a/tests/integration/spend/test_end_user_budget_enforcement.py b/tests/integration/spend/test_end_user_budget_enforcement.py index 29b18f1f261..0a96709f874 100644 --- a/tests/integration/spend/test_end_user_budget_enforcement.py +++ b/tests/integration/spend/test_end_user_budget_enforcement.py @@ -258,9 +258,20 @@ def _observed(upstream: httpx.Client) -> list[JsonValue]: return requests -def _denial_error(denied: httpx.Response) -> dict[str, JsonValue]: +def _denial_error(denied: httpx.Response, route: str) -> dict[str, JsonValue]: assert denied.status_code == 422, denied.text - return object_value(object_value(denied.json())["error"]) + if route == "/v1/messages": + body: Final = object_value(denied.json()) + assert body["type"] == "error", denied.text + anthropic_error: Final = object_value(body["error"]) + assert anthropic_error["type"] == "invalid_request_error", denied.text + assert "param" not in anthropic_error and "code" not in anthropic_error, denied.text + return anthropic_error + openai_error: Final = object_value(object_value(denied.json())["error"]) + assert openai_error["type"] == "budget_exceeded", denied.text + assert openai_error["param"] is None + assert openai_error["code"] == "422" + return openai_error def _prove_end_user_budget_lockout( @@ -304,8 +315,7 @@ def _prove_end_user_budget_lockout( _observed(upstream) denied: Final = _send(gateway, spelling, deployment, key, end_user_id, f"denied {end_user_id}") assert _observed(upstream) == [], denied.text - error: Final = _denial_error(denied) - assert error["type"] == "budget_exceeded", denied.text + error: Final = _denial_error(denied, spelling.route) matched: Final = re.fullmatch( rf"ExceededBudget: End User={re.escape(end_user_id)} over budget\. " rf"Spend=([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?), Budget=0\.05", 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 16210438e82..9d5b82f5c10 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 @@ -118,6 +118,19 @@ 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 + error: Final = object_value(body["error"]) + assert error["type"] == "invalid_request_error" + assert "param" not in error and "code" not in error, response.text + assert str(error["message"]).startswith(f"ExceededBudget: End User={end_user} over budget. Spend="), error[ + "message" + ] + + def _json_object(raw: bytes) -> dict[str, JsonValue]: return _JSON_OBJECT.validate_json(raw) @@ -341,7 +354,7 @@ def test_anthropic_messages_metadata_user_id_is_charged_and_budget_enforced(gate json={**body, "messages": [{"role": "user", "content": f"again {uuid.uuid4().hex}"}]}, headers={"x-api-key": key, "anthropic-version": "2023-06-01"}, ) - _assert_budget_refusal(denied, end_user) + _assert_messages_budget_refusal(denied, end_user) assert _observed(upstream) == [] 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 6d13b91aab5..5c40bfafa08 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -65,12 +65,14 @@ class TestCreateErrorResponse: @pytest.mark.parametrize( ("status_code", "expected_error_type"), - [(418, "invalid_request_error"), (422, "invalid_request_error"), (502, "api_error"), (599, "api_error")], + [(422, "invalid_request_error"), (418, "api_error"), (502, "api_error"), (599, "api_error")], ) - def test_an_unmapped_status_is_the_callers_fault_when_4xx(self, status_code: int, expected_error_type: str): - """An unmapped 4xx is the client's mistake, so it must not answer api_error, which - the Anthropic taxonomy reserves for server-side failures; 5xx keeps api_error.""" - response = AnthropicExceptionMapping.create_error_response(status_code, "mapped only by range") + def test_a_budget_status_is_a_request_error_and_a_status_the_table_does_not_name_is_api_error( + self, status_code: int, expected_error_type: str + ): + """The proxy's budget status is a caller-fault request error, so the table names 422. + A status the table does not name, such as a 418 or a 5xx, keeps api_error.""" + response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status") assert response["error"]["type"] == expected_error_type From 8208298f69b3b55fe3f8e8099d642f9507c4c9bb Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:37:31 -0700 Subject: [PATCH 3/9] fix(proxy): answer the Anthropic error envelope on the /anthropic pass-through The pass-through relays upstream errors in Anthropic's shape, but the proxy's own refusals there (bad key, model access, budget) still answered the OpenAI body, so an Anthropic SDK pointed at /anthropic could not read them --- .../proxy/common_utils/anthropic_error_payload.py | 14 ++++++++------ litellm/proxy/proxy_server.py | 4 ++-- .../authorization/test_unknown_key_generic_401.py | 5 +++-- .../proxy/proxy_server/test_exception_handlers.py | 12 +++++++++--- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py index d303e96427d..3f1924f78d9 100644 --- a/litellm/proxy/common_utils/anthropic_error_payload.py +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -1,8 +1,8 @@ -"""Renders the Anthropic error envelope for the Messages routes, wherever the failure is raised. +"""Renders the Anthropic error envelope for the Anthropic routes, wherever the failure is raised. The central ``ProxyException`` handler answers every route with OpenAI's ``{"error": {...}}`` body, which an Anthropic SDK cannot read. Auth, key, budget and model-access failures are -raised by ``user_api_key_auth`` before the Messages handler runs, so the envelope is picked +raised by ``user_api_key_auth`` before the route handler runs, so the envelope is picked from the request route rather than inside the handler. """ @@ -29,15 +29,17 @@ ANTHROPIC_MESSAGES_ROUTES: Final[frozenset[str]] = frozenset( "/claude_code_gateway/v1/messages/count_tokens", } ) +ANTHROPIC_PASS_THROUGH_PREFIX: Final = "/anthropic/" -def is_anthropic_messages_route(route: str) -> bool: - """True for the Messages routes, whose callers read the Anthropic error envelope. +def uses_anthropic_error_envelope(route: str) -> bool: + """True for the Messages routes and the Anthropic pass-through, whose callers read the Anthropic envelope. Narrower than ``LiteLLMRoutes.anthropic_routes``: the Skills and marketplace routes there - answer their own body shape, and ``/anthropic/v1/messages`` relays the upstream response. + answer their own body shape. The pass-through relays upstream errors as Anthropic sent them, + so the proxy's own refusals there answer the same envelope. """ - return route in ANTHROPIC_MESSAGES_ROUTES + return route in ANTHROPIC_MESSAGES_ROUTES or route.startswith(ANTHROPIC_PASS_THROUGH_PREFIX) def anthropic_error_status_code(exc: ProxyException) -> int: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6a886d862fb..38d1653f6ed 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -471,7 +471,7 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l ) from litellm.proxy.common_utils.anthropic_error_payload import ( anthropic_error_json_response, - is_anthropic_messages_route, + uses_anthropic_error_envelope, ) from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( AuthCacheInvalidationSubscriber, @@ -2192,7 +2192,7 @@ async def openai_exception_handler(request: Request, exc: ProxyException): # NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions log_model_access_denial(exc) headers: Final = exc.headers - if is_anthropic_messages_route(get_request_route(request)): + if uses_anthropic_error_envelope(get_request_route(request)): return anthropic_error_json_response(exc, request) error_dict: Final = with_call_id( JSON_OBJECT.validate_python(exc.to_dict()), diff --git a/tests/integration/authorization/test_unknown_key_generic_401.py b/tests/integration/authorization/test_unknown_key_generic_401.py index 7826c11bec8..da5f43e58ea 100644 --- a/tests/integration/authorization/test_unknown_key_generic_401.py +++ b/tests/integration/authorization/test_unknown_key_generic_401.py @@ -66,6 +66,7 @@ class _AnthropicErrorResponse(BaseModel): GENERIC_ERROR: Final = _ProxyError(message=GENERIC_MESSAGE, type="token_not_found_in_db", param="key", code="401") MESSAGES_PATH: Final = "/v1/messages" +ANTHROPIC_ENVELOPE_PATHS: Final = frozenset({MESSAGES_PATH, "/anthropic/v1/messages"}) ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail(type="authentication_error", message=GENERIC_MESSAGE) @@ -145,8 +146,8 @@ def _assert_messages_refusal(response: httpx.Response, *secrets: str) -> None: def _assert_refusal(path: str, response: httpx.Response, *secrets: str) -> None: - """The Messages routes answer the Anthropic envelope, every other route the OpenAI one.""" - if path == MESSAGES_PATH: + """The Messages and Anthropic pass-through routes answer the Anthropic envelope, every other route the OpenAI one.""" + if path in ANTHROPIC_ENVELOPE_PATHS: _assert_messages_refusal(response, *secrets) else: _assert_generic_refusal(response, *secrets) diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index 6b994e326ca..7ced5b24d96 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -189,10 +189,15 @@ _MESSAGES_ROUTES: Final = ( "/claude_code_gateway/v1/messages", "/claude_code_gateway/v1/messages/count_tokens", ) +_ANTHROPIC_PASS_THROUGH_ROUTES: Final = ( + "/anthropic/v1/messages", + "/anthropic/v1/messages/count_tokens", + "/anthropic/v1/models", +) @pytest.mark.asyncio -@pytest.mark.parametrize("path", _MESSAGES_ROUTES) +@pytest.mark.parametrize("path", (*_MESSAGES_ROUTES, *_ANTHROPIC_PASS_THROUGH_ROUTES)) async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_messages_routes(path): """An auth failure raised before the Messages handler (user_api_key_auth) must reach an Anthropic SDK as {"type": "error", "error": {...}}, not the OpenAI {"error": {...}} body.""" @@ -305,14 +310,15 @@ async def test_openai_exception_handler_messages_route_copies_the_call_id_when_o @pytest.mark.asyncio -async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error(client, monkeypatch): +@pytest.mark.parametrize("path", ("/v1/messages", "/anthropic/v1/messages")) +async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error(client, monkeypatch, path): """End to end through the real user_api_key_auth: a /v1/messages request with no credential is rejected before the handler, and the SDK must see Anthropic's authentication_error envelope, not OpenAI's {"error": {...}} body.""" monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key") monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) - response = client.post("/v1/messages", json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}) + response = client.post(path, json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}) body = response.json() assert response.status_code == 401 From ae4e3eeff1f4c2e94e6b98ddf133cd7afbb4f04a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:29:46 -0700 Subject: [PATCH 4/9] fix(proxy): answer the Anthropic envelope for 404, 405 and unhandled 500 errors on Messages routes A disabled Claude Code gateway 404, a wrong-method 405 and an unhandled 500 on the Messages and /anthropic/ routes kept FastAPI's detail body or the OpenAI shape. The central HTTP and unhandled-exception handlers now pick the Anthropic envelope by route. The dashboard playground toast titles Anthropic error types when the body has no code. --- litellm/proxy/common_request_processing.py | 3 +- .../common_utils/anthropic_error_payload.py | 35 ++---- litellm/proxy/proxy_server.py | 8 ++ .../anthropic/test_anthropic_gateway_wire.py | 7 +- .../proxy_server/test_exception_handlers.py | 112 ++++++++++++------ ui/litellm-dashboard/src/lib/toast.test.ts | 19 +++ ui/litellm-dashboard/src/lib/toast.ts | 14 ++- 7 files changed, 131 insertions(+), 67 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 24a36a7f2dc..67768e00f08 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -29,6 +29,7 @@ import orjson from fastapi import HTTPException, Request, status from fastapi.responses import JSONResponse, Response, StreamingResponse from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.types import Receive, Scope, Send from typing_extensions import Never @@ -748,7 +749,7 @@ def resolve_counted_tool_fields( return resolved -def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, str]) -> ProxyException: +def proxy_exception_from_http_exception(exc: StarletteHTTPException, headers: dict[str, str]) -> ProxyException: raw_detail: Final = attribute_of(exc, "detail", str(exc)) message, structured_fields = serialize_http_exception_detail(raw_detail) existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {} diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py index 3f1924f78d9..de8c460ad43 100644 --- a/litellm/proxy/common_utils/anthropic_error_payload.py +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -1,10 +1,5 @@ -"""Renders the Anthropic error envelope for the Anthropic routes, wherever the failure is raised. - -The central ``ProxyException`` handler answers every route with OpenAI's ``{"error": {...}}`` -body, which an Anthropic SDK cannot read. Auth, key, budget and model-access failures are -raised by ``user_api_key_auth`` before the route handler runs, so the envelope is picked -from the request route rather than inside the handler. -""" +"""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 typing import Final @@ -33,21 +28,10 @@ ANTHROPIC_PASS_THROUGH_PREFIX: Final = "/anthropic/" def uses_anthropic_error_envelope(route: str) -> bool: - """True for the Messages routes and the Anthropic pass-through, whose callers read the Anthropic envelope. - - Narrower than ``LiteLLMRoutes.anthropic_routes``: the Skills and marketplace routes there - answer their own body shape. The pass-through relays upstream errors as Anthropic sent them, - so the proxy's own refusals there answer the same envelope. - """ return route in ANTHROPIC_MESSAGES_ROUTES or route.startswith(ANTHROPIC_PASS_THROUGH_PREFIX) def anthropic_error_status_code(exc: ProxyException) -> int: - """The exception's stringified ``code`` as a status, else 500. - - ``ProxyException.__init__`` stringifies ``code``, so a code-less exception holds the - literal ``"None"``, which must not be parsed as a status. - """ code: Final = exc.code if code.isdigit(): return int(code) @@ -79,7 +63,6 @@ def anthropic_error_content( request_id: str | None, general_settings: Mapping[str, object], ) -> AnthropicErrorResponse: - """The Anthropic ``{"type": "error", "error": {...}}`` body for ``exc``, typed by status.""" envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error( status_code=status_code, raw_message=exc.message, @@ -94,11 +77,6 @@ def anthropic_error_content( def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse: - """The Anthropic error response for a Messages request, span close included. - - Shared by the Messages handlers and the central handler, so a failure raised before the - handler and one raised inside it answer identically. - """ 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, @@ -110,3 +88,12 @@ def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSON 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_internal_server_error_response(request: Request) -> JSONResponse: + content: Final = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + raw_message="Internal server error", + request_id=request.headers.get("x-request-id"), + ) + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=content) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 38d1653f6ed..ada7e46da1b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -462,6 +462,7 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l is_azure_model_router_request, log_llm_api_exception, open_sse_before_first_byte, + proxy_exception_from_http_exception, proxy_exception_from_route_error, request_litellm_call_id, resolve_litellm_call_id, @@ -471,6 +472,7 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l ) from litellm.proxy.common_utils.anthropic_error_payload import ( anthropic_error_json_response, + anthropic_internal_server_error_response, uses_anthropic_error_envelope, ) from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( @@ -2229,6 +2231,10 @@ async def otlp_http_exception_handler(connection: Request | WebSocket, exc: Star if response is not None: _close_dangling_otel_server_span(connection, exc.status_code, exc=exc) return response + if uses_anthropic_error_envelope(get_request_route(connection)): + return anthropic_error_json_response( + proxy_exception_from_http_exception(exc, dict(exc.headers or {})), connection + ) return await http_exception_handler(connection, exc) @@ -2436,6 +2442,8 @@ async def _unhandled_exception_response(request: Request, exc: Exception) -> Res otlp_response: Final = tracing_endpoints.otlp_error_response(request, 500) if otlp_response is not None: return otlp_response + if uses_anthropic_error_envelope(get_request_route(request)): + return anthropic_internal_server_error_response(request) return JSONResponse( status_code=500, content={ diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py index 0f0f1e7676c..e104fad4b3f 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py @@ -97,9 +97,10 @@ def test_shared_gateway_route_is_disabled_without_reaching_provider(gateway: Gat headers=_cli_bearer_headers(gateway.key), ) assert response.status_code == 404, response.text - assert _JSON_OBJECT.validate_json(response.content) == {"detail": "Claude Code gateway is not enabled"}, ( - response.text - ) + assert _JSON_OBJECT.validate_json(response.content) == { + "type": "error", + "error": {"type": "not_found_error", "message": "Claude Code gateway is not enabled"}, + }, response.text assert wire.drain() == () diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index 7ced5b24d96..7676f82bd74 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -173,8 +173,7 @@ async def test_openai_exception_handler_never_fabricates_a_call_id(monkeypatch): # --------------------------------------------------------------------------- -def _model_access_denied_exception(): - """The 403 can_team_access_model raises: OpenAI type team_model_access_denied, param model.""" +def _model_access_denied_exception() -> ProxyException: return ProxyException( message="Team not allowed to access model. Tried to access gpt-5.6", type="team_model_access_denied", @@ -198,11 +197,11 @@ _ANTHROPIC_PASS_THROUGH_ROUTES: Final = ( @pytest.mark.asyncio @pytest.mark.parametrize("path", (*_MESSAGES_ROUTES, *_ANTHROPIC_PASS_THROUGH_ROUTES)) -async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_messages_routes(path): - """An auth failure raised before the Messages handler (user_api_key_auth) must reach an - Anthropic SDK as {"type": "error", "error": {...}}, not the OpenAI {"error": {...}} body.""" - response = await openai_exception_handler(request=_make_request(path=path), exc=_model_access_denied_exception()) - body = json.loads(response.body) +async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_messages_routes(path: str) -> None: + response: Final = await openai_exception_handler( + request=_make_request(path=path), exc=_model_access_denied_exception() + ) + body: Final = json.loads(response.body) assert response.status_code == 403 assert body == { @@ -224,25 +223,25 @@ async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_me (429, "rate_limit_error"), ], ) -async def test_openai_exception_handler_maps_messages_failures_to_anthropic_error_types(status_code, anthropic_type): - """The Anthropic error type follows the HTTP status, so a bad key, a denied model, an - unknown model and a rate limit each read the way an Anthropic client expects.""" - exc = ProxyException(message="denied", type="key_model_access_denied", param="model", code=status_code) +async def test_openai_exception_handler_maps_messages_failures_to_anthropic_error_types( + status_code: int, anthropic_type: str +) -> None: + exc: Final = ProxyException(message="denied", type="key_model_access_denied", param="model", code=status_code) - response = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc) + response: Final = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc) assert response.status_code == status_code assert json.loads(response.body)["error"]["type"] == anthropic_type @pytest.mark.asyncio -async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_messages(): - """A budget failure carries the proxy's own budget status; the Messages route must keep - it and answer the Anthropic envelope rather than the OpenAI body.""" - exc = ProxyException(message="Budget exceeded", type="budget_exceeded", param=None, code=422) +async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_messages() -> None: + exc: Final = ProxyException(message="Budget exceeded", type="budget_exceeded", param=None, code=422) - response = await openai_exception_handler(request=_make_request(path="/v1/messages/count_tokens"), exc=exc) - body = json.loads(response.body) + response: Final = await openai_exception_handler( + request=_make_request(path="/v1/messages/count_tokens"), exc=exc + ) + body: Final = json.loads(response.body) assert response.status_code == 422 assert body["type"] == "error" @@ -252,12 +251,11 @@ async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_ @pytest.mark.asyncio -async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messages_routes(): - """The envelope is chosen by route, so OpenAI routes are untouched.""" - response = await openai_exception_handler( +async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messages_routes() -> None: + response: Final = await openai_exception_handler( request=_make_request(path="/v1/chat/completions"), exc=_model_access_denied_exception() ) - body = json.loads(response.body) + body: Final = json.loads(response.body) assert response.status_code == 403 assert body == { @@ -271,12 +269,11 @@ async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messag @pytest.mark.asyncio -async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthropic_routes(): - """The Skills and marketplace routes are not Messages routes and answer their own body.""" - response = await openai_exception_handler( +async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthropic_routes() -> None: + response: Final = await openai_exception_handler( request=_make_request(path="/v1/skills"), exc=_model_access_denied_exception() ) - body = json.loads(response.body) + body: Final = json.loads(response.body) assert response.status_code == 403 assert "type" not in body @@ -284,11 +281,11 @@ async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthr @pytest.mark.asyncio -async def test_openai_exception_handler_messages_route_copies_the_call_id_when_opted_in(monkeypatch): - """The Messages envelope carries error.litellm_call_id under the same opt-in as the - OpenAI envelope, so an Anthropic SDK's e.body still names the request to look up.""" +async def test_openai_exception_handler_messages_route_copies_the_call_id_when_opted_in( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"include_call_id_in_error_body": True}) - exc = ProxyException( + exc: Final = ProxyException( message="denied", type="team_model_access_denied", param="model", @@ -297,8 +294,8 @@ async def test_openai_exception_handler_messages_route_copies_the_call_id_when_o provider_specific_fields={"guardrail": "keyword-block"}, ) - response = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc) - body = json.loads(response.body) + response: Final = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc) + body: Final = json.loads(response.body) assert response.headers["x-litellm-call-id"] == "call-8302" assert body["error"] == { @@ -311,15 +308,14 @@ async def test_openai_exception_handler_messages_route_copies_the_call_id_when_o @pytest.mark.asyncio @pytest.mark.parametrize("path", ("/v1/messages", "/anthropic/v1/messages")) -async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error(client, monkeypatch, path): - """End to end through the real user_api_key_auth: a /v1/messages request with no - credential is rejected before the handler, and the SDK must see Anthropic's - authentication_error envelope, not OpenAI's {"error": {...}} body.""" +async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error( + client: TestClient, monkeypatch: pytest.MonkeyPatch, path: str +) -> None: monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key") monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) - response = client.post(path, json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}) - body = response.json() + response: Final = client.post(path, json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}) + body: Final = response.json() assert response.status_code == 401 assert body["type"] == "error" @@ -626,6 +622,22 @@ async def test_otel_unhandled_exception_handler_answers_a_db_outage_with_503_no_ assert json.loads(response.body) == _DB_OUTAGE_503_BODY +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ("/v1/messages", "/claude_code_gateway/v1/messages", "/anthropic/v1/messages")) +async def test_otel_unhandled_exception_handler_answers_the_anthropic_api_error_on_the_messages_routes( + path: str, +) -> None: + response: Final = await otel_unhandled_exception_handler( + request=_make_request(path=path), exc=RuntimeError("kaboom") + ) + + assert response.status_code == 500 + assert json.loads(response.body) == { + "type": "error", + "error": {"type": "api_error", "message": "Internal server error"}, + } + + @pytest.mark.asyncio async def test_otel_unhandled_exception_handler_reraises_proxy_exception_error(): """ProxyException / HTTPException / RequestValidationError are re-raised @@ -705,6 +717,30 @@ async def test_http_exception_on_a_plain_http_request_keeps_the_default_json_bod assert json.loads(bytes(response.body)) == {"detail": "not found"} +@pytest.mark.asyncio +async def test_a_disabled_claude_code_gateway_answers_the_anthropic_not_found_error() -> None: + request: Final = _make_request(path="/claude_code_gateway/v1/messages") + + response: Final = await otlp_http_exception_handler( + request, HTTPException(404, "Claude Code gateway is not enabled") + ) + + assert response is not None + assert response.status_code == 404 + assert json.loads(bytes(response.body)) == { + "type": "error", + "error": {"type": "not_found_error", "message": "Claude Code gateway is not enabled"}, + } + + +def test_a_get_on_messages_answers_the_anthropic_envelope_and_keeps_the_allow_header(client: TestClient) -> None: + response: Final = client.get("/v1/messages") + + assert response.status_code == 405 + assert response.headers["allow"] == "POST" + assert response.json() == {"type": "error", "error": {"type": "api_error", "message": "Method Not Allowed"}} + + @pytest.mark.asyncio async def test_http_exception_on_a_websocket_closed_before_accept_sends_nothing_more() -> None: sent: Final[list[Message]] = [] diff --git a/ui/litellm-dashboard/src/lib/toast.test.ts b/ui/litellm-dashboard/src/lib/toast.test.ts index b4a776f123a..e927c3e06ed 100644 --- a/ui/litellm-dashboard/src/lib/toast.test.ts +++ b/ui/litellm-dashboard/src/lib/toast.test.ts @@ -108,6 +108,25 @@ describe("toast", () => { }); }); + it.each([ + [403, "permission_error", sonner.error, "Access Denied"], + [401, "authentication_error", sonner.error, "Authentication Error"], + [429, "rate_limit_error", sonner.warning, "Rate Limit Exceeded"], + [422, "invalid_request_error", sonner.error, "Request Error"], + ] as const)( + "titles an Anthropic SDK %s error on the Messages playground by its %s type", + (status, type, fn, title) => { + const body = JSON.stringify({ type: "error", error: { type, message: "Team not allowed to access model" } }); + toast.fromError( + `Error occurred while generating model response. Please try again. Error: Error: ${status} ${body}`, + ); + expect(fn).toHaveBeenCalledWith(title, { + description: `Error occurred while generating model response. Please try again. Error: Error: ${status} Team not allowed to access model`, + 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 a7a4ce45ad0..465ac7a1f6c 100644 --- a/ui/litellm-dashboard/src/lib/toast.ts +++ b/ui/litellm-dashboard/src/lib/toast.ts @@ -46,6 +46,17 @@ const STATUS_TITLES: Readonly> = { 503: "Service Unavailable", }; +const ANTHROPIC_TYPE_TITLES: Readonly> = { + invalid_request_error: "Request Error", + authentication_error: "Authentication Error", + permission_error: "Access Denied", + not_found_error: "Not Found", + request_too_large: "Request Error", + rate_limit_error: "Rate Limit Exceeded", + api_error: "Server Error", + overloaded_error: "Service Unavailable", +}; + const WARNING_TITLES: ReadonlySet = new Set(["Budget Exceeded", "Rate Limit Exceeded"]); const asRecord = (value: unknown): Record | undefined => @@ -123,7 +134,8 @@ const titleFor = ({ status, proxyType }: ErrorFacts): string => { if (proxyType?.endsWith("_access_denied")) return "Access Denied"; const byType = proxyType === undefined ? undefined : PROXY_TYPE_TITLES[proxyType]; if (byType !== undefined) return byType; - return status === undefined ? "Error" : titleForStatus(status); + if (status !== undefined) return titleForStatus(status); + return (proxyType === undefined ? undefined : ANTHROPIC_TYPE_TITLES[proxyType]) ?? "Error"; }; const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => { From ef24f406a0be04908f1254f3503efea5619e7334 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:09:14 -0700 Subject: [PATCH 5/9] fix(proxy): keep LiteLLM error types and Anthropic envelopes on Messages routes Error bodies on the Messages routes now carry error.litellm_error_type when the LiteLLM type differs from the Anthropic type, so clients and the Admin UI keep causes like budget_exceeded. Unlisted 4xx statuses map to invalid_request_error per Anthropic's errors page, model-list requests that send anthropic-version get the Anthropic envelope, and the admission, decompression and request-size middleware errors use it on these routes too. --- .../exceptions/exception_mapping_utils.py | 11 +++- .../exceptions/exceptions.py | 6 +- .../proxy/anthropic_endpoints/endpoints.py | 5 -- .../common_utils/anthropic_error_payload.py | 49 +++++++++++---- .../admission_control_middleware.py | 23 ++++--- .../request_body_decompression_middleware.py | 29 ++++++--- .../request_size_limit_middleware.py | 10 ++- litellm/proxy/proxy_server.py | 7 +-- .../test_claude_code_gateway_oauth.py | 1 + .../anthropic/test_anthropic_gateway_wire.py | 2 + .../anthropic/test_anthropic_wire.py | 13 +++- .../test_count_tokens_error_shapes_wire.py | 8 ++- .../test_vertex_partner_count_tokens_wire.py | 5 +- .../test_exception_mapping_utils.py | 17 +++-- .../anthropic_endpoints/test_endpoints.py | 1 + .../test_admission_control_middleware.py | 29 +++++++++ ...t_request_body_decompression_middleware.py | 30 +++++++++ .../test_request_size_limit_middleware.py | 19 ++++++ .../proxy_server/test_exception_handlers.py | 29 ++++++++- tests/unit/proxy/test_proxy_token_counter.py | 1 + ui/litellm-dashboard/src/lib/toast.test.ts | 62 ++++++++++++++++--- ui/litellm-dashboard/src/lib/toast.ts | 9 ++- 22 files changed, 297 insertions(+), 69 deletions(-) diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index af58b14ac57..9c726b97630 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -16,16 +16,18 @@ from .exceptions import AnthropicErrorDetail, AnthropicErrorResponse, AnthropicE _PARSED_ERROR_BODY: Final = TypeAdapter(object) # HTTP status code -> Anthropic error type -# Source: https://docs.anthropic.com/en/api/errors +# Source: https://platform.claude.com/docs/en/api/errors ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = { 400: "invalid_request_error", 401: "authentication_error", + 402: "billing_error", 403: "permission_error", 404: "not_found_error", + 409: "conflict_error", 413: "request_too_large", - 422: "invalid_request_error", 429: "rate_limit_error", 500: "api_error", + 504: "timeout_error", 529: "overloaded_error", } @@ -40,7 +42,10 @@ class AnthropicExceptionMapping: @staticmethod def get_error_type(status_code: int) -> AnthropicErrorType: """Map HTTP status code to Anthropic error type.""" - return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error") + 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" @staticmethod def create_error_response( diff --git a/litellm/anthropic_interface/exceptions/exceptions.py b/litellm/anthropic_interface/exceptions/exceptions.py index b48cd2fee6f..18b3ca37a3b 100644 --- a/litellm/anthropic_interface/exceptions/exceptions.py +++ b/litellm/anthropic_interface/exceptions/exceptions.py @@ -6,15 +6,18 @@ from typing import Literal from typing_extensions import NotRequired, ReadOnly, Required, TypedDict # Known Anthropic error types -# Source: https://docs.anthropic.com/en/api/errors +# Source: https://platform.claude.com/docs/en/api/errors AnthropicErrorType = Literal[ "invalid_request_error", "authentication_error", + "billing_error", "permission_error", "not_found_error", + "conflict_error", "request_too_large", "rate_limit_error", "api_error", + "timeout_error", "overloaded_error", ] @@ -25,6 +28,7 @@ class AnthropicErrorDetail(TypedDict): type: AnthropicErrorType message: str provider_specific_fields: NotRequired[ReadOnly[Mapping[str, object]]] + litellm_error_type: NotRequired[ReadOnly[str]] litellm_call_id: NotRequired[ReadOnly[str]] diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 743a659fc37..d245984933e 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -56,11 +56,6 @@ router: Final = APIRouter() def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse: - """The Anthropic error envelope for a failure raised inside a Messages handler. - - Delegates to the shared renderer so a failure raised here and one raised by the auth - layer before the handler (see the central ProxyException handler) answer identically. - """ return anthropic_error_json_response(exc, request) diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py index de8c460ad43..98342e945c2 100644 --- a/litellm/proxy/common_utils/anthropic_error_payload.py +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -6,15 +6,18 @@ from typing import Final from fastapi import Request, status from fastapi.responses import JSONResponse +from starlette.types import Scope from litellm.anthropic_interface.exceptions import ( AnthropicErrorDetail, AnthropicErrorResponse, + AnthropicErrorType, AnthropicExceptionMapping, ) from litellm.proxy._types import ProxyException +from litellm.proxy.auth.auth_utils import get_request_route from litellm.proxy.common_utils.error_body_call_id import error_body_call_id -from litellm.proxy.common_utils.openai_error_payload import LITELLM_CALL_ID_HEADER +from litellm.proxy.common_utils.openai_error_payload import LITELLM_CALL_ID_HEADER, openai_error_type_for_status ANTHROPIC_MESSAGES_ROUTES: Final[frozenset[str]] = frozenset( { @@ -25,10 +28,27 @@ ANTHROPIC_MESSAGES_ROUTES: Final[frozenset[str]] = frozenset( } ) ANTHROPIC_PASS_THROUGH_PREFIX: Final = "/anthropic/" +ANTHROPIC_SHAPED_MODEL_LIST_ROUTES: Final[frozenset[str]] = frozenset( + {"/v1/models", "/models", "/claude_code_gateway/v1/models"} +) -def uses_anthropic_error_envelope(route: str) -> bool: - return route in ANTHROPIC_MESSAGES_ROUTES or route.startswith(ANTHROPIC_PASS_THROUGH_PREFIX) +def uses_anthropic_error_envelope(request: Request) -> bool: + route: Final = get_request_route(request) + if route in ANTHROPIC_MESSAGES_ROUTES or route.startswith(ANTHROPIC_PASS_THROUGH_PREFIX): + return True + return route in ANTHROPIC_SHAPED_MODEL_LIST_ROUTES and "anthropic-version" in request.headers + + +def route_error_content( + scope: Scope, anthropic_type: AnthropicErrorType, message: str, openai_content: Mapping[str, object] +) -> Mapping[str, object]: + request: Final = Request(scope) + if not uses_anthropic_error_envelope(request): + return openai_content + content: Final[AnthropicErrorResponse] = {"type": "error", "error": {"type": anthropic_type, "message": message}} + request_id: Final = request.headers.get("x-request-id") + return content if request_id is None else {**content, "request_id": request_id} def anthropic_error_status_code(exc: ProxyException) -> int: @@ -45,15 +65,22 @@ def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDe return with_fields -def _anthropic_error_detail( - exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None +def _with_litellm_error_type( + exc: ProxyException, status_code: int, detail: AnthropicErrorDetail ) -> AnthropicErrorDetail: + if not exc.type or exc.type in (detail["type"], openai_error_type_for_status(status_code)): + return detail + with_type: Final[AnthropicErrorDetail] = {**detail, "litellm_error_type": exc.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)) if call_id is None: - return _with_provider_specific_fields(exc, detail) - with_call_id: Final[AnthropicErrorDetail] = { - **_with_provider_specific_fields(exc, detail), - "litellm_call_id": call_id, - } + return with_proxy_fields + with_call_id: Final[AnthropicErrorDetail] = {**with_proxy_fields, "litellm_call_id": call_id} return with_call_id @@ -71,7 +98,7 @@ 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, envelope["error"], body_call_id), + "error": _anthropic_error_detail(exc, status_code, envelope["error"], body_call_id), } return content diff --git a/litellm/proxy/middleware/admission_control_middleware.py b/litellm/proxy/middleware/admission_control_middleware.py index 1bb94e23d15..83503d0c3d4 100644 --- a/litellm/proxy/middleware/admission_control_middleware.py +++ b/litellm/proxy/middleware/admission_control_middleware.py @@ -10,6 +10,7 @@ from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send from litellm._logging import verbose_proxy_logger +from litellm.proxy.common_utils.anthropic_error_payload import route_error_content ADMISSION_LEASE_SCOPE_KEY: Final = "litellm.admission_lease" @@ -175,7 +176,7 @@ class AdmissionControlMiddleware: state.record_admission() elif state.get_stats().queued >= settings.max_queued_requests: state.record_rejection("queue_full") - await _overloaded_response(state)(scope, receive, send) + await _overloaded_response(state, scope)(scope, receive, send) return else: state.record_queue() @@ -187,7 +188,7 @@ class AdmissionControlMiddleware: except asyncio.TimeoutError: state.record_dequeue() state.record_rejection("queue_timeout") - await _overloaded_response(state)(scope, receive, send) + await _overloaded_response(state, scope)(scope, receive, send) return except asyncio.CancelledError: state.record_dequeue() @@ -313,18 +314,16 @@ def get_admission_control_settings(settings: Mapping[str, object]) -> AdmissionC ) -def _overloaded_response(state: AdmissionControlState) -> JSONResponse: +def _overloaded_response(state: AdmissionControlState, scope: Scope) -> JSONResponse: stats: Final = state.get_stats() + message: Final = f"Worker at capacity: {stats.admitted} in-flight, {stats.queued} queued requests. Retry later." return JSONResponse( status_code=503, headers={"retry-after": "1"}, - content={ - "error": { - "message": ( - f"Worker at capacity: {stats.admitted} in-flight, {stats.queued} queued requests. Retry later." - ), - "type": "overloaded_error", - "code": "503", - } - }, + content=route_error_content( + scope, + "overloaded_error", + message, + {"error": {"message": message, "type": "overloaded_error", "code": "503"}}, + ), ) diff --git a/litellm/proxy/middleware/request_body_decompression_middleware.py b/litellm/proxy/middleware/request_body_decompression_middleware.py index 5eb8a9bfc91..6a732cc1e8e 100644 --- a/litellm/proxy/middleware/request_body_decompression_middleware.py +++ b/litellm/proxy/middleware/request_body_decompression_middleware.py @@ -12,12 +12,14 @@ from starlette.requests import ClientDisconnect, Request from starlette.responses import JSONResponse from starlette.types import ASGIApp, Message, Receive, Scope, Send +from litellm.anthropic_interface.exceptions import AnthropicErrorType, AnthropicExceptionMapping from litellm.constants import ( MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB, MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB, MAX_REQUEST_BODY_DECOMPRESSION_RATIO, ) from litellm.proxy.auth.auth_utils import get_request_route +from litellm.proxy.common_utils.anthropic_error_payload import route_error_content from litellm.proxy.middleware.request_size_limit_middleware import MaxRequestSizeGetter, send_request_too_large RFC_9659_ZSTD_WINDOW_LOG_MAX: Final = 23 @@ -291,13 +293,24 @@ def _replay_body(body: bytes, receive: Receive, more_body: bool = False) -> Rece def _error_response( + scope: Scope, message: str, status_code: int, error_type: str = "invalid_request_error", headers: Mapping[str, str] | None = None, ) -> JSONResponse: + anthropic_type: Final[AnthropicErrorType] = ( + "overloaded_error" + if error_type == "overloaded_error" + else AnthropicExceptionMapping.get_error_type(status_code) + ) return JSONResponse( - {"error": {"message": message, "type": error_type, "param": None, "code": str(status_code)}}, + route_error_content( + scope, + anthropic_type, + message, + {"error": {"message": message, "type": error_type, "param": None, "code": str(status_code)}}, + ), status_code=status_code, headers=headers, ) @@ -356,6 +369,7 @@ class RequestBodyDecompressionMiddleware: if codec is None: supported: Final = ", ".join(sorted(self.codecs)) await _error_response( + scope, f"This proxy cannot decode {coding} request bodies. Supported encodings: {supported}", 415, headers={"accept-encoding": supported}, @@ -372,7 +386,7 @@ class RequestBodyDecompressionMiddleware: case _Body(data=data): await self._decode_within_budget(scope, receive, send, codec, data, limits) case _TooLarge(): - await send_request_too_large(send=send, max_request_size_mb=max_mb) + await send_request_too_large(scope=scope, send=send, max_request_size_mb=max_mb) case _ClientDisconnected(): return @@ -382,6 +396,7 @@ class RequestBodyDecompressionMiddleware: reservation: Final = _max_decoded_length(codec, data, limits) if not self.budget.try_reserve(reservation): await _error_response( + scope, "The proxy is decoding too many compressed request bodies right now. " "Retry later, or send this body uncompressed", 503, @@ -407,12 +422,12 @@ class RequestBodyDecompressionMiddleware: case _Body(data=body): await self.app(_identity_scope(scope, len(body)), _replay_body(body, receive), send) case _DecodedTooLarge(): - await _error_response(f"Request body is larger than {limits.max_mb:g} MB when decoded", 413)( + await _error_response(scope, f"Request body is larger than {limits.max_mb:g} MB when decoded", 413)( scope, receive, send ) case _OverRatio(): - await _error_response(f"Request body expands more than {limits.max_ratio} times when decoded", 413)( - scope, receive, send - ) + await _error_response( + scope, f"Request body expands more than {limits.max_ratio} times when decoded", 413 + )(scope, receive, send) case _Undecodable(message=message): - await _error_response(message, 400)(scope, receive, send) + await _error_response(scope, message, 400)(scope, receive, send) diff --git a/litellm/proxy/middleware/request_size_limit_middleware.py b/litellm/proxy/middleware/request_size_limit_middleware.py index cbbc79cc2ca..15bbf05bd01 100644 --- a/litellm/proxy/middleware/request_size_limit_middleware.py +++ b/litellm/proxy/middleware/request_size_limit_middleware.py @@ -4,6 +4,8 @@ from typing import Final from starlette.types import ASGIApp, Message, Receive, Scope, Send +from litellm.proxy.common_utils.anthropic_error_payload import route_error_content + MaxRequestSizeGetter = Callable[[], int | float | None] RequestSizeLimitEnabledGetter = Callable[[], bool] @@ -44,7 +46,7 @@ class RequestSizeLimitMiddleware: content_length: Final = _get_content_length(scope=scope) if content_length is not None and content_length > max_request_size_bytes: - await send_request_too_large(send=send, max_request_size_mb=max_request_size_mb) + await send_request_too_large(scope=scope, send=send, max_request_size_mb=max_request_size_mb) return received_body_bytes = 0 @@ -74,7 +76,7 @@ class RequestSizeLimitMiddleware: except RequestEntityTooLarge: if response_started: raise - await send_request_too_large(send=send, max_request_size_mb=max_request_size_mb) + await send_request_too_large(scope=scope, send=send, max_request_size_mb=max_request_size_mb) def _mb_to_bytes(max_request_size_mb: float | None) -> int | None: @@ -98,11 +100,13 @@ def _get_content_length(scope: Scope) -> int | None: async def send_request_too_large( + scope: Scope, send: Send, max_request_size_mb: float | None, ) -> None: + message: Final = f"Request size is too large. Max size is {max_request_size_mb} MB" body: Final = json.dumps( - {"error": f"Request size is too large. Max size is {max_request_size_mb} MB"}, + route_error_content(scope, "request_too_large", message, {"error": message}), separators=(",", ":"), ).encode("utf-8") await send( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ada7e46da1b..95807cf71c4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -405,7 +405,6 @@ from litellm.proxy.auth.auth_object_prefetch import AUTH_OBJECTS_TARGET from litellm.proxy.auth.auth_utils import ( MAX_REQUEST_SIZE_MB_SETTING, check_response_size_is_safe, - get_request_route, is_request_body_safe, log_model_access_denial, log_once_if_budget_reservation_disabled, @@ -2194,7 +2193,7 @@ async def openai_exception_handler(request: Request, exc: ProxyException): # NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions log_model_access_denial(exc) headers: Final = exc.headers - if uses_anthropic_error_envelope(get_request_route(request)): + if uses_anthropic_error_envelope(request): return anthropic_error_json_response(exc, request) error_dict: Final = with_call_id( JSON_OBJECT.validate_python(exc.to_dict()), @@ -2231,7 +2230,7 @@ async def otlp_http_exception_handler(connection: Request | WebSocket, exc: Star if response is not None: _close_dangling_otel_server_span(connection, exc.status_code, exc=exc) return response - if uses_anthropic_error_envelope(get_request_route(connection)): + if uses_anthropic_error_envelope(connection): return anthropic_error_json_response( proxy_exception_from_http_exception(exc, dict(exc.headers or {})), connection ) @@ -2442,7 +2441,7 @@ async def _unhandled_exception_response(request: Request, exc: Exception) -> Res otlp_response: Final = tracing_endpoints.otlp_error_response(request, 500) if otlp_response is not None: return otlp_response - if uses_anthropic_error_envelope(get_request_route(request)): + if uses_anthropic_error_envelope(request): return anthropic_internal_server_error_response(request) return JSONResponse( status_code=500, diff --git a/tests/integration/authorization/test_claude_code_gateway_oauth.py b/tests/integration/authorization/test_claude_code_gateway_oauth.py index 700816c8d31..cb5ae4c3ba4 100644 --- a/tests/integration/authorization/test_claude_code_gateway_oauth.py +++ b/tests/integration/authorization/test_claude_code_gateway_oauth.py @@ -481,6 +481,7 @@ def test_device_authorization_polls_complete_sso_and_mints_a_model_restricted_be f"The requested model '{model_b}' is not available for this API key, or the model name is " "invalid. Check the models available to you and try again." ), + "litellm_error_type": "team_model_access_denied", }, }, model_b_response.text assert upstream.drain() == () diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py index e104fad4b3f..1f57584572b 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py @@ -276,6 +276,7 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en f"The requested model '{restricted_model}' is not available for this API key, or the model name " "is invalid. Check the models available to you and try again." ), + "litellm_error_type": "key_model_access_denied", }, }, restricted_response.text assert wire.drain() == () @@ -292,6 +293,7 @@ def test_enabled_gateway_refuses_model_outside_virtual_key(enabled_gateway: Gate f"The requested model '{result[4]}' is not available for this API key, or the model name is invalid. " "Check the models available to you and try again." ), + "litellm_error_type": "key_model_access_denied", }, } assert result[0] == result[2] == 403, (result[1], result[3]) 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 a448a8cd2da..fe6b8efb423 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py @@ -357,10 +357,14 @@ def test_anthropic_messages_preserves_cli_beta_safeguards_and_result(gateway: Ga @pytest.mark.parametrize( - ("status", "error_type", "error_class", "litellm_error"), + ("status", "error_type", "error_class", "litellm_error", "litellm_error_type"), [ - pytest.param(429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", id="rate_limit"), - pytest.param(400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", id="invalid_request"), + 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" + ), ], ) def test_anthropic_messages_preserves_upstream_error_status_and_body( @@ -369,6 +373,7 @@ 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}} @@ -416,6 +421,7 @@ 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 @@ -470,6 +476,7 @@ 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 a6c3a6dc62f..a5299547ec3 100644 --- a/tests/integration/providers/test_count_tokens_error_shapes_wire.py +++ b/tests/integration/providers/test_count_tokens_error_shapes_wire.py @@ -140,7 +140,11 @@ 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"]}, + "error": { + "type": "authentication_error", + "message": raised.value.body["error"]["message"], + "litellm_error_type": "token_counting_error", + }, } assert missing in raised.value.message, raised.value.message assert wire.drain() == () @@ -210,7 +214,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"], body + assert body["error"] == {**upstream_error["error"], "litellm_error_type": "token_counting_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 c679311e4cc..809e8877312 100644 --- a/tests/integration/providers/test_vertex_partner_count_tokens_wire.py +++ b/tests/integration/providers/test_vertex_partner_count_tokens_wire.py @@ -258,7 +258,10 @@ 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}}, + rejection=lambda message: { + "type": "error", + "error": {"type": "invalid_request_error", "message": message, "litellm_error_type": "token_counting_error"}, + }, count_field="input_tokens", ) _RESPONSES_ROUTE: Final = _CountRoute( 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 5c40bfafa08..7019e68edf6 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -65,13 +65,22 @@ class TestCreateErrorResponse: @pytest.mark.parametrize( ("status_code", "expected_error_type"), - [(422, "invalid_request_error"), (418, "api_error"), (502, "api_error"), (599, "api_error")], + [ + (402, "billing_error"), + (409, "conflict_error"), + (504, "timeout_error"), + (405, "invalid_request_error"), + (418, "invalid_request_error"), + (422, "invalid_request_error"), + (499, "invalid_request_error"), + (502, "api_error"), + (503, "api_error"), + (599, "api_error"), + ], ) - def test_a_budget_status_is_a_request_error_and_a_status_the_table_does_not_name_is_api_error( + def test_a_status_the_docs_do_not_name_is_a_request_error_below_500_and_an_api_error_from_500( self, status_code: int, expected_error_type: str ): - """The proxy's budget status is a caller-fault request error, so the table names 422. - A status the table does not name, such as a 418 or a 5xx, keeps api_error.""" response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status") assert response["error"]["type"] == expected_error_type diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index c7bb05e5d30..5d18c883db3 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -172,6 +172,7 @@ 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/middleware/test_admission_control_middleware.py b/tests/unit/proxy/middleware/test_admission_control_middleware.py index e449ebc83bf..66549b0c351 100644 --- a/tests/unit/proxy/middleware/test_admission_control_middleware.py +++ b/tests/unit/proxy/middleware/test_admission_control_middleware.py @@ -510,3 +510,32 @@ async def test_admission_lease_cannot_be_reused_by_another_worker(state: Admissi response: Final = await _call(parent) assert response[1]["body"] == b"1" assert state.get_stats() == other_state.get_stats() == AdmissionControlStats(0, 0, 0) + + +@pytest.mark.asyncio +async def test_a_messages_request_turned_away_at_capacity_gets_the_anthropic_overloaded_error( + state: AdmissionControlState, +) -> None: + started: Final = asyncio.Event() + release: Final = asyncio.Event() + middleware: Final = AdmissionControlMiddleware( + _handler_with_release(started, release), + lambda: AdmissionControlSettings(1, 0, 1.0), + state, + ) + + first: Final = asyncio.create_task(_call(middleware, path="/v1/messages")) + await started.wait() + rejected: Final = await _call(middleware, path="/v1/messages") + release.set() + await first + + assert rejected[0]["status"] == 503 + assert dict(rejected[0]["headers"])[b"retry-after"] == b"1" + assert json.loads(rejected[1]["body"]) == { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "Worker at capacity: 1 in-flight, 0 queued requests. Retry later.", + }, + } diff --git a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py index ca5774b6be0..f91b3690637 100644 --- a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py +++ b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py @@ -665,3 +665,33 @@ async def test_non_http_scopes_reach_the_app_untouched(scope: Scope): [(app_scope, app_receive)] = seen assert app_scope is scope assert app_receive is receive + + +@pytest.mark.parametrize( + ("body", "max_mb", "status_code", "anthropic_type", "message"), + [ + pytest.param( + zstd.compress(_low_entropy_text(2 * int(CAP_MB * MIB))), + CAP_MB, + 413, + "request_too_large", + f"Request body is larger than {CAP_MB:g} MB when decoded", + id="decoded-past-the-cap", + ), + pytest.param(b"not zstd at all", 50.0, 400, "invalid_request_error", None, id="undecodable"), + ], +) +def test_a_messages_body_the_proxy_cannot_take_gets_the_anthropic_envelope( + body: bytes, max_mb: float, status_code: int, anthropic_type: str, message: str | None +): + response: Final = _post( + _client(max_decompressed_body_size_mb=max_mb), + ZSTD_FRAME_MAGIC + body if message is None else body, + ("zstd",), + path="/claude_code_gateway/v1/messages", + ) + + assert response.status_code == status_code + assert response.json()["type"] == "error" + assert response.json()["error"]["type"] == anthropic_type + assert message is None or response.json()["error"]["message"] == message diff --git a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py index 3e8792e0179..29b4a6a43a1 100644 --- a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py +++ b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py @@ -133,3 +133,22 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte "more_body": False, } assert received_body_bytes == 1024 * 1024 + + +def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_error(): + async def app(scope, receive, send): + await JSONResponse({"ok": True})(scope, receive, send) + + client = TestClient( + RequestSizeLimitMiddleware(app, get_max_request_size_mb=lambda: 1, is_request_size_limit_enabled=lambda: True) + ) + + response = client.post( + "/v1/messages", content=b"x" * (1024 * 1024 + 1), headers={"content-type": "application/json"} + ) + + assert response.status_code == 413 + assert response.json() == { + "type": "error", + "error": {"type": "request_too_large", "message": "Request size is too large. Max size is 1 MB"}, + } diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index 7676f82bd74..8a8916407d1 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -36,9 +36,9 @@ from litellm.proxy.proxy_server import ( from .conftest import normalize -def _make_request(parent_otel_span=None, path="/chat/completions"): +def _make_request(parent_otel_span=None, path="/chat/completions", headers=()): return Request({ - "type": "http", "method": "POST", "path": path, "headers": [], + "type": "http", "method": "POST", "path": path, "headers": list(headers), "state": {"parent_otel_span": parent_otel_span}, }) @@ -209,6 +209,7 @@ async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_me "error": { "type": "permission_error", "message": "Team not allowed to access model. Tried to access gpt-5.6", + "litellm_error_type": "team_model_access_denied", }, } @@ -247,6 +248,7 @@ async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_ assert body["type"] == "error" assert body["error"]["type"] == "invalid_request_error" assert body["error"]["message"] == "Budget exceeded" + assert body["error"]["litellm_error_type"] == "budget_exceeded" assert "param" not in body["error"] and "code" not in body["error"] @@ -268,6 +270,23 @@ async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messag } +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ("/v1/models", "/models", "/claude_code_gateway/v1/models")) +async def test_a_model_list_request_answers_errors_in_the_shape_its_listing_takes(path: str) -> None: + anthropic: Final = await openai_exception_handler( + request=_make_request(path=path, headers=[(b"anthropic-version", b"2023-06-01")]), + exc=_model_access_denied_exception(), + ) + openai: Final = await openai_exception_handler( + request=_make_request(path=path), exc=_model_access_denied_exception() + ) + + assert json.loads(anthropic.body)["error"]["type"] == "permission_error" + assert json.loads(anthropic.body)["type"] == "error" + assert json.loads(openai.body)["error"]["type"] == "team_model_access_denied" + assert "type" not in json.loads(openai.body) + + @pytest.mark.asyncio async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthropic_routes() -> None: response: Final = await openai_exception_handler( @@ -302,6 +321,7 @@ async def test_openai_exception_handler_messages_route_copies_the_call_id_when_o "type": "permission_error", "message": "denied", "provider_specific_fields": {"guardrail": "keyword-block"}, + "litellm_error_type": "team_model_access_denied", "litellm_call_id": "call-8302", } @@ -738,7 +758,10 @@ def test_a_get_on_messages_answers_the_anthropic_envelope_and_keeps_the_allow_he assert response.status_code == 405 assert response.headers["allow"] == "POST" - assert response.json() == {"type": "error", "error": {"type": "api_error", "message": "Method Not Allowed"}} + assert response.json() == { + "type": "error", + "error": {"type": "invalid_request_error", "message": "Method Not Allowed"}, + } @pytest.mark.asyncio diff --git a/tests/unit/proxy/test_proxy_token_counter.py b/tests/unit/proxy/test_proxy_token_counter.py index fa0c9eda02d..f8134aed27f 100644 --- a/tests/unit/proxy/test_proxy_token_counter.py +++ b/tests/unit/proxy/test_proxy_token_counter.py @@ -1525,6 +1525,7 @@ 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 e927c3e06ed..bb0edf791f3 100644 --- a/ui/litellm-dashboard/src/lib/toast.test.ts +++ b/ui/litellm-dashboard/src/lib/toast.test.ts @@ -109,14 +109,55 @@ describe("toast", () => { }); it.each([ - [403, "permission_error", sonner.error, "Access Denied"], - [401, "authentication_error", sonner.error, "Authentication Error"], - [429, "rate_limit_error", sonner.warning, "Rate Limit Exceeded"], - [422, "invalid_request_error", sonner.error, "Request Error"], - ] as const)( - "titles an Anthropic SDK %s error on the Messages playground by its %s type", - (status, type, fn, title) => { - const body = JSON.stringify({ type: "error", error: { type, message: "Team not allowed to access model" } }); + { + status: 403, + type: "permission_error", + litellmType: "team_model_access_denied", + fn: sonner.error, + title: "Access Denied", + }, + { + status: 401, + type: "authentication_error", + litellmType: "auth_error", + fn: sonner.error, + title: "Authentication Error", + }, + { + status: 422, + type: "invalid_request_error", + litellmType: "budget_exceeded", + fn: sonner.warning, + title: "Budget Exceeded", + }, + { + status: 503, + type: "api_error", + litellmType: "no_db_connection", + fn: sonner.error, + title: "Service Unavailable", + }, + { + status: 429, + type: "rate_limit_error", + litellmType: undefined, + fn: sonner.warning, + title: "Rate Limit Exceeded", + }, + { + status: 422, + type: "invalid_request_error", + litellmType: undefined, + fn: sonner.error, + title: "Validation Error", + }, + ])( + "titles an Anthropic SDK $status $type error from the Messages playground by its proxy type $litellmType, else its status", + ({ status, type, litellmType, fn, title }) => { + const body = JSON.stringify({ + type: "error", + error: { type, message: "Team not allowed to access model", litellm_error_type: litellmType }, + }); toast.fromError( `Error occurred while generating model response. Please try again. Error: Error: ${status} ${body}`, ); @@ -127,6 +168,11 @@ describe("toast", () => { }, ); + it("titles an Anthropic error body with no status by its Anthropic type", () => { + toast.fromError(JSON.stringify({ type: "error", error: { type: "permission_error", message: "denied" } })); + expect(sonner.error).toHaveBeenCalledWith("Access Denied", { description: "denied", 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 465ac7a1f6c..8ef545ba0ef 100644 --- a/ui/litellm-dashboard/src/lib/toast.ts +++ b/ui/litellm-dashboard/src/lib/toast.ts @@ -82,11 +82,16 @@ const proxyEnvelope = (payload: unknown): Record | undefined => }; const proxyTypeOf = (payload: unknown): string | undefined => { - const type = proxyEnvelope(payload)?.type; + const envelope = proxyEnvelope(payload); + const type = envelope?.litellm_error_type ?? envelope?.type; return typeof type === "string" ? type : undefined; }; const EMBEDDED_JSON = /\{[\s\S]*\}/; +const STATUS_BEFORE_JSON = /(\d{3})\s*$/; + +const statusBefore = (text: string, embedded: string): number | undefined => + toStatus(text.slice(0, text.indexOf(embedded)).match(STATUS_BEFORE_JSON)?.[1]); const describeText = (text: string): ErrorFacts => { const embedded = text.match(EMBEDDED_JSON)?.[0]; @@ -95,7 +100,7 @@ const describeText = (text: string): ErrorFacts => { return { status: undefined, proxyType: undefined, text: unwrapProxyErrorMessage(text) }; } return { - status: toStatus(proxyEnvelope(parsed)?.code), + status: toStatus(proxyEnvelope(parsed)?.code) ?? statusBefore(text, embedded), proxyType: proxyTypeOf(parsed), text: text.replace(embedded, unwrapProxyErrorMessage(deriveErrorMessage(parsed))).trim(), }; From a5e86700c270fefa55e3824a2e2078f043f609f1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:01:05 -0700 Subject: [PATCH 6/9] test(proxy): expect litellm_error_type in Messages integration tests and type middleware tests --- .../authorization/_hidden_alias_budget.py | 3 --- .../authorization/test_unknown_key_generic_401.py | 8 ++++---- .../test_request_body_decompression_middleware.py | 2 +- .../middleware/test_request_size_limit_middleware.py | 12 +++++++----- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/integration/authorization/_hidden_alias_budget.py b/tests/integration/authorization/_hidden_alias_budget.py index 5b4bec18b94..5e9c2f1a95a 100644 --- a/tests/integration/authorization/_hidden_alias_budget.py +++ b/tests/integration/authorization/_hidden_alias_budget.py @@ -151,9 +151,6 @@ def error_type(response: httpx.Response) -> str: def budget_refusal_error_type(route: str) -> str: - """The ``error.type`` a budget refusal carries on a route label from ``ROUTES``: the - Anthropic envelope on Messages (its budget refusal is raised in - ``user_api_key_auth``), the OpenAI ``budget_exceeded`` type on the other routes.""" return "invalid_request_error" if route == "messages" else "budget_exceeded" diff --git a/tests/integration/authorization/test_unknown_key_generic_401.py b/tests/integration/authorization/test_unknown_key_generic_401.py index da5f43e58ea..dc647b7fc5f 100644 --- a/tests/integration/authorization/test_unknown_key_generic_401.py +++ b/tests/integration/authorization/test_unknown_key_generic_401.py @@ -55,6 +55,7 @@ class _AnthropicErrorDetail(BaseModel): type: str message: str + litellm_error_type: str class _AnthropicErrorResponse(BaseModel): @@ -67,7 +68,9 @@ class _AnthropicErrorResponse(BaseModel): GENERIC_ERROR: Final = _ProxyError(message=GENERIC_MESSAGE, type="token_not_found_in_db", param="key", code="401") MESSAGES_PATH: Final = "/v1/messages" ANTHROPIC_ENVELOPE_PATHS: Final = frozenset({MESSAGES_PATH, "/anthropic/v1/messages"}) -ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail(type="authentication_error", message=GENERIC_MESSAGE) +ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail( + type="authentication_error", message=GENERIC_MESSAGE, litellm_error_type=GENERIC_ERROR.type +) @dataclass(frozen=True, slots=True) @@ -146,7 +149,6 @@ def _assert_messages_refusal(response: httpx.Response, *secrets: str) -> None: def _assert_refusal(path: str, response: httpx.Response, *secrets: str) -> None: - """The Messages and Anthropic pass-through routes answer the Anthropic envelope, every other route the OpenAI one.""" if path in ANTHROPIC_ENVELOPE_PATHS: _assert_messages_refusal(response, *secrets) else: @@ -398,8 +400,6 @@ async def _send_async(client: httpx.AsyncClient, route: _Route, key: str) -> htt async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[tuple[_Route, httpx.Response], ...]: - """Each key's response paired with the route it was sent on, so the caller asserts the - envelope the route actually answered with.""" routes: Final = tuple(ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]] for index in range(len(keys))) async with httpx.AsyncClient(base_url=str(owned.gateway.client.base_url), timeout=60, trust_env=False) as client: responses: Final = await asyncio.gather( diff --git a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py index f91b3690637..2727857c352 100644 --- a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py +++ b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py @@ -683,7 +683,7 @@ async def test_non_http_scopes_reach_the_app_untouched(scope: Scope): ) def test_a_messages_body_the_proxy_cannot_take_gets_the_anthropic_envelope( body: bytes, max_mb: float, status_code: int, anthropic_type: str, message: str | None -): +) -> None: response: Final = _post( _client(max_decompressed_body_size_mb=max_mb), ZSTD_FRAME_MAGIC + body if message is None else body, diff --git a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py index 29b4a6a43a1..0a30b4cf9ef 100644 --- a/tests/unit/proxy/middleware/test_request_size_limit_middleware.py +++ b/tests/unit/proxy/middleware/test_request_size_limit_middleware.py @@ -1,7 +1,9 @@ +from typing import Final + import pytest from starlette.responses import JSONResponse from starlette.testclient import TestClient -from starlette.types import Message +from starlette.types import Message, Receive, Scope, Send from litellm.proxy.middleware.request_size_limit_middleware import ( RequestSizeLimitMiddleware, @@ -135,15 +137,15 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte assert received_body_bytes == 1024 * 1024 -def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_error(): - async def app(scope, receive, send): +def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_error() -> None: + async def app(scope: Scope, receive: Receive, send: Send) -> None: await JSONResponse({"ok": True})(scope, receive, send) - client = TestClient( + client: Final = TestClient( RequestSizeLimitMiddleware(app, get_max_request_size_mb=lambda: 1, is_request_size_limit_enabled=lambda: True) ) - response = client.post( + response: Final = client.post( "/v1/messages", content=b"x" * (1024 * 1024 + 1), headers={"content-type": "application/json"} ) 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 7/9] 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]); From 546b8fd6d96e59fcacea3c44f983f8735b95b8db Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:04:44 -0700 Subject: [PATCH 8/9] fix(proxy): keep the base error types inside the Messages handler and scope toast titles to Anthropic bodies The wider status table (402 billing_error, 409 conflict_error, 504 timeout_error, other unlisted 4xx invalid_request_error) now applies only to errors the proxy raises before the handler. Errors inside the Messages handler, streamed error frames and the SDK streaming adapters keep the merge base's table. The dashboard toast reads a status before embedded JSON and titles by Anthropic type only when the body is an Anthropic error envelope, so other pages keep their titles. --- .../exceptions/exception_mapping_utils.py | 20 ++++--------- .../common_utils/anthropic_error_payload.py | 29 +++++++++++++++++-- .../request_body_decompression_middleware.py | 8 ++--- .../test_exception_mapping_utils.py | 29 ++++++------------- .../anthropic_endpoints/test_endpoints.py | 17 +++++++++++ .../proxy_server/test_exception_handlers.py | 6 ++++ ui/litellm-dashboard/src/lib/toast.test.ts | 8 +++++ ui/litellm-dashboard/src/lib/toast.ts | 26 +++++++++++++---- 8 files changed, 97 insertions(+), 46 deletions(-) diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index d98110cbea5..b4c95fafff3 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -20,19 +20,14 @@ _PARSED_ERROR_BODY: Final = TypeAdapter(object) ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = { 400: "invalid_request_error", 401: "authentication_error", - 402: "billing_error", 403: "permission_error", 404: "not_found_error", - 409: "conflict_error", 413: "request_too_large", 429: "rate_limit_error", 500: "api_error", - 504: "timeout_error", 529: "overloaded_error", } -REQUEST_TIMEOUT_STATUS: Final = 408 - class AnthropicExceptionMapping: """ @@ -44,18 +39,14 @@ class AnthropicExceptionMapping: @staticmethod def get_error_type(status_code: int) -> AnthropicErrorType: """Map HTTP status code to Anthropic error type.""" - named: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code) - if named is not None: - return named - if status_code == REQUEST_TIMEOUT_STATUS or not 400 <= status_code < 500: - return "api_error" - return "invalid_request_error" + return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error") @staticmethod def create_error_response( status_code: int, message: str, request_id: str | None = None, + error_type: AnthropicErrorType | None = None, ) -> AnthropicErrorResponse: """ Create an Anthropic-formatted error response dict. @@ -67,12 +58,10 @@ class AnthropicExceptionMapping: "request_id": "req_..." } """ - error_type: Final = AnthropicExceptionMapping.get_error_type(status_code) - response: Final[AnthropicErrorResponse] = { "type": "error", "error": { - "type": error_type, + "type": error_type or AnthropicExceptionMapping.get_error_type(status_code), "message": message, }, } @@ -141,6 +130,7 @@ class AnthropicExceptionMapping: status_code: int, raw_message: str, request_id: str | None = None, + error_type: AnthropicErrorType | None = None, ) -> AnthropicErrorResponse: """ Transform an error message to Anthropic format. @@ -154,6 +144,7 @@ class AnthropicExceptionMapping: status_code: HTTP status code raw_message: Raw error message (may be JSON string or plain text) request_id: Optional request ID to include + error_type: Anthropic error type to use instead of the one the status maps to Returns: AnthropicErrorResponse dict @@ -179,6 +170,7 @@ class AnthropicExceptionMapping: status_code=status_code, message=message, request_id=request_id, + error_type=error_type, ) diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py index 91d71ee0dd3..89001194f7f 100644 --- a/litellm/proxy/common_utils/anthropic_error_payload.py +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -2,6 +2,7 @@ handler runs, so the central handlers pick the Anthropic envelope from the request route.""" from collections.abc import Callable, Mapping +from types import MappingProxyType from typing import Final from fastapi import Request, status @@ -9,6 +10,7 @@ from fastapi.responses import JSONResponse from starlette.types import Scope from litellm.anthropic_interface.exceptions import ( + ANTHROPIC_ERROR_TYPE_MAP, AnthropicErrorDetail, AnthropicErrorResponse, AnthropicErrorType, @@ -31,6 +33,18 @@ ANTHROPIC_PASS_THROUGH_PREFIX: Final = "/anthropic/" ANTHROPIC_SHAPED_MODEL_LIST_ROUTES: Final[frozenset[str]] = frozenset( {"/v1/models", "/models", "/claude_code_gateway/v1/models"} ) +PROXY_ERROR_TYPE_BY_STATUS: Final[Mapping[int, AnthropicErrorType]] = MappingProxyType( + {**ANTHROPIC_ERROR_TYPE_MAP, 402: "billing_error", 409: "conflict_error", 504: "timeout_error"} +) + + +def proxy_error_type(status_code: int) -> AnthropicErrorType: + named: Final = PROXY_ERROR_TYPE_BY_STATUS.get(status_code) + if named is not None: + return named + if status_code == status.HTTP_408_REQUEST_TIMEOUT or not 400 <= status_code < 500: + return "api_error" + return "invalid_request_error" def uses_anthropic_error_envelope(request: Request) -> bool: @@ -81,16 +95,18 @@ def _with_call_id(detail: AnthropicErrorDetail, call_id: str | None) -> Anthropi return with_call_id -def anthropic_handler_error_content( +def _anthropic_content( exc: ProxyException, status_code: int, request_id: str | None, general_settings: Mapping[str, object], + error_type: AnthropicErrorType | None, ) -> AnthropicErrorResponse: envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error( status_code=status_code, raw_message=exc.message, request_id=request_id, + error_type=error_type, ) body_call_id: Final = error_body_call_id(general_settings, exc.headers.get(LITELLM_CALL_ID_HEADER)) content: Final[AnthropicErrorResponse] = { @@ -100,13 +116,22 @@ def anthropic_handler_error_content( return content +def anthropic_handler_error_content( + exc: ProxyException, + status_code: int, + request_id: str | None, + general_settings: Mapping[str, object], +) -> AnthropicErrorResponse: + return _anthropic_content(exc, status_code, request_id, general_settings, None) + + 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) + content: Final = _anthropic_content(exc, status_code, request_id, general_settings, proxy_error_type(status_code)) with_type: Final[AnthropicErrorResponse] = { **content, "error": _with_litellm_error_type(exc, status_code, content["error"]), diff --git a/litellm/proxy/middleware/request_body_decompression_middleware.py b/litellm/proxy/middleware/request_body_decompression_middleware.py index 6a732cc1e8e..3e144d7bf4c 100644 --- a/litellm/proxy/middleware/request_body_decompression_middleware.py +++ b/litellm/proxy/middleware/request_body_decompression_middleware.py @@ -12,14 +12,14 @@ from starlette.requests import ClientDisconnect, Request from starlette.responses import JSONResponse from starlette.types import ASGIApp, Message, Receive, Scope, Send -from litellm.anthropic_interface.exceptions import AnthropicErrorType, AnthropicExceptionMapping +from litellm.anthropic_interface.exceptions import AnthropicErrorType from litellm.constants import ( MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB, MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB, MAX_REQUEST_BODY_DECOMPRESSION_RATIO, ) from litellm.proxy.auth.auth_utils import get_request_route -from litellm.proxy.common_utils.anthropic_error_payload import route_error_content +from litellm.proxy.common_utils.anthropic_error_payload import proxy_error_type, route_error_content from litellm.proxy.middleware.request_size_limit_middleware import MaxRequestSizeGetter, send_request_too_large RFC_9659_ZSTD_WINDOW_LOG_MAX: Final = 23 @@ -300,9 +300,7 @@ def _error_response( headers: Mapping[str, str] | None = None, ) -> JSONResponse: anthropic_type: Final[AnthropicErrorType] = ( - "overloaded_error" - if error_type == "overloaded_error" - else AnthropicExceptionMapping.get_error_type(status_code) + "overloaded_error" if error_type == "overloaded_error" else proxy_error_type(status_code) ) return JSONResponse( route_error_content( 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 ea21e0a1d9b..4f29791d566 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -63,27 +63,16 @@ class TestCreateErrorResponse: ) assert response["request_id"] == "req_123" - @pytest.mark.parametrize( - ("status_code", "expected_error_type"), - [ - (402, "billing_error"), - (409, "conflict_error"), - (504, "timeout_error"), - (405, "invalid_request_error"), - (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_except_a_timeout_and_an_api_error_from_500( - self, status_code: int, expected_error_type: str - ): + @pytest.mark.parametrize("status_code", [402, 405, 409, 418, 422, 504]) + def test_a_status_outside_the_table_stays_an_api_error_for_the_sdk_and_stream_frames(self, status_code: int): response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status") - assert response["error"]["type"] == expected_error_type + frame = anthropic_error_sse_frame(status_code=status_code, raw_message="scripted status") + assert response["error"]["type"] == "api_error" + assert frame.error_response["error"]["type"] == "api_error" + + def test_an_explicit_error_type_wins_over_the_status_table(self): + response = AnthropicExceptionMapping.create_error_response(402, "Budget exceeded", error_type="billing_error") + assert response["error"]["type"] == "billing_error" class TestExtractErrorMessage: diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index c7bb05e5d30..bd1beada8c1 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -201,6 +201,23 @@ class TestProxyExceptionAnthropicEnvelope: assert fallback.status_code == 500 assert json.loads(fallback.body)["error"]["type"] == "api_error" + @pytest.mark.parametrize("status_code", [402, 409, 422, 504]) + def test_a_failure_inside_the_handler_keeps_the_api_error_type_for_a_status_outside_the_table( + self, status_code: int + ): + import litellm.proxy.anthropic_endpoints.endpoints as ep + from litellm.proxy._types import ProxyException + + request = MagicMock() + request.headers = {} + + response = ep._anthropic_error_json_response( + ProxyException(message="scripted", type="budget_exceeded", param=None, code=status_code), request + ) + + assert response.status_code == status_code + assert json.loads(response.body)["error"] == {"type": "api_error", "message": "scripted"} + @staticmethod def _call_id_error_response(general_settings, provider_specific_fields=None): import litellm.proxy.anthropic_endpoints.endpoints as ep diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index 4d9054051aa..f14c5572cac 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -217,6 +217,12 @@ async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_me (403, "permission_error"), (404, "not_found_error"), (429, "rate_limit_error"), + (402, "billing_error"), + (409, "conflict_error"), + (504, "timeout_error"), + (405, "invalid_request_error"), + (408, "api_error"), + (502, "api_error"), ], ) async def test_openai_exception_handler_maps_messages_failures_to_anthropic_error_types( diff --git a/ui/litellm-dashboard/src/lib/toast.test.ts b/ui/litellm-dashboard/src/lib/toast.test.ts index 5d739db2534..8f50bd5f5c7 100644 --- a/ui/litellm-dashboard/src/lib/toast.test.ts +++ b/ui/litellm-dashboard/src/lib/toast.test.ts @@ -182,6 +182,14 @@ describe("toast", () => { expect(sonner.error).toHaveBeenCalledWith(title, { description: "boom", duration: 6000 }); }); + it.each([ + ["an OpenAI envelope", { error: { type: "invalid_request_error", message: "boom" } }], + ["a body with no Anthropic type", { detail: { error: "boom" } }], + ])("keeps the plain Error title for %s after a status in the message", (_, payload) => { + toast.fromError(`Request failed: 404 ${JSON.stringify(payload)}`); + expect(sonner.error).toHaveBeenCalledWith("Error", { description: "Request failed: 404 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 }); diff --git a/ui/litellm-dashboard/src/lib/toast.ts b/ui/litellm-dashboard/src/lib/toast.ts index aa7aa3aa94e..1546156803c 100644 --- a/ui/litellm-dashboard/src/lib/toast.ts +++ b/ui/litellm-dashboard/src/lib/toast.ts @@ -13,6 +13,7 @@ export type ToastOptions = { type ErrorFacts = { readonly status: number | undefined; readonly proxyType: string | undefined; + readonly anthropicType: string | undefined; readonly text: string; }; @@ -90,6 +91,12 @@ const proxyTypeOf = (payload: unknown): string | undefined => { return typeof type === "string" ? type : undefined; }; +const anthropicTypeOf = (payload: unknown): string | undefined => { + const record = asRecord(payload); + const type = asRecord(record?.error)?.type; + return record?.type === "error" && typeof type === "string" ? type : undefined; +}; + const EMBEDDED_JSON = /\{[\s\S]*\}/; const STATUS_BEFORE_JSON = /(? { const embedded = text.match(EMBEDDED_JSON)?.[0]; const parsed = embedded === undefined ? undefined : parseJson(embedded); if (embedded === undefined || asRecord(parsed) === undefined) { - return { status: undefined, proxyType: undefined, text: unwrapProxyErrorMessage(text) }; + return { status: undefined, proxyType: undefined, anthropicType: undefined, text: unwrapProxyErrorMessage(text) }; } + const anthropicType = anthropicTypeOf(parsed); return { - status: toStatus(proxyEnvelope(parsed)?.code) ?? statusBefore(text, embedded), + status: + toStatus(proxyEnvelope(parsed)?.code) ?? (anthropicType === undefined ? undefined : statusBefore(text, embedded)), proxyType: proxyTypeOf(parsed), + anthropicType, text: text.replace(embedded, unwrapProxyErrorMessage(deriveErrorMessage(parsed))).trim(), }; }; const describeError = (input: unknown): ErrorFacts => { if (input instanceof ApiError) { - return { status: input.status, proxyType: proxyTypeOf(input.body), text: unwrapProxyErrorMessage(input.message) }; + return { + status: input.status, + proxyType: proxyTypeOf(input.body), + anthropicType: anthropicTypeOf(input.body), + text: unwrapProxyErrorMessage(input.message), + }; } if (input instanceof Error || typeof input === "string") { return describeText(input instanceof Error ? input.message : input); @@ -126,6 +141,7 @@ const describeError = (input: unknown): ErrorFacts => { toStatus(record.code) ?? toStatus(proxyEnvelope(payload)?.code), proxyType: proxyTypeOf(payload), + anthropicType: anthropicTypeOf(payload), text: unwrapProxyErrorMessage(deriveErrorMessage(payload)), }; }; @@ -138,12 +154,12 @@ const titleForStatus = (status: number): string => { return "Error"; }; -const titleFor = ({ status, proxyType }: ErrorFacts): string => { +const titleFor = ({ status, proxyType, anthropicType }: ErrorFacts): string => { if (proxyType?.endsWith("_access_denied")) return "Access Denied"; const byType = proxyType === undefined ? undefined : PROXY_TYPE_TITLES[proxyType]; if (byType !== undefined) return byType; if (status !== undefined) return titleForStatus(status); - return (proxyType === undefined ? undefined : ANTHROPIC_TYPE_TITLES[proxyType]) ?? "Error"; + return (anthropicType === undefined ? undefined : ANTHROPIC_TYPE_TITLES[anthropicType]) ?? "Error"; }; const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => { From 48d2cd1c56b10a66fde6541ccf2ee9f454969cd7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:16:02 -0700 Subject: [PATCH 9/9] test(anthropic): mark the new test variables Final --- .../exceptions/test_exception_mapping_utils.py | 6 +++--- tests/unit/proxy/anthropic_endpoints/test_endpoints.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) 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 4f29791d566..210ab6717aa 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -65,13 +65,13 @@ class TestCreateErrorResponse: @pytest.mark.parametrize("status_code", [402, 405, 409, 418, 422, 504]) def test_a_status_outside_the_table_stays_an_api_error_for_the_sdk_and_stream_frames(self, status_code: int): - response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status") - frame = anthropic_error_sse_frame(status_code=status_code, raw_message="scripted status") + response: Final = AnthropicExceptionMapping.create_error_response(status_code, "scripted status") + frame: Final = anthropic_error_sse_frame(status_code=status_code, raw_message="scripted status") assert response["error"]["type"] == "api_error" assert frame.error_response["error"]["type"] == "api_error" def test_an_explicit_error_type_wins_over_the_status_table(self): - response = AnthropicExceptionMapping.create_error_response(402, "Budget exceeded", error_type="billing_error") + response: Final = AnthropicExceptionMapping.create_error_response(402, "Budget exceeded", error_type="billing_error") assert response["error"]["type"] == "billing_error" diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index bd1beada8c1..5d9b3c0a7ab 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -208,10 +208,9 @@ class TestProxyExceptionAnthropicEnvelope: import litellm.proxy.anthropic_endpoints.endpoints as ep from litellm.proxy._types import ProxyException - request = MagicMock() - request.headers = {} + request: Final = MagicMock(headers={}) - response = ep._anthropic_error_json_response( + response: Final = ep._anthropic_error_json_response( ProxyException(message="scripted", type="budget_exceeded", param=None, code=status_code), request )