fix: Return Anthropic error envelopes on Messages auth failures

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
This commit is contained in:
Alberto Perdomo 2026-10-26 12:26:44 +00:00
parent 33b73207e3
commit a0d8f14724
No known key found for this signature in database
6 changed files with 297 additions and 46 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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