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] 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)