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