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).
This commit is contained in:
Ben Younes 2026-08-11 21:06:20 +00:00 • committed by Ousama Ben Younes
parent 95b6cf50a7
commit 294efdcd39
2 changed files with 37 additions and 15 deletions

View file

@ -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(

View file

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