mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(anthropic_endpoints)!: return Anthropic error envelopes on /v1/messages auth failures (#48466)
* fix: Return Anthropic error envelopes on Messages auth failures Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Answer Anthropic envelopes on Messages auth failures without relabelling unmapped statuses Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix(proxy): answer the Anthropic error envelope on the /anthropic pass-through The pass-through relays upstream errors in Anthropic's shape, but the proxy's own refusals there (bad key, model access, budget) still answered the OpenAI body, so an Anthropic SDK pointed at /anthropic could not read them * fix(proxy): answer the Anthropic envelope for 404, 405 and unhandled 500 errors on Messages routes A disabled Claude Code gateway 404, a wrong-method 405 and an unhandled 500 on the Messages and /anthropic/ routes kept FastAPI's detail body or the OpenAI shape. The central HTTP and unhandled-exception handlers now pick the Anthropic envelope by route. The dashboard playground toast titles Anthropic error types when the body has no code. * 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. * test(proxy): expect litellm_error_type in Messages integration tests and type middleware tests * fix(proxy): keep handler error bodies unchanged and timeouts as api_error Errors the Messages and count_tokens handlers already returned in the Anthropic shape keep their original bodies, so litellm_error_type now appears only on errors the proxy raises before the handler. A 408 stays api_error as before, and the playground toast titles the new Anthropic types and ignores long numbers in front of an embedded error * fix(proxy): keep the base error types inside the Messages handler and scope toast titles to Anthropic bodies The wider status table (402 billing_error, 409 conflict_error, 504 timeout_error, other unlisted 4xx invalid_request_error) now applies only to errors the proxy raises before the handler. Errors inside the Messages handler, streamed error frames and the SDK streaming adapters keep the merge base's table. The dashboard toast reads a status before embedded JSON and titles by Anthropic type only when the body is an Anthropic error envelope, so other pages keep their titles. * test(anthropic): mark the new test variables Final --------- Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
50af27eaa6
commit
e63067a23b
26 changed files with 809 additions and 124 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ from typing_extensions import ReadOnly, TypedDict
|
|||
|
||||
import litellm
|
||||
from litellm.anthropic_interface.exceptions import (
|
||||
AnthropicErrorDetail,
|
||||
AnthropicErrorResponse,
|
||||
AnthropicExceptionMapping,
|
||||
)
|
||||
from litellm.integrations.custom_guardrail import ModifyResponseException
|
||||
|
|
@ -34,13 +32,14 @@ from litellm.proxy.common_request_processing import (
|
|||
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,
|
||||
litellm_call_id_headers,
|
||||
openai_error_param,
|
||||
|
|
@ -61,44 +60,8 @@ router: Final = APIRouter()
|
|||
_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -780,7 +781,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 {}
|
||||
|
|
|
|||
172
litellm/proxy/common_utils/anthropic_error_payload.py
Normal file
172
litellm/proxy/common_utils/anthropic_error_payload.py
Normal 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)
|
||||
|
|
@ -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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -488,12 +488,18 @@ 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,
|
||||
resolve_litellm_call_id,
|
||||
should_return_raw_model_name,
|
||||
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,
|
||||
)
|
||||
|
|
@ -2275,6 +2281,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)),
|
||||
|
|
@ -2310,6 +2318,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)
|
||||
|
||||
|
||||
|
|
@ -2517,6 +2529,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={
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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() == ()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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] == []
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -319,6 +319,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) -> 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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue