mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
fix: Return Anthropic error envelopes on Messages auth failures
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
This commit is contained in:
parent
33b73207e3
commit
a0d8f14724
6 changed files with 297 additions and 46 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
110
litellm/proxy/common_utils/anthropic_error_payload.py
Normal file
110
litellm/proxy/common_utils/anthropic_error_payload.py
Normal 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)
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue