mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
fix(proxy): keep LiteLLM error types and Anthropic envelopes on Messages routes
Error bodies on the Messages routes now carry error.litellm_error_type when the LiteLLM type differs from the Anthropic type, so clients and the Admin UI keep causes like budget_exceeded. Unlisted 4xx statuses map to invalid_request_error per Anthropic's errors page, model-list requests that send anthropic-version get the Anthropic envelope, and the admission, decompression and request-size middleware errors use it on these routes too.
This commit is contained in:
parent
ae4e3eeff1
commit
ef24f406a0
22 changed files with 297 additions and 69 deletions
|
|
@ -16,16 +16,18 @@ 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",
|
||||
402: "billing_error",
|
||||
403: "permission_error",
|
||||
404: "not_found_error",
|
||||
409: "conflict_error",
|
||||
413: "request_too_large",
|
||||
422: "invalid_request_error",
|
||||
429: "rate_limit_error",
|
||||
500: "api_error",
|
||||
504: "timeout_error",
|
||||
529: "overloaded_error",
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +42,10 @@ 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")
|
||||
named: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code)
|
||||
if named is not None:
|
||||
return named
|
||||
return "invalid_request_error" if 400 <= status_code < 500 else "api_error"
|
||||
|
||||
@staticmethod
|
||||
def create_error_response(
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -56,11 +56,6 @@ router: Final = APIRouter()
|
|||
|
||||
|
||||
def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
|
||||
"""The Anthropic error envelope for a failure raised inside a Messages handler.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,15 +6,18 @@ 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 (
|
||||
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
|
||||
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(
|
||||
{
|
||||
|
|
@ -25,10 +28,27 @@ ANTHROPIC_MESSAGES_ROUTES: Final[frozenset[str]] = frozenset(
|
|||
}
|
||||
)
|
||||
ANTHROPIC_PASS_THROUGH_PREFIX: Final = "/anthropic/"
|
||||
ANTHROPIC_SHAPED_MODEL_LIST_ROUTES: Final[frozenset[str]] = frozenset(
|
||||
{"/v1/models", "/models", "/claude_code_gateway/v1/models"}
|
||||
)
|
||||
|
||||
|
||||
def uses_anthropic_error_envelope(route: str) -> bool:
|
||||
return route in ANTHROPIC_MESSAGES_ROUTES or route.startswith(ANTHROPIC_PASS_THROUGH_PREFIX)
|
||||
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:
|
||||
|
|
@ -45,15 +65,22 @@ def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDe
|
|||
return with_fields
|
||||
|
||||
|
||||
def _anthropic_error_detail(
|
||||
exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None
|
||||
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 _anthropic_error_detail(
|
||||
exc: ProxyException, status_code: int, detail: AnthropicErrorDetail, call_id: str | None
|
||||
) -> AnthropicErrorDetail:
|
||||
with_proxy_fields: Final = _with_litellm_error_type(exc, status_code, _with_provider_specific_fields(exc, detail))
|
||||
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_proxy_fields
|
||||
with_call_id: Final[AnthropicErrorDetail] = {**with_proxy_fields, "litellm_call_id": call_id}
|
||||
return with_call_id
|
||||
|
||||
|
||||
|
|
@ -71,7 +98,7 @@ def anthropic_error_content(
|
|||
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),
|
||||
"error": _anthropic_error_detail(exc, status_code, envelope["error"], body_call_id),
|
||||
}
|
||||
return content
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, AnthropicExceptionMapping
|
||||
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 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,24 @@ 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 AnthropicExceptionMapping.get_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 +369,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 +386,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 +396,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 +422,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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -405,7 +405,6 @@ 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,
|
||||
|
|
@ -2194,7 +2193,7 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
|
|||
# NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions
|
||||
log_model_access_denial(exc)
|
||||
headers: Final = exc.headers
|
||||
if uses_anthropic_error_envelope(get_request_route(request)):
|
||||
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()),
|
||||
|
|
@ -2231,7 +2230,7 @@ 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(get_request_route(connection)):
|
||||
if uses_anthropic_error_envelope(connection):
|
||||
return anthropic_error_json_response(
|
||||
proxy_exception_from_http_exception(exc, dict(exc.headers or {})), connection
|
||||
)
|
||||
|
|
@ -2442,7 +2441,7 @@ 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(get_request_route(request)):
|
||||
if uses_anthropic_error_envelope(request):
|
||||
return anthropic_internal_server_error_response(request)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
|
|
|
|||
|
|
@ -481,6 +481,7 @@ def test_device_authorization_polls_complete_sso_and_mints_a_model_restricted_be
|
|||
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."
|
||||
),
|
||||
"litellm_error_type": "team_model_access_denied",
|
||||
},
|
||||
}, model_b_response.text
|
||||
assert upstream.drain() == ()
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en
|
|||
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."
|
||||
),
|
||||
"litellm_error_type": "key_model_access_denied",
|
||||
},
|
||||
}, restricted_response.text
|
||||
assert wire.drain() == ()
|
||||
|
|
@ -292,6 +293,7 @@ def test_enabled_gateway_refuses_model_outside_virtual_key(enabled_gateway: Gate
|
|||
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."
|
||||
),
|
||||
"litellm_error_type": "key_model_access_denied",
|
||||
},
|
||||
}
|
||||
assert result[0] == result[2] == 403, (result[1], result[3])
|
||||
|
|
|
|||
|
|
@ -357,10 +357,14 @@ def test_anthropic_messages_preserves_cli_beta_safeguards_and_result(gateway: Ga
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "error_type", "error_class", "litellm_error"),
|
||||
("status", "error_type", "error_class", "litellm_error", "litellm_error_type"),
|
||||
[
|
||||
pytest.param(429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", id="rate_limit"),
|
||||
pytest.param(400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", id="invalid_request"),
|
||||
pytest.param(
|
||||
429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", "throttling_error", id="rate_limit"
|
||||
),
|
||||
pytest.param(
|
||||
400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", None, id="invalid_request"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_anthropic_messages_preserves_upstream_error_status_and_body(
|
||||
|
|
@ -369,6 +373,7 @@ def test_anthropic_messages_preserves_upstream_error_status_and_body(
|
|||
error_type: str,
|
||||
error_class: type[anthropic.APIStatusError],
|
||||
litellm_error: str,
|
||||
litellm_error_type: str | None,
|
||||
) -> None:
|
||||
message: Final = f"synthetic {error_type}"
|
||||
expected_error: Final = {"type": "error", "error": {"type": error_type, "message": message}}
|
||||
|
|
@ -416,6 +421,7 @@ def test_anthropic_messages_preserves_upstream_error_status_and_body(
|
|||
f"litellm.{litellm_error}: AnthropicException - {json.dumps(expected_error)}\n\n"
|
||||
f"LiteLLM: model group '{model}' failed with the error above. No fallback was attempted."
|
||||
),
|
||||
**({"litellm_error_type": litellm_error_type} if litellm_error_type else {}),
|
||||
},
|
||||
}, caught.value.response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
|
@ -470,6 +476,7 @@ def test_anthropic_messages_preserves_upstream_rate_limit_retry_after(gateway: G
|
|||
f"litellm.RateLimitError: AnthropicException - {json.dumps(upstream_error)}\n\n"
|
||||
f"LiteLLM: model group '{model}' failed with the error above. No fallback was attempted."
|
||||
),
|
||||
"litellm_error_type": "throttling_error",
|
||||
},
|
||||
}, caught.value.response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
|
|
|||
|
|
@ -140,7 +140,11 @@ def test_anthropic_count_without_a_provider_credential_is_an_anthropic_401(
|
|||
_anthropic(gateway).messages.count_tokens(model=model, messages=_MESSAGES)
|
||||
assert raised.value.body == {
|
||||
"type": "error",
|
||||
"error": {"type": "authentication_error", "message": raised.value.body["error"]["message"]},
|
||||
"error": {
|
||||
"type": "authentication_error",
|
||||
"message": raised.value.body["error"]["message"],
|
||||
"litellm_error_type": "token_counting_error",
|
||||
},
|
||||
}
|
||||
assert missing in raised.value.message, raised.value.message
|
||||
assert wire.drain() == ()
|
||||
|
|
@ -210,7 +214,7 @@ def test_anthropic_count_request_errors_use_the_anthropic_envelope(keyless_proxy
|
|||
assert rejected.status_code == 400, rejected.text
|
||||
body: Final = _JSON_OBJECT.validate_json(rejected.content)
|
||||
assert set(body) <= {"type", "error", "request_id"}, body
|
||||
assert body["error"] == upstream_error["error"], body
|
||||
assert body["error"] == {**upstream_error["error"], "litellm_error_type": "token_counting_error"}, body
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -258,7 +258,10 @@ class _CountRoute:
|
|||
_MESSAGES_ROUTE: Final = _CountRoute(
|
||||
path=lambda _model: "/v1/messages/count_tokens",
|
||||
body=_bare,
|
||||
rejection=lambda message: {"type": "error", "error": {"type": "invalid_request_error", "message": message}},
|
||||
rejection=lambda message: {
|
||||
"type": "error",
|
||||
"error": {"type": "invalid_request_error", "message": message, "litellm_error_type": "token_counting_error"},
|
||||
},
|
||||
count_field="input_tokens",
|
||||
)
|
||||
_RESPONSES_ROUTE: Final = _CountRoute(
|
||||
|
|
|
|||
|
|
@ -65,13 +65,22 @@ class TestCreateErrorResponse:
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected_error_type"),
|
||||
[(422, "invalid_request_error"), (418, "api_error"), (502, "api_error"), (599, "api_error")],
|
||||
[
|
||||
(402, "billing_error"),
|
||||
(409, "conflict_error"),
|
||||
(504, "timeout_error"),
|
||||
(405, "invalid_request_error"),
|
||||
(418, "invalid_request_error"),
|
||||
(422, "invalid_request_error"),
|
||||
(499, "invalid_request_error"),
|
||||
(502, "api_error"),
|
||||
(503, "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(
|
||||
def test_a_status_the_docs_do_not_name_is_a_request_error_below_500_and_an_api_error_from_500(
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ class TestProxyExceptionAnthropicEnvelope:
|
|||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "Invalid type for 'metadata': expected an object, but got a string instead.",
|
||||
"litellm_error_type": "bad_request_error",
|
||||
},
|
||||
"request_id": "req_test_6468",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -133,3 +133,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():
|
||||
async def app(scope, receive, send):
|
||||
await JSONResponse({"ok": True})(scope, receive, send)
|
||||
|
||||
client = TestClient(
|
||||
RequestSizeLimitMiddleware(app, get_max_request_size_mb=lambda: 1, is_request_size_limit_enabled=lambda: True)
|
||||
)
|
||||
|
||||
response = 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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
})
|
||||
|
||||
|
|
@ -209,6 +209,7 @@ async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_me
|
|||
"error": {
|
||||
"type": "permission_error",
|
||||
"message": "Team not allowed to access model. Tried to access gpt-5.6",
|
||||
"litellm_error_type": "team_model_access_denied",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +248,7 @@ async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_
|
|||
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"]
|
||||
|
||||
|
||||
|
|
@ -268,6 +270,23 @@ async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messag
|
|||
}
|
||||
|
||||
|
||||
@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(
|
||||
|
|
@ -302,6 +321,7 @@ async def test_openai_exception_handler_messages_route_copies_the_call_id_when_o
|
|||
"type": "permission_error",
|
||||
"message": "denied",
|
||||
"provider_specific_fields": {"guardrail": "keyword-block"},
|
||||
"litellm_error_type": "team_model_access_denied",
|
||||
"litellm_call_id": "call-8302",
|
||||
}
|
||||
|
||||
|
|
@ -738,7 +758,10 @@ def test_a_get_on_messages_answers_the_anthropic_envelope_and_keeps_the_allow_he
|
|||
|
||||
assert response.status_code == 405
|
||||
assert response.headers["allow"] == "POST"
|
||||
assert response.json() == {"type": "error", "error": {"type": "api_error", "message": "Method Not Allowed"}}
|
||||
assert response.json() == {
|
||||
"type": "error",
|
||||
"error": {"type": "invalid_request_error", "message": "Method Not Allowed"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1525,6 +1525,7 @@ async def test_count_tokens_without_a_count_api_rejects_a_non_string_tool_result
|
|||
assert json.loads(bytes(response.body))["error"] == {
|
||||
"type": "invalid_request_error",
|
||||
"message": "tool_result toolu_log content.1.text: Input should be a valid string",
|
||||
"litellm_error_type": "token_counting_error",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -109,14 +109,55 @@ describe("toast", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
[403, "permission_error", sonner.error, "Access Denied"],
|
||||
[401, "authentication_error", sonner.error, "Authentication Error"],
|
||||
[429, "rate_limit_error", sonner.warning, "Rate Limit Exceeded"],
|
||||
[422, "invalid_request_error", sonner.error, "Request Error"],
|
||||
] as const)(
|
||||
"titles an Anthropic SDK %s error on the Messages playground by its %s type",
|
||||
(status, type, fn, title) => {
|
||||
const body = JSON.stringify({ type: "error", error: { type, message: "Team not allowed to access model" } });
|
||||
{
|
||||
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}`,
|
||||
);
|
||||
|
|
@ -127,6 +168,11 @@ describe("toast", () => {
|
|||
},
|
||||
);
|
||||
|
||||
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("leaves a string alone when its braces are not a JSON envelope", () => {
|
||||
toast.fromError("Template {name} is invalid");
|
||||
expect(sonner.error).toHaveBeenCalledWith("Error", {
|
||||
|
|
|
|||
|
|
@ -82,11 +82,16 @@ 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 EMBEDDED_JSON = /\{[\s\S]*\}/;
|
||||
const STATUS_BEFORE_JSON = /(\d{3})\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];
|
||||
|
|
@ -95,7 +100,7 @@ const describeText = (text: string): ErrorFacts => {
|
|||
return { status: undefined, proxyType: undefined, text: unwrapProxyErrorMessage(text) };
|
||||
}
|
||||
return {
|
||||
status: toStatus(proxyEnvelope(parsed)?.code),
|
||||
status: toStatus(proxyEnvelope(parsed)?.code) ?? statusBefore(text, embedded),
|
||||
proxyType: proxyTypeOf(parsed),
|
||||
text: text.replace(embedded, unwrapProxyErrorMessage(deriveErrorMessage(parsed))).trim(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue