mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
d258bca229
commit
546b8fd6d9
8 changed files with 97 additions and 46 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue