mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
fix(proxy): keep handler error bodies unchanged and timeouts as api_error
Errors the Messages and count_tokens handlers already returned in the Anthropic shape keep their original bodies, so litellm_error_type now appears only on errors the proxy raises before the handler. A 408 stays api_error as before, and the playground toast titles the new Anthropic types and ignores long numbers in front of an embedded error
This commit is contained in:
parent
a5e86700c2
commit
d258bca229
13 changed files with 67 additions and 46 deletions
|
|
@ -31,6 +31,8 @@ ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = {
|
|||
529: "overloaded_error",
|
||||
}
|
||||
|
||||
REQUEST_TIMEOUT_STATUS: Final = 408
|
||||
|
||||
|
||||
class AnthropicExceptionMapping:
|
||||
"""
|
||||
|
|
@ -45,7 +47,9 @@ class AnthropicExceptionMapping:
|
|||
named: Final = ANTHROPIC_ERROR_TYPE_MAP.get(status_code)
|
||||
if named is not None:
|
||||
return named
|
||||
return "invalid_request_error" if 400 <= status_code < 500 else "api_error"
|
||||
if status_code == REQUEST_TIMEOUT_STATUS or not 400 <= status_code < 500:
|
||||
return "api_error"
|
||||
return "invalid_request_error"
|
||||
|
||||
@staticmethod
|
||||
def create_error_response(
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from litellm.proxy.common_request_processing import (
|
|||
resolve_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.common_utils.anthropic_error_payload import (
|
||||
anthropic_error_json_response,
|
||||
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
|
||||
|
|
@ -56,7 +56,7 @@ router: Final = APIRouter()
|
|||
|
||||
|
||||
def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
|
||||
return anthropic_error_json_response(exc, request)
|
||||
return anthropic_handler_error_json_response(exc, request)
|
||||
|
||||
|
||||
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""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 Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Final
|
||||
|
||||
from fastapi import Request, status
|
||||
|
|
@ -74,17 +74,14 @@ def _with_litellm_error_type(
|
|||
return with_type
|
||||
|
||||
|
||||
def _anthropic_error_detail(
|
||||
exc: ProxyException, status_code: int, detail: AnthropicErrorDetail, call_id: str | None
|
||||
) -> AnthropicErrorDetail:
|
||||
with_proxy_fields: Final = _with_litellm_error_type(exc, status_code, _with_provider_specific_fields(exc, detail))
|
||||
def _with_call_id(detail: AnthropicErrorDetail, call_id: str | None) -> AnthropicErrorDetail:
|
||||
if call_id is None:
|
||||
return with_proxy_fields
|
||||
with_call_id: Final[AnthropicErrorDetail] = {**with_proxy_fields, "litellm_call_id": call_id}
|
||||
return detail
|
||||
with_call_id: Final[AnthropicErrorDetail] = {**detail, "litellm_call_id": call_id}
|
||||
return with_call_id
|
||||
|
||||
|
||||
def anthropic_error_content(
|
||||
def anthropic_handler_error_content(
|
||||
exc: ProxyException,
|
||||
status_code: int,
|
||||
request_id: str | None,
|
||||
|
|
@ -98,12 +95,30 @@ def anthropic_error_content(
|
|||
body_call_id: Final = error_body_call_id(general_settings, exc.headers.get(LITELLM_CALL_ID_HEADER))
|
||||
content: Final[AnthropicErrorResponse] = {
|
||||
**envelope,
|
||||
"error": _anthropic_error_detail(exc, status_code, envelope["error"], body_call_id),
|
||||
"error": _with_call_id(_with_provider_specific_fields(exc, envelope["error"]), body_call_id),
|
||||
}
|
||||
return content
|
||||
|
||||
|
||||
def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
|
||||
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)
|
||||
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,
|
||||
|
|
@ -111,12 +126,18 @@ def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSON
|
|||
|
||||
status_code: Final = anthropic_error_status_code(exc)
|
||||
_close_dangling_otel_server_span(request, status_code, exc=exc)
|
||||
content: Final = anthropic_error_content(
|
||||
exc, status_code, request.headers.get("x-request-id"), general_settings_view()
|
||||
)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -357,14 +357,10 @@ def test_anthropic_messages_preserves_cli_beta_safeguards_and_result(gateway: Ga
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "error_type", "error_class", "litellm_error", "litellm_error_type"),
|
||||
("status", "error_type", "error_class", "litellm_error"),
|
||||
[
|
||||
pytest.param(
|
||||
429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", "throttling_error", id="rate_limit"
|
||||
),
|
||||
pytest.param(
|
||||
400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", None, id="invalid_request"
|
||||
),
|
||||
pytest.param(429, "rate_limit_error", anthropic.RateLimitError, "RateLimitError", id="rate_limit"),
|
||||
pytest.param(400, "invalid_request_error", anthropic.BadRequestError, "BadRequestError", id="invalid_request"),
|
||||
],
|
||||
)
|
||||
def test_anthropic_messages_preserves_upstream_error_status_and_body(
|
||||
|
|
@ -373,7 +369,6 @@ def test_anthropic_messages_preserves_upstream_error_status_and_body(
|
|||
error_type: str,
|
||||
error_class: type[anthropic.APIStatusError],
|
||||
litellm_error: str,
|
||||
litellm_error_type: str | None,
|
||||
) -> None:
|
||||
message: Final = f"synthetic {error_type}"
|
||||
expected_error: Final = {"type": "error", "error": {"type": error_type, "message": message}}
|
||||
|
|
@ -421,7 +416,6 @@ def test_anthropic_messages_preserves_upstream_error_status_and_body(
|
|||
f"litellm.{litellm_error}: AnthropicException - {json.dumps(expected_error)}\n\n"
|
||||
f"LiteLLM: model group '{model}' failed with the error above. No fallback was attempted."
|
||||
),
|
||||
**({"litellm_error_type": litellm_error_type} if litellm_error_type else {}),
|
||||
},
|
||||
}, caught.value.response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
|
@ -476,7 +470,6 @@ def test_anthropic_messages_preserves_upstream_rate_limit_retry_after(gateway: G
|
|||
f"litellm.RateLimitError: AnthropicException - {json.dumps(upstream_error)}\n\n"
|
||||
f"LiteLLM: model group '{model}' failed with the error above. No fallback was attempted."
|
||||
),
|
||||
"litellm_error_type": "throttling_error",
|
||||
},
|
||||
}, caught.value.response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
|
|
|||
|
|
@ -140,11 +140,7 @@ def test_anthropic_count_without_a_provider_credential_is_an_anthropic_401(
|
|||
_anthropic(gateway).messages.count_tokens(model=model, messages=_MESSAGES)
|
||||
assert raised.value.body == {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "authentication_error",
|
||||
"message": raised.value.body["error"]["message"],
|
||||
"litellm_error_type": "token_counting_error",
|
||||
},
|
||||
"error": {"type": "authentication_error", "message": raised.value.body["error"]["message"]},
|
||||
}
|
||||
assert missing in raised.value.message, raised.value.message
|
||||
assert wire.drain() == ()
|
||||
|
|
@ -214,7 +210,7 @@ def test_anthropic_count_request_errors_use_the_anthropic_envelope(keyless_proxy
|
|||
assert rejected.status_code == 400, rejected.text
|
||||
body: Final = _JSON_OBJECT.validate_json(rejected.content)
|
||||
assert set(body) <= {"type", "error", "request_id"}, body
|
||||
assert body["error"] == {**upstream_error["error"], "litellm_error_type": "token_counting_error"}, body
|
||||
assert body["error"] == upstream_error["error"], body
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -258,10 +258,7 @@ class _CountRoute:
|
|||
_MESSAGES_ROUTE: Final = _CountRoute(
|
||||
path=lambda _model: "/v1/messages/count_tokens",
|
||||
body=_bare,
|
||||
rejection=lambda message: {
|
||||
"type": "error",
|
||||
"error": {"type": "invalid_request_error", "message": message, "litellm_error_type": "token_counting_error"},
|
||||
},
|
||||
rejection=lambda message: {"type": "error", "error": {"type": "invalid_request_error", "message": message}},
|
||||
count_field="input_tokens",
|
||||
)
|
||||
_RESPONSES_ROUTE: Final = _CountRoute(
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ def _assert_budget_refusal(response: httpx.Response, end_user: str) -> None:
|
|||
|
||||
|
||||
def _assert_messages_budget_refusal(response: httpx.Response, end_user: str) -> None:
|
||||
"""A budget refusal on /v1/messages keeps its 422 but answers the Anthropic envelope."""
|
||||
assert response.status_code == 422, response.text
|
||||
body: Final = _json_object(response.content)
|
||||
assert body["type"] == "error", response.text
|
||||
|
|
|
|||
|
|
@ -73,12 +73,13 @@ class TestCreateErrorResponse:
|
|||
(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_and_an_api_error_from_500(
|
||||
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
|
||||
):
|
||||
response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status")
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ class TestProxyExceptionAnthropicEnvelope:
|
|||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "Invalid type for 'metadata': expected an object, but got a string instead.",
|
||||
"litellm_error_type": "bad_request_error",
|
||||
},
|
||||
"request_id": "req_test_6468",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,11 +168,6 @@ async def test_openai_exception_handler_never_fabricates_a_call_id(monkeypatch):
|
|||
assert "litellm_call_id" not in body["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# openai_exception_handler: Anthropic envelope on the Messages routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _model_access_denied_exception() -> ProxyException:
|
||||
return ProxyException(
|
||||
message="Team not allowed to access model. Tried to access gpt-5.6",
|
||||
|
|
|
|||
|
|
@ -1525,7 +1525,6 @@ async def test_count_tokens_without_a_count_api_rejects_a_non_string_tool_result
|
|||
assert json.loads(bytes(response.body))["error"] == {
|
||||
"type": "invalid_request_error",
|
||||
"message": "tool_result toolu_log content.1.text: Input should be a valid string",
|
||||
"litellm_error_type": "token_counting_error",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,20 @@ describe("toast", () => {
|
|||
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("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", {
|
||||
|
|
|
|||
|
|
@ -49,11 +49,14 @@ const STATUS_TITLES: Readonly<Record<number, string>> = {
|
|||
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",
|
||||
};
|
||||
|
||||
|
|
@ -88,7 +91,7 @@ const proxyTypeOf = (payload: unknown): string | undefined => {
|
|||
};
|
||||
|
||||
const EMBEDDED_JSON = /\{[\s\S]*\}/;
|
||||
const STATUS_BEFORE_JSON = /(\d{3})\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]);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue