From 95b6cf50a70d11653f4ad183bc3b0df7ee85b840 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:34:28 +0000 Subject: [PATCH 1/2] fix(proxy): stop listing literal wildcard routes as models get_complete_model_list expanded wildcard routes like openai/* into concrete models but, when the router held a deployment for the pattern, left the literal openai/* in the listing too. The /models endpoint then advertised openai/* as a callable model, misleading clients. Drop the literal in the router-backed branch like the other expansion branches already do; it is still surfaced when return_wildcard_routes is set. Also type the local accumulators. Fixes #13752 --- litellm/proxy/auth/model_checks.py | 78 +++++++++++-------- .../proxy/auth/test_model_checks.py | 49 ++++++++++++ 2 files changed, 96 insertions(+), 31 deletions(-) diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index 1625198892f..f7f35d8aa8f 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 @@ -377,44 +377,60 @@ 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) + + def _get_wildcard_models( unique_models: list[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) + 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) - - if wildcard_models: - models_to_remove.add(model) - all_wildcard_models.extend(wildcard_models) + all_wildcard_models: Final = [ + item + for model, expansion in expansions.items() + for item in [*([model] if return_wildcard_routes else []), *expansion.concrete_models] + ] + # The literal pattern (e.g. "openai/*") is a routing directive, not a callable + # model, so drop it from the listing once expanded. It is re-surfaced above only + # when return_wildcard_routes is set (#13752). + models_to_remove: Final = { + model for model, expansion in expansions.items() if expansion.has_router_deployment or expansion.concrete_models + } for model in models_to_remove: unique_models.remove(model) diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 5161554b969..586f455fc46 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -804,3 +804,52 @@ 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) From 294efdcd39db81010be7c6a224ea8c64e86710b0 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:06:20 +0000 Subject: [PATCH 2/2] fix(proxy): stop mutating model list when expanding wildcards (#36398) _get_wildcard_models mutated its unique_models parameter and removed only the first occurrence of each expanded wildcard, so a duplicate literal route (e.g. openai/*) could leak into /v1/models. Return an immutable _WildcardModels tuple and have get_complete_model_list build the result functionally, dropping every expanded literal regardless of duplicates. RED->GREEN: input-mutation regression fails on the old remove-in-place code, passes after. Full tests/test_litellm/proxy/auth suite green (1447 passed). --- litellm/proxy/auth/model_checks.py | 30 +++++++++---------- .../proxy/auth/test_model_checks.py | 22 ++++++++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index f7f35d8aa8f..67a7f6a0d3c 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -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 @@ -407,34 +407,34 @@ def _expand_wildcard_model(model: str, llm_router: Router | None, team_id: str | 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]: +) -> _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) } - all_wildcard_models: Final = [ + expanded_models: Final = tuple( item for model, expansion in expansions.items() for item in [*([model] if return_wildcard_routes else []), *expansion.concrete_models] - ] + ) - # The literal pattern (e.g. "openai/*") is a routing directive, not a callable - # model, so drop it from the listing once expanded. It is re-surfaced above only - # when return_wildcard_routes is set (#13752). - models_to_remove: Final = { + literals_to_drop: Final = frozenset( model for model, expansion in expansions.items() if expansion.has_router_deployment or expansion.concrete_models - } - for model in models_to_remove: - unique_models.remove(model) - - return all_wildcard_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 586f455fc46..21b25ccf755 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -853,3 +853,25 @@ def test_get_complete_model_list_omits_literal_wildcard_when_router_has_deployme ) 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)