mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
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:
parent
f35e49cb83
commit
b7c443b440
4 changed files with 104 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue