From 546b8fd6d96e59fcacea3c44f983f8735b95b8db Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:04:44 -0700 Subject: [PATCH] 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. --- .../exceptions/exception_mapping_utils.py | 20 ++++--------- .../common_utils/anthropic_error_payload.py | 29 +++++++++++++++++-- .../request_body_decompression_middleware.py | 8 ++--- .../test_exception_mapping_utils.py | 29 ++++++------------- .../anthropic_endpoints/test_endpoints.py | 17 +++++++++++ .../proxy_server/test_exception_handlers.py | 6 ++++ ui/litellm-dashboard/src/lib/toast.test.ts | 8 +++++ ui/litellm-dashboard/src/lib/toast.ts | 26 +++++++++++++---- 8 files changed, 97 insertions(+), 46 deletions(-) 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 => {