mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
Merge 48d2cd1c56 into 3b9a59348f
This commit is contained in:
commit
0e96bb780f
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
|
||||
|
|
@ -36,13 +34,14 @@ from litellm.proxy.common_request_processing import (
|
|||
resolve_counted_tool_fields,
|
||||
resolve_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.common_utils.error_body_call_id import error_body_call_id
|
||||
from litellm.proxy.common_utils.anthropic_error_payload import (
|
||||
anthropic_handler_error_json_response,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
|
||||
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
read_request_body,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
LITELLM_CALL_ID_HEADER,
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
|
|
@ -59,44 +58,8 @@ from litellm.router import Router
|
|||
router: Final = APIRouter()
|
||||
|
||||
|
||||
def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDetail) -> AnthropicErrorDetail:
|
||||
if not exc.provider_specific_fields:
|
||||
return detail
|
||||
with_fields: Final[AnthropicErrorDetail] = {**detail, "provider_specific_fields": exc.provider_specific_fields}
|
||||
return with_fields
|
||||
|
||||
|
||||
def _anthropic_error_detail(
|
||||
exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None
|
||||
) -> AnthropicErrorDetail:
|
||||
if call_id is None:
|
||||
return _with_provider_specific_fields(exc, detail)
|
||||
with_call_id: Final[AnthropicErrorDetail] = {
|
||||
**_with_provider_specific_fields(exc, detail),
|
||||
"litellm_call_id": call_id,
|
||||
}
|
||||
return with_call_id
|
||||
|
||||
|
||||
def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
|
||||
from litellm.proxy.proxy_server import (
|
||||
_close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; error JSONResponses returned by the route must stamp the OTel server span like the global ProxyException handler does
|
||||
general_settings_view,
|
||||
)
|
||||
|
||||
status_code: Final = int(exc.code) if exc.code is not None and exc.code.isdigit() else 500
|
||||
_close_dangling_otel_server_span(request, status_code, exc=exc)
|
||||
envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error(
|
||||
status_code=status_code,
|
||||
raw_message=exc.message,
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
)
|
||||
body_call_id: Final = error_body_call_id(general_settings_view(), exc.headers.get(LITELLM_CALL_ID_HEADER))
|
||||
content: Final[AnthropicErrorResponse] = {
|
||||
**envelope,
|
||||
"error": _anthropic_error_detail(exc, envelope["error"], body_call_id),
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=content, headers=exc.headers)
|
||||
return anthropic_handler_error_json_response(exc, request)
|
||||
|
||||
|
||||
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import orjson
|
|||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.types import Receive, Scope, Send
|
||||
from typing_extensions import Never
|
||||
|
||||
|
|
@ -751,7 +752,7 @@ def resolve_counted_tool_fields(
|
|||
return resolved
|
||||
|
||||
|
||||
def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, str]) -> ProxyException:
|
||||
def proxy_exception_from_http_exception(exc: StarletteHTTPException, headers: dict[str, str]) -> ProxyException:
|
||||
raw_detail: Final = attribute_of(exc, "detail", str(exc))
|
||||
message, structured_fields = serialize_http_exception_detail(raw_detail)
|
||||
existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {}
|
||||
|
|
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l
|
|||
is_azure_model_router_request,
|
||||
log_llm_api_exception,
|
||||
open_sse_before_first_byte,
|
||||
proxy_exception_from_http_exception,
|
||||
proxy_exception_from_route_error,
|
||||
request_litellm_call_id,
|
||||
resolve_litellm_call_id,
|
||||
|
|
@ -468,6 +469,11 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l
|
|||
sse_error_payload,
|
||||
ttft_keepalive_interval,
|
||||
)
|
||||
from litellm.proxy.common_utils.anthropic_error_payload import (
|
||||
anthropic_error_json_response,
|
||||
anthropic_internal_server_error_response,
|
||||
uses_anthropic_error_envelope,
|
||||
)
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
|
||||
AuthCacheInvalidationSubscriber,
|
||||
)
|
||||
|
|
@ -2196,6 +2202,8 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
|
|||
# NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions
|
||||
log_model_access_denial(exc)
|
||||
headers: Final = exc.headers
|
||||
if uses_anthropic_error_envelope(request):
|
||||
return anthropic_error_json_response(exc, request)
|
||||
error_dict: Final = with_call_id(
|
||||
JSON_OBJECT.validate_python(exc.to_dict()),
|
||||
error_body_call_id(general_settings_view(), headers.get(LITELLM_CALL_ID_HEADER)),
|
||||
|
|
@ -2231,6 +2239,10 @@ async def otlp_http_exception_handler(connection: Request | WebSocket, exc: Star
|
|||
if response is not None:
|
||||
_close_dangling_otel_server_span(connection, exc.status_code, exc=exc)
|
||||
return response
|
||||
if uses_anthropic_error_envelope(connection):
|
||||
return anthropic_error_json_response(
|
||||
proxy_exception_from_http_exception(exc, dict(exc.headers or {})), connection
|
||||
)
|
||||
return await http_exception_handler(connection, exc)
|
||||
|
||||
|
||||
|
|
@ -2438,6 +2450,8 @@ async def _unhandled_exception_response(request: Request, exc: Exception) -> Res
|
|||
otlp_response: Final = tracing_endpoints.otlp_error_response(request, 500)
|
||||
if otlp_response is not None:
|
||||
return otlp_response
|
||||
if uses_anthropic_error_envelope(request):
|
||||
return anthropic_internal_server_error_response(request)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -203,6 +203,22 @@ class TestProxyExceptionAnthropicEnvelope:
|
|||
assert fallback.status_code == 500
|
||||
assert json.loads(fallback.body)["error"]["type"] == "api_error"
|
||||
|
||||
@pytest.mark.parametrize("status_code", [402, 409, 422, 504])
|
||||
def test_a_failure_inside_the_handler_keeps_the_api_error_type_for_a_status_outside_the_table(
|
||||
self, status_code: int
|
||||
):
|
||||
import litellm.proxy.anthropic_endpoints.endpoints as ep
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
request: Final = MagicMock(headers={})
|
||||
|
||||
response: Final = ep._anthropic_error_json_response(
|
||||
ProxyException(message="scripted", type="budget_exceeded", param=None, code=status_code), request
|
||||
)
|
||||
|
||||
assert response.status_code == status_code
|
||||
assert json.loads(response.body)["error"] == {"type": "api_error", "message": "scripted"}
|
||||
|
||||
@staticmethod
|
||||
def _call_id_error_response(general_settings, provider_specific_fields=None):
|
||||
import litellm.proxy.anthropic_endpoints.endpoints as ep
|
||||
|
|
|
|||
|
|
@ -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