diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index 1625198892f..67a7f6a0d3c 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -2,7 +2,7 @@ ## Common checks for /v1/models and `/model/info` import copy from collections.abc import Sequence -from typing import Any, Final +from typing import Any, Final, NamedTuple import litellm from litellm._logging import verbose_proxy_logger @@ -232,14 +232,14 @@ def get_complete_model_list( model_access_groups_to_return.append(model) return model_access_groups_to_return - all_wildcard_models: Final = _get_wildcard_models( + wildcard: Final = _get_wildcard_models( unique_models=unique_models, return_wildcard_routes=return_wildcard_routes, llm_router=llm_router, team_id=team_id, ) - complete_model_list: Final = unique_models + all_wildcard_models + complete_model_list: Final = list(wildcard.kept_models + wildcard.expanded_models) return complete_model_list @@ -377,48 +377,64 @@ def expand_wildcard_deployments_for_model_info( return expanded +class _WildcardExpansion(NamedTuple): + concrete_models: tuple[str, ...] + has_router_deployment: bool + + +def _expand_wildcard_model(model: str, llm_router: Router | None, team_id: str | None) -> _WildcardExpansion: + if llm_router is None: + return _WildcardExpansion( + tuple(get_known_models_from_wildcard(wildcard_model=model, litellm_params=None)), False + ) + + router_deployments: Final = llm_router.get_model_list(model_name=model, team_id=team_id) + if not router_deployments: + # Router has no deployment for this wildcard (e.g. BYOK team models); fall + # back to expanding from known provider models. + return _WildcardExpansion( + tuple(get_known_models_from_wildcard(wildcard_model=model, litellm_params=None)), False + ) + + concrete_models: Final = tuple( + concrete + for router_model in router_deployments + for concrete in get_known_models_from_wildcard( + wildcard_model=model, + litellm_params=LiteLLM_Params(**router_model["litellm_params"]), + ) + ) + return _WildcardExpansion(concrete_models, True) + + +class _WildcardModels(NamedTuple): + kept_models: tuple[str, ...] + expanded_models: tuple[str, ...] + + def _get_wildcard_models( - unique_models: list[str], + unique_models: Sequence[str], return_wildcard_routes: bool | None = False, llm_router: Router | None = None, team_id: str | None = None, -) -> list[str]: - models_to_remove: Final = set() - all_wildcard_models: Final = [] - for model in unique_models: - if _check_wildcard_routing(model=model): - if return_wildcard_routes: # will add the wildcard route to the list eg: anthropic/*. - all_wildcard_models.append(model) +) -> _WildcardModels: + expansions: Final = { + model: _expand_wildcard_model(model=model, llm_router=llm_router, team_id=team_id) + for model in unique_models + if _check_wildcard_routing(model=model) + } - ## get litellm params from model - if llm_router is not None: - model_list = llm_router.get_model_list(model_name=model, team_id=team_id) - if model_list: - for router_model in model_list: - wildcard_models = get_known_models_from_wildcard( - wildcard_model=model, - litellm_params=LiteLLM_Params(**router_model["litellm_params"]), - ) - all_wildcard_models.extend(wildcard_models) - else: - # Router has no deployment for this wildcard (e.g., BYOK team models) - # Fall back to expanding from known provider models - wildcard_models = get_known_models_from_wildcard(wildcard_model=model, litellm_params=None) - if wildcard_models: - models_to_remove.add(model) - all_wildcard_models.extend(wildcard_models) - else: - # get all known provider models - wildcard_models = get_known_models_from_wildcard(wildcard_model=model, litellm_params=None) + expanded_models: Final = tuple( + item + for model, expansion in expansions.items() + for item in [*([model] if return_wildcard_routes else []), *expansion.concrete_models] + ) - if wildcard_models: - models_to_remove.add(model) - all_wildcard_models.extend(wildcard_models) - - for model in models_to_remove: - unique_models.remove(model) - - return all_wildcard_models + literals_to_drop: Final = frozenset( + model for model, expansion in expansions.items() if expansion.has_router_deployment or expansion.concrete_models + ) + kept_models: Final = tuple(model for model in unique_models if model not in literals_to_drop) + return _WildcardModels(kept_models=kept_models, expanded_models=expanded_models) def get_all_fallbacks( diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 62073f4bf51..84ae9c0fdc3 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -803,3 +803,74 @@ def test_get_complete_model_list_sentinel_only_grants_nothing(): infer_model_from_keys=False, ) assert result == [] + + +def test_get_complete_model_list_omits_literal_wildcard_when_router_has_deployment(): + """Regression for #13752: a configured wildcard route (e.g. openai/*) with a real + router deployment must expand to concrete models without listing the literal pattern + itself, unless return_wildcard_routes is requested.""" + from unittest.mock import MagicMock + + from litellm.proxy.auth.model_checks import get_complete_model_list + + router = MagicMock() + router.get_model_list.return_value = [{"litellm_params": {"model": "openai/*"}}] + + default_result = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=["openai/*"], + user_model=None, + infer_model_from_keys=False, + return_wildcard_routes=False, + llm_router=router, + ) + assert "openai/*" not in default_result + assert any(m.startswith("openai/") and m != "openai/*" for m in default_result) + + with_routes_result = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=["openai/*"], + user_model=None, + infer_model_from_keys=False, + return_wildcard_routes=True, + llm_router=router, + ) + assert with_routes_result.count("openai/*") == 1 + + # No router at all: the wildcard still expands from known provider models and + # the literal is not listed. + no_router_result = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=["openai/*"], + user_model=None, + infer_model_from_keys=False, + return_wildcard_routes=False, + llm_router=None, + ) + assert "openai/*" not in no_router_result + assert any(m.startswith("openai/") and m != "openai/*" for m in no_router_result) + + +def test_get_wildcard_models_does_not_mutate_input_and_drops_all_literals(): + """Regression for #13752: _get_wildcard_models must not mutate the caller's list and + must drop every expanded wildcard literal from kept_models, even when the same literal + appears more than once, so no literal route leaks back into the listing.""" + from unittest.mock import MagicMock + + from litellm.proxy.auth.model_checks import _get_wildcard_models + + router = MagicMock() + router.get_model_list.return_value = [{"litellm_params": {"model": "openai/*"}}] + + unique_models = ["openai/*", "openai/*"] + original = list(unique_models) + + result = _get_wildcard_models(unique_models=unique_models, llm_router=router) + + assert unique_models == original + assert "openai/*" not in result.kept_models + assert result.expanded_models + assert all(m != "openai/*" for m in result.expanded_models)