fix(router): cool down failing deployments when fallbacks use string targets (LIT-10238) (#48913)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-10-28 15:32:27 -07:00 • committed by GitHub
parent 2791822f92
commit 84c4c5aa5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 8 deletions

View file

@ -13,7 +13,7 @@ from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._internal_context import service_target
@ -417,17 +417,35 @@ def _should_run_cooldown_logic(
return True
_FALLBACK_ENTRIES_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, list[str | Mapping[str, object]]] | str, ...])
_FALLBACK_ENTRIES_ADAPTER: Final = TypeAdapter(tuple[object, ...])
_FALLBACK_ENTRY_ADAPTER: Final = TypeAdapter(Mapping[str, list[str | Mapping[str, object]] | str])
def _fallback_target_name(target: str | Mapping[str, object]) -> str:
return target if isinstance(target, str) else str(target.get("model", ""))
def _fallback_target_names(
targets: Sequence[str | Mapping[str, object]] | str,
) -> list[str]: # mutable-ok: mirrors get_fallback_model_group's fallbacks shape
return [targets] if isinstance(targets, str) else [_fallback_target_name(target) for target in targets]
def _fallback_chain_names(
entry: Mapping[str, Sequence[str | Mapping[str, object]]],
) -> Mapping[str, list[str]]: # mutable-ok: mirrors get_fallback_model_group's fallbacks shape
return {key: [_fallback_target_name(target) for target in targets] for key, targets in entry.items()}
entry: object,
) -> Mapping[str, list[str]] | None: # mutable-ok: mirrors get_fallback_model_group's fallbacks shape
try:
validated: Final = _FALLBACK_ENTRY_ADAPTER.validate_python(entry)
except ValidationError:
return None
return {key: _fallback_target_names(targets) for key, targets in validated.items()}
def _fallback_entries(configured: object) -> tuple[object, ...]:
try:
return _FALLBACK_ENTRIES_ADAPTER.validate_python(configured)
except ValidationError:
return ()
def _has_fallback_group(litellm_router_instance: LitellmRouter, requested_model_group: str | None) -> bool:
@ -437,9 +455,7 @@ def _has_fallback_group(litellm_router_instance: LitellmRouter, requested_model_
if requested_model_group is None or not configured:
return False
chains: Final = tuple(
_fallback_chain_names(entry)
for entry in _FALLBACK_ENTRIES_ADAPTER.validate_python(configured)
if isinstance(entry, Mapping)
chain for chain in map(_fallback_chain_names, _fallback_entries(configured)) if chain is not None
)
fallback_group, _ = get_fallback_model_group(fallbacks=chains, model_group=requested_model_group)
return bool(fallback_group)

View file

@ -12,6 +12,7 @@ from pydantic import JsonValue, TypeAdapter
import litellm
from litellm import Router
from litellm.router_utils.cooldown_handlers import async_get_cooldown_deployments
from litellm.types.router import DeploymentTypedDict
_MODEL: Final = "gpt-5.4"
@ -118,3 +119,30 @@ async def test_cooldown_skips_only_the_failed_duplicate_model_deployment() -> No
"/primary/chat/completions",
"/backup/chat/completions",
)
@pytest.mark.parametrize(
"router_kwargs",
[{"fallbacks": [{"primary": "backup"}]}, {"default_fallbacks": "backup"}],
ids=["string_fallback_target", "string_default_fallbacks"],
)
@pytest.mark.asyncio
async def test_string_valued_fallbacks_still_cool_down_the_failing_primary(router_kwargs: dict[str, object]) -> None:
with wire_server(_peer) as wire:
router: Final = Router(
model_list=[
_deployment("primary", f"{wire.url}/primary", "primary-deployment"),
_deployment("backup", f"{wire.url}/backup", "backup-deployment"),
],
allowed_fails=0,
num_retries=0,
**router_kwargs,
)
with pytest.raises(litellm.AuthenticationError):
await router.acompletion(
model="primary",
messages=[{"role": "user", "content": f"cooldown {uuid.uuid4().hex}"}],
)
cooled: Final = await async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
assert tuple(cooled) == ("primary-deployment",)

View file

@ -25,6 +25,7 @@ from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callba
from litellm.router_utils.cooldown_handlers import (
_get_deployment_cooldown_policy,
_has_explicit_allowed_fails_policy_for_exception,
_has_fallback_group,
_increment_allowed_fails,
_is_cooldown_required,
_resolve_allowed_fails_from_policy,
@ -3149,6 +3150,8 @@ BENCHED: Final = (litellm.Timeout, litellm.Timeout, RouterRateLimitError, Router
{},
BENCHED,
),
({"allowed_fails": 1, "fallbacks": [{"test-model": "backup-model"}]}, {}, BENCHED),
({"allowed_fails": 1, "default_fallbacks": "backup-model"}, {}, BENCHED),
],
ids=[
"allowed_fails",
@ -3157,6 +3160,8 @@ BENCHED: Final = (litellm.Timeout, litellm.Timeout, RouterRateLimitError, Router
"policy_names_timeout",
"allowed_fails_with_a_fallback_group",
"allowed_fails_with_a_dict_fallback_target",
"allowed_fails_with_a_string_fallback_target",
"allowed_fails_with_a_string_default_fallbacks",
],
)
@pytest.mark.asyncio
@ -3180,6 +3185,33 @@ async def test_router_allowed_fails_benches_a_lone_deployment_only_when_the_poli
assert len(route.calls) == outcomes.count(litellm.Timeout)
@pytest.mark.parametrize(
("fallbacks", "expected"),
[
([{"test-model": "backup-model"}], True),
([{"*": "backup-model"}], True),
([{"test-model": ["backup-model"]}], True),
([{"test-model": 5}, {"*": "backup-model"}], True),
([{"test-model": 5}], False),
([{"other-model": ["backup-model"]}], False),
],
ids=[
"string_target",
"string_catch_all_from_default_fallbacks",
"list_target",
"unparseable_entry_does_not_hide_the_catch_all",
"unparseable_entry_alone",
"another_groups_chain",
],
)
def test_has_fallback_group_reads_string_targets_and_skips_unparseable_entries(
fallbacks: Sequence[Mapping[str, object]], expected: bool
) -> None:
router: Final = Router(model_list=[TIMED_OUT_DEPLOYMENT], fallbacks=list(fallbacks))
assert _has_fallback_group(router, "test-model") is expected
async def _group_call_outcome(router: Router) -> str:
try:
response: Final = await router.acompletion(