fix(proxy): skip wildcard servers a key cannot reach on /v1/model/info

/v1/model/info listed every wildcard deployment and filtered by the key
afterwards, so a key limited to one model waited on every wildcard server and
sent listing calls to servers it cannot use. The listing now skips a wildcard
whose patterns match none of the key's models before it asks the server.

A key or team granted hosted_vllm/* with no deployment of that name now lists
no models, like openrouter/*, since chat answers 400 for every id the
environment server would list.
This commit is contained in:
mateo-berri 2026-11-02 13:11:57 -08:00
parent f35e49cb83
commit b7c443b440
4 changed files with 104 additions and 4 deletions

View file

@ -6,7 +6,7 @@ import copy
import functools
import itertools
import re
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Collection, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from types import MappingProxyType
from typing import Final, TypeVar
@ -35,7 +35,9 @@ _CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields)
_MODEL_LISTING_POOL: Final = ThreadPoolExecutor(thread_name_prefix="litellm-model-listing")
_PROVIDERS_LISTED_ONLY_THROUGH_A_DEPLOYMENT: Final = frozenset({LlmProviders.OPENROUTER.value})
_PROVIDERS_LISTED_ONLY_THROUGH_A_DEPLOYMENT: Final = frozenset(
{LlmProviders.OPENROUTER.value, LlmProviders.HOSTED_VLLM.value}
)
_Listed: Final = TypeVar("_Listed")
@ -562,9 +564,23 @@ def _names_no_deployment_serves_by_name(
return tuple(name for name in names if not deployed_by_name(name, team_id))
def _matches_a_pattern(name: str, patterns: Sequence[str]) -> bool:
return any(_wildcard_name_regex(pattern).match(name) for pattern in patterns)
def _can_expand_to_an_allowed_name(
model_name: str, litellm_model: str, allowed_model_names: Collection[str] | None
) -> bool:
if allowed_model_names is None or not _check_wildcard_routing(model_name):
return True
patterns: Final = tuple(pattern for pattern in (model_name, litellm_model) if _check_wildcard_routing(pattern))
return any(_matches_a_pattern(name, patterns) for name in allowed_model_names)
def expand_wildcard_deployments_for_model_info(
deployments: list[dict[str, object]],
llm_router: Router | None = None,
allowed_model_names: Collection[str] | None = None,
) -> list[dict[str, object]]:
"""Expand wildcard deployments into one row per known provider model.
@ -581,6 +597,8 @@ def expand_wildcard_deployments_for_model_info(
raw_params = deployment.get("litellm_params")
litellm_params_dict: dict[str, object] = raw_params if isinstance(raw_params, dict) else {}
litellm_model = str(litellm_params_dict.get("model") or "")
if not _can_expand_to_an_allowed_name(model_name, litellm_model, allowed_model_names):
continue
# Determine the wildcard pattern to expand.
# Branch order matters: only fall to litellm_model when model_name is

View file

@ -17990,8 +17990,6 @@ async def model_info_v1(
alias_models: Final = copy.deepcopy(registry.deployments(source="alias"))
all_models.extend(alias_models)
all_models = await run_model_listing(partial(expand_wildcard_deployments_for_model_info, all_models, llm_router))
allowed_model_names: Final = await _get_v1_model_info_allowed_model_names_with_access_groups(
user_api_key_dict=user_api_key_dict,
llm_router=llm_router,
@ -18000,6 +17998,15 @@ async def model_info_v1(
proxy_logging_obj=proxy_logging_obj,
)
all_models = await run_model_listing(
partial(
expand_wildcard_deployments_for_model_info,
all_models,
llm_router,
allowed_model_names=allowed_model_names,
)
)
all_models = _filter_v1_model_info_deployments(
all_models=all_models,
allowed_model_names=allowed_model_names,

View file

@ -1572,6 +1572,40 @@ def test_key_granted_openrouter_wildcard_without_an_openrouter_deployment_lists_
assert (listed, catalog.call_count) == ([], 0)
@respx.mock
def test_key_granted_hosted_vllm_wildcard_without_a_hosted_vllm_deployment_lists_no_environment_server(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm
from litellm import Router
from litellm.proxy.auth.model_checks import get_complete_model_list
monkeypatch.setattr(litellm, "check_provider_endpoint", True)
monkeypatch.setenv("HOSTED_VLLM_API_BASE", "http://hosted-vllm-environment-only.test:8000/v1")
environment_server: Final = respx.get("http://hosted-vllm-environment-only.test:8000/v1/models").mock(
return_value=httpx.Response(200, json={"data": [{"id": "qwen2.5"}]})
)
router: Final = Router(
model_list=[
{
"model_name": "vllm-team/*",
"litellm_params": {"model": "hosted_vllm/*", "api_base": "http://vllm-team.test:8000/v1"},
}
]
)
listed: Final = get_complete_model_list(
key_models=["hosted_vllm/*"],
team_models=[],
proxy_model_list=router.model_names,
user_model=None,
infer_model_from_keys=False,
llm_router=router,
)
assert (listed, environment_server.call_count) == ([], 0)
@contextmanager
def _openai_catalog_behind_a_catch_all(
monkeypatch: pytest.MonkeyPatch, concrete_name: str, concrete_model_info: Mapping[str, str]

View file

@ -19,6 +19,7 @@ from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
import litellm
@ -576,6 +577,46 @@ async def test_v1_model_info_star_wildcard_filter_keeps_provider_expansion(monke
assert router.registry().names("gpt-4o", kind="wildcard") == frozenset({"openai/gpt-4o"})
@pytest.mark.parametrize(
("key_models", "expected_names", "expected_listings"),
[(["qwen-open"], ["qwen-open"], 0), (["hosted_vllm/*"], ["hosted_vllm/qwen2.5"], 1)],
ids=["key-without-the-wildcard", "key-granted-the-wildcard"],
)
def test_v1_model_info_asks_a_wildcard_server_only_for_keys_that_can_call_it(
client, auth_as, monkeypatch, request, key_models, expected_names, expected_listings
):
from litellm.proxy._types import LitellmUserRoles
wildcard_server: Final = f"http://vllm-{request.node.callspec.id}.test:8000/v1"
monkeypatch.setattr(litellm, "check_provider_endpoint", True)
router: Final = litellm.Router(
model_list=[
{
"model_name": "qwen-open",
"litellm_params": {"model": "hosted_vllm/qwen2.5", "api_base": "http://vllm-open.test:8000/v1"},
},
{"model_name": "hosted_vllm/*", "litellm_params": {"model": "hosted_vllm/*", "api_base": wildcard_server}},
]
)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", router.get_model_list())
monkeypatch.setattr(proxy_server, "user_model", None)
monkeypatch.setattr(proxy_server, "prisma_client", None)
with respx.mock(assert_all_called=False) as upstream:
listing: Final = upstream.get(f"{wildcard_server}/models").mock(
return_value=httpx.Response(200, json={"data": [{"id": "qwen2.5"}]})
)
with auth_as(role=LitellmUserRoles.INTERNAL_USER, models=key_models):
response: Final = client.get("/v1/model/info")
assert response.status_code == 200, response.text
assert ([model["model_name"] for model in response.json()["data"]], listing.call_count) == (
expected_names,
expected_listings,
)
# ---------------------------------------------------------------------------
# GET /model/info — team BYOK scoping (issue #30983)
# ---------------------------------------------------------------------------