From a0d8f147248d189bb17a0f498351e175d627c590 Mon Sep 17 00:00:00 2001 From: Alberto Perdomo Date: Mon, 26 Oct 2026 12:26:44 +0000 Subject: [PATCH] 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 # ---------------------------------------------------------------------------