fix(exceptions): map upstream 402 to PaymentRequiredError and cool down 402 deployments (#44879)

* fix(exceptions): keep upstream 402 status and cool down 402 deployments

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

* fix(exceptions): map 402 to PaymentRequiredError subclass of BadRequestError

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

* fix(exceptions): annotate PaymentRequiredError methods

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

* fix(router): skip 402 cooldown on single-deployment model groups

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

* fix(exceptions): single prefix and 402 fallback response for PaymentRequiredError

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

* fix(anthropic): map billing_error to PaymentRequiredError regardless of status

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

* fix(router): honor explicit allowed-fails policy for single-deployment 402s

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

* revert(anthropic): drop billing_error body mapping to PaymentRequiredError

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

* fix(exceptions): type the PaymentRequiredError constructor parameters

* test(integration): cover 402 PaymentRequiredError mapping and cooldown

---------

Co-authored-by: Mubashir Osmani <mubashir@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-06 20:35:13 -07:00 • committed by GitHub
parent d1cfe17518
commit e98bbc2f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1176 additions and 11 deletions

View file

@ -1404,6 +1404,7 @@ from .exceptions import (
BadGatewayError,
OpenAIError,
ContextWindowExceededError,
PaymentRequiredError as PaymentRequiredError,
ContentPolicyViolationError,
BudgetExceededError,
APIError,

View file

@ -573,6 +573,54 @@ class ContextWindowExceededError(BadRequestError):
return _message
class PaymentRequiredError(BadRequestError):
def __init__(
self,
message: str,
model: str,
llm_provider: str,
response: httpx.Response | None = None,
litellm_debug_info: str | None = None,
) -> None:
response_is_valid: Final = (
response is not None
and isinstance(response, httpx.Response)
and hasattr(response, "_request")
and getattr(response, "_request", None) is not None
)
response_for_parent: Final = (
response
if response_is_valid
else httpx.Response(
status_code=402,
request=httpx.Request(method="GET", url="https://litellm.ai"),
)
)
super().__init__(
message=message,
model=model,
llm_provider=llm_provider,
response=response_for_parent,
litellm_debug_info=litellm_debug_info,
)
self.status_code = 402
self.message = f"litellm.PaymentRequiredError: {message}"
def __str__(self) -> str:
return (
self.message
+ (f" LiteLLM Retried: {self.num_retries} times" if self.num_retries else "")
+ (f", LiteLLM Max Retries: {self.max_retries}" if self.max_retries else "")
)
def __repr__(self) -> str:
return (
self.message
+ (f" LiteLLM Retried: {self.num_retries} times" if self.num_retries else "")
+ (f", LiteLLM Max Retries: {self.max_retries}" if self.max_retries else "")
)
# sub class of bad request error - meant to help us catch guardrails-related errors on proxy.
class RejectedRequestError(BadRequestError):
def __init__(
@ -977,6 +1025,7 @@ LITELLM_EXCEPTION_TYPES: Final = [
PermissionDeniedError,
RateLimitError,
ContextWindowExceededError,
PaymentRequiredError,
RejectedRequestError,
ContentPolicyViolationError,
InternalServerError,

View file

@ -28,6 +28,7 @@ from ..exceptions import (
ContextWindowExceededError,
InternalServerError,
NotFoundError,
PaymentRequiredError,
PermissionDeniedError,
RateLimitError,
ServiceUnavailableError,
@ -2327,6 +2328,14 @@ def _map_exception_by_status(
litellm_debug_info=extra_information,
exception_status_code=status_code,
)
case 402:
raise PaymentRequiredError(
message=message,
model=model,
llm_provider=custom_llm_provider,
response=response,
litellm_debug_info=extra_information,
)
case _ if status_code < 500:
raise BadRequestError(
message=message,

View file

@ -250,11 +250,7 @@ def _is_cooldown_required(
# Cool down 429 Rate Limit Errors
return True
elif exception_status == 401:
# Cool down 401 Auth Errors
return True
elif exception_status == 408 or exception_status == 404:
elif exception_status in (401, 402, 408, 404):
return True
else:
@ -362,8 +358,17 @@ def _should_cooldown_deployment(
or litellm_router_instance.team_model_has_alternatives(deployment)
)
## CHECK DEPLOYMENT-LEVEL POLICY FIRST (overrides router-level)
exception_status_int: Final = cast_exception_status_to_int(exception_status)
dep_policy, dep_allowed_fails = _get_deployment_cooldown_policy(litellm_router_instance, deployment)
if (
is_single_deployment_model_group
and exception_status_int == 402
and _resolve_allowed_fails_from_policy(dep_policy, original_exception) is None
and litellm_router_instance.get_allowed_fails_from_policy(original_exception) is None
):
return False
## CHECK DEPLOYMENT-LEVEL POLICY FIRST (overrides router-level)
if dep_policy is not None or dep_allowed_fails is not None:
return _should_cooldown_based_on_deployment_policy(
litellm_router_instance,
@ -398,7 +403,6 @@ def _should_cooldown_deployment(
num_fails_this_minute,
)
exception_status_int: Final = cast_exception_status_to_int(exception_status)
if exception_status_int == 429 and not is_single_deployment_model_group:
return True
elif percent_fails == 1.0 and total_requests_this_minute >= SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD:
@ -412,7 +416,7 @@ def _should_cooldown_deployment(
# Only apply error rate cooldown when we have enough requests to make the percentage meaningful
return True
elif litellm.should_retry(status_code=cast_exception_status_to_int(exception_status)) is False:
elif litellm.should_retry(status_code=exception_status_int) is False:
return True
return False

View file

@ -0,0 +1,564 @@
from __future__ import annotations
import json
import re
import signal
import uuid
from collections.abc import Callable, Iterator, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import anthropic
import httpx
import openai
import psutil
import pytest
import yaml
from integration._support.client import GATEWAY_LIMITS, Gateway, Scenario, eventually, gateway_from_environment
from integration._support.openai_wire import chat_reply, responses_reply
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.prometheus_series import PROXY_FAILURES, Sample, scrape
from integration._support.redis_process import OwnedRedis, owned_redis
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.routing.test_payment_required_mapping import (
OPENAI_MODEL,
PAYMENT_REQUIRED,
anthropic_client,
anthropic_error,
anthropic_params,
assert_payment_required_row,
chat_body,
deployment,
is_model_info_probe,
model_list_reply,
new_group,
new_marker,
openai_client,
openai_params,
openai_refusal,
post,
provider_calls,
)
from pydantic import JsonValue
from redis import Redis
pytestmark: Final = pytest.mark.timeout(300)
COOLDOWN_SECONDS: Final = 300
RELOAD_SECONDS: Final = 2
NO_DEPLOYMENTS: Final = "No deployments available"
COOLED_DOWN: Final = "litellm_deployment_cooled_down_total"
PRIMARY_GROUP: Final = new_group()
FALLBACK_GROUP: Final = new_group()
MARKER: Final = re.compile(rb"pr402-[0-9a-f]{32}(?:-\d+)?")
WORKER_PID: Final = re.compile(r"Started server process \[(\d+)\]")
@dataclass(frozen=True, slots=True)
class Outcome:
marker: str
status: int
detail: str
call_id: str
@dataclass(frozen=True, slots=True)
class Call:
marker: str
path: str
body: Mapping[str, JsonValue]
@dataclass(frozen=True, slots=True)
class CooldownRig:
gateway: Gateway
cache: OwnedRedis
log: Path
def cooled(self, deployment_id: str) -> bool:
return cooled(self.cache, deployment_id)
def cooled(cache: OwnedRedis, deployment_id: str) -> bool:
with Redis(host=cache.host, port=cache.port) as client:
return int(client.exists(f"deployment:{deployment_id}:cooldown")) == 1
def marker_in(request: Request) -> str:
found: Final = MARKER.search(request.body)
assert found is not None, (request.method, request.target, request.headers, request.body[:300])
return found.group(0).decode()
def markers_received(wire: Wire) -> tuple[str, ...]:
return tuple(marker_in(request) for request in provider_calls(wire))
def refusing_reply(request: Request) -> Reply:
return anthropic_error(402, f"scripted 402 {marker_in(request)}")
def healthy_reply(request: Request) -> Reply:
if is_model_info_probe(request):
return model_list_reply()
marker: Final = marker_in(request)
body: Final = json.loads(request.body)
stream: Final = isinstance(body, dict) and body.get("stream") is True
if request.target.endswith("/responses"):
return responses_reply(f"resp_{marker}", OPENAI_MODEL, f"served {marker}", stream=stream)
return chat_reply(f"chatcmpl-{marker}", OPENAI_MODEL, f"served {marker}", stream=stream)
def router_settings(cache: OwnedRedis, **extra: JsonValue) -> dict[str, JsonValue]:
return {
"num_retries": 0,
"cooldown_time": COOLDOWN_SECONDS,
"redis_host": cache.host,
"redis_port": cache.port,
**extra,
}
def cooldown_config(
directory: Path,
settings: Mapping[str, JsonValue],
*,
model_list: Sequence[Mapping[str, JsonValue]] = (),
prometheus: bool = True,
) -> Path:
base: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
assert isinstance(base, dict)
litellm_settings: Final = (
{**base["litellm_settings"], "callbacks": ["prometheus"]} if prometheus else base["litellm_settings"]
)
config: Final = {
**base,
"litellm_settings": litellm_settings,
"router_settings": dict(settings),
**({"model_list": [dict(entry) for entry in model_list]} if model_list else {}),
}
path: Final = directory / f"cooldown-{uuid.uuid4().hex[:8]}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[CooldownRig]:
directory: Final = tmp_path_factory.mktemp("payment-required-cooldown")
with ExitStack() as stack:
upstream: Final = stack.enter_context(gateway_from_environment())
cache: Final = stack.enter_context(owned_redis(directory))
settings: Final = router_settings(cache, fallbacks=[{PRIMARY_GROUP: [FALLBACK_GROUP]}])
owned: Final = stack.enter_context(
owned_proxy_process(
upstream,
directory,
{"PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": str(RELOAD_SECONDS)},
config=cooldown_config(directory, settings),
remove_environment=("PROMETHEUS_MULTIPROC_DIR",),
)
)
yield CooldownRig(owned.gateway, cache, owned.log)
def two_deployments(scenario: Scenario, group: str, refusing: Wire, healthy: Wire) -> str:
refused_id: Final = deployment(scenario, group, anthropic_params(refusing))
deployment(scenario, group, openai_params(healthy))
return refused_id
def raw_chat(gateway: Gateway, group: str, marker: str, **extra: JsonValue) -> Outcome:
response: Final = post(gateway, "/v1/chat/completions", chat_body(group, marker, **extra))
return Outcome(marker, response.status_code, response.text, response.headers["x-litellm-call-id"])
def raw_call(gateway: Gateway, call: Call) -> Outcome:
response: Final = post(gateway, call.path, call.body)
return Outcome(call.marker, response.status_code, response.text, response.headers["x-litellm-call-id"])
def openai_chat(client: openai.OpenAI, group: str, marker: str) -> Outcome:
try:
raw: Final = client.chat.completions.with_raw_response.create(
model=group, messages=[{"role": "user", "content": marker}]
)
except openai.APIStatusError as error:
return Outcome(marker, error.status_code, error.message, error.response.headers["x-litellm-call-id"])
return Outcome(marker, raw.status_code, raw.parse().id, raw.headers["x-litellm-call-id"])
def anthropic_message(client: anthropic.Anthropic, group: str, marker: str) -> Outcome:
try:
raw: Final = client.messages.with_raw_response.create(
model=group, max_tokens=32, messages=[{"role": "user", "content": marker}]
)
except anthropic.APIStatusError as error:
return Outcome(marker, error.status_code, error.message, error.response.headers["x-litellm-call-id"])
block: Final = raw.parse().content[0]
assert isinstance(block, anthropic.types.TextBlock), block
return Outcome(marker, raw.status_code, block.text, raw.headers["x-litellm-call-id"])
def until_refused(attempt: Callable[[str], Outcome], tries: int = 40) -> tuple[Outcome, ...]:
def walk() -> Iterator[Outcome]:
for _ in range(tries):
outcome: Final = attempt(new_marker())
yield outcome
if outcome.status != 200:
return
history: Final = tuple(walk())
assert history[-1].status != 200, "the refusing deployment was never picked"
return history
def assert_refused(outcome: Outcome) -> None:
assert outcome.status == 402, (outcome.status, outcome.detail)
assert PAYMENT_REQUIRED in outcome.detail, outcome.detail
def assert_served(outcomes: Sequence[Outcome], identity: Callable[[str], str]) -> None:
assert all(outcome.status == 200 for outcome in outcomes), [(o.status, o.detail[:200]) for o in outcomes]
assert all(identity(outcome.marker) in outcome.detail for outcome in outcomes), [o.detail[:200] for o in outcomes]
def chat_identity(marker: str) -> str:
return f"chatcmpl-{marker}"
def served_text(marker: str) -> str:
return f"served {marker}"
def response_identity(marker: str) -> str:
return f"resp_{marker}"
def markers(outcomes: Sequence[Outcome]) -> tuple[str, ...]:
return tuple(outcome.marker for outcome in outcomes)
def failure_samples(gateway: Gateway, group: str) -> tuple[Sample, ...]:
return tuple(
sample
for sample in scrape(gateway)
if sample.name == PROXY_FAILURES and sample.labels.get("requested_model") == group
)
def cooled_down_samples(gateway: Gateway, deployment_id: str) -> tuple[Sample, ...]:
return tuple(
sample
for sample in scrape(gateway)
if sample.name == COOLED_DOWN and sample.labels.get("model_id") == deployment_id
)
def assert_payment_required_metrics(gateway: Gateway, group: str, deployment_id: str) -> None:
failures: Final = eventually(lambda: failure_samples(gateway, group), lambda found: len(found) >= 1, seconds=10)
assert [(sample.labels["exception_class"], sample.labels["exception_status"]) for sample in failures] == [
("Anthropic.PaymentRequiredError", "402")
], failures
cooldowns: Final = eventually(
lambda: cooled_down_samples(gateway, deployment_id), lambda found: len(found) >= 1, seconds=10
)
assert [sample.labels["exception_status"] for sample in cooldowns] == ["402"], cooldowns
def test_chat_openai_sdk_402_cools_the_deployment_and_the_healthy_one_serves_after(rig: CooldownRig) -> None:
group: Final = new_group()
with (
wire_server(refusing_reply) as refusing,
wire_server(healthy_reply) as healthy,
rig.gateway.scenario() as scenario,
):
refused_id: Final = two_deployments(scenario, group, refusing, healthy)
client: Final = openai_client(rig.gateway)
history: Final = until_refused(lambda marker: openai_chat(client, group, marker))
assert_refused(history[-1])
assert_served(history[:-1], chat_identity)
eventually(lambda: rig.cooled(refused_id), lambda seen: seen, seconds=10)
assert markers_received(refusing) == (history[-1].marker,)
assert markers_received(healthy) == markers(history[:-1])
served: Final = tuple(openai_chat(client, group, new_marker()) for _ in range(20))
assert_served(served, chat_identity)
assert markers_received(healthy) == markers(served)
assert markers_received(refusing) == ()
assert_payment_required_row(history[-1].call_id, refused_id)
assert_payment_required_metrics(rig.gateway, group, refused_id)
def test_messages_anthropic_sdk_402_cools_the_deployment_and_the_healthy_one_serves_after(rig: CooldownRig) -> None:
group: Final = new_group()
with (
wire_server(refusing_reply) as refusing,
wire_server(healthy_reply) as healthy,
rig.gateway.scenario() as scenario,
):
refused_id: Final = two_deployments(scenario, group, refusing, healthy)
client: Final = anthropic_client(rig.gateway)
history: Final = until_refused(lambda marker: anthropic_message(client, group, marker))
assert_refused(history[-1])
assert_served(history[:-1], served_text)
eventually(lambda: rig.cooled(refused_id), lambda seen: seen, seconds=10)
assert markers_received(refusing) == (history[-1].marker,)
assert markers_received(healthy) == markers(history[:-1])
served: Final = tuple(anthropic_message(client, group, new_marker()) for _ in range(20))
assert_served(served, served_text)
assert markers_received(healthy) == markers(served)
assert markers_received(refusing) == ()
assert_payment_required_row(history[-1].call_id, refused_id)
@pytest.mark.parametrize("stream", (False, True), ids=("json", "stream"))
def test_responses_httpx_402_cools_the_deployment_and_the_healthy_one_serves_after(
rig: CooldownRig, stream: bool
) -> None:
group: Final = new_group()
with (
wire_server(refusing_reply) as refusing,
wire_server(healthy_reply) as healthy,
rig.gateway.scenario() as scenario,
):
refused_id: Final = two_deployments(scenario, group, refusing, healthy)
def attempt(marker: str) -> Outcome:
return raw_call(
rig.gateway, Call(marker, "/v1/responses", {"model": group, "input": marker, "stream": stream})
)
history: Final = until_refused(attempt)
assert_refused(history[-1])
assert_served(history[:-1], response_identity)
eventually(lambda: rig.cooled(refused_id), lambda seen: seen, seconds=10)
assert markers_received(refusing) == (history[-1].marker,)
assert markers_received(healthy) == markers(history[:-1])
served: Final = tuple(attempt(new_marker()) for _ in range(20))
assert_served(served, response_identity)
assert markers_received(healthy) == markers(served)
assert markers_received(refusing) == ()
assert_payment_required_row(history[-1].call_id, refused_id)
@pytest.mark.parametrize(
"model_info",
({}, {"allowed_fails_policy": None}, {"allowed_fails_policy": {}}),
ids=("missing", "null", "empty"),
)
def test_a_lone_402_deployment_without_a_policy_is_never_cooled(
rig: CooldownRig, model_info: dict[str, JsonValue]
) -> None:
group: Final = new_group()
with wire_server(refusing_reply) as refusing, rig.gateway.scenario() as scenario:
refused_id: Final = deployment(scenario, group, anthropic_params(refusing), model_info=model_info)
refused: Final = tuple(raw_chat(rig.gateway, group, new_marker()) for _ in range(5))
for outcome in refused:
assert_refused(outcome)
assert_payment_required_row(outcome.call_id, refused_id)
assert not rig.cooled(refused_id)
sixth: Final = raw_chat(rig.gateway, group, new_marker())
assert_refused(sixth)
assert markers_received(refusing) == (*markers(refused), sixth.marker)
@pytest.mark.timeout(900)
def test_a_router_allowed_fails_policy_cools_a_lone_402_deployment_past_its_count(tmp_path: Path) -> None:
group: Final = new_group()
with ExitStack() as stack:
upstream: Final = stack.enter_context(gateway_from_environment())
cache: Final = stack.enter_context(owned_redis(tmp_path))
settings: Final = router_settings(cache, allowed_fails_policy={"BadRequestErrorAllowedFails": 1})
owned: Final = stack.enter_context(
owned_proxy_process(
upstream,
tmp_path,
{},
config=cooldown_config(tmp_path, settings),
remove_environment=("PROMETHEUS_MULTIPROC_DIR",),
)
)
refusing: Final = stack.enter_context(wire_server(refusing_reply))
scenario: Final = stack.enter_context(owned.gateway.scenario())
refused_id: Final = deployment(scenario, group, anthropic_params(refusing))
first: Final = raw_chat(owned.gateway, group, new_marker())
second: Final = raw_chat(owned.gateway, group, new_marker())
assert_refused(first)
assert_refused(second)
eventually(lambda: cooled(cache, refused_id), lambda seen: seen, seconds=10)
third: Final = raw_chat(owned.gateway, group, new_marker())
assert third.status == 429 and NO_DEPLOYMENTS in third.detail, (third.status, third.detail)
assert markers_received(refusing) == (first.marker, second.marker)
def test_a_deployment_allowed_fails_policy_cools_a_lone_402_deployment(rig: CooldownRig) -> None:
group: Final = new_group()
policy: Final = {"allowed_fails_policy": {"BadRequestErrorAllowedFails": 0}}
with wire_server(refusing_reply) as refusing, rig.gateway.scenario() as scenario:
refused_id: Final = deployment(scenario, group, anthropic_params(refusing), model_info=policy)
first: Final = raw_chat(rig.gateway, group, new_marker())
assert_refused(first)
eventually(lambda: rig.cooled(refused_id), lambda seen: seen, seconds=10)
second: Final = raw_chat(rig.gateway, group, new_marker())
assert second.status == 429 and NO_DEPLOYMENTS in second.detail, (second.status, second.detail)
assert markers_received(refusing) == (first.marker,)
def test_a_plain_allowed_fails_count_leaves_a_lone_402_deployment_warm(rig: CooldownRig) -> None:
group: Final = new_group()
with wire_server(refusing_reply) as refusing, rig.gateway.scenario() as scenario:
refused_id: Final = deployment(scenario, group, anthropic_params(refusing), model_info={"allowed_fails": 0})
refused: Final = tuple(raw_chat(rig.gateway, group, new_marker()) for _ in range(5))
for outcome in refused:
assert_refused(outcome)
assert not rig.cooled(refused_id)
sixth: Final = raw_chat(rig.gateway, group, new_marker())
assert_refused(sixth)
assert markers_received(refusing) == (*markers(refused), sixth.marker)
def test_an_openai_compatible_402_cools_the_deployment_too(rig: CooldownRig) -> None:
group: Final = new_group()
with (
wire_server(openai_refusal) as refusing,
wire_server(healthy_reply) as healthy,
rig.gateway.scenario() as scenario,
):
refused_id: Final = deployment(scenario, group, openai_params(refusing))
deployment(scenario, group, openai_params(healthy))
history: Final = until_refused(lambda marker: raw_chat(rig.gateway, group, marker))
assert history[-1].status == 402 and "scripted 402" in history[-1].detail, history[-1]
assert_served(history[:-1], chat_identity)
eventually(lambda: rig.cooled(refused_id), lambda seen: seen, seconds=10)
assert markers_received(refusing) == (history[-1].marker,)
served: Final = tuple(raw_chat(rig.gateway, group, new_marker()) for _ in range(20))
assert_served(served, chat_identity)
assert markers_received(refusing) == ()
assert markers_received(healthy) == (*markers(history[:-1]), *markers(served))
def test_a_lone_402_primary_with_a_fallback_group_stays_warm_and_the_fallback_answers(rig: CooldownRig) -> None:
with (
wire_server(refusing_reply) as refusing,
wire_server(healthy_reply) as healthy,
rig.gateway.scenario() as scenario,
):
primary_id: Final = deployment(scenario, PRIMARY_GROUP, anthropic_params(refusing))
deployment(scenario, FALLBACK_GROUP, openai_params(healthy))
served: Final = tuple(raw_chat(rig.gateway, PRIMARY_GROUP, new_marker()) for _ in range(3))
assert_served(served, chat_identity)
assert markers_received(refusing) == markers(served)
assert markers_received(healthy) == markers(served)
assert not rig.cooled(primary_id)
def test_a_deployment_policy_added_while_402_traffic_flows_starts_the_cooldown(rig: CooldownRig) -> None:
group: Final = new_group()
with wire_server(refusing_reply) as refusing, rig.gateway.scenario() as scenario:
refused_id: Final = deployment(scenario, group, anthropic_params(refusing))
before: Final = tuple(raw_chat(rig.gateway, group, new_marker()) for _ in range(3))
for outcome in before:
assert_refused(outcome)
assert not rig.cooled(refused_id)
policy: Final = {"id": refused_id, "allowed_fails_policy": {"BadRequestErrorAllowedFails": 0}}
patched: Final = rig.gateway.request("PATCH", f"/model/{refused_id}/update", {"model_info": policy})
assert patched.status_code == 200, patched.text
def probe() -> tuple[int, bool]:
return raw_chat(rig.gateway, group, new_marker()).status, rig.cooled(refused_id)
eventually(probe, lambda seen: seen[1], seconds=RELOAD_SECONDS * 10)
after: Final = raw_chat(rig.gateway, group, new_marker())
assert after.status == 429 and NO_DEPLOYMENTS in after.detail, (after.status, after.detail)
assert len(markers_received(refusing)) >= 4
def test_a_cooled_402_deployment_comes_back_after_its_own_cooldown_time(rig: CooldownRig) -> None:
group: Final = new_group()
with (
wire_server(refusing_reply) as refusing,
wire_server(healthy_reply) as healthy,
rig.gateway.scenario() as scenario,
):
refused_id: Final = deployment(scenario, group, {**anthropic_params(refusing), "cooldown_time": 2})
deployment(scenario, group, openai_params(healthy))
history: Final = until_refused(lambda marker: raw_chat(rig.gateway, group, marker))
assert_refused(history[-1])
eventually(lambda: rig.cooled(refused_id), lambda seen: seen, seconds=10)
eventually(lambda: rig.cooled(refused_id), lambda seen: not seen, seconds=10)
again: Final = until_refused(lambda marker: raw_chat(rig.gateway, group, marker))
assert_refused(again[-1])
assert markers_received(refusing) == (history[-1].marker, again[-1].marker)
def burst_call(group: str, index: int) -> Call:
marker: Final = f"{new_marker()}-{index}"
stream: Final = index % 2 == 1
match index % 3:
case 0:
return Call(marker, "/v1/chat/completions", chat_body(group, marker, stream=stream))
case 1:
body: Final = {"model": group, "max_tokens": 32, "messages": [{"role": "user", "content": marker}]}
return Call(marker, "/v1/messages", {**body, "stream": stream})
case _:
return Call(marker, "/v1/responses", {"model": group, "input": marker, "stream": stream})
def worker_pids(log: Path) -> tuple[int, ...]:
return tuple(int(pid) for pid in WORKER_PID.findall(log.read_text()))
def ready_workers(log: Path) -> int:
return log.read_text().count("Application startup complete.")
@pytest.mark.timeout(900)
def test_chaos_burst_cooldown_holds_across_both_workers_and_a_worker_kill(tmp_path: Path) -> None:
group: Final = new_group()
refused_id: Final = f"refusing-{uuid.uuid4().hex[:12]}"
healthy_id: Final = f"healthy-{uuid.uuid4().hex[:12]}"
with ExitStack() as stack:
upstream: Final = stack.enter_context(gateway_from_environment())
cache: Final = stack.enter_context(owned_redis(tmp_path))
refusing: Final = stack.enter_context(wire_server(refusing_reply))
healthy: Final = stack.enter_context(wire_server(healthy_reply))
model_list: Final = (
{"model_name": group, "litellm_params": anthropic_params(refusing), "model_info": {"id": refused_id}},
{"model_name": group, "litellm_params": openai_params(healthy), "model_info": {"id": healthy_id}},
)
config: Final = cooldown_config(tmp_path, router_settings(cache), model_list=model_list, prometheus=False)
owned: Final = stack.enter_context(owned_proxy_process(upstream, tmp_path, {}, config=config, workers=2))
eventually(lambda: ready_workers(owned.log), lambda ready: ready == 2, seconds=graceful_stop_seconds())
client: Final = stack.enter_context(
httpx.Client(base_url=owned.gateway.client.base_url, timeout=60, trust_env=False, limits=GATEWAY_LIMITS)
)
patient: Final = Gateway(client, owned.gateway.key, owned.gateway.upstream_url)
calls: Final = tuple(burst_call(group, index) for index in range(30))
with ThreadPoolExecutor(max_workers=30) as pool:
outcomes: Final = tuple(pool.map(lambda call: raw_call(patient, call), calls))
assert {outcome.status for outcome in outcomes} <= {200, 402}, [(o.status, o.detail[:200]) for o in outcomes]
refused: Final = tuple(outcome for outcome in outcomes if outcome.status == 402)
served: Final = tuple(outcome for outcome in outcomes if outcome.status == 200)
assert refused, "the burst never reached the refusing deployment"
for outcome in refused:
assert_refused(outcome)
assert all(outcome.marker in outcome.detail for outcome in served), [o.detail[:200] for o in served]
assert sorted(markers_received(refusing)) == sorted(markers(refused))
assert sorted(markers_received(healthy)) == sorted(markers(served))
eventually(lambda: cooled(cache, refused_id), lambda seen: seen, seconds=10)
warm: Final = tuple(raw_chat(patient, group, new_marker()) for _ in range(20))
assert_served(warm, chat_identity)
assert markers_received(refusing) == ()
assert markers_received(healthy) == markers(warm)
victim: Final = next(pid for pid in sorted(worker_pids(owned.log)) if psutil.pid_exists(pid))
ready_before: Final = ready_workers(owned.log)
psutil.Process(victim).send_signal(signal.SIGKILL)
eventually(
lambda: ready_workers(owned.log), lambda ready: ready > ready_before, seconds=graceful_stop_seconds()
)
after_kill: Final = tuple(raw_chat(patient, group, new_marker()) for _ in range(20))
assert_served(after_kill, chat_identity)
assert markers_received(refusing) == ()
assert markers_received(healthy) == markers(after_kill)

View file

@ -0,0 +1,387 @@
from __future__ import annotations
import asyncio
import json
import os
import uuid
from collections.abc import Callable, Mapping
from typing import Final, TypeVar
import anthropic
import httpx
import openai
import pytest
from integration._support.client import Gateway, Scenario, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.openai_wire import chat_reply, openai_error
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
from litellm.constants import PROXY_CONFIG_RELOAD_INTERVAL_SECONDS
T = TypeVar("T")
PROXY_WORKERS: Final = int(os.environ.get("INTEGRATION_PROXY_WORKERS", "1"))
WORKER_SYNC_SECONDS: Final = 0.0 if PROXY_WORKERS == 1 else PROXY_CONFIG_RELOAD_INTERVAL_SECONDS + 5.0
FRESH_CONNECTION: Final = {"Connection": "close"}
UNKNOWN_MODEL: Final = "Invalid model name passed in model="
PAYMENT_REQUIRED: Final = "litellm.PaymentRequiredError: AnthropicException"
BAD_REQUEST: Final = "litellm.BadRequestError: AnthropicException"
ANTHROPIC_MODEL: Final = "anthropic/claude-sonnet-5"
OPENAI_MODEL: Final = "openai/gpt-5.4"
CREDIT_BALANCE_TEXT: Final = "Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits."
FAILURE_ROW: Final = (
"SELECT status, model_id, metadata::jsonb -> 'error_information' AS error "
'FROM "LiteLLM_SpendLogs" WHERE request_id = %s'
)
MODEL_INFO_PROBE: Final = ("GET", "/v1/models")
def is_model_info_probe(request: Request) -> bool:
return (request.method, request.target) == MODEL_INFO_PROBE
def model_list_reply() -> Reply:
return Reply(body=b'{"object": "list", "data": []}')
def openai_refusal(request: Request) -> Reply:
return model_list_reply() if is_model_info_probe(request) else openai_error(402)
def provider_calls(wire: Wire) -> tuple[Request, ...]:
return tuple(request for request in wire.drain() if not is_model_info_probe(request))
def new_marker() -> str:
return f"pr402-{uuid.uuid4().hex}"
def new_group() -> str:
return f"integration-402-{uuid.uuid4().hex[:12]}"
def anthropic_error(status: int, message: str, *, content_type: str = "application/json") -> Reply:
body: Final = json.dumps({"type": "error", "error": {"type": "invalid_request_error", "message": message}})
return Reply(status=status, body=body.encode(), content_type=content_type)
def anthropic_params(wire: Wire) -> dict[str, JsonValue]:
return {"model": ANTHROPIC_MODEL, "api_base": wire.url, "api_key": "synthetic-anthropic-key"}
def openai_params(wire: Wire) -> dict[str, JsonValue]:
return {"model": OPENAI_MODEL, "api_base": wire.url + "/v1", "api_key": "synthetic-openai-key"}
def deployment(
scenario: Scenario,
group: str,
litellm_params: Mapping[str, JsonValue],
*,
model_info: Mapping[str, JsonValue] | None = None,
) -> str:
created: Final = scenario.gateway.post(
"/model/new",
{
"model_name": group,
"litellm_params": dict(litellm_params),
"model_info": dict(model_info) if model_info is not None else {},
},
)
identity: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(scenario.delete_model, identity)
return identity
def settled(send: Callable[[], T], text: Callable[[T], str]) -> T:
return eventually(send, lambda observed: UNKNOWN_MODEL not in text(observed), seconds=WORKER_SYNC_SECONDS + 10)
def post(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> httpx.Response:
return settled(
lambda: gateway.request("POST", path, body, headers=FRESH_CONNECTION), lambda response: response.text
)
def chat_body(group: str, marker: str, **extra: JsonValue) -> dict[str, JsonValue]:
return {"model": group, "messages": [{"role": "user", "content": marker}], **extra}
def upstream_calls(wire: Wire, marker: str) -> tuple[str, ...]:
received: Final = provider_calls(wire)
assert all(marker.encode() in request.body for request in received), received
return tuple(f"{request.method} {request.target}" for request in received)
def failure_row(call_id: str) -> dict[str, JsonValue]:
rows: Final = eventually(lambda: read_rows(FAILURE_ROW, (call_id,)), lambda found: len(found) == 1, seconds=30)
return rows[0]
def assert_payment_required_row(call_id: str, deployment_id: str) -> None:
row: Final = failure_row(call_id)
error: Final = object_value(row["error"])
assert row["status"] == "failure", row
assert row["model_id"] == deployment_id, row
assert (error["error_class"], error["error_code"], error["llm_provider"]) == (
"PaymentRequiredError",
"402",
"anthropic",
), row
def openai_client(gateway: Gateway) -> openai.OpenAI:
return openai.OpenAI(
base_url=str(gateway.client.base_url).rstrip("/") + "/v1",
api_key=gateway.key,
max_retries=0,
default_headers=FRESH_CONNECTION,
)
def anthropic_client(gateway: Gateway) -> anthropic.Anthropic:
return anthropic.Anthropic(
base_url=str(gateway.client.base_url).rstrip("/"),
api_key=gateway.key,
max_retries=0,
default_headers=FRESH_CONNECTION,
)
def test_chat_completions_openai_sdk_gets_402_payment_required(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
client: Final = openai_client(gateway)
def send() -> openai.APIStatusError:
with pytest.raises(openai.APIStatusError) as caught:
client.chat.completions.create(model=group, messages=[{"role": "user", "content": marker}])
return caught.value
error: Final = settled(send, lambda failure: failure.message)
assert error.status_code == 402, error.message
assert PAYMENT_REQUIRED in error.message, error.message
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(error.response.headers["x-litellm-call-id"], deployment_id)
async def drain_chat_stream(client: openai.AsyncOpenAI, group: str, marker: str) -> None:
stream: Final = await client.chat.completions.create(
model=group, messages=[{"role": "user", "content": marker}], stream=True
)
async for _ in stream:
pass
async def drain_messages_stream(client: anthropic.AsyncAnthropic, group: str, marker: str) -> None:
async with client.messages.stream(
model=group, max_tokens=32, messages=[{"role": "user", "content": marker}]
) as stream:
async for _ in stream:
pass
def test_chat_completions_stream_openai_async_sdk_gets_402_payment_required(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
async def attempt() -> openai.APIStatusError:
async with openai.AsyncOpenAI(
base_url=str(gateway.client.base_url).rstrip("/") + "/v1",
api_key=gateway.key,
max_retries=0,
default_headers=FRESH_CONNECTION,
) as client:
with pytest.raises(openai.APIStatusError) as caught:
await drain_chat_stream(client, group, marker)
return caught.value
error: Final = settled(lambda: asyncio.run(attempt()), lambda failure: failure.message)
assert error.status_code == 402, error.message
assert PAYMENT_REQUIRED in error.message, error.message
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(error.response.headers["x-litellm-call-id"], deployment_id)
def test_messages_anthropic_sdk_gets_402_payment_required(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
client: Final = anthropic_client(gateway)
def send() -> anthropic.APIStatusError:
with pytest.raises(anthropic.APIStatusError) as caught:
client.messages.create(model=group, max_tokens=32, messages=[{"role": "user", "content": marker}])
return caught.value
error: Final = settled(send, lambda failure: failure.message)
assert error.status_code == 402, error.message
assert PAYMENT_REQUIRED in error.message, error.message
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(error.response.headers["x-litellm-call-id"], deployment_id)
def test_messages_stream_anthropic_async_sdk_gets_402_payment_required(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
async def attempt() -> anthropic.APIStatusError:
async with anthropic.AsyncAnthropic(
base_url=str(gateway.client.base_url).rstrip("/"),
api_key=gateway.key,
max_retries=0,
default_headers=FRESH_CONNECTION,
) as client:
with pytest.raises(anthropic.APIStatusError) as caught:
await drain_messages_stream(client, group, marker)
return caught.value
error: Final = settled(lambda: asyncio.run(attempt()), lambda failure: failure.message)
assert error.status_code == 402, error.message
assert PAYMENT_REQUIRED in error.message, error.message
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(error.response.headers["x-litellm-call-id"], deployment_id)
@pytest.mark.parametrize("stream", (False, True), ids=("json", "stream"))
def test_responses_httpx_gets_402_payment_required(gateway: Gateway, stream: bool) -> None:
marker: Final = new_marker()
with wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
response: Final = post(gateway, "/v1/responses", {"model": group, "input": marker, "stream": stream})
assert response.status_code == 402, response.text
assert PAYMENT_REQUIRED in response.text, response.text
assert response.headers["content-type"].startswith("application/json"), response.headers
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(response.headers["x-litellm-call-id"], deployment_id)
def test_402_with_a_text_body_still_maps_to_payment_required(gateway: Gateway) -> None:
marker: Final = new_marker()
text: Final = f"credits exhausted {marker}"
with (
wire_server(lambda _: Reply(status=402, body=text.encode(), content_type="text/plain")) as wire,
gateway.scenario() as scenario,
):
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
response: Final = post(gateway, "/v1/chat/completions", chat_body(group, marker))
assert response.status_code == 402, response.text
assert PAYMENT_REQUIRED in response.text, response.text
assert text in response.text, response.text
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(response.headers["x-litellm-call-id"], deployment_id)
def test_402_with_an_empty_body_still_maps_to_payment_required(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(lambda _: Reply(status=402, body=b"")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
response: Final = post(gateway, "/v1/chat/completions", chat_body(group, marker))
assert response.status_code == 402, response.text
assert PAYMENT_REQUIRED in response.text, response.text
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
assert_payment_required_row(response.headers["x-litellm-call-id"], deployment_id)
def test_402_with_a_5kb_message_maps_and_a_healthy_deployment_keeps_serving(gateway: Gateway) -> None:
marker: Final = new_marker()
long_message: Final = f"{marker} " + "x" * 5120
with (
wire_server(lambda _: anthropic_error(402, long_message)) as broke,
wire_server(
lambda _: chat_reply(f"chatcmpl-{marker}", OPENAI_MODEL, f"served {marker}", stream=False)
) as healthy,
gateway.scenario() as scenario,
):
broke_group: Final = new_group()
healthy_group: Final = new_group()
deployment_id: Final = deployment(scenario, broke_group, anthropic_params(broke))
deployment(scenario, healthy_group, openai_params(healthy))
refused: Final = post(gateway, "/v1/chat/completions", chat_body(broke_group, marker))
assert refused.status_code == 402, refused.text
assert PAYMENT_REQUIRED in refused.text, refused.text
assert "x" * 512 in refused.text, refused.text
assert upstream_calls(broke, marker) == ("POST /v1/messages",)
assert_payment_required_row(refused.headers["x-litellm-call-id"], deployment_id)
served: Final = post(gateway, "/v1/chat/completions", chat_body(healthy_group, f"{marker} healthy"))
assert served.status_code == 200, served.text
assert served.json()["choices"][0]["message"]["content"] == f"served {marker}", served.text
assert upstream_calls(healthy, marker) == ("POST /v1/chat/completions",)
def test_unauthenticated_request_to_a_402_deployment_is_refused_before_the_wire(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment(scenario, group, anthropic_params(wire))
response: Final = gateway.client.post("/v1/chat/completions", json=chat_body(group, marker))
assert response.status_code == 401, response.text
assert provider_calls(wire) == ()
def test_anthropic_400_credit_balance_text_stays_a_bad_request(gateway: Gateway) -> None:
marker: Final = new_marker()
with (
wire_server(lambda _: anthropic_error(400, f"{CREDIT_BALANCE_TEXT} {marker}")) as wire,
gateway.scenario() as scenario,
):
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, anthropic_params(wire))
response: Final = post(gateway, "/v1/chat/completions", chat_body(group, marker))
assert response.status_code == 400, response.text
assert BAD_REQUEST in response.text, response.text
assert upstream_calls(wire, marker) == ("POST /v1/messages",)
row: Final = failure_row(response.headers["x-litellm-call-id"])
error: Final = object_value(row["error"])
assert (row["status"], row["model_id"]) == ("failure", deployment_id), row
assert (error["error_class"], error["error_code"]) == ("BadRequestError", "400"), row
def test_openai_compatible_402_reaches_the_caller_as_402(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(openai_refusal) as wire, gateway.scenario() as scenario:
group: Final = new_group()
deployment_id: Final = deployment(scenario, group, openai_params(wire))
response: Final = post(gateway, "/v1/chat/completions", chat_body(group, marker))
assert response.status_code == 402, response.text
assert "scripted 402" in response.text, response.text
assert upstream_calls(wire, marker) == ("POST /v1/chat/completions",)
row: Final = failure_row(response.headers["x-litellm-call-id"])
error: Final = object_value(row["error"])
assert (row["status"], row["model_id"]) == ("failure", deployment_id), row
assert (error["error_code"], error["llm_provider"]) == ("402", "openai"), row
def test_disabled_cooldowns_keep_picking_the_402_deployment(gateway: Gateway) -> None:
marker: Final = new_marker()
with (
wire_server(lambda _: anthropic_error(402, f"scripted 402 {marker}")) as broke,
wire_server(
lambda _: chat_reply(f"chatcmpl-{marker}", OPENAI_MODEL, f"served {marker}", stream=False)
) as healthy,
gateway.scenario() as scenario,
):
group: Final = new_group()
deployment(scenario, group, anthropic_params(broke))
deployment(scenario, group, openai_params(healthy))
responses: Final = tuple(
post(gateway, "/v1/chat/completions", chat_body(group, f"{marker}-{index}")) for index in range(40)
)
statuses: Final = tuple(response.status_code for response in responses)
assert statuses.count(402) >= 2 and statuses.count(200) >= 1, statuses
assert set(statuses) == {200, 402}, statuses
refusals: Final = tuple(response for response in responses if response.status_code == 402)
assert all(PAYMENT_REQUIRED in response.text for response in refusals), [r.text for r in refusals]
assert len(upstream_calls(broke, marker)) == len(refusals)
assert len(upstream_calls(healthy, marker)) == statuses.count(200)

View file

@ -773,6 +773,14 @@ class TestRouterLevelGetAllowedFailsFromPolicy:
exc = litellm.NotFoundError("404 error", "openai", "gpt-4")
assert router.get_allowed_fails_from_policy(exc) == 1
def test_payment_required_error_uses_bad_request_allowed_fails(self):
assert (
self._make_router(BadRequestErrorAllowedFails=6).get_allowed_fails_from_policy(
litellm.PaymentRequiredError("402 error", "openai", "gpt-4")
)
== 6
)
def test_unmatched_exception_returns_none(self):
router = self._make_router(InternalServerErrorAllowedFails=5)
exc = litellm.RateLimitError("429", "openai", "gpt-4")

View file

@ -265,6 +265,114 @@ def test_should_cooldown_deployment_auth_limit_error(testing_litellm_router):
)
@pytest.mark.parametrize("exception_status", (401, 402))
def test_is_cooldown_required_for_account_errors(testing_litellm_router, exception_status):
assert (
_is_cooldown_required(
litellm_router_instance=testing_litellm_router,
model_id="test_deployment",
exception_status=exception_status,
)
is True
)
@pytest.mark.parametrize("allowed_fails", (None, 0))
def test_single_deployment_402_does_not_cooldown(
allowed_fails: int | None,
) -> None:
assert (
_should_cooldown_deployment(
Router(
model_list=[
{
"model_name": "gpt-5-mini",
"litellm_params": {"model": "gpt-5-mini"},
"model_info": {"id": "dep-1"},
},
],
allowed_fails=allowed_fails,
),
"dep-1",
402,
litellm.PaymentRequiredError(
message="Insufficient credits",
model="gpt-5-mini",
llm_provider="openai",
),
)
is False
)
def test_single_deployment_402_respects_router_allowed_fails_policy() -> None:
assert (
_should_cooldown_deployment(
Router(
model_list=[
{
"model_name": "gpt-5-mini",
"litellm_params": {"model": "gpt-5-mini"},
"model_info": {"id": "dep-1"},
},
],
allowed_fails_policy=AllowedFailsPolicy(BadRequestErrorAllowedFails=0),
),
"dep-1",
402,
litellm.PaymentRequiredError(
message="Insufficient credits",
model="gpt-5-mini",
llm_provider="openai",
),
)
is True
)
def test_single_deployment_402_respects_deployment_allowed_fails_policy() -> None:
assert (
_should_cooldown_deployment(
Router(
model_list=[
{
"model_name": "gpt-5-mini",
"litellm_params": {"model": "gpt-5-mini"},
"model_info": {
"id": "dep-1",
"allowed_fails_policy": {"BadRequestErrorAllowedFails": 0},
},
},
],
),
"dep-1",
402,
litellm.PaymentRequiredError(
message="Insufficient credits",
model="gpt-5-mini",
llm_provider="openai",
),
)
is True
)
def test_multi_deployment_402_cools_down(testing_litellm_router: Router) -> None:
assert (
_should_cooldown_deployment(
testing_litellm_router,
"test_deployment",
402,
litellm.PaymentRequiredError(
message="Insufficient credits",
model="gpt-5-mini",
llm_provider="openai",
),
)
is True
)
@pytest.mark.asyncio
async def test_should_cooldown_deployment(testing_litellm_router):
"""

View file

@ -745,11 +745,12 @@ class _UpstreamHTTPError(Exception):
self.response = httpx.Response(status_code=status_code, request=self.request, text="upstream failure")
UPSTREAM_STATUS_CODES = (400, 401, 403, 404, 408, 422, 429, 500, 503)
UPSTREAM_STATUS_CODES = (400, 401, 402, 403, 404, 408, 422, 429, 500, 503)
OPENAI_SHAPED = {
400: (litellm.BadRequestError, 400),
401: (litellm.AuthenticationError, 401),
402: (litellm.PaymentRequiredError, 402),
403: (litellm.APIError, 403),
404: (litellm.NotFoundError, 404),
408: (litellm.Timeout, 408),
@ -764,8 +765,10 @@ PERMISSION_DENIED = (litellm.PermissionDeniedError, 403)
STATUS_KEYED = {**OPENAI_SHAPED, 403: PERMISSION_DENIED}
DEVIATIONS_FROM_THE_OPENAI_SHAPE = {
"ai21": {402: (litellm.APIError, 402)},
"anthropic": {403: PERMISSION_DENIED},
"azure": {500: (litellm.APIError, 500)},
"azure": {402: (litellm.APIError, 402), 500: (litellm.APIError, 500)},
"azure_ai": {402: (litellm.APIError, 402)},
"bedrock": {
403: PERMISSION_DENIED,
500: (litellm.ServiceUnavailableError, 503),
@ -776,13 +779,19 @@ DEVIATIONS_FROM_THE_OPENAI_SHAPE = {
403: PERMISSION_DENIED,
422: (litellm.BadRequestError, 400),
},
"deepseek": {402: (litellm.APIError, 402)},
"fireworks_ai": {402: (litellm.APIError, 402)},
"gemini": {403: PERMISSION_DENIED},
"groq": {402: (litellm.APIError, 402)},
"huggingface": {
402: (litellm.APIError, 402),
404: (litellm.APIError, 404),
422: (litellm.APIError, 422),
500: (litellm.APIError, 500),
},
"mistral": {402: (litellm.APIError, 402)},
"nlp_cloud": {
402: (litellm.RateLimitError, 429),
403: (litellm.AuthenticationError, 403),
404: (litellm.APIError, 404),
408: (litellm.APIError, 408),
@ -790,20 +799,26 @@ DEVIATIONS_FROM_THE_OPENAI_SHAPE = {
503: (litellm.APIError, 503),
},
"ollama": {403: PERMISSION_DENIED},
"openrouter": {500: (litellm.APIError, 500)},
"openai": {402: (litellm.APIError, 402)},
"openrouter": {402: (litellm.APIError, 402), 500: (litellm.APIError, 500)},
"perplexity": {402: (litellm.APIError, 402)},
"replicate": {
403: (litellm.APIError, 500),
404: (litellm.APIError, 500),
402: (litellm.APIError, 500),
422: (litellm.UnprocessableEntityError, 422),
500: (litellm.ServiceUnavailableError, 503),
503: (litellm.APIError, 500),
},
"runwayml": {402: (litellm.APIError, 402)},
"sagemaker": {
403: PERMISSION_DENIED,
500: (litellm.ServiceUnavailableError, 503),
},
"together_ai": {402: (litellm.APIError, 402)},
"vertex_ai": {403: PERMISSION_DENIED},
"vllm": {403: PERMISSION_DENIED},
"xai": {402: (litellm.APIError, 402)},
}
PROVIDERS_WITH_A_HANDLER = (
@ -936,6 +951,16 @@ def test_a_provider_without_a_handler_maps_by_the_upstream_status(provider, stat
assert raised.value.status_code == expected_status
assert raised.value.llm_provider == provider
assert raised.value.model == "test-model"
if status_code == 402:
assert isinstance(raised.value, litellm.BadRequestError)
assert raised.value.response.status_code == 402
assert str(raised.value).startswith("litellm.PaymentRequiredError: ")
assert "litellm.BadRequestError:" not in str(raised.value)
assert repr(raised.value) == str(raised.value)
def test_payment_required_error_without_response_uses_402_response() -> None:
assert litellm.PaymentRequiredError("x", "m", "p").response.status_code == 402
def test_a_minimax_bad_key_is_an_authentication_error(quiet_exception_mapping):

View file

@ -79,6 +79,16 @@ def test_subclass_falls_back_to_the_parent_field():
)
def test_payment_required_error_uses_bad_request_retries():
assert (
get_num_retries_from_retry_policy(
exception=_error(litellm.PaymentRequiredError),
retry_policy=RetryPolicy(BadRequestErrorRetries=0, DefaultRetries=3),
)
== 0
)
@pytest.mark.parametrize("exception_type", (litellm.BadGatewayError,))
def test_default_retries_covers_exceptions_without_a_specific_field(exception_type: type[Exception]):
exception: Final = _error(exception_type)