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.
This commit is contained in:
mateo-berri 2026-10-28 18:04:44 -07:00
parent d258bca229
commit 546b8fd6d9
8 changed files with 97 additions and 46 deletions

View file

@ -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,
)

View file

@ -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"]),

View file

@ -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(

View file

@ -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:

View file

@ -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

View file

@ -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(

View file

@ -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 });

View file

@ -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 = /(?<!\d)([1-5]\d{2})\s*$/;
@ -100,18 +107,26 @@ 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) ?? 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 => {