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
This commit is contained in:
mateo-berri 2026-10-28 03:37:31 -07:00
parent f6d5cdd12d
commit 8208298f69
4 changed files with 22 additions and 13 deletions

View file

@ -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:

View file

@ -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()),

View file

@ -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)

View file

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