litellm/tests/unit/router_utils/test_cooldown_handlers.py
yucheng-berri 3e18a4b55d
fix(router): record cooled deployments on the all-cooled failure row (#47120)
* fix(router): record cooled deployments on the all-cooled failure row

A RouterRateLimitError now carries the cooled deployments of the route the
request resolves to for the caller's team (model group, alias, wildcard or
team route), each with its masked api_base. get_error_information copies them
into error_information.error_cooldown_deployments so the SpendLogs failure row
and every logging callback can tell which deployments were cooling

* fix(router): name cooled deployments from the router's own resolved route

Use the deployments the router resolved for the request, before health and
cooldown filtering, instead of re-deriving the route. Alias chains, the
provider-model fallback, wildcards and team filtering now match routing by
construction. A routing-strategy refusal records null.

* fix(router): name only the cooled deployments tag and region routing allow

The refusal's cooled-deployment list now leaves out deployments the
request's tags or the end user's allowed region keep it from, so a
tenant's refusal row no longer shows another tenant's api_base

* fix(router): keep failure logging working for refusal subclasses that skip init

A RouterRateLimitError subclass that never calls its parent init now logs a null cooled-deployment list instead of breaking the failure payload

* test(router): call the refusal routing helpers directly

The router coverage check needs each router function called by name from a unit test

---------

Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-19 13:18:20 -07:00

3365 lines
133 KiB
Python

import asyncio
import copy
import importlib
import itertools
import random
import time
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
import litellm
from litellm import Router
from litellm._internal_context import current_service_target
from litellm.caching.dual_cache import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials
from litellm.router_utils.cooldown_cache import CooldownCache, CooldownCacheValue
from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callback
from litellm.router_utils.cooldown_handlers import (
_get_deployment_cooldown_policy,
_has_explicit_allowed_fails_policy_for_exception,
_increment_allowed_fails,
_is_cooldown_required,
_resolve_allowed_fails_from_policy,
_should_cooldown_based_on_deployment_policy,
_should_cooldown_deployment,
_should_run_cooldown_logic,
async_get_cooldown_deployments,
cast_exception_status_to_int,
get_cooldown_deployments,
mark_advisor_orchestration_failure,
should_cooldown_based_on_allowed_fails_policy,
)
from litellm.router_utils.fallback_event_handlers import (
_trigger_cooldown_for_failed_deployment,
)
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
get_deployment_failures_for_current_minute,
increment_deployment_failures_for_current_minute,
increment_deployment_successes_for_current_minute,
)
from litellm.types.router import (
AllowedFailsPolicy,
DeploymentTypedDict,
LiteLLMParamsTypedDict,
RouterRateLimitError,
)
from litellm.types.utils import CooldownDeploymentInfo, CredentialItem
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
class TestGetDeploymentCooldownPolicy:
def _make_router(self, deployment_id: str, model_info: dict | None = None):
router = MagicMock()
if model_info is None:
router.get_model_info.return_value = None
else:
router.get_model_info.return_value = {"model_info": model_info}
return router
def test_deployment_not_found_returns_none_none(self):
router = self._make_router("dep-1")
policy, allowed = _get_deployment_cooldown_policy(router, "dep-1")
assert policy is None
assert allowed is None
def test_no_model_info_returns_none_none(self):
router = MagicMock()
router.get_model_info.return_value = {"model_info": {}}
policy, allowed = _get_deployment_cooldown_policy(router, "dep-1")
assert policy is None
assert allowed is None
def test_returns_policy_dict_and_allowed_fails(self):
router = self._make_router(
"dep-1",
{"allowed_fails_policy": {"RateLimitErrorAllowedFails": 2}, "allowed_fails": 3},
)
policy, allowed = _get_deployment_cooldown_policy(router, "dep-1")
assert policy == {"RateLimitErrorAllowedFails": 2}
assert allowed == 3
def test_non_dict_policy_treated_as_none(self):
router = self._make_router("dep-1", {"allowed_fails_policy": "invalid", "allowed_fails": 5})
policy, allowed = _get_deployment_cooldown_policy(router, "dep-1")
assert policy is None
assert allowed == 5
def test_allowed_fails_only(self):
router = self._make_router("dep-1", {"allowed_fails": 1})
policy, allowed = _get_deployment_cooldown_policy(router, "dep-1")
assert policy is None
assert allowed == 1
class TestResolveAllowedFailsFromPolicy:
def test_none_policy_returns_none(self):
exc = litellm.RateLimitError("429", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(None, exc) is None
def test_matching_rate_limit_error(self):
policy = {"RateLimitErrorAllowedFails": 3}
exc = litellm.RateLimitError("429", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) == 3
def test_matching_internal_server_error(self):
policy = {"InternalServerErrorAllowedFails": 5}
exc = litellm.InternalServerError("500", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) == 5
def test_matching_service_unavailable_error(self):
policy = {"ServiceUnavailableErrorAllowedFails": 4}
exc = litellm.ServiceUnavailableError("503", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) == 4
def test_matching_bad_gateway_error(self):
policy = {"BadGatewayErrorAllowedFails": 2}
exc = litellm.BadGatewayError("502", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) == 2
def test_matching_not_found_error(self):
policy = {"NotFoundErrorAllowedFails": 1}
exc = litellm.NotFoundError("404", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) == 1
def test_unmatched_exception_returns_none(self):
policy = {"RateLimitErrorAllowedFails": 3}
exc = litellm.InternalServerError("500", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) is None
def test_field_absent_from_policy_returns_none(self):
policy: dict[str, int] = {}
exc = litellm.InternalServerError("500", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) is None
def test_content_policy_violation_not_shadowed_by_bad_request_error(self):
"""ContentPolicyViolationError subclasses BadRequestError, so if
BadRequestError were checked first, this would incorrectly resolve to
BadRequestErrorAllowedFails (10) instead of
ContentPolicyViolationErrorAllowedFails (2)."""
policy = {"BadRequestErrorAllowedFails": 10, "ContentPolicyViolationErrorAllowedFails": 2}
exc = litellm.ContentPolicyViolationError("flagged content", "openai", "gpt-4")
assert _resolve_allowed_fails_from_policy(policy, exc) == 2
class TestShouldCooldownBasedOnDeploymentPolicy:
def _make_router(self, model_info: dict | None = None):
router = MagicMock()
if model_info is None:
router.get_model_info.return_value = None
else:
router.get_model_info.return_value = model_info
return router
def test_policy_match_uses_exception_type_as_cache_key_suffix(self):
policy = {"RateLimitErrorAllowedFails": 0}
exc = litellm.RateLimitError("429", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = True
result = _should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, policy, None, is_single_deployment_model_group=False
)
assert result is True
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["allowed_fails_override"] == 0
assert call_kwargs["cache_key_suffix"] == "RateLimitError"
def test_no_policy_match_uses_dep_allowed_fails_and_generic_suffix(self):
policy: dict[str, int] = {}
exc = litellm.InternalServerError("500", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = False
result = _should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, policy, dep_allowed_fails=3, is_single_deployment_model_group=False
)
assert result is False
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["allowed_fails_override"] == 3
assert call_kwargs["cache_key_suffix"] == "generic"
def test_dep_allowed_fails_on_single_deployment_group_does_not_cooldown(self):
"""A generic, deployment-wide allowed_fails predates the per-exception-type
policy and is a less deliberate opt-in, so on a single-deployment model group
it must not silently disable the "avoid cooldowns on single deployment model
groups" safety net."""
exc = litellm.InternalServerError("500", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
result = _should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, None, dep_allowed_fails=3, is_single_deployment_model_group=True
)
assert result is False
mock_sc.assert_not_called()
def test_named_policy_on_single_deployment_group_still_cools_down(self):
"""Unlike a generic allowed_fails, an explicit per-exception-type policy entry
is a deliberate opt-in and must still apply on a single-deployment group."""
policy = {"RateLimitErrorAllowedFails": 0}
exc = litellm.RateLimitError("429", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = True
result = _should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, policy, None, is_single_deployment_model_group=True
)
assert result is True
mock_sc.assert_called_once()
def test_no_policy_and_no_dep_allowed_fails_defers_to_router_level(self):
"""When neither a deployment policy nor a deployment-wide allowed_fails covers
this exception, defer to router-level behavior instead of forcing an
immediate cooldown (allowed_fails_override=0 would trip on the first failure
of any exception type the deployment's config doesn't mention)."""
exc = litellm.InternalServerError("500", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = True
_should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, None, None, is_single_deployment_model_group=False
)
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["allowed_fails_override"] is None
assert call_kwargs["cache_key_suffix"] is None
def test_partial_policy_without_dep_allowed_fails_defers_for_uncovered_exception(self):
"""A deployment that only sets RateLimitErrorAllowedFails must not force a
zero-fail threshold on an unrelated TimeoutError; it should defer to
router-level behavior for exception types its policy doesn't mention."""
policy = {"RateLimitErrorAllowedFails": 0}
exc = litellm.Timeout("timed out", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = False
_should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, policy, dep_allowed_fails=None, is_single_deployment_model_group=False
)
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["allowed_fails_override"] is None
assert call_kwargs["cache_key_suffix"] is None
def test_cooldown_time_from_model_info_passed_through(self):
exc = litellm.RateLimitError("429", "openai", "gpt-4")
router = self._make_router({"litellm_params": {}, "model_info": {"cooldown_time": 120.0}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = True
_should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, None, None, is_single_deployment_model_group=False
)
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["cooldown_time_override"] == 120.0
def test_cooldown_time_from_litellm_params_used_as_fallback(self):
"""cooldown_time has pre-existing litellm_params support on the primary
failure path, so it must still be honored here when model_info doesn't
set it."""
exc = litellm.RateLimitError("429", "openai", "gpt-4")
router = self._make_router({"litellm_params": {"cooldown_time": 120.0}, "model_info": {}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = True
_should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, None, None, is_single_deployment_model_group=False
)
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["cooldown_time_override"] == 120.0
def test_cooldown_time_from_model_info_takes_priority_over_litellm_params(self):
exc = litellm.RateLimitError("429", "openai", "gpt-4")
router = self._make_router({"litellm_params": {"cooldown_time": 120.0}, "model_info": {"cooldown_time": 15.0}})
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = True
_should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, None, None, is_single_deployment_model_group=False
)
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["cooldown_time_override"] == 15.0
def test_model_info_none_passes_none_cooldown_time(self):
exc = litellm.RateLimitError("429", "openai", "gpt-4")
router = self._make_router(None)
with patch("litellm.router_utils.cooldown_handlers.should_cooldown_based_on_allowed_fails_policy") as mock_sc:
mock_sc.return_value = False
_should_cooldown_based_on_deployment_policy(
router, "dep-1", exc, None, None, is_single_deployment_model_group=False
)
call_kwargs = mock_sc.call_args[1]
assert call_kwargs["cooldown_time_override"] is None
class TestShouldCooldownBasedOnAllowedFailsPolicy:
def _make_router(self, cooldown_time: float = 60.0, cache: DualCache | None = None) -> MagicMock:
router = MagicMock()
router.cooldown_time = cooldown_time
router.allowed_fails = 0
router.allowed_fails_policy = None
router.get_allowed_fails_from_policy.return_value = None
router.cache = cache if cache is not None else DualCache(in_memory_cache=InMemoryCache())
return router
def test_cooldown_time_override_zero_is_not_falsy(self):
"""cooldown_time_override=0 must be honored; it must not fall through to the router-level value."""
router = self._make_router(cooldown_time=60.0)
router.cache = MagicMock()
router.cache.increment_cache.return_value = 1
exc = litellm.RateLimitError("429", "openai", "gpt-4")
should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=router,
deployment="dep-1",
original_exception=exc,
allowed_fails_override=5,
cooldown_time_override=0.0,
)
increment_call = router.cache.increment_cache.call_args
assert increment_call is not None
assert increment_call[1]["ttl"] == 0.0, (
"cooldown_time_override=0 should be used as TTL, not the router-level 60.0"
)
def test_fail_counter_is_shared_across_router_instances(self):
"""Two workers (two Router objects over one shared cache) must pool their failures toward allowed_fails."""
shared_cache = DualCache(in_memory_cache=InMemoryCache())
workers = (self._make_router(cache=shared_cache), self._make_router(cache=shared_cache))
exc = litellm.AuthenticationError("401", "openai", "gpt-4")
results = [
should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=workers[i % 2],
deployment="dep-1",
original_exception=exc,
allowed_fails_override=5,
)
for i in range(6)
]
assert results == [False, False, False, False, False, True]
assert shared_cache.get_cache(key="deployment:dep-1:allowed_fails") == 6
def test_fleet_wide_count_from_redis_decides_cooldown(self):
"""The Redis (fleet-wide) count decides, even when this process has only seen one failure."""
redis_cache = MagicMock()
redis_cache.increment_cache.return_value = 6
router = self._make_router(cache=DualCache(in_memory_cache=InMemoryCache(), redis_cache=redis_cache))
exc = litellm.AuthenticationError("401", "openai", "gpt-4")
result = should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=router,
deployment="dep-1",
original_exception=exc,
allowed_fails_override=5,
)
assert result is True
redis_cache.increment_cache.assert_called_once_with("deployment:dep-1:allowed_fails", 1, ttl=60.0)
def test_redis_outage_falls_back_to_this_workers_count(self):
"""When every Redis increment fails, the worker's own in-memory count must still cool the deployment down."""
redis_cache = MagicMock()
redis_cache.increment_cache.side_effect = ConnectionError("redis down")
router = self._make_router(cache=DualCache(in_memory_cache=InMemoryCache(), redis_cache=redis_cache))
exc = litellm.AuthenticationError("401", "openai", "gpt-4")
results = [
should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=router,
deployment="dep-1",
original_exception=exc,
allowed_fails_override=5,
)
for _ in range(6)
]
assert results == [False, False, False, False, False, True]
assert redis_cache.increment_cache.call_count == 6
class TestRoutingGroupCooldownAlternatives:
def _router(self, routing_groups=None):
from litellm import Router
return Router(
model_list=[
{
"model_name": "solo-member",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test"},
"model_info": {"id": "cg-deploy-1"},
},
{
"model_name": "other-member",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"},
"model_info": {"id": "cg-deploy-2"},
},
],
routing_groups=routing_groups,
)
def test_group_call_429_cools_down_member_with_alternatives(self):
from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment
router = self._router(
routing_groups=[
{
"group_name": "grouped",
"models": ["solo-member", "other-member"],
"routing_strategy": "simple-shuffle",
}
]
)
assert (
_should_cooldown_deployment(
litellm_router_instance=router,
deployment="cg-deploy-1",
exception_status=429,
original_exception=Exception("rate limited"),
requested_model_group="grouped",
)
is True
)
def test_direct_member_429_keeps_single_deployment_exemption(self):
from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment
router = self._router(
routing_groups=[
{
"group_name": "grouped",
"models": ["solo-member", "other-member"],
"routing_strategy": "simple-shuffle",
}
]
)
assert (
_should_cooldown_deployment(
litellm_router_instance=router,
deployment="cg-deploy-1",
exception_status=429,
original_exception=Exception("rate limited"),
requested_model_group="solo-member",
)
is False
)
def test_429_without_request_context_keeps_exemption(self):
from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment
router = self._router(routing_groups=None)
assert (
_should_cooldown_deployment(
litellm_router_instance=router,
deployment="cg-deploy-1",
exception_status=429,
original_exception=Exception("rate limited"),
)
is False
)
class TestTeamModelCooldownAlternatives:
def _router(self, team_deployments: int, blocked_ids: frozenset[str] = frozenset()) -> litellm.Router:
return litellm.Router(
model_list=[
{
"model_name": f"model_name_team-1_{i}",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"},
"model_info": {
"id": f"team-deploy-{i}",
"team_id": "team-1",
"team_public_model_name": "team-gpt-4o-mini",
"blocked": f"team-deploy-{i}" in blocked_ids,
},
}
for i in range(team_deployments)
]
)
def test_429_on_team_deployment_with_sibling_cools_down(self):
from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment
router = self._router(team_deployments=2)
assert (
_should_cooldown_deployment(
litellm_router_instance=router,
deployment="team-deploy-0",
exception_status=429,
original_exception=Exception("rate limited"),
requested_model_group="team-gpt-4o-mini",
)
is True
)
def test_429_on_only_team_deployment_keeps_single_deployment_exemption(self):
from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment
router = self._router(team_deployments=1)
assert (
_should_cooldown_deployment(
litellm_router_instance=router,
deployment="team-deploy-0",
exception_status=429,
original_exception=Exception("rate limited"),
requested_model_group="team-gpt-4o-mini",
)
is False
)
def test_429_with_only_a_blocked_sibling_keeps_single_deployment_exemption(self):
from litellm.router_utils.cooldown_handlers import _should_cooldown_deployment
router = self._router(team_deployments=2, blocked_ids=frozenset({"team-deploy-1"}))
assert (
_should_cooldown_deployment(
litellm_router_instance=router,
deployment="team-deploy-0",
exception_status=429,
original_exception=Exception("rate limited"),
requested_model_group="team-gpt-4o-mini",
)
is False
)
class TestIncrementAllowedFailsServiceTarget:
def test_fail_counter_bump_declares_the_router_cooldowns_key_family(self):
"""The allowed_fails INCR is cooldown bookkeeping, so its service span must read
``redis.incr router_cooldowns`` rather than a bare ``redis.incr``."""
seen: list[str | None] = []
cache = MagicMock(spec=DualCache)
def _increment(**_kwargs):
seen.append(current_service_target())
return 2
cache.increment_cache.side_effect = _increment
assert _increment_allowed_fails(cache, "deployment:dep-1:fails", ttl=60.0) == 2
assert seen == ["router_cooldowns"]
assert current_service_target() is None
def test_in_memory_fallback_reads_under_the_same_target(self):
seen: list[str | None] = []
cache = MagicMock(spec=DualCache)
cache.increment_cache.side_effect = ConnectionError("redis down")
def _get(**_kwargs):
seen.append(current_service_target())
return 4
cache.get_cache.side_effect = _get
assert _increment_allowed_fails(cache, "deployment:dep-1:fails", ttl=60.0) == 4
assert seen == ["router_cooldowns"]
@pytest.fixture()
def _vcr_outcome_gate(request, vcr):
install_live_call_probe(request, vcr)
yield
record_vcr_outcome(request, vcr)
@pytest.fixture(scope="function")
def setup_and_teardown():
"""
This fixture reloads litellm before every function. To speed up testing by removing callbacks being chained.
"""
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
importlib.reload(litellm)
try:
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
importlib.reload(litellm.proxy.proxy_server)
except Exception:
pass
loop = asyncio.get_event_loop_policy().new_event_loop()
asyncio.set_event_loop(loop)
yield
loop.close()
asyncio.set_event_loop(None)
def _make_router(model_list: list, **kwargs) -> Router:
return Router(model_list=model_list, **kwargs)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestDeploymentLevelAllowedFails:
def test_deployment_level_allowed_fails_overrides_router_level(self):
"""
A deployment with model_info.allowed_fails=0 must enter cooldown after 1
failure even when the router-level allowed_fails=10.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {
"id": "primary",
"allowed_fails": 0,
},
},
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {"id": "secondary"},
},
],
allowed_fails=10,
)
_exception = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="primary",
exception_status=429,
original_exception=_exception,
)
assert should_cooldown is True, "Deployment-level allowed_fails=0 should force cooldown after first failure"
def test_deployment_level_allowed_fails_does_not_affect_other_deployments(self):
"""
A deployment without model_info.allowed_fails must still use the router-level
allowed_fails and not be pulled into cooldown prematurely.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {
"id": "primary",
"allowed_fails": 0,
},
},
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {"id": "secondary"},
},
],
allowed_fails=10,
)
_exception = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="secondary",
exception_status=429,
original_exception=_exception,
)
assert should_cooldown is False, (
"secondary has no deployment-level policy; with allowed_fails=10 it should not cool down on first failure"
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestDeploymentLevelAllowedFailsPolicyByExceptionType:
def test_rate_limit_error_triggers_cooldown_with_zero_threshold(self):
"""
RateLimitErrorAllowedFails=0 must trigger cooldown after 1 RateLimitError
even when allowed_fails=5 for other exception types.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {
"id": "primary",
"allowed_fails_policy": {
"RateLimitErrorAllowedFails": 0,
"InternalServerErrorAllowedFails": 5,
},
},
},
],
allowed_fails=10,
)
rate_limit_exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="primary",
exception_status=429,
original_exception=rate_limit_exc,
)
assert should_cooldown is True, "RateLimitErrorAllowedFails=0 must trigger cooldown on first rate limit error"
def test_internal_server_error_respects_per_exception_threshold(self):
"""
InternalServerErrorAllowedFails=5 must allow 5 InternalServerErrors before cooldown.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {
"id": "primary",
"allowed_fails_policy": {
"RateLimitErrorAllowedFails": 0,
"InternalServerErrorAllowedFails": 5,
},
},
},
],
allowed_fails=10,
)
ise = litellm.InternalServerError("Internal error", "openai", "gpt-4")
for _ in range(5):
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="primary",
exception_status=500,
original_exception=ise,
)
assert should_cooldown is False, "Should not cooldown within the allowed_fails threshold"
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="primary",
exception_status=500,
original_exception=ise,
)
assert should_cooldown is True, "Should cooldown after exceeding InternalServerErrorAllowedFails=5"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestExceptionTypeCountersTrackedIndependently:
def test_cache_key_suffix_separates_exception_type_counters(self):
"""
When cache_key_suffix is provided, fail counters for different exception types
must be independent; RateLimitError fails must not bleed into generic counters.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {"id": "primary"},
},
],
allowed_fails=10,
)
rate_limit_exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
ise = litellm.InternalServerError("Internal error", "openai", "gpt-4")
for _ in range(3):
should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=router,
deployment="primary",
original_exception=rate_limit_exc,
allowed_fails_override=5,
cache_key_suffix="RateLimitError",
)
rl_counter = router.cache.get_cache(key="deployment:primary:allowed_fails:RateLimitError") or 0
generic_counter = router.cache.get_cache(key="deployment:primary:allowed_fails:generic") or 0
assert rl_counter == 3, "RateLimitError counter should be 3"
assert generic_counter == 0, "generic counter must be untouched by RateLimitError increments"
should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=router,
deployment="primary",
original_exception=ise,
allowed_fails_override=5,
cache_key_suffix="generic",
)
generic_counter_after = router.cache.get_cache(key="deployment:primary:allowed_fails:generic") or 0
rl_counter_after = router.cache.get_cache(key="deployment:primary:allowed_fails:RateLimitError") or 0
assert generic_counter_after == 1, "generic counter should now be 1"
assert rl_counter_after == 3, "RateLimitError counter must remain unchanged after InternalServerError"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestCooldownCacheTTLCorrection:
def _make_cooldown_cache(self) -> CooldownCache:
in_memory = InMemoryCache()
dual_cache = DualCache(in_memory_cache=in_memory)
return CooldownCache(cache=dual_cache, default_cooldown_time=60.0)
def test_expired_entry_evicted_and_not_returned(self):
"""
An entry with timestamp+cooldown_time in the past must be evicted from
in-memory cache and excluded from the active cooldown list.
"""
cc = self._make_cooldown_cache()
model_id = "expired-deployment"
key = CooldownCache.get_cooldown_cache_key(model_id)
expired_value: CooldownCacheValue = {
"exception_received": "Rate limit",
"status_code": "429",
"timestamp": time.time() - 120.0,
"cooldown_time": 60.0,
}
cc.in_memory_cache.set_cache(key, expired_value, ttl=600)
active = cc.get_active_cooldowns(model_ids=[model_id], parent_otel_span=None)
assert active == [], "Expired cooldown entry must not appear in active cooldowns"
assert cc.in_memory_cache.get_cache(key) is None, "Expired entry must be evicted from in-memory cache"
def test_active_entry_is_returned(self):
"""
An entry whose cooldown window has not elapsed must appear in the active list.
"""
cc = self._make_cooldown_cache()
model_id = "active-deployment"
key = CooldownCache.get_cooldown_cache_key(model_id)
active_value: CooldownCacheValue = {
"exception_received": "Rate limit",
"status_code": "429",
"timestamp": time.time(),
"cooldown_time": 60.0,
}
cc.in_memory_cache.set_cache(key, active_value, ttl=60)
active = cc.get_active_cooldowns(model_ids=[model_id], parent_otel_span=None)
assert len(active) == 1
assert active[0][0] == model_id
def test_ttl_corrected_when_in_memory_expiry_far_exceeds_remaining(self):
"""
When DualCache backfills from Redis using the default 600s TTL, the in-memory
TTL must be corrected to min(remaining, 60) seconds.
"""
cc = self._make_cooldown_cache()
model_id = "backfilled-deployment"
key = CooldownCache.get_cooldown_cache_key(model_id)
remaining = 30.0
value: CooldownCacheValue = {
"exception_received": "Rate limit",
"status_code": "429",
"timestamp": time.time() - (60.0 - remaining),
"cooldown_time": 60.0,
}
cc.in_memory_cache.set_cache(key, value, ttl=600)
before_expiry = cc.in_memory_cache.ttl_dict.get(key)
assert before_expiry is not None
cc.get_active_cooldowns(model_ids=[model_id], parent_otel_span=None)
after_expiry = cc.in_memory_cache.ttl_dict.get(key)
assert after_expiry is not None
corrected_remaining = after_expiry - time.time()
assert corrected_remaining <= 60.0, "Corrected TTL must not exceed 60s"
assert corrected_remaining > 0, "Corrected TTL must be positive (cooldown still active)"
@pytest.mark.asyncio
async def test_async_expired_entry_evicted(self):
"""
Async path must also evict expired entries.
"""
cc = self._make_cooldown_cache()
model_id = "async-expired"
key = CooldownCache.get_cooldown_cache_key(model_id)
expired_value: CooldownCacheValue = {
"exception_received": "Rate limit",
"status_code": "429",
"timestamp": time.time() - 120.0,
"cooldown_time": 60.0,
}
cc.in_memory_cache.set_cache(key, expired_value, ttl=600)
active = await cc.async_get_active_cooldowns(model_ids=[model_id], parent_otel_span=None)
assert active == [], "Expired entry must not appear in async active cooldowns"
assert cc.in_memory_cache.get_cache(key) is None
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestFallbackDeploymentCooldown:
def test_trigger_cooldown_for_failed_deployment_calls_set_cooldown(self):
"""
_trigger_cooldown_for_failed_deployment must call set_cooldown_deployments
with the deployment ID stamped on the exception.
"""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={},
exception=exc,
)
mock_set_cooldown.assert_called_once()
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["deployment"] == "fallback-deployment"
assert call_kwargs["original_exception"] is exc
def test_trigger_cooldown_no_op_when_deployment_id_missing(self):
"""
_trigger_cooldown_for_failed_deployment must not raise and must skip
set_cooldown_deployments when the exception has no failed_deployment_id.
"""
mock_router = MagicMock()
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={},
exception=RuntimeError("no stamped deployment id"),
)
mock_set_cooldown.assert_not_called()
def test_trigger_cooldown_does_not_trust_caller_supplied_metadata_bucket(self):
"""
A metadata bucket can't reliably be told apart from a caller-supplied one
without knowing the call's function_name, so a client with permission to
set metadata must not be able to get an arbitrary deployment cooled down
by forging a deployment_model_name marker.
"""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
kwargs = {
"metadata": {
"model_info": {"id": "attacker-chosen-deployment"},
"deployment_model_name": "gpt-4",
}
}
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs=kwargs,
exception=exc,
)
mock_set_cooldown.assert_not_called()
def test_trigger_cooldown_increments_failure_counter_before_cooldown_check(self):
"""
The fallback path must feed the same per-minute failure counter the
primary path uses, or repeated fallback failures never accumulate toward
the default percent-fail-rate cooldown threshold.
"""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with (
patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown,
patch(
"litellm.router_utils.fallback_event_handlers.increment_deployment_failures_for_current_minute"
) as mock_increment,
):
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
mock_increment.assert_called_once_with(
litellm_router_instance=mock_router, deployment_id="fallback-deployment"
)
mock_set_cooldown.assert_called_once()
def test_trigger_cooldown_uses_deployment_cooldown_time_override(self):
"""
When the deployment has a model_info.cooldown_time, that value must be
passed as time_to_cooldown rather than the router-level cooldown_time.
"""
mock_router = MagicMock()
mock_router.cooldown_time = 300.0
mock_router.get_model_info.return_value = {"model_info": {"cooldown_time": 30.0}}
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={},
exception=exc,
)
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 30.0, (
"Deployment-level cooldown_time must override router-level value"
)
def test_trigger_cooldown_skipped_for_advisor_orchestration_failure(self):
"""
A failure tagged as originating from advisor orchestration (not the selected
deployment) must not cool down the fallback deployment, matching the same
guard already applied in Router.deployment_callback_on_failure.
"""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
mark_advisor_orchestration_failure(exc)
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={},
exception=exc,
)
mock_set_cooldown.assert_not_called()
def test_trigger_cooldown_falls_back_to_litellm_params_cooldown_time(self):
"""
cooldown_time has pre-existing litellm_params support on the primary
failure path (Router.deployment_callback_on_failure), so it must still be
honored as a fallback when model_info doesn't set it, unlike the new
allowed_fails/allowed_fails_policy fields which are model_info-only.
"""
mock_router = MagicMock()
mock_router.cooldown_time = 300.0
mock_router.get_model_info.return_value = {"litellm_params": {"cooldown_time": 30.0}}
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={},
exception=exc,
)
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 30.0, (
"litellm_params.cooldown_time must still be honored as a fallback"
)
def test_trigger_cooldown_prefers_model_info_cooldown_time_over_litellm_params(self):
mock_router = MagicMock()
mock_router.cooldown_time = 300.0
mock_router.get_model_info.return_value = {
"model_info": {"cooldown_time": 15.0},
"litellm_params": {"cooldown_time": 30.0},
}
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={},
exception=exc,
)
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 15.0, "model_info.cooldown_time must take priority"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestSingleDeploymentModelGroupProtection:
def test_generic_allowed_fails_does_not_bypass_single_deployment_protection(self):
"""
Setting only a generic model_info.allowed_fails on a single-deployment model
group must not disable the "avoid cooldowns on single deployment model groups"
safety net; before this feature existed the field had no effect at all here,
so a plain 500 error must behave the same as the no-policy control.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {"id": "solo", "allowed_fails": 1},
},
],
)
exc = Exception("Internal error")
for _ in range(2):
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="solo",
exception_status=500,
original_exception=exc,
)
assert should_cooldown is False, (
"single-deployment model group must stay protected from a generic allowed_fails override"
)
def test_named_exception_policy_still_overrides_single_deployment_protection(self):
"""
Unlike a generic allowed_fails, an explicit per-exception-type allowed_fails_policy
entry is a deliberate, unambiguous opt-in and must still apply even on a
single-deployment model group.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {
"id": "solo",
"allowed_fails_policy": {"RateLimitErrorAllowedFails": 0},
},
},
],
)
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="solo",
exception_status=429,
original_exception=exc,
)
assert should_cooldown is True, "explicit per-exception-type policy must still cool down a solo deployment"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestShouldCooldownBasedOnAllowedFailsPolicyFalsyZero:
def test_router_level_policy_of_zero_is_not_swallowed_by_allowed_fails(self):
"""
Router.get_allowed_fails_from_policy returning 0 (a legitimate "cooldown after
the very first failure" policy) must not be treated as falsy and replaced by
router.allowed_fails.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {"id": "primary"},
},
],
allowed_fails=10,
allowed_fails_policy=AllowedFailsPolicy(RateLimitErrorAllowedFails=0),
)
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
should_cooldown = should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance=router,
deployment="primary",
original_exception=exc,
)
assert should_cooldown is True, "RateLimitErrorAllowedFails=0 must cool down after the first failure"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestResolveAllowedFailsFromPolicyFallsThrough:
def test_none_value_on_first_match_falls_through_to_next_type(self):
"""
ContentPolicyViolationError is also a BadRequestError; if the policy names
ContentPolicyViolationError but leaves its value unset (None) while setting
BadRequestErrorAllowedFails, resolution must fall through to the
BadRequestError entry rather than stopping at the first isinstance match.
"""
policy = {
"ContentPolicyViolationErrorAllowedFails": None,
"BadRequestErrorAllowedFails": 3,
}
exc = litellm.ContentPolicyViolationError("flagged", "openai", "gpt-4")
result = _resolve_allowed_fails_from_policy(policy=policy, exception=exc)
assert result == 3, "must fall through to BadRequestErrorAllowedFails when the more specific field is unset"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestDeploymentCallbackOnFailureCooldownTimePrecedence:
def test_model_info_cooldown_time_used_in_primary_sync_path(self):
"""
Router.deployment_callback_on_failure (the primary sync failure-callback path,
as opposed to the fallback path covered by TestFallbackDeploymentCooldown) must
also honor a model_info.cooldown_time, not just litellm_params.cooldown_time.
"""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4"},
"model_info": {"id": "primary", "cooldown_time": 15.0},
},
],
)
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
kwargs = {
"exception": exc,
"litellm_params": {
"model_info": {"id": "primary", "cooldown_time": 15.0},
},
}
with patch("litellm.router.set_cooldown_deployments") as mock_set_cooldown:
router.deployment_callback_on_failure(
kwargs=kwargs,
completion_response=None,
start_time=0,
end_time=1,
)
mock_set_cooldown.assert_called_once()
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 15.0, (
"model_info.cooldown_time must be honored in the primary sync failure-callback path"
)
def test_litellm_params_cooldown_time_still_honored_as_fallback(self):
"""cooldown_time has pre-existing litellm_params support on this primary
path; it must keep working when model_info doesn't set it."""
router = _make_router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4", "cooldown_time": 20.0},
"model_info": {"id": "primary"},
},
],
)
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
kwargs = {
"exception": exc,
"litellm_params": {
"model_info": {"id": "primary"},
"cooldown_time": 20.0,
},
}
with patch("litellm.router.set_cooldown_deployments") as mock_set_cooldown:
router.deployment_callback_on_failure(
kwargs=kwargs,
completion_response=None,
start_time=0,
end_time=1,
)
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 20.0, "litellm_params.cooldown_time must still be honored"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestCallerScopedOAuthAuthFailureCooldown:
@staticmethod
def _router(
model_id: str,
litellm_params: dict[str, object],
allowed_fails_policy: dict[str, int] | None = None,
) -> Router:
return _make_router(
model_list=[
{
"model_name": "copilot",
"litellm_params": {
"model": "microsoft_365_copilot/chat",
**litellm_params,
},
"model_info": {
"id": model_id,
**({"allowed_fails_policy": allowed_fails_policy} if allowed_fails_policy is not None else {}),
},
}
]
)
@staticmethod
def _auth_exception(status: int) -> Exception:
if status == 401:
return litellm.AuthenticationError("caller assertion rejected", "microsoft_365_copilot", "copilot")
return litellm.PermissionDeniedError(
"caller assertion rejected",
"microsoft_365_copilot",
"copilot",
response=httpx.Response(
status_code=403,
request=httpx.Request("GET", "https://litellm.ai"),
),
)
@staticmethod
def _callback(router: Router, model_id: str, exception: Exception) -> bool:
return router.deployment_callback_on_failure(
kwargs={
"exception": exception,
"litellm_params": {"model_info": {"id": model_id}},
},
completion_response=None,
start_time=0,
end_time=1,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("status", (401, 403))
async def test_inline_oauth_caller_auth_failure_does_not_cooldown(self, status: int) -> None:
model_id: Final = "inline-oauth"
router: Final = self._router(
model_id,
{
"token_exchange_endpoint": "https://identity.example.com/token",
"client_id": "copilot-client",
"client_secret": "copilot-secret",
},
)
result: Final = self._callback(router, model_id, self._auth_exception(status))
assert result is False
assert get_deployment_failures_for_current_minute(router, model_id) == 0
assert get_cooldown_deployments(router, parent_otel_span=None) == []
@pytest.mark.asyncio
@pytest.mark.parametrize("status", (401, 403))
async def test_named_oauth_credential_caller_auth_failure_does_not_cooldown(
self,
status: int,
monkeypatch: pytest.MonkeyPatch,
) -> None:
model_id: Final = "named-oauth"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="copilot-oauth",
credential_values={
"token_exchange_endpoint": "https://identity.example.com/token",
"client_id": "copilot-client",
"client_secret": "copilot-secret",
},
credential_info={"custom_llm_provider": "microsoft_365_copilot"},
)
],
)
router: Final = self._router(model_id, {"litellm_credential_name": "copilot-oauth"})
result: Final = self._callback(router, model_id, self._auth_exception(status))
assert result is False
assert get_deployment_failures_for_current_minute(router, model_id) == 0
assert get_cooldown_deployments(router, parent_otel_span=None) == []
@pytest.mark.asyncio
async def test_api_key_deployment_still_cools_down_on_401(self) -> None:
model_id: Final = "api-key-only"
router: Final = self._router(model_id, {"api_key": "sk-test"})
self._callback(
router,
model_id,
litellm.AuthenticationError("upstream rejected the API key", "openai", "gpt-4o-mini"),
)
assert get_deployment_failures_for_current_minute(router, model_id) == 1
assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id]
@pytest.mark.asyncio
@pytest.mark.parametrize("status", (429, 500))
async def test_oauth_deployment_still_cools_down_on_provider_failures(self, status: int) -> None:
model_id: Final = f"oauth-provider-{status}"
router: Final = self._router(
model_id,
{
"token_exchange_endpoint": "https://identity.example.com/token",
"client_id": "copilot-client",
"client_secret": "copilot-secret",
},
allowed_fails_policy={
"RateLimitErrorAllowedFails": 0,
"InternalServerErrorAllowedFails": 0,
},
)
exception: Final[Exception] = (
litellm.RateLimitError("rate limited", "microsoft_365_copilot", "copilot")
if status == 429
else litellm.InternalServerError("provider failed", "microsoft_365_copilot", "copilot")
)
self._callback(router, model_id, exception)
assert get_deployment_failures_for_current_minute(router, model_id) == 1
assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id]
@pytest.mark.parametrize("status", (401, 403))
def test_fallback_oauth_caller_auth_failure_does_not_cooldown(self, status: int) -> None:
model_id: Final = f"fallback-oauth-{status}"
router: Final = self._router(
model_id,
{
"token_exchange_endpoint": "https://identity.example.com/token",
"client_id": "copilot-client",
"client_secret": "copilot-secret",
},
)
exception: Final = self._auth_exception(status)
exception.failed_deployment_id = model_id
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception)
assert get_deployment_failures_for_current_minute(router, model_id) == 0
assert get_cooldown_deployments(router, parent_otel_span=None) == []
@pytest.mark.asyncio
async def test_fallback_api_key_auth_failure_still_cools_down(self) -> None:
model_id: Final = "fallback-api-key"
router: Final = self._router(model_id, {"api_key": "sk-test"})
exception: Final = litellm.AuthenticationError("API key rejected", "openai", "gpt-4o-mini")
exception.failed_deployment_id = model_id
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception)
assert get_deployment_failures_for_current_minute(router, model_id) == 1
assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id]
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestPerUserCopilotAuthFailureCooldown:
@staticmethod
def _router(
model_id: str,
litellm_params: Mapping[str, object],
allowed_fails_policy: Mapping[str, int] | None = None,
) -> Router:
return _make_router(
model_list=[
{
"model_name": "copilot",
"litellm_params": {
"model": "github_copilot/gpt-4o",
**litellm_params,
},
"model_info": {
"id": model_id,
"allowed_fails_policy": dict(allowed_fails_policy)
if allowed_fails_policy is not None
else None,
},
}
]
)
@staticmethod
def _callback(router: Router, model_id: str, exception: Exception) -> bool:
return router.deployment_callback_on_failure(
kwargs={
"exception": exception,
"litellm_params": {"model_info": {"id": model_id}},
},
completion_response=None,
start_time=0,
end_time=1,
)
@pytest.mark.asyncio
async def test_per_user_oauth_caller_auth_failure_does_not_cooldown(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.constants import GITHUB_COPILOT_AUTH_TYPE_KEY, GITHUB_COPILOT_PER_USER_AUTH_TYPE
model_id: Final = "per-user-oauth"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="copilot-per-user",
credential_values={GITHUB_COPILOT_AUTH_TYPE_KEY: GITHUB_COPILOT_PER_USER_AUTH_TYPE},
credential_info={"custom_llm_provider": "github_copilot"},
)
],
)
router: Final = self._router(model_id, {"litellm_credential_name": "copilot-per-user"})
exception: Final = litellm.CallerCredentialAuthenticationError(
message="reconnect", llm_provider="github_copilot", model=""
)
result: Final = self._callback(router, model_id, exception)
assert result is False
assert get_cooldown_deployments(router, parent_otel_span=None) == []
@pytest.mark.asyncio
async def test_shared_mode_copilot_auth_failure_still_cools_down(self) -> None:
from litellm.llms.github_copilot.authenticator import Authenticator
model_id: Final = "shared-mode"
with (
patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"),
patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"),
):
router: Final = self._router(model_id, {"api_key": "sk-shared"})
self._callback(
router,
model_id,
litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o"),
)
assert get_deployment_failures_for_current_minute(router, model_id) == 1
assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id]
@pytest.mark.asyncio
async def test_fallback_per_user_caller_auth_failure_does_not_cooldown(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.constants import GITHUB_COPILOT_AUTH_TYPE_KEY, GITHUB_COPILOT_PER_USER_AUTH_TYPE
model_id: Final = "fallback-per-user"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="copilot-per-user",
credential_values={GITHUB_COPILOT_AUTH_TYPE_KEY: GITHUB_COPILOT_PER_USER_AUTH_TYPE},
credential_info={"custom_llm_provider": "github_copilot"},
)
],
)
router: Final = self._router(model_id, {"litellm_credential_name": "copilot-per-user"})
exception: Final = litellm.CallerCredentialAuthenticationError(
message="reconnect", llm_provider="github_copilot", model=""
)
exception.failed_deployment_id = model_id
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception)
assert get_cooldown_deployments(router, parent_otel_span=None) == []
@pytest.mark.asyncio
async def test_fallback_shared_mode_auth_failure_still_cools_down(self) -> None:
from litellm.llms.github_copilot.authenticator import Authenticator
model_id: Final = "fallback-shared"
with (
patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"),
patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"),
):
router: Final = self._router(model_id, {"api_key": "sk-shared"})
exception: Final = litellm.AuthenticationError(
"upstream rejected the shared token", "github_copilot", "gpt-4o"
)
exception.failed_deployment_id = model_id
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception)
assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id]
@pytest.mark.asyncio
async def test_fallback_rate_limit_on_per_user_deployment_does_not_cooldown(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.constants import GITHUB_COPILOT_AUTH_TYPE_KEY, GITHUB_COPILOT_PER_USER_AUTH_TYPE
model_id: Final = "fallback-per-user-429"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="copilot-per-user",
credential_values={GITHUB_COPILOT_AUTH_TYPE_KEY: GITHUB_COPILOT_PER_USER_AUTH_TYPE},
credential_info={"custom_llm_provider": "github_copilot"},
)
],
)
router: Final = self._router(
model_id,
{"litellm_credential_name": "copilot-per-user"},
allowed_fails_policy={"RateLimitErrorAllowedFails": 0},
)
exception: Final = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o")
exception.failed_deployment_id = model_id
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception)
assert get_cooldown_deployments(router, parent_otel_span=None) == []
@pytest.mark.asyncio
async def test_fallback_rate_limit_on_shared_deployment_still_cools_down(self) -> None:
from litellm.llms.github_copilot.authenticator import Authenticator
model_id: Final = "fallback-shared-429"
with (
patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"),
patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"),
):
router: Final = self._router(
model_id,
{"api_key": "sk-shared"},
allowed_fails_policy={"RateLimitErrorAllowedFails": 0},
)
exception: Final = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o")
exception.failed_deployment_id = model_id
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception)
assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id]
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestNewAllowedFailsPolicyFields:
def test_service_unavailable_error_matched_by_policy(self):
"""
ServiceUnavailableError must be matched against ServiceUnavailableErrorAllowedFails.
"""
policy = {"ServiceUnavailableErrorAllowedFails": 0}
exc = litellm.ServiceUnavailableError("Service unavailable", "openai", "gpt-4")
result = _resolve_allowed_fails_from_policy(policy=policy, exception=exc)
assert result == 0
def test_bad_gateway_error_matched_by_policy(self):
"""
BadGatewayError must be matched against BadGatewayErrorAllowedFails.
"""
policy = {"BadGatewayErrorAllowedFails": 2}
exc = litellm.BadGatewayError("Bad gateway", "openai", "gpt-4")
result = _resolve_allowed_fails_from_policy(policy=policy, exception=exc)
assert result == 2
def test_not_found_error_matched_by_policy(self):
"""
NotFoundError must be matched against NotFoundErrorAllowedFails.
"""
policy = {"NotFoundErrorAllowedFails": 1}
exc = litellm.NotFoundError("Not found", "openai", "gpt-4")
result = _resolve_allowed_fails_from_policy(policy=policy, exception=exc)
assert result == 1
def test_unknown_exception_type_returns_none(self):
"""
An exception type not in the policy mapping must return None.
"""
policy = {"RateLimitErrorAllowedFails": 0}
exc = ValueError("unexpected error")
result = _resolve_allowed_fails_from_policy(policy=policy, exception=exc)
assert result is None
def test_allowed_fails_policy_model_accepts_new_fields(self):
"""
AllowedFailsPolicy Pydantic model must accept the three new fields.
"""
policy = AllowedFailsPolicy(
ServiceUnavailableErrorAllowedFails=3,
BadGatewayErrorAllowedFails=2,
NotFoundErrorAllowedFails=1,
)
assert policy.ServiceUnavailableErrorAllowedFails == 3
assert policy.BadGatewayErrorAllowedFails == 2
assert policy.NotFoundErrorAllowedFails == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestRouterLevelGetAllowedFailsFromPolicy:
"""Router.get_allowed_fails_from_policy must handle all AllowedFailsPolicy fields."""
def _make_router(self, **policy_kwargs):
return Router(
model_list=[{"model_name": "gpt-4", "litellm_params": {"model": "gpt-4", "api_key": "fake"}}],
allowed_fails_policy=AllowedFailsPolicy(**policy_kwargs),
)
def test_internal_server_error_returned(self):
router = self._make_router(InternalServerErrorAllowedFails=7)
exc = litellm.InternalServerError("500 error", "openai", "gpt-4")
assert router.get_allowed_fails_from_policy(exc) == 7
def test_service_unavailable_error_returned(self):
router = self._make_router(ServiceUnavailableErrorAllowedFails=4)
exc = litellm.ServiceUnavailableError("503 error", "openai", "gpt-4")
assert router.get_allowed_fails_from_policy(exc) == 4
def test_bad_gateway_error_returned(self):
router = self._make_router(BadGatewayErrorAllowedFails=2)
exc = litellm.BadGatewayError("502 error", "openai", "gpt-4")
assert router.get_allowed_fails_from_policy(exc) == 2
def test_not_found_error_returned(self):
router = self._make_router(NotFoundErrorAllowedFails=1)
exc = litellm.NotFoundError("404 error", "openai", "gpt-4")
assert router.get_allowed_fails_from_policy(exc) == 1
def test_unmatched_exception_returns_none(self):
router = self._make_router(InternalServerErrorAllowedFails=5)
exc = litellm.RateLimitError("429", "openai", "gpt-4")
assert router.get_allowed_fails_from_policy(exc) is None
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_router_cooldown_event_callback_no_deployment():
"""
Test the router_cooldown_event_callback function
Ensures that the router_cooldown_event_callback function does not raise an error when no deployment is found
In this scenario it should do nothing
"""
# Mock Router instance
mock_router = MagicMock()
mock_router.get_deployment.return_value = None
await router_cooldown_event_callback(
litellm_router_instance=mock_router,
deployment_id="test-deployment",
exception_status="429",
cooldown_time=60.0,
)
# Assert that the router's get_deployment method was called
mock_router.get_deployment.assert_called_once_with(model_id="test-deployment")
@pytest.fixture
def testing_litellm_router():
return Router(
model_list=[
{
"model_name": "gpt-5-mini",
"litellm_params": {"model": "gpt-5-mini"},
"model_id": "test_deployment",
},
{
"model_name": "test_deployment",
"litellm_params": {"model": "openai/test_deployment"},
"model_id": "test_deployment_2",
},
{
"model_name": "test_deployment",
"litellm_params": {"model": "openai/test_deployment-2"},
"model_id": "test_deployment_3",
},
]
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_run_cooldown_logic(testing_litellm_router):
testing_litellm_router.disable_cooldowns = True
# don't run cooldown logic if disable_cooldowns is True
assert _should_run_cooldown_logic(testing_litellm_router, "test_deployment", 500, Exception("Test")) is False
# don't cooldown if deployment is None
testing_litellm_router.disable_cooldowns = False
assert _should_run_cooldown_logic(testing_litellm_router, None, 500, Exception("Test")) is False
# don't cooldown if it's a provider default deployment
testing_litellm_router.provider_default_deployment_ids = ["test_deployment"]
assert _should_run_cooldown_logic(testing_litellm_router, "test_deployment", 500, Exception("Test")) is False
@pytest.fixture
def single_deployment_router():
"""A router with one deployment whose model_info.id is the lookup-able
"dep-1" (unlike `testing_litellm_router`'s top-level "model_id" key, which
is not absorbed into model_info.id and so never resolves via
get_model_info/get_model_group)."""
return Router(
model_list=[
{
"model_name": "gpt-5-mini",
"litellm_params": {"model": "gpt-5-mini"},
"model_info": {"id": "dep-1"},
},
]
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_run_cooldown_logic_generic_bad_request_excluded_by_default(
single_deployment_router,
):
"""A generic BadRequestError/ContentPolicyViolationError (400) is excluded from
cooldown evaluation by _is_cooldown_required when no allowed_fails_policy is
configured for that exception type. This is the pre-existing, intentional
default: a client error is usually not the deployment's fault."""
exc = litellm.BadRequestError("bad request", "openai", "gpt-5-mini")
assert _should_run_cooldown_logic(single_deployment_router, "dep-1", 400, exc) is False
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_run_cooldown_logic_router_level_policy_does_not_override_bad_request_exclusion(
single_deployment_router,
):
"""A router-level allowed_fails_policy is a pre-existing, router-wide setting that
predates the per-deployment override feature, so it must keep its existing behavior
and stay subject to the generic 4XX exclusion. Only an explicit deployment-level
policy (an unambiguous per-exception opt-in for that one deployment) overrides it;
see test_should_run_cooldown_logic_explicit_deployment_level_policy_overrides_content_policy_exclusion."""
exc = litellm.BadRequestError("bad request", "openai", "gpt-5-mini")
single_deployment_router.allowed_fails_policy = AllowedFailsPolicy(BadRequestErrorAllowedFails=5)
assert _should_run_cooldown_logic(single_deployment_router, "dep-1", 400, exc) is False
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_run_cooldown_logic_explicit_deployment_level_policy_overrides_content_policy_exclusion(
single_deployment_router,
):
"""Same as the router-level case, but for a deployment-level allowed_fails_policy
entry (this PR's per-deployment feature) targeting ContentPolicyViolationError."""
exc = litellm.ContentPolicyViolationError("flagged content", "openai", "gpt-5-mini")
deployment_dict = single_deployment_router.get_model_info(id="dep-1")
deployment_dict["model_info"]["allowed_fails_policy"] = {"ContentPolicyViolationErrorAllowedFails": 0}
assert _should_run_cooldown_logic(single_deployment_router, "dep-1", 400, exc) is True
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
class TestHasExplicitAllowedFailsPolicyForException:
def test_no_policy_anywhere_returns_false(self, single_deployment_router):
exc = litellm.BadRequestError("bad request", "openai", "gpt-5-mini")
assert _has_explicit_allowed_fails_policy_for_exception(single_deployment_router, "dep-1", exc) is False
def test_router_level_policy_for_matching_exception_returns_false(self, single_deployment_router):
"""Deliberately scoped to deployment-level only: a router-level policy
predates this feature and must not be treated as an explicit per-exception
opt-in for cooldown-gate purposes."""
exc = litellm.RateLimitError("rate limited", "openai", "gpt-5-mini")
single_deployment_router.allowed_fails_policy = AllowedFailsPolicy(RateLimitErrorAllowedFails=3)
assert _has_explicit_allowed_fails_policy_for_exception(single_deployment_router, "dep-1", exc) is False
def test_router_level_policy_for_different_exception_returns_false(self, single_deployment_router):
exc = litellm.BadRequestError("bad request", "openai", "gpt-5-mini")
single_deployment_router.allowed_fails_policy = AllowedFailsPolicy(RateLimitErrorAllowedFails=3)
assert _has_explicit_allowed_fails_policy_for_exception(single_deployment_router, "dep-1", exc) is False
def test_deployment_level_policy_for_matching_exception_returns_true(self, single_deployment_router):
exc = litellm.ContentPolicyViolationError("flagged", "openai", "gpt-5-mini")
deployment_dict = single_deployment_router.get_model_info(id="dep-1")
deployment_dict["model_info"]["allowed_fails_policy"] = {"ContentPolicyViolationErrorAllowedFails": 0}
assert _has_explicit_allowed_fails_policy_for_exception(single_deployment_router, "dep-1", exc) is True
def test_none_deployment_returns_false(self, single_deployment_router):
exc = litellm.RateLimitError("rate limited", "openai", "gpt-5-mini")
single_deployment_router.allowed_fails_policy = AllowedFailsPolicy(RateLimitErrorAllowedFails=3)
assert _has_explicit_allowed_fails_policy_for_exception(single_deployment_router, None, exc) is False
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_cooldown_deployment_rate_limit_error(testing_litellm_router):
"""
Test the _should_cooldown_deployment function when a rate limit error occurs
"""
# Test 429 error (rate limit) -> always cooldown a deployment returning 429s
_exception = litellm.exceptions.RateLimitError("Rate limit", "openai", "gpt-5-mini")
assert _should_cooldown_deployment(testing_litellm_router, "test_deployment", 429, _exception) is True
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_cooldown_deployment_auth_limit_error(testing_litellm_router):
"""
Test the _should_cooldown_deployment function when an auth limit error occurs
"""
# Test 401 error (auth limit) -> always cooldown a deployment returning 401s
_exception = litellm.exceptions.AuthenticationError("Unauthorized", "openai", "gpt-5-mini")
assert _should_cooldown_deployment(testing_litellm_router, "test_deployment", 401, _exception) is True
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@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.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@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
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
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
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
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
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
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.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_should_cooldown_deployment(testing_litellm_router):
"""
Cooldown a deployment if it fails 60% of requests in 1 minute - DEFAULT threshold is 50%
"""
import logging
from litellm._logging import verbose_router_logger
verbose_router_logger.setLevel(logging.DEBUG)
# Test 429 error (rate limit) -> always cooldown a deployment returning 429s
_exception = litellm.exceptions.RateLimitError("Rate limit", "openai", "gpt-5-mini")
assert _should_cooldown_deployment(testing_litellm_router, "test_deployment", 429, _exception) is True
available_deployment = testing_litellm_router.get_available_deployment(model="test_deployment")
print("available_deployment", available_deployment)
assert available_deployment is not None
deployment_id = available_deployment["model_info"]["id"]
print("deployment_id", deployment_id)
# set current success for deployment to 40
for _ in range(40):
increment_deployment_successes_for_current_minute(
litellm_router_instance=testing_litellm_router, deployment_id=deployment_id
)
# now we fail 40 requests in a row
tasks = []
for _ in range(41):
tasks.append(
testing_litellm_router.acompletion(
model=deployment_id,
messages=[{"role": "user", "content": "Hello, world!"}],
max_tokens=100,
mock_response="litellm.InternalServerError",
)
)
try:
await asyncio.gather(*tasks)
except Exception:
pass
await asyncio.sleep(1)
# expect this to fail since it's now 51% of requests are failing
assert _should_cooldown_deployment(testing_litellm_router, deployment_id, 500, Exception("Test")) is True
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_should_cooldown_deployment_allowed_fails_set_on_router():
"""
Test the _should_cooldown_deployment function when Router.allowed_fails is set
"""
# Create a Router instance with a test deployment
router = Router(
model_list=[
{
"model_name": "gpt-5-mini",
"litellm_params": {"model": "gpt-5-mini"},
"model_id": "test_deployment",
},
]
)
# Set up allowed_fails for the test deployment
router.allowed_fails = 100
# should not cooldown when fails are below the allowed limit
for _ in range(100):
assert _should_cooldown_deployment(router, "test_deployment", 500, Exception("Test")) is False
assert _should_cooldown_deployment(router, "test_deployment", 500, Exception("Test")) is True
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_increment_deployment_successes_for_current_minute_does_not_write_to_redis(
testing_litellm_router,
):
"""
Ensure tracking deployment metrics does not write to redis
Important - If it writes to redis on every request it will seriously impact performance / latency
"""
from litellm.caching.dual_cache import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.caching.redis_cache import RedisCache
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
increment_deployment_successes_for_current_minute,
)
# Mock RedisCache
mock_redis_cache = MagicMock(spec=RedisCache)
testing_litellm_router.cache = DualCache(redis_cache=mock_redis_cache, in_memory_cache=InMemoryCache())
# Call the function we're testing
increment_deployment_successes_for_current_minute(
litellm_router_instance=testing_litellm_router, deployment_id="test_deployment"
)
increment_deployment_failures_for_current_minute(
litellm_router_instance=testing_litellm_router, deployment_id="test_deployment"
)
time.sleep(1)
# Assert that no methods were called on the mock_redis_cache
assert not mock_redis_cache.method_calls, "RedisCache methods should not be called"
print(
"in memory cache values=",
testing_litellm_router.cache.in_memory_cache.cache_dict,
)
assert testing_litellm_router.cache.in_memory_cache.get_cache("test_deployment:successes") is not None
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_cast_exception_status_to_int():
assert cast_exception_status_to_int(200) == 200
assert cast_exception_status_to_int("404") == 404
assert cast_exception_status_to_int("invalid") == 500
@pytest.fixture
def router():
return Router(
model_list=[
{
"model_name": "gpt-5.5",
"litellm_params": {"model": "gpt-5.5"},
"model_info": {
"id": "gpt-4--0",
},
}
]
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_successes_for_current_minute")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_failures_for_current_minute")
def test_should_cooldown_high_traffic_all_fails(mock_failures, mock_successes, router):
# Simulate 10 failures, 0 successes
from litellm.constants import SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD
mock_failures.return_value = SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD + 1
mock_successes.return_value = 0
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="gpt-4--0",
exception_status=500,
original_exception=Exception("Test error"),
)
assert should_cooldown is True, "Should cooldown when all requests fail with sufficient traffic"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_successes_for_current_minute")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_failures_for_current_minute")
def test_no_cooldown_low_traffic(mock_failures, mock_successes, router):
# Simulate 3 failures (below MIN_TRAFFIC_THRESHOLD)
mock_failures.return_value = 3
mock_successes.return_value = 0
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="gpt-4--0",
exception_status=500,
original_exception=Exception("Test error"),
)
assert should_cooldown is False, "Should not cooldown when traffic is below threshold"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_successes_for_current_minute")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_failures_for_current_minute")
def test_cooldown_rate_limit(mock_failures, mock_successes, router):
"""
Don't cooldown single deployment models, for anything besides traffic
"""
mock_failures.return_value = 1
mock_successes.return_value = 0
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="gpt-4--0",
exception_status=429, # Rate limit error
original_exception=Exception("Rate limit exceeded"),
)
assert should_cooldown is False, "Should not cooldown on rate limit error for single deployment models"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_successes_for_current_minute")
@patch("litellm.router_utils.cooldown_handlers.get_deployment_failures_for_current_minute")
def test_mixed_success_failure(mock_failures, mock_successes, router):
# Simulate 3 failures, 7 successes
mock_failures.return_value = 3
mock_successes.return_value = 7
should_cooldown = _should_cooldown_deployment(
litellm_router_instance=router,
deployment="gpt-4--0",
exception_status=500,
original_exception=Exception("Test error"),
)
assert should_cooldown is False, "Should not cooldown when failure rate is below threshold"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_is_cooldown_required_empty_string_exception_status(testing_litellm_router):
"""
Test that _is_cooldown_required returns False when exception_status is an empty string
"""
result = _is_cooldown_required(
litellm_router_instance=testing_litellm_router,
model_id="test_deployment",
exception_status="",
)
assert result is False, "Should not require cooldown when exception_status is empty string"
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_should_cooldown_deployment_minimum_request_threshold(testing_litellm_router):
"""
Test that error rate cooldown does NOT trigger on first failure.
Fixes GitHub issue #17418: Error Rate Cooldown Triggers on First Failed Request
The problem: With DEFAULT_FAILURE_THRESHOLD_PERCENT=0.5 (50%), a deployment
gets cooled down after just 1 failed request because 1/1 = 100% > 50%.
The fix: Add a minimum request threshold (DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS)
before applying error rate cooldown.
"""
from litellm.constants import DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS
# Get a deployment that's not a single-deployment model group
# (test_deployment_2 and test_deployment_3 are both for "test_deployment" model)
available_deployment = testing_litellm_router.get_available_deployment(model="test_deployment")
assert available_deployment is not None
deployment_id = available_deployment["model_info"]["id"]
# Simulate only 1 failure (below minimum threshold)
# This should NOT trigger cooldown even though 100% > 50%
increment_deployment_failures_for_current_minute(
litellm_router_instance=testing_litellm_router, deployment_id=deployment_id
)
_exception = litellm.exceptions.InternalServerError("Internal error", "openai", "gpt-5-mini")
# With only 1 request, should NOT cooldown (below minimum threshold)
should_cooldown = _should_cooldown_deployment(testing_litellm_router, deployment_id, 500, _exception)
assert should_cooldown is False, (
f"Should NOT cooldown with only 1 failed request (below minimum threshold of {DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS})"
)
# Now add more failures to reach the minimum threshold
for _ in range(DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS - 1):
increment_deployment_failures_for_current_minute(
litellm_router_instance=testing_litellm_router, deployment_id=deployment_id
)
# Now with enough requests (all failures), it SHOULD trigger cooldown
should_cooldown = _should_cooldown_deployment(testing_litellm_router, deployment_id, 500, _exception)
assert should_cooldown is True, (
f"Should cooldown when we have {DEFAULT_FAILURE_THRESHOLD_MINIMUM_REQUESTS} failed requests (100% failure rate)"
)
@pytest.mark.asyncio
async def test_dynamic_cooldowns():
"""
Assert kwargs for completion/embedding have 'cooldown_time' as a litellm_param
"""
# litellm.set_verbose = True
tmp_mock = MagicMock()
litellm.failure_callback = [tmp_mock]
router = Router(
model_list=[
{
"model_name": "my-fake-model",
"litellm_params": {
"model": "openai/gpt-1",
"api_key": "my-key",
"mock_response": Exception("this is an error"),
},
}
],
cooldown_time=60,
)
try:
_ = router.completion(
model="my-fake-model",
messages=[{"role": "user", "content": "Hey, how's it going?"}],
cooldown_time=0,
num_retries=0,
)
except Exception:
pass
tmp_mock.assert_called_once()
print(tmp_mock.call_count)
assert "cooldown_time" in tmp_mock.call_args[0][0]["litellm_params"]
assert tmp_mock.call_args[0][0]["litellm_params"]["cooldown_time"] == 0
@pytest.mark.asyncio
async def test_cooldown_time_zero_uses_zero_not_default():
"""
Test that when cooldown_time=0 is passed, it uses 0 instead of the default cooldown time
AND that the early exit logic prevents cooldown entirely
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"cooldown_time": 0,
},
},
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4",
},
},
],
cooldown_time=300,
num_retries=0,
)
with patch.object(router.cooldown_cache, "add_deployment_to_cooldown") as mock_add_cooldown:
try:
await router.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hey, how's it going?"}],
mock_response="litellm.RateLimitError",
)
except litellm.RateLimitError:
pass
mock_add_cooldown.assert_not_called()
cooldown_list = await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
assert len(cooldown_list) == 0
healthy_deployments, _ = await router._async_get_healthy_deployments(model="gpt-3.5-turbo", parent_otel_span=None)
assert len(healthy_deployments) == 1
def test_should_run_cooldown_logic_early_exit_on_zero_cooldown():
"""
Unit test for _should_run_cooldown_logic to verify early exit when time_to_cooldown is 0
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
},
"model_info": {
"id": "test-deployment-id",
},
}
],
cooldown_time=300,
)
result = _should_run_cooldown_logic(
litellm_router_instance=router,
deployment="test-deployment-id",
exception_status=429,
original_exception=litellm.RateLimitError("test error", "openai", "gpt-3.5-turbo"),
time_to_cooldown=0.0,
)
assert result is False, "Should not run cooldown logic when time_to_cooldown is 0"
result = _should_run_cooldown_logic(
litellm_router_instance=router,
deployment="test-deployment-id",
exception_status=429,
original_exception=litellm.RateLimitError("test error", "openai", "gpt-3.5-turbo"),
time_to_cooldown=1e-10,
)
assert result is False, "Should not run cooldown logic when time_to_cooldown is effectively 0"
result = _should_run_cooldown_logic(
litellm_router_instance=router,
deployment="test-deployment-id",
exception_status=429,
original_exception=litellm.RateLimitError("test error", "openai", "gpt-3.5-turbo"),
time_to_cooldown=None,
)
assert result is True, "Should run cooldown logic when time_to_cooldown is None"
result = _should_run_cooldown_logic(
litellm_router_instance=router,
deployment="test-deployment-id",
exception_status=429,
original_exception=litellm.RateLimitError("test error", "openai", "gpt-3.5-turbo"),
time_to_cooldown=60.0,
)
assert result is True, "Should run cooldown logic when time_to_cooldown is positive"
@pytest.mark.parametrize("num_deployments", [1, 2])
def test_single_deployment_no_cooldowns(num_deployments: int):
"""
Do not cooldown on single deployment.
Cooldown on multiple deployments.
"""
model_list = []
for i in range(num_deployments):
model = DeploymentTypedDict(
model_name="gpt-3.5-turbo",
litellm_params=LiteLLMParamsTypedDict(
model="gpt-3.5-turbo",
),
)
model_list.append(model)
router = Router(model_list=model_list, num_retries=0)
with patch.object(router.cooldown_cache, "add_deployment_to_cooldown", new=MagicMock()) as mock_client:
try:
router.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hey, how's it going?"}],
mock_response="litellm.RateLimitError",
)
except litellm.RateLimitError:
pass
if num_deployments == 1:
mock_client.assert_not_called()
else:
mock_client.assert_called_once()
@pytest.mark.asyncio
async def test_single_deployment_no_cooldowns_test_prod():
"""
Do not cooldown on single deployment.
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
},
},
{
"model_name": "gpt-5",
"litellm_params": {
"model": "openai/gpt-5",
},
},
{
"model_name": "gpt-12",
"litellm_params": {
"model": "openai/gpt-12",
},
},
],
num_retries=0,
)
with patch.object(router.cooldown_cache, "add_deployment_to_cooldown", new=MagicMock()) as mock_client:
try:
await router.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hey, how's it going?"}],
mock_response="litellm.RateLimitError",
)
except litellm.RateLimitError:
pass
await asyncio.sleep(2)
mock_client.assert_not_called()
@pytest.mark.asyncio()
async def test_high_traffic_cooldowns_all_healthy_deployments():
"""
PROD TEST - 3 deployments, each deployment fails 25% requests. Assert that no deployments get put into cooldown
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com",
},
},
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com-2",
},
},
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com-3",
},
},
],
set_verbose=True,
debug_level="DEBUG",
)
all_deployment_ids = router.get_model_ids()
from collections import defaultdict
# Create a defaultdict to track successes and failures for each model ID
model_stats = defaultdict(lambda: {"successes": 0, "failures": 0})
litellm.set_verbose = True
for _ in range(100):
try:
model_id = random.choice(all_deployment_ids)
num_successes = model_stats[model_id]["successes"]
num_failures = model_stats[model_id]["failures"]
total_requests = num_failures + num_successes
if total_requests > 0:
print(
"num failures= ",
num_failures,
"num successes= ",
num_successes,
"num_failures/total = ",
num_failures / total_requests,
)
if total_requests == 0:
mock_response = "hi"
elif num_failures / total_requests <= 0.25:
# Randomly decide between fail and succeed
if random.random() < 0.5:
mock_response = "hi"
else:
mock_response = "litellm.InternalServerError"
else:
mock_response = "hi"
await router.acompletion(
model=model_id,
messages=[{"role": "user", "content": "Hey, how's it going?"}],
mock_response=mock_response,
)
model_stats[model_id]["successes"] += 1
await asyncio.sleep(0.0001)
except litellm.InternalServerError:
model_stats[model_id]["failures"] += 1
pass
except Exception as e:
print("Failed test model stats=", model_stats)
raise e
print("model_stats: ", model_stats)
cooldown_list = await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
assert len(cooldown_list) == 0
@pytest.mark.asyncio()
async def test_high_traffic_cooldowns_one_bad_deployment():
"""
PROD TEST - 3 deployments, 1- deployment fails 6/10 requests, assert that bad deployment gets put into cooldown
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com",
},
},
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com-2",
},
},
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com-3",
},
},
],
set_verbose=True,
debug_level="DEBUG",
)
all_deployment_ids = router.get_model_ids()
from collections import defaultdict
# Create a defaultdict to track successes and failures for each model ID
model_stats = defaultdict(lambda: {"successes": 0, "failures": 0})
bad_deployment_id = random.choice(all_deployment_ids)
litellm.set_verbose = True
for _ in range(100):
try:
model_id = random.choice(all_deployment_ids)
num_successes = model_stats[model_id]["successes"]
num_failures = model_stats[model_id]["failures"]
total_requests = num_failures + num_successes
if total_requests > 0:
print(
"num failures= ",
num_failures,
"num successes= ",
num_successes,
"num_failures/total = ",
num_failures / total_requests,
)
if total_requests == 0:
mock_response = "hi"
elif bad_deployment_id == model_id:
if num_failures / total_requests <= 0.6:
mock_response = "litellm.InternalServerError"
elif num_failures / total_requests <= 0.25:
# Randomly decide between fail and succeed
if random.random() < 0.5:
mock_response = "hi"
else:
mock_response = "litellm.InternalServerError"
else:
mock_response = "hi"
await router.acompletion(
model=model_id,
messages=[{"role": "user", "content": "Hey, how's it going?"}],
mock_response=mock_response,
)
model_stats[model_id]["successes"] += 1
await asyncio.sleep(0.0001)
except litellm.InternalServerError:
model_stats[model_id]["failures"] += 1
pass
except Exception as e:
print("Failed test model stats=", model_stats)
raise e
print("model_stats: ", model_stats)
cooldown_list = await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
assert len(cooldown_list) == 1
@pytest.mark.asyncio()
async def test_high_traffic_cooldowns_one_rate_limited_deployment():
"""
PROD TEST - 3 deployments, 1- deployment fails 6/10 requests, assert that bad deployment gets put into cooldown
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com",
},
},
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com-2",
},
},
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_base": "https://api.openai.com-3",
},
},
],
set_verbose=True,
debug_level="DEBUG",
)
all_deployment_ids = router.get_model_ids()
from collections import defaultdict
# Create a defaultdict to track successes and failures for each model ID
model_stats = defaultdict(lambda: {"successes": 0, "failures": 0})
bad_deployment_id = random.choice(all_deployment_ids)
litellm.set_verbose = True
for _ in range(100):
try:
model_id = random.choice(all_deployment_ids)
num_successes = model_stats[model_id]["successes"]
num_failures = model_stats[model_id]["failures"]
total_requests = num_failures + num_successes
if total_requests > 0:
print(
"num failures= ",
num_failures,
"num successes= ",
num_successes,
"num_failures/total = ",
num_failures / total_requests,
)
if total_requests == 0:
mock_response = "hi"
elif bad_deployment_id == model_id:
if num_failures / total_requests <= 0.6:
mock_response = "litellm.RateLimitError"
elif num_failures / total_requests <= 0.25:
# Randomly decide between fail and succeed
if random.random() < 0.5:
mock_response = "hi"
else:
mock_response = "litellm.InternalServerError"
else:
mock_response = "hi"
await router.acompletion(
model=model_id,
messages=[{"role": "user", "content": "Hey, how's it going?"}],
mock_response=mock_response,
)
model_stats[model_id]["successes"] += 1
await asyncio.sleep(0.0001)
except litellm.InternalServerError:
model_stats[model_id]["failures"] += 1
pass
except litellm.RateLimitError:
model_stats[bad_deployment_id]["failures"] += 1
pass
except Exception as e:
print("Failed test model stats=", model_stats)
raise e
print("model_stats: ", model_stats)
cooldown_list = await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
assert len(cooldown_list) == 1
def test_router_fallbacks_with_cooldowns_and_model_id():
"""
Test that after a RateLimitError, the router can still route subsequent
requests to the same deployment (i.e., mock errors don't permanently
cool down the deployment).
"""
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo"},
"model_info": {
"id": "123",
},
}
],
routing_strategy="usage-based-routing-v2",
)
try:
router.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "hi"}],
mock_response="litellm.RateLimitError",
)
except litellm.RateLimitError:
pass
response = router.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "hi"}],
mock_response="hello",
)
assert response is not None
@pytest.mark.asyncio()
async def test_router_fallbacks_with_cooldowns_and_dynamic_credentials():
"""
A 429 answered to a caller-supplied credential cools down none of the shared deployments,
so the next credential still reaches them, while a 429 owned by a shared deployment does
"""
from litellm.router_utils.cooldown_handlers import async_get_cooldown_deployments
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo"},
"model_info": {"id": deployment_id},
}
for deployment_id in ("123", "456")
],
num_retries=0,
)
messages = [{"role": "user", "content": "hi"}]
with pytest.raises(litellm.RateLimitError):
await router.acompletion(
model="gpt-3.5-turbo", messages=messages, api_key="my-bad-key-1", mock_response="litellm.RateLimitError"
)
await asyncio.sleep(1)
assert await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None) == []
response = await router.acompletion(
model="gpt-3.5-turbo", messages=messages, api_key="my-good-key-2", mock_response="served with credential 2"
)
assert response.choices[0].message.content == "served with credential 2"
with pytest.raises(litellm.RateLimitError):
await router.acompletion(model="gpt-3.5-turbo", messages=messages, mock_response="litellm.RateLimitError")
await asyncio.sleep(1)
cooled_down = await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
assert len(cooled_down) == 1 and cooled_down[0] in {"123", "456"}
@pytest.mark.parametrize(
"exception,status",
[
(
litellm.CallerCredentialAuthenticationError(message="reconnect", llm_provider="github_copilot", model=""),
401,
),
(litellm.CallerCredentialRateLimitError(message="slow down", llm_provider="github_copilot", model=""), 429),
],
)
def test_caller_credential_errors_never_cool_down_the_shared_deployment(single_deployment_router, exception, status):
"""A per-user credential failure is scoped to one caller's stored token; cooling down the
shared deployment would punish every other user on the group."""
assert _should_run_cooldown_logic(single_deployment_router, "dep-1", status, exception) is False
@pytest.mark.asyncio
async def test_bad_request_error_does_not_cool_down_a_deployment(
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
return_value=httpx.Response(
400,
json={
"error": {
"message": "invalid request",
"type": "invalid_request_error",
"param": None,
"code": "invalid_request",
}
},
)
)
router: Final = litellm.Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/test-model", "api_key": "test-key"},
"model_info": {"id": "test-deployment"},
}
],
allowed_fails=0,
num_retries=0,
)
with pytest.raises(litellm.BadRequestError):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "invalid request"}],
)
cooldown_deployments: Final = await async_get_cooldown_deployments(
litellm_router_instance=router,
parent_otel_span=None,
)
assert len(route.calls) == 1
assert tuple(cooldown_deployments) == ()
@pytest.mark.parametrize("use_policy", [False, True], ids=["allowed_fails", "allowed_fails_policy"])
@pytest.mark.asyncio
async def test_allowed_fails_cools_a_single_deployment(
use_policy: bool,
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
side_effect=httpx.ReadTimeout("upstream timeout")
)
policy_kwargs: Final = (
{"allowed_fails_policy": AllowedFailsPolicy(TimeoutErrorAllowedFails=1)}
if use_policy
else {"allowed_fails": 1}
)
router: Final = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/test-model", "api_key": "test-key"},
"model_info": {"id": "test-deployment"},
}
],
cooldown_time=300,
num_retries=0,
**policy_kwargs,
)
for _ in range(2):
with pytest.raises(litellm.Timeout):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "timeout test"}],
)
cooldown_deployments: Final = await async_get_cooldown_deployments(
litellm_router_instance=router,
parent_otel_span=None,
)
assert len(route.calls) == 2
assert tuple(cooldown_deployments) == ("test-deployment",)
def test_per_user_session_upstream_errors_never_cool_down_the_shared_deployment(single_deployment_router):
"""A 429/401 from the caller's own Copilot seat is scoped to that user; it must
not cool down the shared deployment for every other caller."""
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
session_kwargs = {
"github_copilot_user_session": GithubCopilotUserSession(
token="copilot-token", api_base="https://api.githubcopilot.com"
)
}
for exc, status in (
(litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o"), 429),
(litellm.AuthenticationError("copilot 401", "github_copilot", "gpt-4o"), 401),
(litellm.InternalServerError("copilot 500", "github_copilot", "gpt-4o"), 500),
):
assert (
_should_run_cooldown_logic(single_deployment_router, "dep-1", status, exc, request_kwargs=session_kwargs)
is False
)
def test_shared_mode_upstream_429_still_cools_down_the_deployment(single_deployment_router):
"""Regression: without the session marker the same 429 is a deployment-health
signal and cools down exactly as before."""
exc = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o")
assert (
_should_run_cooldown_logic(
single_deployment_router, "dep-1", 429, exc, request_kwargs={"model": "github_copilot/gpt-4o"}
)
is True
)
assert _should_run_cooldown_logic(single_deployment_router, "dep-1", 429, exc) is True
REFUSAL_MESSAGES: Final = [{"role": "user", "content": "cooldown"}]
SECRET_API_BASE: Final = "https://first.example/v1?key=sk-scripted-secret"
FIRST_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-1", api_base=mask_api_base_credentials(SECRET_API_BASE))
SECOND_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-2", api_base=None)
THIRD_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-3", api_base=None)
OTHER_TEAMS_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-team-b", api_base=None)
FIRST_WILDCARD: Final = CooldownDeploymentInfo(model_id="wildcard-1", api_base="https://wildcard.example/v1")
SECOND_WILDCARD: Final = CooldownDeploymentInfo(model_id="wildcard-2", api_base=None)
ALIAS_TARGET: Final = CooldownDeploymentInfo(model_id="tier-1", api_base="https://tier.example/v1")
PROVIDER_MODEL: Final = CooldownDeploymentInfo(model_id="friendly-1", api_base=None)
EVERY_DEPLOYMENT: Final = (
"refused-1",
"refused-2",
"refused-3",
"refused-team-b",
"neighbour-1",
"wildcard-1",
"wildcard-2",
"tier-1",
"friendly-1",
)
def _cooled_router(routing_strategy: str, cooled: tuple[str, ...]) -> Router:
router: Final = Router(
routing_strategy=routing_strategy,
model_group_alias={"entry": "tier", "tier": "refused"},
model_list=[
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "api_base": SECRET_API_BASE, "use_in_pass_through": True},
"model_info": {"id": "refused-1"},
},
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "use_in_pass_through": True, "tpm": 1},
"model_info": {"id": "refused-2"},
},
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "tpm": 1},
"model_info": {"id": "refused-3"},
},
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "use_in_pass_through": True},
"model_info": {"id": "refused-team-b", "team_id": "team-b"},
},
{
"model_name": "neighbour",
"litellm_params": {"model": "openai/gpt-5.4", "api_base": "https://neighbour.example/v1"},
"model_info": {"id": "neighbour-1"},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*", "api_base": "https://wildcard.example/v1"},
"model_info": {"id": "wildcard-1"},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*"},
"model_info": {"id": "wildcard-2"},
},
{
"model_name": "tier",
"litellm_params": {"model": "openai/gpt-5.4", "api_base": "https://tier.example/v1"},
"model_info": {"id": "tier-1"},
},
{
"model_name": "friendly",
"litellm_params": {"model": "anthropic/claude-friendly"},
"model_info": {"id": "friendly-1"},
},
],
)
for model_id in cooled:
router.cooldown_cache.add_deployment_to_cooldown(
model_id=model_id,
original_exception=litellm.RateLimitError("scripted 429", "openai", "gpt-5.4"),
exception_status=429,
cooldown_time=60,
)
return router
def _team_request(team_id: str | None) -> dict[str, object]:
return {"metadata": {"user_api_key_team_id": team_id} if team_id else {}}
def _select(router: Router, entrypoint: str, model: str, request_kwargs: dict[str, object]) -> object:
match entrypoint:
case "async":
return asyncio.run(
router.async_get_available_deployment(
model=model, messages=REFUSAL_MESSAGES, request_kwargs=request_kwargs
)
)
case "async-pass-through":
return asyncio.run(
router.async_get_available_deployment_for_pass_through(model=model, request_kwargs=request_kwargs)
)
case "pass-through":
return router.get_available_deployment_for_pass_through(model=model, request_kwargs=request_kwargs)
case "pass-through-chat":
return router.get_available_deployment_for_pass_through(
model=model, messages=REFUSAL_MESSAGES, request_kwargs=request_kwargs
)
case _:
return router.get_available_deployment(
model=model, messages=REFUSAL_MESSAGES, request_kwargs=request_kwargs
)
ALL_COOLED: Final = (FIRST_REFUSED, SECOND_REFUSED, THIRD_REFUSED)
STRATEGY_COOLED: Final = ("refused-1", "refused-team-b", "neighbour-1")
WILDCARD_COOLED: Final = (FIRST_WILDCARD, SECOND_WILDCARD)
@pytest.mark.parametrize(
("routing_strategy", "cooled", "entrypoint", "model", "team_id", "expected"),
(
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "refused", "team-a", ALL_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "refused", "team-a", ALL_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "pass-through", "refused", "team-a", (FIRST_REFUSED, SECOND_REFUSED)),
("simple-shuffle", EVERY_DEPLOYMENT, "async-pass-through", "refused", "team-a", ALL_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "refused", "team-b", (*ALL_COOLED, OTHER_TEAMS_REFUSED)),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "refused", None, ALL_COOLED),
("cost-based-routing", STRATEGY_COOLED, "sync", "refused", "team-a", None),
("cost-based-routing", STRATEGY_COOLED, "async", "refused", "team-a", None),
("cost-based-routing", STRATEGY_COOLED, "pass-through", "refused", "team-a", None),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "openai/gpt-5.4-mini", "team-a", WILDCARD_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "openai/gpt-5.4-mini", "team-a", WILDCARD_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "entry", "team-a", (ALIAS_TARGET,)),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "entry", "team-a", (ALIAS_TARGET,)),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "anthropic/claude-friendly", "team-a", (PROVIDER_MODEL,)),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "anthropic/claude-friendly", "team-a", (PROVIDER_MODEL,)),
),
ids=(
"all-cooled-sync",
"all-cooled-async",
"all-cooled-pass-through",
"all-cooled-async-pass-through",
"all-cooled-owning-team",
"all-cooled-async-no-team",
"strategy-refused-sync",
"strategy-refused-async",
"strategy-refused-pass-through",
"wildcard-sync",
"wildcard-async",
"alias-chain-sync",
"alias-chain-async",
"provider-model-sync",
"provider-model-async",
),
)
def test_no_deployment_error_names_the_cooled_deployments_the_request_could_route_to(
routing_strategy: str,
cooled: tuple[str, ...],
entrypoint: str,
model: str,
team_id: str | None,
expected: tuple[CooldownDeploymentInfo, ...] | None,
) -> None:
router: Final = _cooled_router(routing_strategy, cooled)
with pytest.raises(RouterRateLimitError) as refusal:
_select(router, entrypoint, model, _team_request(team_id))
assert refusal.value.cooldown_deployments == expected, refusal.value.cooldown_list
@pytest.mark.parametrize("entrypoint", ("sync", "async"))
def test_no_deployment_error_names_only_the_cooled_part_of_a_route_emptied_by_other_filters(entrypoint: str) -> None:
router: Final = _cooled_router("simple-shuffle", STRATEGY_COOLED)
request_kwargs: Final = {**_team_request("team-a"), "_excluded_deployment_ids": ["refused-2", "refused-3"]}
with pytest.raises(RouterRateLimitError) as refusal:
_select(router, entrypoint, "refused", request_kwargs)
assert refusal.value.cooldown_deployments == (FIRST_REFUSED,), refusal.value.cooldown_list
assert "sk-scripted-secret" not in repr(refusal.value.cooldown_deployments)
TENANT_A: Final = CooldownDeploymentInfo(model_id="tenant-a-1", api_base="https://tenant-a.example/v1")
TENANT_B: Final = CooldownDeploymentInfo(model_id="tenant-b-1", api_base="https://tenant-b.example/v1")
SHARED: Final = CooldownDeploymentInfo(model_id="shared-1", api_base="https://shared.example/v1")
EVERY_TENANT: Final = (TENANT_A, TENANT_B, SHARED)
TENANT_ROUTES: Final = ((TENANT_A, "tenant-a", "eu"), (TENANT_B, "tenant-b", "us"), (SHARED, "default", "eu"))
def _tenant_router(cooled: tuple[CooldownDeploymentInfo, ...], **router_settings: bool) -> Router:
router: Final = Router(
model_list=[
{
"model_name": "tenants",
"litellm_params": {
"model": "openai/gpt-5.4",
"api_base": deployment["api_base"],
"tags": [tag],
"region_name": region,
"use_in_pass_through": True,
},
"model_info": {"id": deployment["model_id"]},
}
for deployment, tag, region in TENANT_ROUTES
],
**router_settings,
)
for deployment in cooled:
router.cooldown_cache.add_deployment_to_cooldown(
model_id=deployment["model_id"],
original_exception=litellm.RateLimitError("scripted 429", "openai", "gpt-5.4"),
exception_status=429,
cooldown_time=60,
)
return router
TAGS_ON: Final = {"enable_tag_filtering": True}
REGION_CHECKS_ON: Final = {"enable_pre_call_checks": True}
BOTH_ON: Final = {**TAGS_ON, **REGION_CHECKS_ON}
@pytest.mark.parametrize(
("router_settings", "entrypoint", "request_kwargs", "expected"),
(
(TAGS_ON, "async", {"metadata": {"tags": ["tenant-a"]}}, (TENANT_A,)),
(TAGS_ON, "async", {"metadata": {"tags": ["tenant-b"]}}, (TENANT_B,)),
(TAGS_ON, "async", {"metadata": {}}, EVERY_TENANT),
(TAGS_ON, "async", {}, EVERY_TENANT),
(TAGS_ON, "async", {"metadata": "unparsed"}, ()),
(TAGS_ON, "async", {"litellm_metadata": {"tags": ["tenant-a"]}}, (TENANT_A,)),
(TAGS_ON, "async", {"metadata": {"tags": ["!tenant-a", "&tenant-b"]}}, (TENANT_B,)),
(TAGS_ON, "async", {"metadata": {"tags": ["!tenant-a"]}}, (TENANT_B, SHARED)),
(TAGS_ON, "async", {"metadata": {"tags": ["tenant-z"]}}, (SHARED,)),
(TAGS_ON, "async-pass-through", {"metadata": {"tags": ["tenant-a"]}}, (TENANT_A,)),
({}, "async", {"metadata": {"tags": ["tenant-a"]}, "enable_tag_filtering": True}, (TENANT_A,)),
({}, "async", {"metadata": {"tags": ["tenant-a"]}}, EVERY_TENANT),
(REGION_CHECKS_ON, "async", {"metadata": {}, "allowed_model_region": "eu"}, (TENANT_A, SHARED)),
(REGION_CHECKS_ON, "sync", {"metadata": {}, "allowed_model_region": "eu"}, (TENANT_A, SHARED)),
(REGION_CHECKS_ON, "pass-through-chat", {"metadata": {}, "allowed_model_region": "eu"}, (TENANT_A, SHARED)),
(REGION_CHECKS_ON, "pass-through", {"metadata": {}, "allowed_model_region": "eu"}, EVERY_TENANT),
({}, "sync", {"metadata": {}, "allowed_model_region": "eu"}, EVERY_TENANT),
(REGION_CHECKS_ON, "sync", {"metadata": {}, "allowed_model_region": 1}, ()),
(
{**TAGS_ON, **REGION_CHECKS_ON},
"async",
{"metadata": {"tags": ["tenant-a"]}, "allowed_model_region": "us"},
(),
),
),
ids=(
"tag-a",
"tag-b",
"untagged-falls-back-past-the-default",
"no-metadata",
"metadata-not-a-mapping",
"tag-litellm-metadata",
"tag-constraints",
"tag-exclusion-falls-back-in-tiers",
"unknown-tag-gets-default",
"tag-async-pass-through",
"tag-request-level-switch",
"tags-off",
"region-async",
"region-sync",
"region-pass-through",
"region-pass-through-without-input",
"region-without-pre-call-checks",
"region-not-a-string",
"region-and-tag-allow-none",
),
)
def test_no_deployment_error_names_only_the_cooled_deployments_tag_and_region_routing_allow(
router_settings: dict[str, bool],
entrypoint: str,
request_kwargs: dict[str, object],
expected: tuple[CooldownDeploymentInfo, ...],
) -> None:
router: Final = _tenant_router(EVERY_TENANT, **router_settings)
with pytest.raises(RouterRateLimitError) as refusal:
_select(router, entrypoint, "tenants", request_kwargs)
assert refusal.value.cooldown_deployments == expected, refusal.value.cooldown_list
def test_naming_tag_routed_deployments_leaves_the_request_metadata_untouched() -> None:
metadata: Final[dict[str, object]] = {"tags": ["tenant-a"]}
with pytest.raises(RouterRateLimitError) as refusal:
_select(_tenant_router(EVERY_TENANT, **TAGS_ON), "async", "tenants", {"metadata": metadata})
assert refusal.value.cooldown_deployments == (TENANT_A,)
assert metadata == {"tags": ["tenant-a"]}
@pytest.mark.parametrize(
("has_request_input", "expected"), ((True, (TENANT_A, SHARED)), (False, EVERY_TENANT)), ids=("chat", "no-input")
)
def test_region_routable_deployments_follow_the_pre_call_region_check(
has_request_input: bool, expected: tuple[CooldownDeploymentInfo, ...]
) -> None:
router: Final = _tenant_router((), **REGION_CHECKS_ON)
kept: Final = router._region_routable_deployments(
router.get_model_list(model_name="tenants") or [], {"allowed_model_region": "eu"}, has_request_input
)
assert router._deployment_ids(kept) == frozenset(deployment["model_id"] for deployment in expected)
def test_routable_cooled_deployment_ids_skip_healthy_deployments_and_peel_tag_fallbacks() -> None:
router: Final = _tenant_router((TENANT_A, SHARED), **TAGS_ON)
routable: Final = asyncio.run(
router._async_routable_cooled_deployment_ids(
model="tenants",
request_deployments=router.get_model_list(model_name="tenants") or [],
cooldown_deployments=(TENANT_A["model_id"], SHARED["model_id"]),
request_kwargs={"metadata": {}},
has_request_input=True,
)
)
assert routable == frozenset((TENANT_A["model_id"], SHARED["model_id"]))
REACHABILITY_REQUESTS: Final = (
{"metadata": {"tags": ["tenant-a"]}},
{"metadata": {}},
{"metadata": {"tags": ["!tenant-a"]}},
{"metadata": {"tags": ["tenant-z"]}},
{"metadata": {"tags": ["!tenant-a", "&tenant-b"]}},
{"metadata": {}, "allowed_model_region": "eu"},
{"metadata": {"tags": ["tenant-a"]}, "allowed_model_region": "us"},
)
HEALTH_STATES: Final = tuple(
itertools.chain.from_iterable(
itertools.combinations(EVERY_TENANT, size) for size in range(1, len(EVERY_TENANT) + 1)
)
)
def _routable_while_up(up: tuple[CooldownDeploymentInfo, ...], request_kwargs: dict[str, object]) -> set[str]:
router: Final = _tenant_router(tuple(deployment for deployment in EVERY_TENANT if deployment not in up), **BOTH_ON)
try:
pool: Final = asyncio.run(
router.async_get_healthy_deployments(
model="tenants", request_kwargs=copy.deepcopy(request_kwargs), messages=REFUSAL_MESSAGES
)
)
except ValueError:
return set()
return {str(deployment["model_info"]["id"]) for deployment in pool}
@pytest.mark.parametrize("request_kwargs", REACHABILITY_REQUESTS)
def test_the_refusal_names_each_cooled_deployment_some_cooldown_state_routes_the_request_to(
request_kwargs: dict[str, object],
) -> None:
reachable: Final = set().union(*(_routable_while_up(up, request_kwargs) for up in HEALTH_STATES))
with pytest.raises(RouterRateLimitError) as refusal:
_select(_tenant_router(EVERY_TENANT, **BOTH_ON), "async", "tenants", copy.deepcopy(request_kwargs))
assert {deployment["model_id"] for deployment in refusal.value.cooldown_deployments or ()} == reachable