diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index 6d683bfa94e..cc3d8aac388 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -267,9 +267,7 @@ def get_complete_model_list( expand_wildcard_routes=expand_wildcard_routes, ) - complete_model_list: Final = unique_models + all_wildcard_models - - return complete_model_list + return list(dict.fromkeys(unique_models + all_wildcard_models)) def _hydrate_litellm_credential_name( @@ -399,18 +397,15 @@ def _deployment_team_id(deployment: Mapping[str, object]) -> str | None: def _cached_wildcard_routing(llm_router: Router | None) -> Callable[[str, str | None], frozenset[str]] | None: + return None if llm_router is None else functools.cache(llm_router.wildcard_routed_models) + + +def _deployed_by_name(llm_router: Router | None) -> Callable[[str, str | None], bool]: if llm_router is None: - return None + return lambda _model, _team_id: False deployed_names: Final = frozenset(llm_router.model_names) team_deployed_names: Final = frozenset(llm_router.team_model_to_deployment_indices) - wildcard_routed_models: Final = llm_router.wildcard_routed_models - - def routed_models(model: str, team_id: str | None) -> frozenset[str]: - if model in deployed_names or (team_id, model) in team_deployed_names: - return frozenset() - return wildcard_routed_models(model, team_id) - - return functools.cache(routed_models) + return lambda model, team_id: model in deployed_names or (team_id, model) in team_deployed_names def _routes_to_model( @@ -522,6 +517,25 @@ def _routed_deployment_model(name_pattern: str, listed_name: str, deployment_mod ) +def _names_listed_under_wildcard( + deployment: Mapping[str, object], + wildcard_pattern: str, + litellm_params: LiteLLM_Params | None, + routed_models: Callable[[str, str | None], frozenset[str]] | None, + deployed_by_name: Callable[[str, str | None], bool], +) -> tuple[str, ...]: + team_id: Final = _deployment_team_id(deployment) + return tuple( + name + for name in get_known_models_from_wildcard( + wildcard_model=wildcard_pattern, + litellm_params=litellm_params, + routes_to_model=_routes_to_model(routed_models, team_id), + ) + if not deployed_by_name(name, team_id) + ) + + def expand_wildcard_deployments_for_model_info( deployments: list[dict[str, object]], llm_router: Router | None = None, @@ -535,6 +549,7 @@ def expand_wildcard_deployments_for_model_info( """ expanded: Final[list[dict[str, object]]] = [] routed_models: Final = _cached_wildcard_routing(llm_router) + deployed_by_name: Final = _deployed_by_name(llm_router) for deployment in deployments: model_name = str(deployment.get("model_name") or "") raw_params = deployment.get("litellm_params") @@ -559,10 +574,8 @@ def expand_wildcard_deployments_for_model_info( except Exception: expanded.append(deployment) continue - expanded_names = get_known_models_from_wildcard( - wildcard_model=wildcard_pattern, - litellm_params=litellm_params, - routes_to_model=_routes_to_model(routed_models, _deployment_team_id(deployment)), + expanded_names = _names_listed_under_wildcard( + deployment, wildcard_pattern, litellm_params, routed_models, deployed_by_name ) if not expanded_names: expanded.append(deployment) diff --git a/tests/unit/proxy/auth/test_model_checks.py b/tests/unit/proxy/auth/test_model_checks.py index dec17ef2476..49289299ea3 100644 --- a/tests/unit/proxy/auth/test_model_checks.py +++ b/tests/unit/proxy/auth/test_model_checks.py @@ -1248,7 +1248,7 @@ def _openai_catalog_behind_a_catch_all( ("model_name_team-x_gpt-4o", {"team_id": "team-x", "team_public_model_name": "gpt-4o"}, "team-x"), ], ) -def test_catch_all_wildcard_never_lists_a_name_a_concrete_deployment_serves( +def test_catch_all_wildcard_lists_a_name_a_concrete_deployment_serves_once( monkeypatch: pytest.MonkeyPatch, concrete_name: str, concrete_model_info: Mapping[str, str], team_id: str | None ) -> None: from litellm.proxy.auth.model_checks import get_complete_model_list @@ -1283,6 +1283,64 @@ def test_catch_all_wildcard_model_info_keeps_only_the_concrete_row_for_a_deploye ] +@contextmanager +def _openrouter_catalog_beside_a_named_deployment( + monkeypatch: pytest.MonkeyPatch, wildcard_name: str, named: str +) -> Generator[Router, None, None]: + import litellm + + monkeypatch.setattr(litellm, "check_provider_endpoint", True) + monkeypatch.delenv("OPENROUTER_API_BASE", raising=False) + with respx.mock(assert_all_called=False) as respx_mock: + respx_mock.get("https://openrouter.ai/api/v1/models/user").mock( + return_value=httpx.Response( + 200, json={"data": [{"id": "openai/gpt-5.4-mini"}, {"id": "anthropic/claude-haiku-5-5"}]} + ) + ) + yield Router( + model_list=[ + { + "model_name": wildcard_name, + "litellm_params": {"model": "openrouter/*", "api_key": f"sk-or-wildcard-{wildcard_name}"}, + }, + { + "model_name": named, + "litellm_params": {"model": "openrouter/openai/gpt-5.4-mini", "api_key": "sk-or-named"}, + }, + ] + ) + + +@pytest.mark.parametrize( + ("wildcard_name", "named"), + [("openrouter/*", "openrouter/openai/gpt-5.4-mini"), ("or-team/*", "or-team/openai/gpt-5.4-mini")], +) +def test_provider_wildcard_lists_a_name_a_named_deployment_serves_once( + monkeypatch: pytest.MonkeyPatch, wildcard_name: str, named: str +) -> None: + from litellm.proxy.auth.model_checks import expand_wildcard_deployments_for_model_info, get_complete_model_list + + with _openrouter_catalog_beside_a_named_deployment(monkeypatch, wildcard_name, named) as router: + listed: Final = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=list(router.model_names), + user_model=None, + infer_model_from_keys=False, + llm_router=router, + ) + rows: Final = expand_wildcard_deployments_for_model_info( + [dict(deployment) for deployment in router.get_model_list() or ()], llm_router=router + ) + + vendor_prefix: Final = wildcard_name.removesuffix("*") + assert sorted(listed) == [f"{vendor_prefix}anthropic/claude-haiku-5-5", named] + assert sorted((row["model_name"], row["litellm_params"]["api_key"]) for row in rows) == [ + (f"{vendor_prefix}anthropic/claude-haiku-5-5", f"sk-or-wildcard-{wildcard_name}"), + (named, "sk-or-named"), + ] + + @respx.mock def test_custom_prefix_that_starts_a_discovered_id_still_prefixes_every_listed_model( monkeypatch: pytest.MonkeyPatch,