mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
2791822f92
commit
84c4c5aa5c
3 changed files with 84 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue