fix(proxy): answer an upstream overload with 503 server_is_overloaded on OpenAI routes (#49993)

* fix(anthropic): answer a 503 as 529 overloaded_error on /v1/messages

Anthropic's error contract has no 503, so a client built against it (Claude Code) treats a 503 from /v1/messages as a plain 5xx and retries it ten times instead of taking its overload path. A Bedrock 503 now leaves /v1/messages and /v1/messages/count_tokens as 529 overloaded_error, for both a JSON answer and a stream that fails before its first byte.

An Anthropic 529 overloaded_error now maps to litellm.ServiceUnavailableError (503) instead of InternalServerError (500), so OpenAI-shaped routes answer it as 503, the status OpenAI documents for an overloaded engine, and router retry and allowed-fails policies key it as ServiceUnavailableError.

* test(proxy): expect overloaded_error on a 503 stream error after a keepalive ping

* fix(proxy): record the 529 the Anthropic routes answer and type the chat stream error frame

The messages route stamps the answered status on the exception before post_call_failure_hook runs, so the spend log row, the OTel server span, the failed-request metric, and alerting record the 529 the client saw instead of the deployment's 503, while the deployment failure metric and cooldown keep the deployment's own status. A chat completions stream error frame names the type its status stands for instead of the null .type ServiceUnavailableError carries. An Anthropic-shaped 503 envelope passes through as 529 overloaded_error through a validated TypedDict, and count_tokens answers every ProxyException as the Anthropic envelope. Five integration tests that pinned 503 on the messages route now expect 529

* test(proxy): expect the top-level Anthropic envelope from count_tokens errors

count_tokens now answers a ProxyException as a JSONResponse carrying the
Anthropic error envelope instead of raising HTTPException, which FastAPI
wrapped in {"detail": ...}. The three legacy error-format tests now read
the response body and check that no detail wrap is left.

* test(proxy): cover the 529 overloaded_error answer across routes, cooldown, and retry budgets

* fix(anthropic): keep Anthropic overloads as InternalServerError and answer 529 from the provider status

An upstream Anthropic 529 stays litellm.InternalServerError (status 500) so
InternalServerErrorRetries, InternalServerErrorAllowedFails, SDK handlers,
and the OpenAI-shaped routes behave as on main. The error now carries
provider_status_code, and the /v1/messages routes answer 529
overloaded_error from it.

* refactor(proxy): unwrap a mid-stream fallback error once instead of recursing for the provider status

* fix(anthropic): keep count_tokens' detail body, end keepalive streams with an Anthropic error event, and log any 529 as an overload

* test(integration): cover the audit round 3 cells and expect 529 on the count_tokens 503 cases

* fix(proxy): re-raise an unchanged stream HTTPException as itself and walk nested mid-stream fallbacks for the provider status

* fix(proxy): walk nested mid-stream fallbacks without recursion

* fix(anthropic): preserve 503s and answer upstream 529s on messages routes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(anthropic): answer an upstream Anthropic 529 as 529 on count_tokens

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(anthropic): use integration coverage for keepalive errors

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): answer an upstream overload with 503 server_is_overloaded on OpenAI routes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): pass answered overload status to failure hooks

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): preserve Anthropic route exception handling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(proxy): bind the transformed failure without rebinding the parameter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): observe the overload failure hook through a real ProxyLogging

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mateo Wang 2026-11-04 16:37:55 -08:00 • committed by GitHub
parent 733b776403
commit 7a641e5172
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 291 additions and 56 deletions

View file

@ -1,10 +1,29 @@
from typing import Final, TypeVar
from litellm.exceptions import InternalServerError, MidStreamFallbackError
from litellm.exceptions import APIError, InternalServerError, MidStreamFallbackError
_PROVIDER_OVERLOAD_STATUS_CODE: Final = 529
_OPENAI_OVERLOAD_ANSWERED_STATUS_CODE: Final = 503
_Raised = TypeVar("_Raised", bound=Exception)
def is_provider_overload(exc: Exception) -> bool:
return _provider_status_code(exc) == _PROVIDER_OVERLOAD_STATUS_CODE
def with_openai_overload_status(exc: _Raised) -> _Raised:
return (
_copy_carrying(
_copy_carrying(exc, "provider_status_code", _PROVIDER_OVERLOAD_STATUS_CODE),
"status_code",
_OPENAI_OVERLOAD_ANSWERED_STATUS_CODE,
)
if is_provider_overload(exc)
else exc
)
def _provider_status_code(exc: Exception) -> int | None:
unwrapped = exc # rebind-ok: the recursion gate requires iterative wrapper traversal
while isinstance(unwrapped, MidStreamFallbackError) and unwrapped.original_exception is not None:
@ -12,6 +31,10 @@ def _provider_status_code(exc: Exception) -> int | None:
match unwrapped:
case InternalServerError(provider_status_code=int() as provider_status):
return provider_status
case APIError(provider_status_code=int() as provider_status):
return provider_status
case APIError(status_code=int() as provider_status):
return provider_status
case _:
return None

View file

@ -95,6 +95,7 @@ from litellm.litellm_core_utils.streaming_handler import (
)
from litellm.litellm_core_utils.tool_fields import DropPolicy, MalformedField, drop_policy, resolve_tool_fields
from litellm.proxy._types import LiteLLMRoutes, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.answered_status import is_provider_overload, with_openai_overload_status
from litellm.proxy.auth.auth_checks import (
can_key_call_resolved_model,
is_model_cost_zero,
@ -115,6 +116,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
)
from litellm.proxy.common_utils.openai_error_payload import (
LITELLM_CALL_ID_HEADER,
OverloadedProxyException,
attribute_of,
error_status_code,
openai_error_param,
@ -164,6 +166,7 @@ _ChunkT: Final = TypeVar("_ChunkT")
KNOWN_PROXY_ROUTES: Final = frozenset(
route for member in LiteLLMRoutes for route in member.value if route.startswith("/")
)
_OPENAI_OVERLOAD_ROUTE_TYPES: Final = frozenset({"acompletion", "atext_completion", "aresponses"})
ProxyRouteType: TypeAlias = Literal[
"acompletion",
@ -4398,28 +4401,28 @@ class ProxyBaseLLMRequestProcessing:
version: str | None = None,
) -> Never:
"""Raises ProxyException (OpenAI API compatible) if an exception is raised"""
log_llm_api_exception(e, self.litellm_call_id)
answered: Final = with_openai_overload_status(e) if self.route_type in _OPENAI_OVERLOAD_ROUTE_TYPES else e
log_llm_api_exception(answered, self.litellm_call_id)
billing_logging_obj: Final = _REQUEST_FIELDS.validate_python(self.data).get("litellm_logging_obj")
failed_in_agentic_follow_up: Final = is_agentic_followup_failure(e)
# Allow callbacks to transform the error response
transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
original_exception=answered,
request_data=self.data,
dispatched_call_type=self.route_type,
)
# Use transformed exception if callback returned one, otherwise use original
if transformed_exception is not None:
e = transformed_exception
litellm_debug_info: Final = getattr(e, "litellm_debug_info", "")
failure: Final = transformed_exception if transformed_exception is not None else answered
litellm_debug_info: Final = getattr(failure, "litellm_debug_info", "")
verbose_proxy_logger.debug(
"An error occurred: %s %s",
e,
failure,
litellm_debug_info,
)
timeout: Final = getattr(
e, "timeout", None
failure, "timeout", None
) # returns the timeout set by the wrapper. Used for testing if model-specific timeout are set correctly
_litellm_logging_obj: Final = (
billing_logging_obj if isinstance(billing_logging_obj, LiteLLMLoggingObj) else None
@ -4451,11 +4454,11 @@ class ProxyBaseLLMRequestProcessing:
# a failed request reports no timing, matching /v1/chat/completions
read_timing_from_logging_obj=False,
)
headers = getattr(e, "headers", None) or {}
headers = getattr(failure, "headers", None) or {}
if not headers:
_response: Final = attribute_of(e, "response")
_response: Final = attribute_of(failure, "response")
_response_headers: Final = getattr(_response, "headers", None) if _response is not None else None
_provider_headers: Final = _response_headers or getattr(e, "litellm_response_headers", None)
_provider_headers: Final = _response_headers or getattr(failure, "litellm_response_headers", None)
if _provider_headers:
headers = get_response_headers(dict(_provider_headers))
headers.update(custom_headers)
@ -4475,21 +4478,21 @@ class ProxyBaseLLMRequestProcessing:
safe_headers: Final = {k: v for k, v in headers.items() if k.lower() not in UNSAFE_PROXY_RESPONSE_HEADERS}
self._apply_router_cooldown_retry_after(safe_headers, e)
self._apply_router_cooldown_retry_after(safe_headers, failure)
if isinstance(e, ProxyException):
e.headers = {
**{k: v for k, v in e.headers.items() if k.lower() not in UNSAFE_PROXY_RESPONSE_HEADERS},
if isinstance(failure, ProxyException):
failure.headers = {
**{k: v for k, v in failure.headers.items() if k.lower() not in UNSAFE_PROXY_RESPONSE_HEADERS},
**{k: v if isinstance(v, str) else str(v) for k, v in safe_headers.items()},
}
raise e
raise failure
if isinstance(e, HTTPException):
raise proxy_exception_from_http_exception(e, safe_headers)
elif isinstance(e, httpx.HTTPStatusError):
if isinstance(failure, HTTPException):
raise proxy_exception_from_http_exception(failure, safe_headers)
elif isinstance(failure, httpx.HTTPStatusError):
# Handle httpx.HTTPStatusError - extract actual error from response
# This matches the original behavior before the refactor in commit 511d435f6f
http_status_error: Final[httpx.HTTPStatusError] = e
http_status_error: Final[httpx.HTTPStatusError] = failure
error_body: Final = await http_status_error.response.aread()
error_text: Final = error_body.decode("utf-8")
@ -4499,12 +4502,12 @@ class ProxyBaseLLMRequestProcessing:
detail={"error": error_text},
headers=error_headers,
)
error_msg: Final = f"{e}"
error_msg: Final = f"{failure}"
# Check for AttributeError in the exception chain.
# The AttributeError may be wrapped in multiple layers
# (e.g. AttributeError -> OpenAIException -> APIConnectionError),
# so walk __cause__, __context__, and original_exception recursively.
has_attribute_error: Final = _has_attribute_error_in_chain(e)
has_attribute_error: Final = _has_attribute_error_in_chain(failure)
if has_attribute_error:
raise ProxyException(
@ -4519,12 +4522,12 @@ class ProxyBaseLLMRequestProcessing:
# VertexAIError, etc.) all have a status_code attribute reflecting
# the upstream API response. Use it to return the correct HTTP code
# instead of defaulting to 500.
_exc_status_code: Final = getattr(e, "status_code", None)
_exc_status_code: Final = getattr(failure, "status_code", None)
if _exc_status_code is not None and isinstance(_exc_status_code, int) and 400 <= _exc_status_code <= 599:
_code = _exc_status_code
else:
_code = status.HTTP_500_INTERNAL_SERVER_ERROR
if should_report_bug(e):
if should_report_bug(failure):
proxy_server_request: Final = self.data.get("proxy_server_request")
request_url: Final = (
proxy_server_request.get("url") if isinstance(proxy_server_request, Mapping) else None
@ -4533,24 +4536,33 @@ class ProxyBaseLLMRequestProcessing:
verbose_proxy_logger.error(
bug_report_notice(
build_proxy_bug_report(
e,
failure,
call_type=allowlisted(request_path, KNOWN_PROXY_ROUTES),
custom_llm_provider=self.data.get("custom_llm_provider"),
stream=self.data.get("stream"),
)
)
)
client_message: Final = getattr(e, "message", error_msg)
client_message: Final = getattr(failure, "message", error_msg)
redacted_client_message: Final = redact_internal_details_from_client_message(
strip_bug_report_notice(client_message) if isinstance(client_message, str) else error_msg
)
provider_specific_fields: Final = getattr(
failure, "provider_specific_fields", None
) or relayed_provider_specific_fields(failure)
if self.route_type in _OPENAI_OVERLOAD_ROUTE_TYPES and is_provider_overload(failure):
raise OverloadedProxyException(
message=redacted_client_message,
headers=cast(dict[str, str], safe_headers), # cast-ok: ProxyException coerces header values to strings
provider_specific_fields=provider_specific_fields,
)
raise ProxyException(
message=redact_internal_details_from_client_message(
strip_bug_report_notice(client_message) if isinstance(client_message, str) else error_msg
),
type=openai_error_type(e, _code),
param=openai_error_param(e),
openai_code=getattr(e, "code", None),
message=redacted_client_message,
type=openai_error_type(failure, _code),
param=openai_error_param(failure),
openai_code=getattr(failure, "code", None),
code=_code,
provider_specific_fields=getattr(e, "provider_specific_fields", None)
or relayed_provider_specific_fields(e),
provider_specific_fields=provider_specific_fields,
headers=safe_headers,
)

View file

@ -17,6 +17,7 @@ from litellm.proxy.db.db_lookup_gate import DBLookupDeadlineExceeded
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler, exception_chain
LITELLM_CALL_ID_HEADER: Final = "x-litellm-call-id"
_SERVER_IS_OVERLOADED_CODE: Final = "server_is_overloaded"
_OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
{
@ -44,6 +45,31 @@ _FAILOVER_REFUSALS: Final = (
)
class OverloadedProxyException(ProxyException):
def __init__(
self,
message: str,
headers: dict[str, str] | None, # mutable-ok: matches ProxyException's constructor
provider_specific_fields: dict[str, object] | None, # mutable-ok: matches ProxyException's constructor
) -> None:
super().__init__( # pyright: ignore[reportUnknownMemberType] # ProxyException omits generic arguments
message=message,
type="service_unavailable_error",
param=None,
code=status.HTTP_503_SERVICE_UNAVAILABLE,
headers=None if headers is None else dict(headers),
provider_specific_fields=provider_specific_fields,
)
def to_dict(self) -> dict[str, object]: # mutable-ok: matches ProxyException.to_dict
return {
**_ERROR_BODY.validate_python(
super().to_dict() # pyright: ignore[reportUnknownMemberType] # ProxyException omits generic arguments
),
"code": _SERVER_IS_OVERLOADED_CODE,
}
def attribute_of(value: object, name: str, default: object = None) -> object:
return getattr(value, name, default)

View file

@ -426,6 +426,7 @@ from litellm.proxy.agent_endpoints.auth.agent_access_groups import CeilingResolv
from litellm.proxy.analytics_endpoints.analytics_endpoints import (
router as analytics_router,
)
from litellm.proxy.anthropic_endpoints.answered_status import is_provider_overload
from litellm.proxy.auth.auth_checks import (
ROLE_BASED_PERMISSIONS_ADAPTER,
ExperimentalUIJWTToken,
@ -13375,6 +13376,13 @@ async def completion(
_response.choices[0].text = e.message
return _response
except Exception as e:
if is_provider_overload(e):
raise await base_llm_response_processor.handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
version=version,
)
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,

View file

@ -140,6 +140,16 @@ def _assert_internal_server_error(response: httpx.Response) -> None:
assert "InternalServerError" in string_value(error["message"]), response.text
def _assert_openai_overloaded(response: httpx.Response) -> None:
assert response.status_code == 503, response.text
error: Final = _openai_error(response)
assert set(error) == {"message", "type", "param", "code"}, response.text
assert string_value(error["message"]), response.text
assert error["type"] == "service_unavailable_error", response.text
assert error["param"] is None, response.text
assert error["code"] == "server_is_overloaded", response.text
def _one_post(wire: Wire, target: str) -> None:
assert [(request.method, unquote(request.target)) for request in wire.drain()] == [("POST", target)]
@ -361,7 +371,7 @@ def test_messages_overloaded_frame_before_content_without_a_retry_budget_is_rela
]
def test_chat_completions_overloaded_frame_before_content_answers_500(gateway: Gateway) -> None:
def test_chat_completions_overloaded_frame_before_content_answers_503(gateway: Gateway) -> None:
prompt: Final = _prompt()
with wire_server(_always(_overloaded_before_content(prompt))) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY)
@ -370,24 +380,24 @@ def test_chat_completions_overloaded_frame_before_content_answers_500(gateway: G
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True, "num_retries": 0},
)
_assert_internal_server_error(response)
_assert_openai_overloaded(response)
_one_post(wire, "/v1/messages")
def test_chat_completions_529_answers_500_to_the_sync_sdk(gateway: Gateway) -> None:
def test_chat_completions_529_answers_503_to_the_sync_sdk(gateway: Gateway) -> None:
with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY)
with pytest.raises(openai.APIStatusError) as caught:
_openai_sdk(gateway).chat.completions.create(
model=model, messages=[{"role": "user", "content": _prompt()}], extra_body={"num_retries": 0}
)
assert caught.value.status_code == 500, caught.value.response.text
_assert_internal_server_error(caught.value.response)
assert caught.value.status_code == 503, caught.value.response.text
_assert_openai_overloaded(caught.value.response)
_one_post(wire, "/v1/messages")
_assert_logged_failure(model, rows=1, error_class="InternalServerError", code="500")
_assert_logged_failure(model, rows=1, error_class="InternalServerError", code="503")
async def test_chat_completions_stream_529_answers_500_to_the_async_sdk(gateway: Gateway) -> None:
async def test_chat_completions_stream_529_answers_503_to_the_async_sdk(gateway: Gateway) -> None:
with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY)
with pytest.raises(openai.APIStatusError) as caught:
@ -397,19 +407,31 @@ async def test_chat_completions_stream_529_answers_500_to_the_async_sdk(gateway:
stream=True,
extra_body={"num_retries": 0},
)
assert caught.value.status_code == 500, caught.value.response.text
_assert_internal_server_error(caught.value.response)
assert caught.value.status_code == 503, caught.value.response.text
_assert_openai_overloaded(caught.value.response)
_one_post(wire, "/v1/messages")
def test_completions_529_answers_503(gateway: Gateway) -> None:
with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY)
response: Final = gateway.request(
"POST",
"/v1/completions",
{"model": model, "prompt": _prompt(), "max_tokens": 16, "num_retries": 0},
)
_assert_openai_overloaded(response)
_one_post(wire, "/v1/messages")
@pytest.mark.parametrize("shape", ("non-stream", "stream"))
def test_responses_529_answers_500(gateway: Gateway, shape: Shape) -> None:
def test_responses_529_answers_503(gateway: Gateway, shape: Shape) -> None:
with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY)
response: Final = gateway.request(
"POST", "/v1/responses", {"model": model, "input": _prompt(), "stream": shape == "stream", "num_retries": 0}
)
_assert_internal_server_error(response)
_assert_openai_overloaded(response)
_one_post(wire, "/v1/messages")

View file

@ -215,7 +215,7 @@ def _until(rig: _Rig, name: str, model: str) -> tuple[Sample, ...]:
(
pytest.param("/v1/messages", _MESSAGES_ANTHROPIC, 529, "500", "InternalServerError", id="messages-anthropic"),
pytest.param("/v1/messages", _MESSAGES_BEDROCK, 503, "503", "ServiceUnavailableError", id="messages-bedrock"),
pytest.param("/v1/chat/completions", _CHAT_ANTHROPIC, 500, "500", "InternalServerError", id="chat-anthropic"),
pytest.param("/v1/chat/completions", _CHAT_ANTHROPIC, 503, "500", "InternalServerError", id="chat-anthropic"),
),
)
def test_the_failed_request_metric_counts_the_answered_status_and_the_deployment_metric_the_deployment_status(
@ -245,7 +245,7 @@ def _server_spans(rig: _Rig, since: int, trace_id: str) -> tuple[Span, ...]:
("route", "model", "answered"),
(
pytest.param("/v1/messages", _TRACED_MESSAGES, 529, id="messages"),
pytest.param("/v1/chat/completions", _TRACED_CHAT, 500, id="chat"),
pytest.param("/v1/chat/completions", _TRACED_CHAT, 503, id="chat"),
),
)
def test_the_server_span_carries_the_status_the_route_answered(
@ -313,7 +313,7 @@ def _datadog_status_codes(rig: _Rig, alias: str, expected: int) -> tuple[tuple[s
(
pytest.param("/v1/messages", _DATADOG_MESSAGES_ANTHROPIC, 529, "500", id="messages-anthropic"),
pytest.param("/v1/messages", _DATADOG_MESSAGES_BEDROCK, 503, "503", id="messages-bedrock"),
pytest.param("/v1/chat/completions", _DATADOG_CHAT_ANTHROPIC, 500, "500", id="chat-anthropic"),
pytest.param("/v1/chat/completions", _DATADOG_CHAT_ANTHROPIC, 503, "500", id="chat-anthropic"),
),
)
def test_the_datadog_proxy_failure_log_carries_the_answered_status_and_the_llm_failure_log_the_deployment_status(
@ -361,7 +361,7 @@ def test_a_fail_closed_guardrail_outage_is_answered_and_recorded_with_the_routes
(
pytest.param("/v1/messages", _TRACED_MESSAGES, 529, id="messages-anthropic"),
pytest.param("/v1/messages", _MESSAGES_BEDROCK, 503, id="messages-bedrock"),
pytest.param("/v1/chat/completions", _TRACED_CHAT, 500, id="chat-anthropic"),
pytest.param("/v1/chat/completions", _TRACED_CHAT, 503, id="chat-anthropic"),
),
)
def test_the_otel_v2_server_span_carries_the_status_the_route_answered(

View file

@ -22,6 +22,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
import litellm
from litellm._uuid import uuid
from litellm.exceptions import APIError, InternalServerError, MidStreamFallbackError
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.anthropic_interface.exceptions import AnthropicErrorSseFrame, anthropic_error_sse_frame
from litellm.caching.caching import DualCache
@ -5127,6 +5128,138 @@ class TestHandleLLMApiExceptionRetryAfter:
assert "All deployments for selected model are in cooldown" not in body["message"]
class TestHandleLLMApiExceptionProviderOverload:
class _RecordingFailureHook(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.original_exception: Exception | None = None
async def async_post_call_failure_hook(
self,
request_data: dict,
original_exception: Exception,
user_api_key_dict: UserAPIKeyAuth,
traceback_str: str | None = None,
) -> None:
self.original_exception = original_exception
async def _invoke(
self,
exc: Exception,
route_type: str,
monkeypatch: pytest.MonkeyPatch,
recording_hook: _RecordingFailureHook | None = None,
) -> ProxyException:
processor: Final = ProxyBaseLLMRequestProcessing(data={})
processor.route_type = route_type
callback: Final = recording_hook if recording_hook is not None else self._RecordingFailureHook()
monkeypatch.setattr(litellm, "callbacks", [callback])
proxy_logging_obj: Final = ProxyLogging(user_api_key_cache=DualCache())
with pytest.raises(ProxyException) as raised:
await processor.handle_llm_api_exception(
e=exc,
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
proxy_logging_obj=proxy_logging_obj,
)
return raised.value
@pytest.mark.parametrize("route_type", ("acompletion", "atext_completion", "aresponses"))
async def test_openai_routes_answer_provider_overload_as_503(
self, route_type: str, monkeypatch: pytest.MonkeyPatch
) -> None:
exc: Final = InternalServerError(
message="Overloaded",
llm_provider="anthropic",
model="claude",
provider_status_code=529,
)
proxy_exc: Final = await self._invoke(exc, route_type, monkeypatch)
assert proxy_exc.code == "503"
assert proxy_exc.type == "service_unavailable_error"
assert proxy_exc.to_dict()["code"] == "server_is_overloaded"
@pytest.mark.parametrize(
("route_type", "provider_status_code"),
(("acompletion", 500), ("anthropic_messages", 529)),
)
async def test_non_overload_and_non_openai_routes_keep_their_status(
self, route_type: str, provider_status_code: int, monkeypatch: pytest.MonkeyPatch
) -> None:
exc: Final = InternalServerError(
message="Internal server error",
llm_provider="anthropic",
model="claude",
provider_status_code=provider_status_code,
)
proxy_exc: Final = await self._invoke(exc, route_type, monkeypatch)
assert proxy_exc.code == "500"
assert proxy_exc.type == "internal_server_error"
assert proxy_exc.to_dict()["code"] == "500"
async def test_openai_route_answers_wrapped_provider_overload_as_503(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
original: Final = InternalServerError(
message="Overloaded",
llm_provider="anthropic",
model="claude",
provider_status_code=529,
)
exc: Final = MidStreamFallbackError(
message="Fallback failed",
llm_provider="anthropic",
model="claude",
original_exception=original,
)
proxy_exc: Final = await self._invoke(exc, "acompletion", monkeypatch)
assert proxy_exc.code == "503"
assert proxy_exc.type == "service_unavailable_error"
assert proxy_exc.to_dict()["code"] == "server_is_overloaded"
async def test_anthropic_text_api_overload_answers_503(self, monkeypatch: pytest.MonkeyPatch) -> None:
exc: Final = APIError(
status_code=529,
message="Overloaded",
llm_provider="anthropic_text",
model="claude",
)
proxy_exc: Final = await self._invoke(exc, "atext_completion", monkeypatch)
assert proxy_exc.code == "503"
assert proxy_exc.type == "service_unavailable_error"
assert proxy_exc.to_dict()["code"] == "server_is_overloaded"
@pytest.mark.parametrize(
("route_type", "expected_status"),
(("acompletion", 503), ("anthropic_messages", 500)),
)
async def test_failure_hook_receives_the_answered_overload_status(
self, route_type: str, expected_status: int, monkeypatch: pytest.MonkeyPatch
) -> None:
exc: Final = InternalServerError(
message="Overloaded",
llm_provider="anthropic",
model="claude",
provider_status_code=529,
)
recording_hook: Final = self._RecordingFailureHook()
await self._invoke(exc, route_type, monkeypatch, recording_hook)
original_exception: Final = recording_hook.original_exception
assert original_exception is not None
assert original_exception.status_code == expected_status
class TestHandleLLMApiExceptionFramingHeaders:
"""HTTP-framing headers on the provider exception must be stripped before the
proxy builds its own response, or they conflict with the framing the proxy
@ -7755,7 +7888,10 @@ class TestPreCallWithFallbacksOnLocalRateLimit:
"litellm_params": {"model": "openai/text-embedding-3-small", "api_key": "sk-synthetic"},
"model_info": {"enforce_supported_endpoints": True, "supported_endpoints": ["/v1/embeddings"]},
},
{"model_name": "chat-model", "litellm_params": {"model": "openai/gpt-5.4-nano", "api_key": "sk-synthetic"}},
{
"model_name": "chat-model",
"litellm_params": {"model": "openai/gpt-5.4-nano", "api_key": "sk-synthetic"},
},
],
fallbacks=[{"rate-limited": ["embed-alias", "chat-model"]}],
)
@ -9829,10 +9965,13 @@ def _hook_data(**kwargs: object) -> object:
@pytest.mark.asyncio
@pytest.mark.parametrize("prompt, proxy_field, client_field", [
({"input": "hi"}, "litellm_metadata", "metadata"),
({"messages": [{"role": "user", "content": "hi"}]}, "metadata", "litellm_metadata"),
])
@pytest.mark.parametrize(
"prompt, proxy_field, client_field",
[
({"input": "hi"}, "litellm_metadata", "metadata"),
({"messages": [{"role": "user", "content": "hi"}]}, "metadata", "litellm_metadata"),
],
)
async def test_cursor_route_writes_proxy_fields_only_to_the_field_its_body_shape_picks(
prompt: dict[str, str | list[dict[str, str]]],
proxy_field: str,
@ -9842,7 +9981,9 @@ async def test_cursor_route_writes_proxy_fields_only_to_the_field_its_body_shape
logging: Final = MagicMock(spec=ProxyLogging)
logging.pre_call_hook = AsyncMock(side_effect=_hook_data)
processor: Final = ProxyBaseLLMRequestProcessing(data={"model": "gpt-x", "metadata": {"client": "cursor"}, **prompt})
processor: Final = ProxyBaseLLMRequestProcessing(
data={"model": "gpt-x", "metadata": {"client": "cursor"}, **prompt}
)
data, _ = await processor.common_processing_pre_call_logic(
request=Request({"type": "http", "method": "POST", "path": "/cursor/chat/completions", "headers": []}),
general_settings={},
@ -9858,6 +9999,7 @@ async def test_cursor_route_writes_proxy_fields_only_to_the_field_its_body_shape
assert "queue_time_seconds" not in data.get(client_field, {})
assert data["metadata"]["client"] == "cursor"
class TestPerRequestModelGroupAlias:
"""``router_settings.model_group_alias`` on a key or team has to be resolved
by the proxy: the Router resolves aliases from its own shared instance
@ -12693,7 +12835,9 @@ class TestResponseCostFromLoggingObjForCacheHits:
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
)
cost_headers: Final = {name: value for name, value in headers.items() if name.startswith("x-litellm-response-cost")}
cost_headers: Final = {
name: value for name, value in headers.items() if name.startswith("x-litellm-response-cost")
}
assert cost_headers == {"x-litellm-response-cost-output": "0.0012"}
def test_a_fresh_zero_cost_v1_messages_call_still_sends_no_cost_header(self):