This commit is contained in:
alberto 2026-10-27 14:08:01 -07:00 • committed by GitHub
commit f53baf3d95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 398 additions and 84 deletions

View file

@ -23,6 +23,7 @@ ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = {
403: "permission_error",
404: "not_found_error",
413: "request_too_large",
422: "invalid_request_error",
429: "rate_limit_error",
500: "api_error",
529: "overloaded_error",

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
@ -36,13 +34,14 @@ from litellm.proxy.common_request_processing import (
resolve_counted_tool_fields,
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,
@ -55,44 +54,13 @@ from litellm.router import Router
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)
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")

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

@ -402,6 +402,7 @@ from litellm.proxy.auth.auth_object_prefetch import AUTH_OBJECTS_TARGET
from litellm.proxy.auth.auth_utils import (
MAX_REQUEST_SIZE_MB_SETTING,
check_response_size_is_safe,
get_request_route,
is_request_body_safe,
log_model_access_denial,
log_once_if_budget_reservation_disabled,
@ -465,6 +466,10 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l
sse_error_payload,
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,
)
@ -2184,6 +2189,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

@ -150,6 +150,13 @@ def error_type(response: httpx.Response) -> str:
return str(object_value(JSON_OBJECT.validate_json(response.content)["error"])["type"])
def budget_refusal_error_type(route: str) -> str:
"""The ``error.type`` a budget refusal carries on a route label from ``ROUTES``: the
Anthropic envelope on Messages (its budget refusal is raised in
``user_api_key_auth``), the OpenAI ``budget_exceeded`` type on the other routes."""
return "invalid_request_error" if route == "messages" else "budget_exceeded"
def settle(
send: Callable[[str], httpx.Response], status: int, *, burst: int = GATEWAY_BURST, seconds: float = 60
) -> None:

View file

@ -44,6 +44,7 @@ from integration.authorization._hidden_alias_budget import (
async_anthropic_client,
async_openai_client,
base_url,
budget_refusal_error_type,
error_type,
fresh_chat,
fresh_message,
@ -191,17 +192,18 @@ def _assert_refused(
key: str,
model: str,
prefix: str,
route: str = "chat",
send: Callable[[Gateway, str, str, str], httpx.Response] = fresh_chat,
) -> None:
marker: Final = f"{prefix}-" + uuid.uuid4().hex
refused: Final = send(candidate, model, key, marker)
assert _refusal(refused) == (BUDGET_EXCEEDED, "budget_exceeded"), refused.text
assert _refusal(refused) == (BUDGET_EXCEEDED, budget_refusal_error_type(route)), refused.text
assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0
def _assert_refused_on_every_route(rig: ChainRig, candidate: Gateway, key: str, model: str) -> None:
for route, send in ROUTES:
_assert_refused(rig, candidate, key, model, f"refused-{route}", send)
_assert_refused(rig, candidate, key, model, f"refused-{route}", route, send)
def _assert_provider_failure(response: httpx.Response) -> None:

View file

@ -474,15 +474,14 @@ def test_device_authorization_polls_complete_sso_and_mints_a_model_restricted_be
)
assert model_b_response.status_code == 403, model_b_response.text
assert _JSON_OBJECT.validate_json(model_b_response.content) == {
"type": "error",
"error": {
"type": "permission_error",
"message": (
f"The requested model '{model_b}' is not available for this API key, or the model name is "
"invalid. Check the models available to you and try again."
),
"type": "team_model_access_denied",
"param": "model",
"code": "403",
}
},
}, model_b_response.text
assert upstream.drain() == ()

View file

@ -695,9 +695,11 @@ def test_cli_session_token_is_denied_once_its_team_budget_is_exhausted(
key=key,
)
assert refused.status_code == 422, f"{refused.status_code} {refused.text}"
error: Final = object_value(JSON_OBJECT.validate_json(refused.content)["error"])
assert error["type"] == "budget_exceeded", refused.text
assert error["code"] == "422", refused.text
body: Final = JSON_OBJECT.validate_json(refused.content)
assert body["type"] == "error", refused.text
error: Final = object_value(body["error"])
assert error["type"] == "invalid_request_error", refused.text
assert "param" not in error and "code" not in error, refused.text
message: Final = string_value(error["message"])
assert "Budget has been exceeded!" in message, refused.text
assert f"Team={team}" in message, refused.text

View file

@ -38,6 +38,7 @@ from integration.authorization._hidden_alias_budget import (
async_anthropic_client,
async_openai_client,
base_url,
budget_refusal_error_type,
chat_statuses,
clear_provider_script,
error_type,
@ -599,7 +600,7 @@ def _assert_refused_like_target(rig: AliasRig, candidate: Gateway, key: str, sha
target_marker: Final = f"target-{route}-" + uuid.uuid4().hex
via_shadow: Final = send(candidate, shadow, key, shadow_marker)
by_name: Final = send(candidate, target, key, target_marker)
assert _refusal(by_name) == (BUDGET_EXCEEDED, "budget_exceeded"), by_name.text
assert _refusal(by_name) == (BUDGET_EXCEEDED, budget_refusal_error_type(route)), by_name.text
assert via_shadow.status_code == by_name.status_code, via_shadow.text
assert error_type(via_shadow) == error_type(by_name), via_shadow.text
observed: Final = upstream_requests(rig.gateway.upstream_url)
@ -621,7 +622,7 @@ def _assert_refused_on_every_route(rig: AliasRig, candidate: Gateway, key: str,
marker: Final = f"chain-{route}-" + uuid.uuid4().hex
refused: Final = send(candidate, model, key, marker)
assert refused.status_code == BUDGET_EXCEEDED, refused.text
assert error_type(refused) == "budget_exceeded", refused.text
assert error_type(refused) == budget_refusal_error_type(route), refused.text
assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0
@ -828,9 +829,9 @@ def _deployment_id(rig: AliasRig, name: str) -> str:
return identities[0]
def _assert_budget_refusal(status_code: int, body: str) -> None:
def _assert_budget_refusal(status_code: int, body: str, route: str = "chat") -> None:
assert status_code == BUDGET_EXCEEDED, body
assert "budget_exceeded" in body, body
assert budget_refusal_error_type(route) in body, body
def _assert_no_served_row(key: str, marker: str) -> None:
@ -881,7 +882,7 @@ async def test_exhausted_key_is_refused_on_shadowing_alias_through_streamed_anth
extra_headers=spend_marker(marker),
) as stream:
await stream.get_final_message()
_assert_budget_refusal(refused.value.status_code, str(refused.value.body))
_assert_budget_refusal(refused.value.status_code, str(refused.value.body), route="messages")
assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0

View file

@ -50,7 +50,23 @@ class _ErrorResponse(BaseModel):
error: _ProxyError
class _AnthropicErrorDetail(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
type: str
message: str
class _AnthropicErrorResponse(BaseModel):
model_config = ConfigDict(frozen=True)
type: str
error: _AnthropicErrorDetail
GENERIC_ERROR: Final = _ProxyError(message=GENERIC_MESSAGE, type="token_not_found_in_db", param="key", code="401")
MESSAGES_PATH: Final = "/v1/messages"
ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail(type="authentication_error", message=GENERIC_MESSAGE)
@dataclass(frozen=True, slots=True)
@ -119,6 +135,23 @@ def _assert_generic_refusal(response: httpx.Response, *secrets: str) -> None:
assert secret not in response.text, response.text
def _assert_messages_refusal(response: httpx.Response, *secrets: str) -> None:
assert response.status_code == 401, response.text
body: Final = _AnthropicErrorResponse.model_validate_json(response.content)
assert body.type == "error", response.text
assert body.error == ANTHROPIC_GENERIC_ERROR, response.text
for secret in secrets:
assert secret not in response.text, response.text
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:
_assert_messages_refusal(response, *secrets)
else:
_assert_generic_refusal(response, *secrets)
def _side(gateway: Gateway, peer: Gateway, side: Side) -> Gateway:
return gateway if side == "gateway" else peer
@ -129,7 +162,10 @@ def test_an_unknown_key_gets_the_generic_401_without_reaching_the_provider(
gateway: Gateway, peer: Gateway, provider: SharedProvider, route: str, side: Side
) -> None:
key: Final = _unknown_key()
_assert_generic_refusal(_send(_side(gateway, peer, side), ROUTES[route], key), key, key[-4:], _digest(key))
request_route: Final = ROUTES[route]
_assert_refusal(
request_route.path, _send(_side(gateway, peer, side), request_route, key), key, key[-4:], _digest(key)
)
assert provider.received() == ()
@ -183,8 +219,9 @@ def test_two_different_unknown_keys_in_both_headers_get_the_generic_401_naming_n
bearer: Final = _unknown_key()
api_key: Final = _unknown_key()
headers: Final = {**_headers("authorization", bearer), **_headers("x-api-key", api_key)}
for path, body in (("/v1/chat/completions", CHAT), ("/v1/messages", MESSAGES)):
_assert_generic_refusal(
for path, body in (("/v1/chat/completions", CHAT), (MESSAGES_PATH, MESSAGES)):
_assert_refusal(
path,
gateway.client.post(path, json=dict(body), headers=headers),
bearer,
api_key,
@ -359,16 +396,15 @@ async def _send_async(client: httpx.AsyncClient, route: _Route, key: str) -> htt
)
async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[httpx.Response, ...]:
async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[tuple[_Route, httpx.Response], ...]:
"""Each key's response paired with the route it was sent on, so the caller asserts the
envelope the route actually answered with."""
routes: Final = tuple(ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]] for index in range(len(keys)))
async with httpx.AsyncClient(base_url=str(owned.gateway.client.base_url), timeout=60, trust_env=False) as client:
return tuple(
await asyncio.gather(
*(
_send_async(client, ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]], key)
for index, key in enumerate(keys)
)
)
responses: Final = await asyncio.gather(
*(_send_async(client, route, key) for route, key in zip(routes, keys, strict=True))
)
return tuple(zip(routes, responses, strict=True))
def _log_after(owned: OwnedProxy, offset: int) -> str:
@ -397,7 +433,7 @@ async def test_a_burst_across_a_worker_kill_answers_every_unknown_key_with_the_g
lambda text: all(_console_line(key) in text for key in keys),
30,
)
for response, key in zip((*during, *after), keys, strict=True):
_assert_generic_refusal(response, key, _digest(key))
for (route, response), key in zip((*during, *after), keys, strict=True):
_assert_refusal(route.path, response, key, _digest(key))
assert [window.count(_console_line(key)) for key in keys] == [1] * len(keys)
assert [key for key in keys if key in window] == []

View file

@ -268,15 +268,14 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en
)
assert restricted_response.status_code == 403, restricted_response.text
assert _JSON_OBJECT.validate_json(restricted_response.content) == {
"type": "error",
"error": {
"type": "permission_error",
"message": (
f"The requested model '{restricted_model}' is not available for this API key, or the model name "
"is invalid. Check the models available to you and try again."
),
"type": "key_model_access_denied",
"param": "model",
"code": "403",
}
},
}, restricted_response.text
assert wire.drain() == ()
@ -285,15 +284,14 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en
def test_enabled_gateway_refuses_model_outside_virtual_key(enabled_gateway: Gateway) -> None:
result: Final = _restricted_model_responses(enabled_gateway)
expected_body: Final = {
"type": "error",
"error": {
"type": "permission_error",
"message": (
f"The requested model '{result[4]}' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
),
"type": "key_model_access_denied",
"param": "model",
"code": "403",
}
},
}
assert result[0] == result[2] == 403, (result[1], result[3])
assert _JSON_OBJECT.validate_json(result[1]) == expected_body, result[1]

View file

@ -258,9 +258,20 @@ def _observed(upstream: httpx.Client) -> list[JsonValue]:
return requests
def _denial_error(denied: httpx.Response) -> dict[str, JsonValue]:
def _denial_error(denied: httpx.Response, route: str) -> dict[str, JsonValue]:
assert denied.status_code == 422, denied.text
return object_value(object_value(denied.json())["error"])
if route == "/v1/messages":
body: Final = object_value(denied.json())
assert body["type"] == "error", denied.text
anthropic_error: Final = object_value(body["error"])
assert anthropic_error["type"] == "invalid_request_error", denied.text
assert "param" not in anthropic_error and "code" not in anthropic_error, denied.text
return anthropic_error
openai_error: Final = object_value(object_value(denied.json())["error"])
assert openai_error["type"] == "budget_exceeded", denied.text
assert openai_error["param"] is None
assert openai_error["code"] == "422"
return openai_error
def _prove_end_user_budget_lockout(
@ -304,8 +315,7 @@ def _prove_end_user_budget_lockout(
upstream.delete("/__observations").raise_for_status()
denied: Final = _send(gateway, spelling, deployment, key, end_user_id, f"denied {end_user_id}")
assert _observed(upstream) == [], denied.text
error: Final = _denial_error(denied)
assert error["type"] == "budget_exceeded", denied.text
error: Final = _denial_error(denied, spelling.route)
matched: Final = re.fullmatch(
rf"ExceededBudget: End User={re.escape(end_user_id)} over budget\. "
rf"Spend=([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?), Budget=0\.05",

View file

@ -120,6 +120,19 @@ def _assert_budget_refusal(response: httpx.Response, end_user: str) -> None:
]
def _assert_messages_budget_refusal(response: httpx.Response, end_user: str) -> None:
"""A budget refusal on /v1/messages keeps its 422 but answers the Anthropic envelope."""
assert response.status_code == 422, response.text
body: Final = _json_object(response.content)
assert body["type"] == "error", response.text
error: Final = object_value(body["error"])
assert error["type"] == "invalid_request_error"
assert "param" not in error and "code" not in error, response.text
assert str(error["message"]).startswith(f"ExceededBudget: End User={end_user} over budget. Spend="), error[
"message"
]
def _json_object(raw: bytes) -> dict[str, JsonValue]:
return _JSON_OBJECT.validate_json(raw)
@ -345,7 +358,7 @@ def test_anthropic_messages_metadata_user_id_is_charged_and_budget_enforced(gate
json={**body, "messages": [{"role": "user", "content": f"again {uuid.uuid4().hex}"}]},
headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
)
_assert_budget_refusal(denied, end_user)
_assert_messages_budget_refusal(denied, end_user)
assert _observed(upstream) == []

View file

@ -63,10 +63,17 @@ 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"),
[(422, "invalid_request_error"), (418, "api_error"), (502, "api_error"), (599, "api_error")],
)
def test_a_budget_status_is_a_request_error_and_a_status_the_table_does_not_name_is_api_error(
self, status_code: int, expected_error_type: str
):
"""The proxy's budget status is a caller-fault request error, so the table names 422.
A status the table does not name, such as a 418 or a 5xx, keeps api_error."""
response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status")
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
# ---------------------------------------------------------------------------