This commit is contained in:
alberto 2026-10-29 01:16:15 +00:00 • committed by GitHub
commit 0e96bb780f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 809 additions and 124 deletions

View file

@ -16,7 +16,7 @@ from .exceptions import AnthropicErrorDetail, AnthropicErrorResponse, AnthropicE
_PARSED_ERROR_BODY: Final = TypeAdapter(object)
# HTTP status code -> Anthropic error type
# Source: https://docs.anthropic.com/en/api/errors
# Source: https://platform.claude.com/docs/en/api/errors
ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = {
400: "invalid_request_error",
401: "authentication_error",
@ -46,6 +46,7 @@ class AnthropicExceptionMapping:
status_code: int,
message: str,
request_id: str | None = None,
error_type: AnthropicErrorType | None = None,
) -> AnthropicErrorResponse:
"""
Create an Anthropic-formatted error response dict.
@ -57,12 +58,10 @@ class AnthropicExceptionMapping:
"request_id": "req_..."
}
"""
error_type: Final = AnthropicExceptionMapping.get_error_type(status_code)
response: Final[AnthropicErrorResponse] = {
"type": "error",
"error": {
"type": error_type,
"type": error_type or AnthropicExceptionMapping.get_error_type(status_code),
"message": message,
},
}
@ -131,6 +130,7 @@ class AnthropicExceptionMapping:
status_code: int,
raw_message: str,
request_id: str | None = None,
error_type: AnthropicErrorType | None = None,
) -> AnthropicErrorResponse:
"""
Transform an error message to Anthropic format.
@ -144,6 +144,7 @@ class AnthropicExceptionMapping:
status_code: HTTP status code
raw_message: Raw error message (may be JSON string or plain text)
request_id: Optional request ID to include
error_type: Anthropic error type to use instead of the one the status maps to
Returns:
AnthropicErrorResponse dict
@ -169,6 +170,7 @@ class AnthropicExceptionMapping:
status_code=status_code,
message=message,
request_id=request_id,
error_type=error_type,
)

View file

@ -6,15 +6,18 @@ from typing import Literal
from typing_extensions import NotRequired, ReadOnly, Required, TypedDict
# Known Anthropic error types
# Source: https://docs.anthropic.com/en/api/errors
# Source: https://platform.claude.com/docs/en/api/errors
AnthropicErrorType = Literal[
"invalid_request_error",
"authentication_error",
"billing_error",
"permission_error",
"not_found_error",
"conflict_error",
"request_too_large",
"rate_limit_error",
"api_error",
"timeout_error",
"overloaded_error",
]
@ -25,6 +28,7 @@ class AnthropicErrorDetail(TypedDict):
type: AnthropicErrorType
message: str
provider_specific_fields: NotRequired[ReadOnly[Mapping[str, object]]]
litellm_error_type: NotRequired[ReadOnly[str]]
litellm_call_id: NotRequired[ReadOnly[str]]

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_handler_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,
@ -59,44 +58,8 @@ 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,
)
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)
return anthropic_handler_error_json_response(exc, request)
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")

View file

@ -30,6 +30,7 @@ import orjson
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Receive, Scope, Send
from typing_extensions import Never
@ -751,7 +752,7 @@ def resolve_counted_tool_fields(
return resolved
def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, str]) -> ProxyException:
def proxy_exception_from_http_exception(exc: StarletteHTTPException, headers: dict[str, str]) -> ProxyException:
raw_detail: Final = attribute_of(exc, "detail", str(exc))
message, structured_fields = serialize_http_exception_detail(raw_detail)
existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {}

View file

@ -0,0 +1,172 @@
"""Auth, budget and model-access failures are raised by ``user_api_key_auth`` before the Messages
handler runs, so the central handlers pick the Anthropic envelope from the request route."""
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import Final
from fastapi import Request, status
from fastapi.responses import JSONResponse
from starlette.types import Scope
from litellm.anthropic_interface.exceptions import (
ANTHROPIC_ERROR_TYPE_MAP,
AnthropicErrorDetail,
AnthropicErrorResponse,
AnthropicErrorType,
AnthropicExceptionMapping,
)
from litellm.proxy._types import ProxyException
from litellm.proxy.auth.auth_utils import get_request_route
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, openai_error_type_for_status
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",
}
)
ANTHROPIC_PASS_THROUGH_PREFIX: Final = "/anthropic/"
ANTHROPIC_SHAPED_MODEL_LIST_ROUTES: Final[frozenset[str]] = frozenset(
{"/v1/models", "/models", "/claude_code_gateway/v1/models"}
)
PROXY_ERROR_TYPE_BY_STATUS: Final[Mapping[int, AnthropicErrorType]] = MappingProxyType(
{**ANTHROPIC_ERROR_TYPE_MAP, 402: "billing_error", 409: "conflict_error", 504: "timeout_error"}
)
def proxy_error_type(status_code: int) -> AnthropicErrorType:
named: Final = PROXY_ERROR_TYPE_BY_STATUS.get(status_code)
if named is not None:
return named
if status_code == status.HTTP_408_REQUEST_TIMEOUT or not 400 <= status_code < 500:
return "api_error"
return "invalid_request_error"
def uses_anthropic_error_envelope(request: Request) -> bool:
route: Final = get_request_route(request)
if route in ANTHROPIC_MESSAGES_ROUTES or route.startswith(ANTHROPIC_PASS_THROUGH_PREFIX):
return True
return route in ANTHROPIC_SHAPED_MODEL_LIST_ROUTES and "anthropic-version" in request.headers
def route_error_content(
scope: Scope, anthropic_type: AnthropicErrorType, message: str, openai_content: Mapping[str, object]
) -> Mapping[str, object]:
request: Final = Request(scope)
if not uses_anthropic_error_envelope(request):
return openai_content
content: Final[AnthropicErrorResponse] = {"type": "error", "error": {"type": anthropic_type, "message": message}}
request_id: Final = request.headers.get("x-request-id")
return content if request_id is None else {**content, "request_id": request_id}
def anthropic_error_status_code(exc: ProxyException) -> int:
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 _with_litellm_error_type(
exc: ProxyException, status_code: int, detail: AnthropicErrorDetail
) -> AnthropicErrorDetail:
if not exc.type or exc.type in (detail["type"], openai_error_type_for_status(status_code)):
return detail
with_type: Final[AnthropicErrorDetail] = {**detail, "litellm_error_type": exc.type}
return with_type
def _with_call_id(detail: AnthropicErrorDetail, call_id: str | None) -> AnthropicErrorDetail:
if call_id is None:
return detail
with_call_id: Final[AnthropicErrorDetail] = {**detail, "litellm_call_id": call_id}
return with_call_id
def _anthropic_content(
exc: ProxyException,
status_code: int,
request_id: str | None,
general_settings: Mapping[str, object],
error_type: AnthropicErrorType | None,
) -> AnthropicErrorResponse:
envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=exc.message,
request_id=request_id,
error_type=error_type,
)
body_call_id: Final = error_body_call_id(general_settings, exc.headers.get(LITELLM_CALL_ID_HEADER))
content: Final[AnthropicErrorResponse] = {
**envelope,
"error": _with_call_id(_with_provider_specific_fields(exc, envelope["error"]), body_call_id),
}
return content
def anthropic_handler_error_content(
exc: ProxyException,
status_code: int,
request_id: str | None,
general_settings: Mapping[str, object],
) -> AnthropicErrorResponse:
return _anthropic_content(exc, status_code, request_id, general_settings, None)
def anthropic_error_content(
exc: ProxyException,
status_code: int,
request_id: str | None,
general_settings: Mapping[str, object],
) -> AnthropicErrorResponse:
content: Final = _anthropic_content(exc, status_code, request_id, general_settings, proxy_error_type(status_code))
with_type: Final[AnthropicErrorResponse] = {
**content,
"error": _with_litellm_error_type(exc, status_code, content["error"]),
}
return with_type
def _anthropic_json_response(
exc: ProxyException,
request: Request,
build_content: Callable[[ProxyException, int, str | None, Mapping[str, object]], AnthropicErrorResponse],
) -> JSONResponse:
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 = build_content(exc, status_code, request.headers.get("x-request-id"), general_settings_view())
return JSONResponse(status_code=status_code, content=content, headers=exc.headers)
def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
return _anthropic_json_response(exc, request, anthropic_error_content)
def anthropic_handler_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
return _anthropic_json_response(exc, request, anthropic_handler_error_content)
def anthropic_internal_server_error_response(request: Request) -> JSONResponse:
content: Final = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
raw_message="Internal server error",
request_id=request.headers.get("x-request-id"),
)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=content)

View file

@ -10,6 +10,7 @@ from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_utils.anthropic_error_payload import route_error_content
ADMISSION_LEASE_SCOPE_KEY: Final = "litellm.admission_lease"
@ -175,7 +176,7 @@ class AdmissionControlMiddleware:
state.record_admission()
elif state.get_stats().queued >= settings.max_queued_requests:
state.record_rejection("queue_full")
await _overloaded_response(state)(scope, receive, send)
await _overloaded_response(state, scope)(scope, receive, send)
return
else:
state.record_queue()
@ -187,7 +188,7 @@ class AdmissionControlMiddleware:
except asyncio.TimeoutError:
state.record_dequeue()
state.record_rejection("queue_timeout")
await _overloaded_response(state)(scope, receive, send)
await _overloaded_response(state, scope)(scope, receive, send)
return
except asyncio.CancelledError:
state.record_dequeue()
@ -313,18 +314,16 @@ def get_admission_control_settings(settings: Mapping[str, object]) -> AdmissionC
)
def _overloaded_response(state: AdmissionControlState) -> JSONResponse:
def _overloaded_response(state: AdmissionControlState, scope: Scope) -> JSONResponse:
stats: Final = state.get_stats()
message: Final = f"Worker at capacity: {stats.admitted} in-flight, {stats.queued} queued requests. Retry later."
return JSONResponse(
status_code=503,
headers={"retry-after": "1"},
content={
"error": {
"message": (
f"Worker at capacity: {stats.admitted} in-flight, {stats.queued} queued requests. Retry later."
),
"type": "overloaded_error",
"code": "503",
}
},
content=route_error_content(
scope,
"overloaded_error",
message,
{"error": {"message": message, "type": "overloaded_error", "code": "503"}},
),
)

View file

@ -12,12 +12,14 @@ from starlette.requests import ClientDisconnect, Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from litellm.anthropic_interface.exceptions import AnthropicErrorType
from litellm.constants import (
MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB,
MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB,
MAX_REQUEST_BODY_DECOMPRESSION_RATIO,
)
from litellm.proxy.auth.auth_utils import get_request_route
from litellm.proxy.common_utils.anthropic_error_payload import proxy_error_type, route_error_content
from litellm.proxy.middleware.request_size_limit_middleware import MaxRequestSizeGetter, send_request_too_large
RFC_9659_ZSTD_WINDOW_LOG_MAX: Final = 23
@ -291,13 +293,22 @@ def _replay_body(body: bytes, receive: Receive, more_body: bool = False) -> Rece
def _error_response(
scope: Scope,
message: str,
status_code: int,
error_type: str = "invalid_request_error",
headers: Mapping[str, str] | None = None,
) -> JSONResponse:
anthropic_type: Final[AnthropicErrorType] = (
"overloaded_error" if error_type == "overloaded_error" else proxy_error_type(status_code)
)
return JSONResponse(
{"error": {"message": message, "type": error_type, "param": None, "code": str(status_code)}},
route_error_content(
scope,
anthropic_type,
message,
{"error": {"message": message, "type": error_type, "param": None, "code": str(status_code)}},
),
status_code=status_code,
headers=headers,
)
@ -356,6 +367,7 @@ class RequestBodyDecompressionMiddleware:
if codec is None:
supported: Final = ", ".join(sorted(self.codecs))
await _error_response(
scope,
f"This proxy cannot decode {coding} request bodies. Supported encodings: {supported}",
415,
headers={"accept-encoding": supported},
@ -372,7 +384,7 @@ class RequestBodyDecompressionMiddleware:
case _Body(data=data):
await self._decode_within_budget(scope, receive, send, codec, data, limits)
case _TooLarge():
await send_request_too_large(send=send, max_request_size_mb=max_mb)
await send_request_too_large(scope=scope, send=send, max_request_size_mb=max_mb)
case _ClientDisconnected():
return
@ -382,6 +394,7 @@ class RequestBodyDecompressionMiddleware:
reservation: Final = _max_decoded_length(codec, data, limits)
if not self.budget.try_reserve(reservation):
await _error_response(
scope,
"The proxy is decoding too many compressed request bodies right now. "
"Retry later, or send this body uncompressed",
503,
@ -407,12 +420,12 @@ class RequestBodyDecompressionMiddleware:
case _Body(data=body):
await self.app(_identity_scope(scope, len(body)), _replay_body(body, receive), send)
case _DecodedTooLarge():
await _error_response(f"Request body is larger than {limits.max_mb:g} MB when decoded", 413)(
await _error_response(scope, f"Request body is larger than {limits.max_mb:g} MB when decoded", 413)(
scope, receive, send
)
case _OverRatio():
await _error_response(f"Request body expands more than {limits.max_ratio} times when decoded", 413)(
scope, receive, send
)
await _error_response(
scope, f"Request body expands more than {limits.max_ratio} times when decoded", 413
)(scope, receive, send)
case _Undecodable(message=message):
await _error_response(message, 400)(scope, receive, send)
await _error_response(scope, message, 400)(scope, receive, send)

View file

@ -4,6 +4,8 @@ from typing import Final
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from litellm.proxy.common_utils.anthropic_error_payload import route_error_content
MaxRequestSizeGetter = Callable[[], int | float | None]
RequestSizeLimitEnabledGetter = Callable[[], bool]
@ -44,7 +46,7 @@ class RequestSizeLimitMiddleware:
content_length: Final = _get_content_length(scope=scope)
if content_length is not None and content_length > max_request_size_bytes:
await send_request_too_large(send=send, max_request_size_mb=max_request_size_mb)
await send_request_too_large(scope=scope, send=send, max_request_size_mb=max_request_size_mb)
return
received_body_bytes = 0
@ -74,7 +76,7 @@ class RequestSizeLimitMiddleware:
except RequestEntityTooLarge:
if response_started:
raise
await send_request_too_large(send=send, max_request_size_mb=max_request_size_mb)
await send_request_too_large(scope=scope, send=send, max_request_size_mb=max_request_size_mb)
def _mb_to_bytes(max_request_size_mb: float | None) -> int | None:
@ -98,11 +100,13 @@ def _get_content_length(scope: Scope) -> int | None:
async def send_request_too_large(
scope: Scope,
send: Send,
max_request_size_mb: float | None,
) -> None:
message: Final = f"Request size is too large. Max size is {max_request_size_mb} MB"
body: Final = json.dumps(
{"error": f"Request size is too large. Max size is {max_request_size_mb} MB"},
route_error_content(scope, "request_too_large", message, {"error": message}),
separators=(",", ":"),
).encode("utf-8")
await send(

View file

@ -461,6 +461,7 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l
is_azure_model_router_request,
log_llm_api_exception,
open_sse_before_first_byte,
proxy_exception_from_http_exception,
proxy_exception_from_route_error,
request_litellm_call_id,
resolve_litellm_call_id,
@ -468,6 +469,11 @@ 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,
anthropic_internal_server_error_response,
uses_anthropic_error_envelope,
)
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
AuthCacheInvalidationSubscriber,
)
@ -2196,6 +2202,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 uses_anthropic_error_envelope(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)),
@ -2231,6 +2239,10 @@ async def otlp_http_exception_handler(connection: Request | WebSocket, exc: Star
if response is not None:
_close_dangling_otel_server_span(connection, exc.status_code, exc=exc)
return response
if uses_anthropic_error_envelope(connection):
return anthropic_error_json_response(
proxy_exception_from_http_exception(exc, dict(exc.headers or {})), connection
)
return await http_exception_handler(connection, exc)
@ -2438,6 +2450,8 @@ async def _unhandled_exception_response(request: Request, exc: Exception) -> Res
otlp_response: Final = tracing_endpoints.otlp_error_response(request, 500)
if otlp_response is not None:
return otlp_response
if uses_anthropic_error_envelope(request):
return anthropic_internal_server_error_response(request)
return JSONResponse(
status_code=500,
content={

View file

@ -150,6 +150,10 @@ 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:
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,15 @@ 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",
}
"litellm_error_type": "team_model_access_denied",
},
}, 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,27 @@ class _ErrorResponse(BaseModel):
error: _ProxyError
class _AnthropicErrorDetail(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
type: str
message: str
litellm_error_type: 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_ENVELOPE_PATHS: Final = frozenset({MESSAGES_PATH, "/anthropic/v1/messages"})
ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail(
type="authentication_error", message=GENERIC_MESSAGE, litellm_error_type=GENERIC_ERROR.type
)
@dataclass(frozen=True, slots=True)
@ -119,6 +139,22 @@ 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:
if path in ANTHROPIC_ENVELOPE_PATHS:
_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 +165,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 +222,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 +399,13 @@ 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], ...]:
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 +434,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

@ -97,9 +97,10 @@ def test_shared_gateway_route_is_disabled_without_reaching_provider(gateway: Gat
headers=_cli_bearer_headers(gateway.key),
)
assert response.status_code == 404, response.text
assert _JSON_OBJECT.validate_json(response.content) == {"detail": "Claude Code gateway is not enabled"}, (
response.text
)
assert _JSON_OBJECT.validate_json(response.content) == {
"type": "error",
"error": {"type": "not_found_error", "message": "Claude Code gateway is not enabled"},
}, response.text
assert wire.drain() == ()
@ -268,15 +269,15 @@ 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",
}
"litellm_error_type": "key_model_access_denied",
},
}, restricted_response.text
assert wire.drain() == ()
@ -285,15 +286,15 @@ 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",
}
"litellm_error_type": "key_model_access_denied",
},
}
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,18 @@ def _assert_budget_refusal(response: httpx.Response, end_user: str) -> None:
]
def _assert_messages_budget_refusal(response: httpx.Response, end_user: str) -> None:
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 +357,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,16 @@ 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")
@pytest.mark.parametrize("status_code", [402, 405, 409, 418, 422, 504])
def test_a_status_outside_the_table_stays_an_api_error_for_the_sdk_and_stream_frames(self, status_code: int):
response: Final = AnthropicExceptionMapping.create_error_response(status_code, "scripted status")
frame: Final = anthropic_error_sse_frame(status_code=status_code, raw_message="scripted status")
assert response["error"]["type"] == "api_error"
assert frame.error_response["error"]["type"] == "api_error"
def test_an_explicit_error_type_wins_over_the_status_table(self):
response: Final = AnthropicExceptionMapping.create_error_response(402, "Budget exceeded", error_type="billing_error")
assert response["error"]["type"] == "billing_error"
class TestExtractErrorMessage:

View file

@ -203,6 +203,22 @@ class TestProxyExceptionAnthropicEnvelope:
assert fallback.status_code == 500
assert json.loads(fallback.body)["error"]["type"] == "api_error"
@pytest.mark.parametrize("status_code", [402, 409, 422, 504])
def test_a_failure_inside_the_handler_keeps_the_api_error_type_for_a_status_outside_the_table(
self, status_code: int
):
import litellm.proxy.anthropic_endpoints.endpoints as ep
from litellm.proxy._types import ProxyException
request: Final = MagicMock(headers={})
response: Final = ep._anthropic_error_json_response(
ProxyException(message="scripted", type="budget_exceeded", param=None, code=status_code), request
)
assert response.status_code == status_code
assert json.loads(response.body)["error"] == {"type": "api_error", "message": "scripted"}
@staticmethod
def _call_id_error_response(general_settings, provider_specific_fields=None):
import litellm.proxy.anthropic_endpoints.endpoints as ep

View file

@ -510,3 +510,32 @@ async def test_admission_lease_cannot_be_reused_by_another_worker(state: Admissi
response: Final = await _call(parent)
assert response[1]["body"] == b"1"
assert state.get_stats() == other_state.get_stats() == AdmissionControlStats(0, 0, 0)
@pytest.mark.asyncio
async def test_a_messages_request_turned_away_at_capacity_gets_the_anthropic_overloaded_error(
state: AdmissionControlState,
) -> None:
started: Final = asyncio.Event()
release: Final = asyncio.Event()
middleware: Final = AdmissionControlMiddleware(
_handler_with_release(started, release),
lambda: AdmissionControlSettings(1, 0, 1.0),
state,
)
first: Final = asyncio.create_task(_call(middleware, path="/v1/messages"))
await started.wait()
rejected: Final = await _call(middleware, path="/v1/messages")
release.set()
await first
assert rejected[0]["status"] == 503
assert dict(rejected[0]["headers"])[b"retry-after"] == b"1"
assert json.loads(rejected[1]["body"]) == {
"type": "error",
"error": {
"type": "overloaded_error",
"message": "Worker at capacity: 1 in-flight, 0 queued requests. Retry later.",
},
}

View file

@ -665,3 +665,33 @@ async def test_non_http_scopes_reach_the_app_untouched(scope: Scope):
[(app_scope, app_receive)] = seen
assert app_scope is scope
assert app_receive is receive
@pytest.mark.parametrize(
("body", "max_mb", "status_code", "anthropic_type", "message"),
[
pytest.param(
zstd.compress(_low_entropy_text(2 * int(CAP_MB * MIB))),
CAP_MB,
413,
"request_too_large",
f"Request body is larger than {CAP_MB:g} MB when decoded",
id="decoded-past-the-cap",
),
pytest.param(b"not zstd at all", 50.0, 400, "invalid_request_error", None, id="undecodable"),
],
)
def test_a_messages_body_the_proxy_cannot_take_gets_the_anthropic_envelope(
body: bytes, max_mb: float, status_code: int, anthropic_type: str, message: str | None
) -> None:
response: Final = _post(
_client(max_decompressed_body_size_mb=max_mb),
ZSTD_FRAME_MAGIC + body if message is None else body,
("zstd",),
path="/claude_code_gateway/v1/messages",
)
assert response.status_code == status_code
assert response.json()["type"] == "error"
assert response.json()["error"]["type"] == anthropic_type
assert message is None or response.json()["error"]["message"] == message

View file

@ -1,7 +1,9 @@
from typing import Final
import pytest
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
from starlette.types import Message
from starlette.types import Message, Receive, Scope, Send
from litellm.proxy.middleware.request_size_limit_middleware import (
RequestSizeLimitMiddleware,
@ -133,3 +135,22 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte
"more_body": False,
}
assert received_body_bytes == 1024 * 1024
def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_error() -> None:
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await JSONResponse({"ok": True})(scope, receive, send)
client: Final = TestClient(
RequestSizeLimitMiddleware(app, get_max_request_size_mb=lambda: 1, is_request_size_limit_enabled=lambda: True)
)
response: Final = client.post(
"/v1/messages", content=b"x" * (1024 * 1024 + 1), headers={"content-type": "application/json"}
)
assert response.status_code == 413
assert response.json() == {
"type": "error",
"error": {"type": "request_too_large", "message": "Request size is too large. Max size is 1 MB"},
}

View file

@ -36,9 +36,9 @@ from litellm.proxy.proxy_server import (
from .conftest import normalize
def _make_request(parent_otel_span=None, path="/chat/completions"):
def _make_request(parent_otel_span=None, path="/chat/completions", headers=()):
return Request({
"type": "http", "method": "POST", "path": path, "headers": [],
"type": "http", "method": "POST", "path": path, "headers": list(headers),
"state": {"parent_otel_span": parent_otel_span},
})
@ -168,6 +168,182 @@ async def test_openai_exception_handler_never_fabricates_a_call_id(monkeypatch):
assert "litellm_call_id" not in body["error"]
def _model_access_denied_exception() -> ProxyException:
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",
)
_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, *_ANTHROPIC_PASS_THROUGH_ROUTES))
async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_messages_routes(path: str) -> None:
response: Final = await openai_exception_handler(
request=_make_request(path=path), exc=_model_access_denied_exception()
)
body: Final = 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",
"litellm_error_type": "team_model_access_denied",
},
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,anthropic_type",
[
(401, "authentication_error"),
(403, "permission_error"),
(404, "not_found_error"),
(429, "rate_limit_error"),
(402, "billing_error"),
(409, "conflict_error"),
(504, "timeout_error"),
(405, "invalid_request_error"),
(408, "api_error"),
(502, "api_error"),
],
)
async def test_openai_exception_handler_maps_messages_failures_to_anthropic_error_types(
status_code: int, anthropic_type: str
) -> None:
exc: Final = ProxyException(message="denied", type="key_model_access_denied", param="model", code=status_code)
response: Final = 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() -> None:
exc: Final = ProxyException(message="Budget exceeded", type="budget_exceeded", param=None, code=422)
response: Final = await openai_exception_handler(
request=_make_request(path="/v1/messages/count_tokens"), exc=exc
)
body: Final = 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 body["error"]["litellm_error_type"] == "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() -> None:
response: Final = await openai_exception_handler(
request=_make_request(path="/v1/chat/completions"), exc=_model_access_denied_exception()
)
body: Final = 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
@pytest.mark.parametrize("path", ("/v1/models", "/models", "/claude_code_gateway/v1/models"))
async def test_a_model_list_request_answers_errors_in_the_shape_its_listing_takes(path: str) -> None:
anthropic: Final = await openai_exception_handler(
request=_make_request(path=path, headers=[(b"anthropic-version", b"2023-06-01")]),
exc=_model_access_denied_exception(),
)
openai: Final = await openai_exception_handler(
request=_make_request(path=path), exc=_model_access_denied_exception()
)
assert json.loads(anthropic.body)["error"]["type"] == "permission_error"
assert json.loads(anthropic.body)["type"] == "error"
assert json.loads(openai.body)["error"]["type"] == "team_model_access_denied"
assert "type" not in json.loads(openai.body)
@pytest.mark.asyncio
async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthropic_routes() -> None:
response: Final = await openai_exception_handler(
request=_make_request(path="/v1/skills"), exc=_model_access_denied_exception()
)
body: Final = 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: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"include_call_id_in_error_body": True})
exc: Final = 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: Final = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc)
body: Final = 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_error_type": "team_model_access_denied",
"litellm_call_id": "call-8302",
}
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ("/v1/messages", "/anthropic/v1/messages"))
async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error(
client: TestClient, monkeypatch: pytest.MonkeyPatch, path: str
) -> None:
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
response: Final = client.post(path, json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]})
body: Final = 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
# ---------------------------------------------------------------------------
@ -467,6 +643,22 @@ async def test_otel_unhandled_exception_handler_answers_a_db_outage_with_503_no_
assert json.loads(response.body) == _DB_OUTAGE_503_BODY
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ("/v1/messages", "/claude_code_gateway/v1/messages", "/anthropic/v1/messages"))
async def test_otel_unhandled_exception_handler_answers_the_anthropic_api_error_on_the_messages_routes(
path: str,
) -> None:
response: Final = await otel_unhandled_exception_handler(
request=_make_request(path=path), exc=RuntimeError("kaboom")
)
assert response.status_code == 500
assert json.loads(response.body) == {
"type": "error",
"error": {"type": "api_error", "message": "Internal server error"},
}
@pytest.mark.asyncio
async def test_otel_unhandled_exception_handler_reraises_proxy_exception_error():
"""ProxyException / HTTPException / RequestValidationError are re-raised
@ -541,6 +733,33 @@ async def test_http_exception_on_a_plain_http_request_keeps_the_default_json_bod
assert json.loads(bytes(response.body)) == {"detail": "not found"}
@pytest.mark.asyncio
async def test_a_disabled_claude_code_gateway_answers_the_anthropic_not_found_error() -> None:
request: Final = _make_request(path="/claude_code_gateway/v1/messages")
response: Final = await otlp_http_exception_handler(
request, HTTPException(404, "Claude Code gateway is not enabled")
)
assert response is not None
assert response.status_code == 404
assert json.loads(bytes(response.body)) == {
"type": "error",
"error": {"type": "not_found_error", "message": "Claude Code gateway is not enabled"},
}
def test_a_get_on_messages_answers_the_anthropic_envelope_and_keeps_the_allow_header(client: TestClient) -> None:
response: Final = client.get("/v1/messages")
assert response.status_code == 405
assert response.headers["allow"] == "POST"
assert response.json() == {
"type": "error",
"error": {"type": "invalid_request_error", "message": "Method Not Allowed"},
}
@pytest.mark.asyncio
async def test_http_exception_on_a_websocket_closed_before_accept_sends_nothing_more() -> None:
sent: Final[list[Message]] = []

View file

@ -108,6 +108,93 @@ describe("toast", () => {
});
});
it.each([
{
status: 403,
type: "permission_error",
litellmType: "team_model_access_denied",
fn: sonner.error,
title: "Access Denied",
},
{
status: 401,
type: "authentication_error",
litellmType: "auth_error",
fn: sonner.error,
title: "Authentication Error",
},
{
status: 422,
type: "invalid_request_error",
litellmType: "budget_exceeded",
fn: sonner.warning,
title: "Budget Exceeded",
},
{
status: 503,
type: "api_error",
litellmType: "no_db_connection",
fn: sonner.error,
title: "Service Unavailable",
},
{
status: 429,
type: "rate_limit_error",
litellmType: undefined,
fn: sonner.warning,
title: "Rate Limit Exceeded",
},
{
status: 422,
type: "invalid_request_error",
litellmType: undefined,
fn: sonner.error,
title: "Validation Error",
},
])(
"titles an Anthropic SDK $status $type error from the Messages playground by its proxy type $litellmType, else its status",
({ status, type, litellmType, fn, title }) => {
const body = JSON.stringify({
type: "error",
error: { type, message: "Team not allowed to access model", litellm_error_type: litellmType },
});
toast.fromError(
`Error occurred while generating model response. Please try again. Error: Error: ${status} ${body}`,
);
expect(fn).toHaveBeenCalledWith(title, {
description: `Error occurred while generating model response. Please try again. Error: Error: ${status} Team not allowed to access model`,
duration: 6000,
});
},
);
it("titles an Anthropic error body with no status by its Anthropic type", () => {
toast.fromError(JSON.stringify({ type: "error", error: { type: "permission_error", message: "denied" } }));
expect(sonner.error).toHaveBeenCalledWith("Access Denied", { description: "denied", duration: 6000 });
});
it.each([
["billing_error", "Request Error"],
["conflict_error", "Already Exists"],
["timeout_error", "Server Error"],
])("titles an Anthropic %s body with no status as %s", (type, title) => {
toast.fromError(JSON.stringify({ type: "error", error: { type, message: "boom" } }));
expect(sonner.error).toHaveBeenCalledWith(title, { description: "boom", duration: 6000 });
});
it.each([
["an OpenAI envelope", { error: { type: "invalid_request_error", message: "boom" } }],
["a body with no Anthropic type", { detail: { error: "boom" } }],
])("keeps the plain Error title for %s after a status in the message", (_, payload) => {
toast.fromError(`Request failed: 404 ${JSON.stringify(payload)}`);
expect(sonner.error).toHaveBeenCalledWith("Error", { description: "Request failed: 404 boom", duration: 6000 });
});
it("reads no status from a number longer than three digits before the envelope", () => {
toast.fromError(`request 98765 ${JSON.stringify({ error: { message: "boom" } })}`);
expect(sonner.error).toHaveBeenCalledWith("Error", { description: "request 98765 boom", duration: 6000 });
});
it("leaves a string alone when its braces are not a JSON envelope", () => {
toast.fromError("Template {name} is invalid");
expect(sonner.error).toHaveBeenCalledWith("Error", {

View file

@ -13,6 +13,7 @@ export type ToastOptions = {
type ErrorFacts = {
readonly status: number | undefined;
readonly proxyType: string | undefined;
readonly anthropicType: string | undefined;
readonly text: string;
};
@ -46,6 +47,20 @@ const STATUS_TITLES: Readonly<Record<number, string>> = {
503: "Service Unavailable",
};
const ANTHROPIC_TYPE_TITLES: Readonly<Record<string, string>> = {
invalid_request_error: "Request Error",
authentication_error: "Authentication Error",
billing_error: "Request Error",
permission_error: "Access Denied",
not_found_error: "Not Found",
conflict_error: "Already Exists",
request_too_large: "Request Error",
rate_limit_error: "Rate Limit Exceeded",
api_error: "Server Error",
timeout_error: "Server Error",
overloaded_error: "Service Unavailable",
};
const WARNING_TITLES: ReadonlySet<string> = new Set(["Budget Exceeded", "Rate Limit Exceeded"]);
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
@ -71,28 +86,47 @@ const proxyEnvelope = (payload: unknown): Record<string, unknown> | undefined =>
};
const proxyTypeOf = (payload: unknown): string | undefined => {
const type = proxyEnvelope(payload)?.type;
const envelope = proxyEnvelope(payload);
const type = envelope?.litellm_error_type ?? envelope?.type;
return typeof type === "string" ? type : undefined;
};
const anthropicTypeOf = (payload: unknown): string | undefined => {
const record = asRecord(payload);
const type = asRecord(record?.error)?.type;
return record?.type === "error" && typeof type === "string" ? type : undefined;
};
const EMBEDDED_JSON = /\{[\s\S]*\}/;
const STATUS_BEFORE_JSON = /(?<!\d)([1-5]\d{2})\s*$/;
const statusBefore = (text: string, embedded: string): number | undefined =>
toStatus(text.slice(0, text.indexOf(embedded)).match(STATUS_BEFORE_JSON)?.[1]);
const describeText = (text: string): ErrorFacts => {
const embedded = text.match(EMBEDDED_JSON)?.[0];
const parsed = embedded === undefined ? undefined : parseJson(embedded);
if (embedded === undefined || asRecord(parsed) === undefined) {
return { status: undefined, proxyType: undefined, text: unwrapProxyErrorMessage(text) };
return { status: undefined, proxyType: undefined, anthropicType: undefined, text: unwrapProxyErrorMessage(text) };
}
const anthropicType = anthropicTypeOf(parsed);
return {
status: toStatus(proxyEnvelope(parsed)?.code),
status:
toStatus(proxyEnvelope(parsed)?.code) ?? (anthropicType === undefined ? undefined : statusBefore(text, embedded)),
proxyType: proxyTypeOf(parsed),
anthropicType,
text: text.replace(embedded, unwrapProxyErrorMessage(deriveErrorMessage(parsed))).trim(),
};
};
const describeError = (input: unknown): ErrorFacts => {
if (input instanceof ApiError) {
return { status: input.status, proxyType: proxyTypeOf(input.body), text: unwrapProxyErrorMessage(input.message) };
return {
status: input.status,
proxyType: proxyTypeOf(input.body),
anthropicType: anthropicTypeOf(input.body),
text: unwrapProxyErrorMessage(input.message),
};
}
if (input instanceof Error || typeof input === "string") {
return describeText(input instanceof Error ? input.message : input);
@ -107,6 +141,7 @@ const describeError = (input: unknown): ErrorFacts => {
toStatus(record.code) ??
toStatus(proxyEnvelope(payload)?.code),
proxyType: proxyTypeOf(payload),
anthropicType: anthropicTypeOf(payload),
text: unwrapProxyErrorMessage(deriveErrorMessage(payload)),
};
};
@ -119,11 +154,12 @@ const titleForStatus = (status: number): string => {
return "Error";
};
const titleFor = ({ status, proxyType }: ErrorFacts): string => {
const titleFor = ({ status, proxyType, anthropicType }: ErrorFacts): string => {
if (proxyType?.endsWith("_access_denied")) return "Access Denied";
const byType = proxyType === undefined ? undefined : PROXY_TYPE_TITLES[proxyType];
if (byType !== undefined) return byType;
return status === undefined ? "Error" : titleForStatus(status);
if (status !== undefined) return titleForStatus(status);
return (anthropicType === undefined ? undefined : ANTHROPIC_TYPE_TITLES[anthropicType]) ?? "Error";
};
const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => {