diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index aa53954da8f..5d5ab4f224f 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -1,6 +1,7 @@ # What is this? ## Common checks for /v1/models and `/model/info` -from typing import Dict, List, Optional, Set +import copy +from typing import Any, Dict, List, Optional, Set import litellm from litellm._logging import verbose_proxy_logger @@ -281,8 +282,18 @@ def _hydrate_litellm_credential_name( def get_known_models_from_wildcard( wildcard_model: str, litellm_params: Optional[LiteLLM_Params] = None ) -> List[str]: + wildcard_model_to_expand = ( + litellm_params.model + if wildcard_model == "*" + and litellm_params is not None + and _check_wildcard_routing(litellm_params.model) + and "/" in litellm_params.model + else wildcard_model + ) try: - wildcard_provider_prefix, wildcard_suffix = wildcard_model.split("/", 1) + wildcard_provider_prefix, wildcard_suffix = wildcard_model_to_expand.split( + "/", 1 + ) except ValueError: # safely fail return [] @@ -341,6 +352,68 @@ def get_known_models_from_wildcard( return suffix_appended_wildcard_models or [] +def expand_wildcard_deployments_for_model_info( + deployments: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Expand wildcard deployments into one row per known provider model. + + PR #30025 changed /model/info to read from llm_router.model_list (correct, + so team-scoped rows are included). This function restores wildcard expansion + on top of that: a wildcard deployment like model_name="*" / litellm_params.model="openai/*" + becomes one entry per known openai model, matching /v1/models behaviour. + """ + expanded: list[dict[str, Any]] = [] + for deployment in deployments: + model_name = str(deployment.get("model_name") or "") + raw_params = deployment.get("litellm_params") + litellm_params_dict: dict[str, Any] = ( + raw_params if isinstance(raw_params, dict) else {} + ) + litellm_model = str(litellm_params_dict.get("model") or "") + + # Determine the wildcard pattern to expand. + # Branch order matters: only fall to litellm_model when model_name is + # also a wildcard, so a concrete model_name is never overwritten. + if _check_wildcard_routing(model_name) and "/" in model_name: + wildcard_pattern = model_name + elif _check_wildcard_routing(model_name) and _check_wildcard_routing( + litellm_model + ): + wildcard_pattern = litellm_model + elif _check_wildcard_routing(model_name): + wildcard_pattern = model_name + else: + expanded.append(deployment) + continue + + try: + litellm_params = ( + LiteLLM_Params.model_validate(litellm_params_dict) + if litellm_params_dict + else None + ) + except Exception: + expanded.append(deployment) + continue + expanded_names = get_known_models_from_wildcard( + wildcard_model=wildcard_pattern, + litellm_params=litellm_params, + ) + if not expanded_names: + expanded.append(deployment) + continue + + for name in expanded_names: + row = copy.deepcopy(deployment) + row["model_name"] = name + params = row.get("litellm_params") + if isinstance(params, dict): + params["model"] = name + expanded.append(row) + + return expanded + + def _get_wildcard_models( unique_models: List[str], return_wildcard_routes: Optional[bool] = False, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3c91f1bc1d7..b49d8ad7965 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -269,6 +269,7 @@ def generate_feedback_box(): from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.litellm_license import LicenseCheck from litellm.proxy.auth.model_checks import ( + expand_wildcard_deployments_for_model_info, get_all_fallbacks, get_complete_model_list, get_key_models, @@ -13430,6 +13431,8 @@ async def model_info_v1( alias_models = copy.deepcopy(llm_router.get_model_list_from_model_alias()) all_models.extend(alias_models) + all_models = expand_wildcard_deployments_for_model_info(all_models) + allowed_model_names = _get_v1_model_info_allowed_model_names( user_api_key_dict=user_api_key_dict, llm_router=llm_router, diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 261485e8965..e30bfb15938 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -552,7 +552,8 @@ def test_get_key_models_all_team_models_recursive_team(): from litellm.proxy._types import SpecialModelNames user_api_key_dict = type( - "obj", (object,), + "obj", + (object,), { "models": [SpecialModelNames.all_team_models.value], "team_id": "team-1", @@ -617,3 +618,71 @@ def test_get_team_models_all_team_models_expands_with_access_groups(): assert "model-b" in result assert "group-1" in result assert "group-2" in result + + +def test_expand_wildcard_deployments_non_wildcard_passthrough(): + """Non-wildcard deployments must be returned unchanged.""" + from litellm.proxy.auth.model_checks import ( + expand_wildcard_deployments_for_model_info, + ) + + deployment = {"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}} + result = expand_wildcard_deployments_for_model_info([deployment]) + assert result == [deployment] + + +def test_expand_wildcard_deployments_openai_wildcard(): + """openai/* should expand into ≥1 known openai model entries.""" + from unittest.mock import patch + + from litellm.proxy.auth.model_checks import ( + expand_wildcard_deployments_for_model_info, + ) + + fake_models = ["openai/gpt-4o", "openai/gpt-4o-mini"] + deployment = { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*"}, + } + with patch( + "litellm.proxy.auth.model_checks.get_known_models_from_wildcard", + return_value=fake_models, + ): + result = expand_wildcard_deployments_for_model_info([deployment]) + + assert len(result) == 2 + assert all(r["model_name"] in fake_models for r in result) + assert all(r["litellm_params"]["model"] in fake_models for r in result) + + +def test_expand_wildcard_concrete_model_name_with_wildcard_litellm_params(): + """Concrete model_name must not be overwritten when only litellm_params.model is wildcard.""" + from litellm.proxy.auth.model_checks import ( + expand_wildcard_deployments_for_model_info, + ) + + deployment = { + "model_name": "my-custom-alias", + "litellm_params": {"model": "openai/*"}, + } + result = expand_wildcard_deployments_for_model_info([deployment]) + # model_name is not a wildcard, so the deployment passes through unchanged + assert result == [deployment] + + +def test_expand_wildcard_invalid_litellm_params_passthrough(): + """Deployments with invalid litellm_params must pass through unchanged (no 500).""" + from litellm.proxy.auth.model_checks import ( + expand_wildcard_deployments_for_model_info, + ) + + deployment = { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "max_retries": "not-an-int-field-that-breaks", + }, + } + # Even if LiteLLM_Params construction fails the deployment should survive + result = expand_wildcard_deployments_for_model_info([deployment]) + assert result == [deployment] diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py index 3bf14c08d14..00c3c5b1e74 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py @@ -128,6 +128,44 @@ def test_v1_model_info_no_model_list_error(client, auth_as, null_router, path): assert "LLM Model List not loaded" in response.text +def test_v1_model_info_star_wildcard_filter_keeps_provider_expansion(monkeypatch): + from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth + from litellm.proxy.auth import model_checks + + def fake_get_provider_models(provider, litellm_params=None): + if provider == "openai": + return ["gpt-4o"] + return [] + + deployment = { + "model_name": "*", + "litellm_params": {"model": "openai/*"}, + } + router = MagicMock() + router.get_model_access_groups = MagicMock(return_value={}) + router.get_model_names = MagicMock(return_value=["*"]) + router.get_model_list = MagicMock(return_value=[deployment]) + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + expanded_deployments = proxy_server.expand_wildcard_deployments_for_model_info( + [deployment] + ) + allowed_model_names = proxy_server._get_v1_model_info_allowed_model_names( + user_api_key_dict=UserAPIKeyAuth( + api_key="sk-test", + models=[SpecialModelNames.all_proxy_models.value], + ), + llm_router=router, + ) + + result = proxy_server._filter_v1_model_info_deployments( + all_models=expanded_deployments, + allowed_model_names=allowed_model_names, + ) + + assert [model["model_name"] for model in result] == ["openai/gpt-4o"] + + # --------------------------------------------------------------------------- # GET /model/info — team BYOK scoping (issue #30983) # ---------------------------------------------------------------------------