diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index d98110cbea5..b4c95fafff3 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -20,19 +20,14 @@ _PARSED_ERROR_BODY: Final = TypeAdapter(object) ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = { 400: "invalid_request_error", 401: "authentication_error", - 402: "billing_error", 403: "permission_error", 404: "not_found_error", - 409: "conflict_error", 413: "request_too_large", 429: "rate_limit_error", 500: "api_error", - 504: "timeout_error", 529: "overloaded_error", } -REQUEST_TIMEOUT_STATUS: Final = 408 - class AnthropicExceptionMapping: """ @@ -44,18 +39,14 @@ class AnthropicExceptionMapping: @staticmethod def get_error_type(status_code: int) -> AnthropicErrorType: """Map HTTP status code to Anthropic error type.""" - named: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code) - if named is not None: - return named - if status_code == REQUEST_TIMEOUT_STATUS or not 400 <= status_code < 500: - return "api_error" - return "invalid_request_error" + return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error") @staticmethod def create_error_response( status_code: int, message: str, request_id: str | None = None, + error_type: AnthropicErrorType | None = None, ) -> AnthropicErrorResponse: """ Create an Anthropic-formatted error response dict. @@ -67,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, }, } @@ -141,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. @@ -154,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 @@ -179,6 +170,7 @@ class AnthropicExceptionMapping: status_code=status_code, message=message, request_id=request_id, + error_type=error_type, ) diff --git a/litellm/proxy/common_utils/anthropic_error_payload.py b/litellm/proxy/common_utils/anthropic_error_payload.py index 91d71ee0dd3..89001194f7f 100644 --- a/litellm/proxy/common_utils/anthropic_error_payload.py +++ b/litellm/proxy/common_utils/anthropic_error_payload.py @@ -2,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ from fastapi.responses import JSONResponse from starlette.types import Scope from litellm.anthropic_interface.exceptions import ( + ANTHROPIC_ERROR_TYPE_MAP, AnthropicErrorDetail, AnthropicErrorResponse, AnthropicErrorType, @@ -31,6 +33,18 @@ 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: @@ -81,16 +95,18 @@ def _with_call_id(detail: AnthropicErrorDetail, call_id: str | None) -> Anthropi return with_call_id -def anthropic_handler_error_content( +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] = { @@ -100,13 +116,22 @@ def anthropic_handler_error_content( 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_handler_error_content(exc, status_code, request_id, general_settings) + 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"]), diff --git a/litellm/proxy/middleware/request_body_decompression_middleware.py b/litellm/proxy/middleware/request_body_decompression_middleware.py index 6a732cc1e8e..3e144d7bf4c 100644 --- a/litellm/proxy/middleware/request_body_decompression_middleware.py +++ b/litellm/proxy/middleware/request_body_decompression_middleware.py @@ -12,14 +12,14 @@ from starlette.requests import ClientDisconnect, Request from starlette.responses import JSONResponse from starlette.types import ASGIApp, Message, Receive, Scope, Send -from litellm.anthropic_interface.exceptions import AnthropicErrorType, AnthropicExceptionMapping +from litellm.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 route_error_content +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 @@ -300,9 +300,7 @@ def _error_response( headers: Mapping[str, str] | None = None, ) -> JSONResponse: anthropic_type: Final[AnthropicErrorType] = ( - "overloaded_error" - if error_type == "overloaded_error" - else AnthropicExceptionMapping.get_error_type(status_code) + "overloaded_error" if error_type == "overloaded_error" else proxy_error_type(status_code) ) return JSONResponse( route_error_content( diff --git a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py index ea21e0a1d9b..4f29791d566 100644 --- a/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py +++ b/tests/unit/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -63,27 +63,16 @@ class TestCreateErrorResponse: ) assert response["request_id"] == "req_123" - @pytest.mark.parametrize( - ("status_code", "expected_error_type"), - [ - (402, "billing_error"), - (409, "conflict_error"), - (504, "timeout_error"), - (405, "invalid_request_error"), - (418, "invalid_request_error"), - (422, "invalid_request_error"), - (499, "invalid_request_error"), - (408, "api_error"), - (502, "api_error"), - (503, "api_error"), - (599, "api_error"), - ], - ) - def test_a_status_the_docs_do_not_name_is_a_request_error_below_500_except_a_timeout_and_an_api_error_from_500( - self, status_code: int, expected_error_type: str - ): + @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 = AnthropicExceptionMapping.create_error_response(status_code, "scripted status") - assert response["error"]["type"] == expected_error_type + frame = 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 = AnthropicExceptionMapping.create_error_response(402, "Budget exceeded", error_type="billing_error") + assert response["error"]["type"] == "billing_error" class TestExtractErrorMessage: diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index c7bb05e5d30..bd1beada8c1 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -201,6 +201,23 @@ 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 = MagicMock() + request.headers = {} + + response = 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 diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index 4d9054051aa..f14c5572cac 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -217,6 +217,12 @@ async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_me (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( diff --git a/ui/litellm-dashboard/src/lib/toast.test.ts b/ui/litellm-dashboard/src/lib/toast.test.ts index 5d739db2534..8f50bd5f5c7 100644 --- a/ui/litellm-dashboard/src/lib/toast.test.ts +++ b/ui/litellm-dashboard/src/lib/toast.test.ts @@ -182,6 +182,14 @@ describe("toast", () => { 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 }); diff --git a/ui/litellm-dashboard/src/lib/toast.ts b/ui/litellm-dashboard/src/lib/toast.ts index aa7aa3aa94e..1546156803c 100644 --- a/ui/litellm-dashboard/src/lib/toast.ts +++ b/ui/litellm-dashboard/src/lib/toast.ts @@ -13,6 +13,7 @@ export type ToastOptions = { type ErrorFacts = { readonly status: number | undefined; readonly proxyType: string | undefined; + readonly anthropicType: string | undefined; readonly text: string; }; @@ -90,6 +91,12 @@ const proxyTypeOf = (payload: unknown): string | undefined => { 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 = /(? { 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) ?? statusBefore(text, embedded), + 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); @@ -126,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)), }; }; @@ -138,12 +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; if (status !== undefined) return titleForStatus(status); - return (proxyType === undefined ? undefined : ANTHROPIC_TYPE_TITLES[proxyType]) ?? "Error"; + return (anthropicType === undefined ? undefined : ANTHROPIC_TYPE_TITLES[anthropicType]) ?? "Error"; }; const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => {