fix(router): reject string fallback targets at Router init (LIT-10238) (#48943)

* fix(router): reject string fallback targets at Router init (LIT-10238)

* fix(router): keep direct target fallback entries when rejecting string targets

* test(router): set string fallbacks after init in cooldown tolerance tests

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-10-28 16:32:45 -07:00 • committed by GitHub
parent 9e878cfae2
commit 96fe78f96e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 63 additions and 7 deletions

View file

@ -1370,7 +1370,7 @@ class Router:
},
]
router = Router(model_list=model_list, fallbacks=[{"azure-gpt-3.5-turbo": "openai-gpt-3.5-turbo"}])
router = Router(model_list=model_list, fallbacks=[{"azure-gpt-3.5-turbo": ["openai-gpt-3.5-turbo"]}])
```
"""
@ -1596,6 +1596,10 @@ class Router:
if default_fallbacks is not None or litellm.default_fallbacks is not None:
_fallbacks = default_fallbacks or litellm.default_fallbacks
if isinstance(_fallbacks, str):
raise TypeError(
f"default_fallbacks must be a list of model names, got '{_fallbacks}'. Use ['{_fallbacks}']"
)
if self.fallbacks is not None:
self.fallbacks.append({"*": _fallbacks})
else:
@ -2839,7 +2843,7 @@ class Router:
self._initialize_core_endpoints()
self._initialize_specialized_endpoints()
def validate_fallbacks(self, fallback_param: list | None):
def validate_fallbacks(self, fallback_param: Sequence[Mapping[str, object]] | None):
"""
Validate the fallbacks parameter.
"""
@ -2852,6 +2856,13 @@ class Router:
raise ValueError(
f"Dictionary '{fallback_dict}' must have exactly one key, but has {len(fallback_dict)} keys."
)
((model_group, targets),) = fallback_dict.items()
if isinstance(targets, list) or check_non_standard_fallback_format(fallbacks=[fallback_dict]):
continue
raise TypeError(
f"Fallbacks for '{model_group}' must be a list of model names, got '{targets}'. "
f"Use {{'{model_group}': ['{targets}']}}"
)
def _add_encrypted_content_affinity_check(self, enable_global_affinity: bool) -> None:
def _move_before_deployment_affinity(

View file

@ -3150,8 +3150,6 @@ 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",
@ -3160,8 +3158,6 @@ 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
@ -3207,7 +3203,8 @@ async def test_router_allowed_fails_benches_a_lone_deployment_only_when_the_poli
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))
router: Final = Router(model_list=[TIMED_OUT_DEPLOYMENT])
router.fallbacks = list(fallbacks)
assert _has_fallback_group(router, "test-model") is expected

View file

@ -31228,3 +31228,51 @@ async def test_anthropic_caller_rate_limit_does_not_block_other_credentials(
finally:
await GLOBAL_LOGGING_WORKER.flush()
await client.close()
_FALLBACK_VALIDATION_MODEL_LIST: Final = (
{"model_name": "gpt-4", "litellm_params": {"model": "openai/gpt-4", "api_key": "fake-key"}},
{"model_name": "gpt-3.5", "litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "fake-key"}},
)
@pytest.mark.parametrize(
("router_kwargs", "expected_message"),
[
({"fallbacks": [{"gpt-4": "gpt-3.5"}]}, "Fallbacks for 'gpt-4' must be a list of model names, got 'gpt-3.5'"),
({"default_fallbacks": "gpt-3.5"}, "default_fallbacks must be a list of model names, got 'gpt-3.5'"),
(
{"content_policy_fallbacks": [{"gpt-4": "gpt-3.5"}]},
"Fallbacks for 'gpt-4' must be a list of model names, got 'gpt-3.5'",
),
],
ids=["string_fallback_target", "string_default_fallbacks", "string_content_policy_fallback_target"],
)
def test_router_rejects_a_string_fallback_target_at_init(
router_kwargs: Mapping[str, object], expected_message: str
) -> None:
with pytest.raises(TypeError, match="must be a list of model names") as excinfo:
Router(model_list=list(_FALLBACK_VALIDATION_MODEL_LIST), **router_kwargs)
assert expected_message in str(excinfo.value)
@pytest.mark.parametrize(
("router_kwargs", "expected_fallbacks"),
[
({"fallbacks": [{"gpt-4": ["gpt-3.5"]}]}, [{"gpt-4": ["gpt-3.5"]}]),
({"default_fallbacks": ["gpt-3.5"]}, [{"*": ["gpt-3.5"]}]),
(
{"fallbacks": [{"gpt-4": [{"model": "gpt-3.5", "api_key": "other-key"}]}]},
[{"gpt-4": [{"model": "gpt-3.5", "api_key": "other-key"}]}],
),
({"fallbacks": [{"model": "gpt-3.5"}]}, [{"model": "gpt-3.5"}]),
],
ids=["list_fallback_target", "list_default_fallbacks", "dict_override_fallback_target", "direct_target_entry"],
)
def test_router_keeps_supported_fallback_shapes(
router_kwargs: Mapping[str, object], expected_fallbacks: list[dict[str, object]]
) -> None:
router: Final = Router(model_list=list(_FALLBACK_VALIDATION_MODEL_LIST), **router_kwargs)
assert router.fallbacks == expected_fallbacks