From 1e5403288ce0731c8bd1a2222a87f40692218915 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:51:16 -0700 Subject: [PATCH] feat(proxy): honor model_info.discoverable on the model listing endpoints (#42825) * feat(proxy): honor model_info.discoverable on the model listing endpoints A model_list entry marked model_info: {discoverable: false} is left out of GET /v1/models (OpenAI and Anthropic shapes, scope=expand and wildcard routes included), the list path of GET /v1/model/info and GET /model_group/info for every caller without the admin view, while direct requests naming the model keep routing to it. The field defaults to None so an absent flag reads as discoverable and nothing is persisted or echoed for configs that never set it. * fix(proxy): hide flagged team models under their public name and cover the scope=expand filter The discoverability lookup now resolves a listed name with the caller's team context, so a team-scoped deployment marked discoverable: false drops out for that team's keys under its public name instead of failing open. The scope=expand branch is now exercised by a team admin caller, and the OCI secrets test builds a real UserAPIKeyAuth instead of a spec mock that has no pydantic fields. * perf(proxy): resolve only candidate names in the discoverable filter --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/proxy/_types.py | 1 + .../common_utils/discoverable_model_filter.py | 118 +++++++++ litellm/proxy/proxy_server.py | 29 ++- litellm/types/router.py | 1 + .../test_discoverable_model_filter.py | 156 ++++++++++++ .../proxy/test_model_list_discoverable.py | 229 ++++++++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 9 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 8 files changed, 533 insertions(+), 14 deletions(-) create mode 100644 litellm/proxy/common_utils/discoverable_model_filter.py create mode 100644 tests/test_litellm/proxy/common_utils/test_discoverable_model_filter.py create mode 100644 tests/test_litellm/proxy/test_model_list_discoverable.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 54574ed64e3..4affa55f903 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1133,6 +1133,7 @@ class ModelInfo(LiteLLMPydanticObjectBase): ] | None ) + discoverable: bool | None = None model_config = ConfigDict(protected_namespaces=(), extra="allow") diff --git a/litellm/proxy/common_utils/discoverable_model_filter.py b/litellm/proxy/common_utils/discoverable_model_filter.py new file mode 100644 index 00000000000..d22f1a9f6dc --- /dev/null +++ b/litellm/proxy/common_utils/discoverable_model_filter.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Final + +from pydantic import TypeAdapter + +from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider, get_llm_provider +from litellm.proxy._types import UserAPIKeyAuth, user_api_key_has_admin_view + +if TYPE_CHECKING: + from litellm.router import Router + from litellm.types.router import RouterModelGroupAliasItem + +_PATTERN_DEPLOYMENTS: Final = TypeAdapter(Mapping[str, tuple[Mapping[str, object], ...]]) + + +def is_undiscoverable_deployment(deployment: Mapping[str, object]) -> bool: + model_info: Final = deployment.get("model_info") + if not isinstance(model_info, Mapping): + return False + return "discoverable" in model_info and model_info["discoverable"] is False + + +def is_undiscoverable_model_name(model_name: str, llm_router: Router | None, team_id: str | None) -> bool: + if llm_router is None: + return False + deployments: Final = llm_router.get_model_list(model_name=model_name, team_id=team_id) + if not deployments: + return False + return all(is_undiscoverable_deployment(deployment) for deployment in deployments) + + +def _team_public_model_name(deployment: Mapping[str, object]) -> object: + model_info: Final = deployment.get("model_info") + return model_info.get("team_public_model_name") if isinstance(model_info, Mapping) else None + + +def _alias_target(alias: str | RouterModelGroupAliasItem) -> str: + return alias if isinstance(alias, str) else alias["model"] + + +def _undiscoverable_served_names( + undiscoverable_rows: Iterable[Mapping[str, object]], + model_group_alias: Mapping[str, str | RouterModelGroupAliasItem], +) -> frozenset[str]: + served: Final = frozenset( + name + for row in undiscoverable_rows + for name in (row.get("model_name"), _team_public_model_name(row)) + if isinstance(name, str) + ) + aliases: Final = frozenset(alias for alias, target in model_group_alias.items() if _alias_target(target) in served) + return served | aliases + + +def _undiscoverable_patterns(llm_router: Router, team_id: str | None) -> tuple[re.Pattern[str], ...]: + team_pattern_router: Final = llm_router.team_pattern_routers.get(team_id) if team_id is not None else None + pattern_routers: Final = ( + (llm_router.pattern_router,) + if team_pattern_router is None + else (llm_router.pattern_router, team_pattern_router) + ) + return tuple( + re.compile(regex) + for pattern_router in pattern_routers + for regex, deployments in _PATTERN_DEPLOYMENTS.validate_python(pattern_router.patterns).items() + if any(is_undiscoverable_deployment(deployment) for deployment in deployments) + ) + + +def _resolved_provider(model_name: str) -> str | None: + try: + return get_llm_provider(model=model_name)[1] + except Exception: # noqa: BLE001 # get_llm_provider raises when the provider is unknown; the name then routes as-is + return None + + +def _matches_undiscoverable_pattern(model_name: str, patterns: tuple[re.Pattern[str], ...]) -> bool: + if not patterns: + return False + if any(pattern.match(model_name) for pattern in patterns): + return True + provider: Final = declared_authenticating_provider(model_name) or _resolved_provider(model_name) + return any(pattern.match(f"{provider}/{model_name}") for pattern in patterns) + + +def undiscoverable_model_names( + model_names: Iterable[str], + llm_router: Router | None, + user_api_key_dict: UserAPIKeyAuth, + team_id: str | None, +) -> frozenset[str]: + if llm_router is None or user_api_key_has_admin_view(user_api_key_dict): + return frozenset() + undiscoverable_rows: Final = tuple( + row for row in llm_router.get_model_list() or () if is_undiscoverable_deployment(row) + ) + if not undiscoverable_rows: + return frozenset() + served_names: Final = _undiscoverable_served_names(undiscoverable_rows, llm_router.model_group_alias) + patterns: Final = _undiscoverable_patterns(llm_router, team_id) + return frozenset( + name + for name in model_names + if (name in served_names or _matches_undiscoverable_pattern(name, patterns)) + and is_undiscoverable_model_name(name, llm_router, team_id) + ) + + +def discoverable_rows( + rows: Iterable[Mapping[str, object]], + user_api_key_dict: UserAPIKeyAuth, +) -> tuple[Mapping[str, object], ...]: + if user_api_key_has_admin_view(user_api_key_dict): + return tuple(rows) + return tuple(row for row in rows if not is_undiscoverable_deployment(row)) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 26a423d7162..7e18db742cd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -399,6 +399,7 @@ from litellm.proxy.common_utils.config_includes import resolve_include_file_path from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber from litellm.proxy.common_utils.debug_utils import init_verbose_loggers from litellm.proxy.common_utils.debug_utils import router as debugging_endpoints_router +from litellm.proxy.common_utils.discoverable_model_filter import discoverable_rows, undiscoverable_model_names from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, @@ -11241,9 +11242,11 @@ async def model_list( only_model_access_groups=only_model_access_groups or False, ) - # Hide paused/unhealthy models from the public listing - if hidden_names: - all_models = [m for m in all_models if m not in hidden_names] + expanded_undiscoverable_names: Final = undiscoverable_model_names( + all_models, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id + ) + if hidden_names or expanded_undiscoverable_names: + all_models = [m for m in all_models if m not in hidden_names and m not in expanded_undiscoverable_names] # Surface the public team name by default; legacy internal keys via flag. # The internal routing key drives the metadata/fallback lookup, while the @@ -11294,9 +11297,11 @@ async def model_list( user_api_key_cache=user_api_key_cache, ) - # Hide paused/unhealthy models from the public listing - if hidden_names: - all_models = [m for m in all_models if m not in hidden_names] + undiscoverable_names: Final = undiscoverable_model_names( + all_models, llm_router, user_api_key_dict, team_id or user_api_key_dict.team_id + ) + if hidden_names or undiscoverable_names: + all_models = [m for m in all_models if m not in hidden_names and m not in undiscoverable_names] # Surface the public team name by default; legacy internal keys via flag. # The internal routing key drives the metadata/fallback lookup, while the @@ -15793,7 +15798,10 @@ async def model_info_v1( general_settings=general_settings, llm_router=llm_router, ) - visible_models: Final = [model for model in all_models if model.get("model_name") not in hidden_names] + visible_models: Final = discoverable_rows( + (model for model in all_models if model.get("model_name") not in hidden_names), + user_api_key_dict, + ) verbose_proxy_logger.debug("all_models: %s", visible_models) return _model_info_json_response(visible_models) @@ -16072,8 +16080,13 @@ async def model_group_info( user_api_key_cache=user_api_key_cache, ) ) + undiscoverable_group_names: Final = undiscoverable_model_names( + all_models_str, llm_router, user_api_key_dict, user_api_key_dict.team_id + ) model_groups: list[ModelGroupInfoProxy] = _get_model_group_info( - llm_router=llm_router, all_models_str=all_models_str, model_group=model_group + llm_router=llm_router, + all_models_str=[name for name in all_models_str if name not in undiscoverable_group_names], + model_group=model_group, ) # Append A2A agents to model groups diff --git a/litellm/types/router.py b/litellm/types/router.py index 57bd4263894..c0f724584fd 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -246,6 +246,7 @@ class ModelInfo(MirroredPricingParams): # admin-toggled pause flag; mirrors LiteLLM_ProxyModelTable.blocked blocked: bool | None = None + discoverable: bool | None = None access_windows: tuple[ModelAccessWindow, ...] | None = None diff --git a/tests/test_litellm/proxy/common_utils/test_discoverable_model_filter.py b/tests/test_litellm/proxy/common_utils/test_discoverable_model_filter.py new file mode 100644 index 00000000000..17619afbb07 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_discoverable_model_filter.py @@ -0,0 +1,156 @@ +""" +Tests for the operator-declared discoverability filter shared by the model +listing endpoints: a deployment marked `model_info: {discoverable: false}` is +hidden from listings for callers without the admin view while it still routes. +""" + +import pytest + +from litellm import Router +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.common_utils.discoverable_model_filter import ( + discoverable_rows, + undiscoverable_model_names, +) + + +def _deployment(model_name: str, model: str = "openai/gpt-4o", **model_info): + return { + "model_name": model_name, + "litellm_params": {"model": model, "api_key": "sk-fake"}, + "model_info": {"id": f"{model_name}-id", **model_info}, + } + + +def _router(*deployments, **router_kwargs) -> Router: + return Router(model_list=list(deployments), **router_kwargs) + + +def _non_admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-test", user_role=LitellmUserRoles.INTERNAL_USER) + + +def _admin(role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN) -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-test", user_role=role) + + +def test_flagged_model_is_undiscoverable_for_non_admin(): + router = _router(_deployment("gpt-4"), _deployment("internal-evaluator", discoverable=False)) + + assert undiscoverable_model_names(["gpt-4", "internal-evaluator"], router, _non_admin(), None) == { + "internal-evaluator" + } + + +def test_missing_flag_and_explicit_true_are_discoverable(): + router = _router(_deployment("gpt-4"), _deployment("public-eval", discoverable=True)) + + assert undiscoverable_model_names(["gpt-4", "public-eval"], router, _non_admin(), None) == frozenset() + + +@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) +def test_admin_view_sees_flagged_models(role): + router = _router(_deployment("internal-evaluator", discoverable=False)) + + assert undiscoverable_model_names(["internal-evaluator"], router, _admin(role), None) == frozenset() + + +def test_group_with_one_discoverable_deployment_stays_listed(): + router = _router( + _deployment("shared", discoverable=False), + { + "model_name": "shared", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-fake"}, + "model_info": {"id": "shared-public"}, + }, + ) + + assert undiscoverable_model_names(["shared"], router, _non_admin(), None) == frozenset() + + +def test_unknown_name_and_missing_router_fail_open(): + router = _router(_deployment("internal-evaluator", discoverable=False)) + + assert undiscoverable_model_names(["not-configured"], router, _non_admin(), None) == frozenset() + assert undiscoverable_model_names(["internal-evaluator"], None, _non_admin(), None) == frozenset() + + +def test_alias_follows_its_target_deployments(): + router = _router( + _deployment("gpt-4"), + _deployment("internal-evaluator", discoverable=False), + model_group_alias={"eval": "internal-evaluator", "chat": "gpt-4"}, + ) + + assert undiscoverable_model_names(["eval", "chat"], router, _non_admin(), None) == {"eval"} + + +def test_wildcard_expansions_follow_the_wildcard_entry(): + router = _router(_deployment("gpt-4"), _deployment("anthropic/*", model="anthropic/*", discoverable=False)) + + hidden = undiscoverable_model_names( + ["gpt-4", "anthropic/*", "anthropic/claude-opus-5"], router, _non_admin(), None + ) + + assert hidden == {"anthropic/*", "anthropic/claude-opus-5"} + + +def test_flagged_team_model_is_undiscoverable_for_its_team_member(): + router = _router( + _deployment("gpt-4"), + _deployment( + "model_name_team1_abc", team_id="team1", team_public_model_name="team-gpt", discoverable=False + ), + ) + member = UserAPIKeyAuth( + api_key="sk-test", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team1", team_models=["team-gpt"] + ) + + assert undiscoverable_model_names(["gpt-4", "team-gpt"], router, member, "team1") == {"team-gpt"} + + +def test_hidden_model_still_routes_for_direct_requests(): + router = _router(_deployment("gpt-4"), _deployment("internal-evaluator", discoverable=False)) + + assert "internal-evaluator" in undiscoverable_model_names(["internal-evaluator"], router, _non_admin(), None) + deployment = router.get_available_deployment( + model="internal-evaluator", messages=[{"role": "user", "content": "hi"}] + ) + assert deployment["model_name"] == "internal-evaluator" + + +def test_discoverable_rows_drops_flagged_rows_only_for_non_admin(): + rows = [ + {"model_name": "gpt-4", "model_info": {"id": "a"}}, + {"model_name": "internal-evaluator", "model_info": {"id": "b", "discoverable": False}}, + {"model_name": "no-model-info"}, + ] + + assert [row["model_name"] for row in discoverable_rows(rows, _non_admin())] == ["gpt-4", "no-model-info"] + assert [row["model_name"] for row in discoverable_rows(rows, _admin())] == [ + "gpt-4", + "internal-evaluator", + "no-model-info", + ] + + +def test_expanded_name_served_by_a_discoverable_wildcard_too_stays_listed(): + router = _router( + _deployment("anthropic/*", model="anthropic/*", discoverable=False), + _deployment("anthropic/claude-*", model="anthropic/claude-*"), + ) + + hidden = undiscoverable_model_names( + ["anthropic/claude-opus-5", "anthropic/other-model"], router, _non_admin(), None + ) + + assert hidden == {"anthropic/other-model"} + + +def test_hidden_alias_of_a_flagged_model_is_undiscoverable(): + router = _router( + _deployment("internal-evaluator", discoverable=False), + model_group_alias={"eval": {"model": "internal-evaluator", "hidden": True}}, + ) + + assert undiscoverable_model_names(["eval"], router, _non_admin(), None) == {"eval"} diff --git a/tests/test_litellm/proxy/test_model_list_discoverable.py b/tests/test_litellm/proxy/test_model_list_discoverable.py new file mode 100644 index 00000000000..bcd52479f2c --- /dev/null +++ b/tests/test_litellm/proxy/test_model_list_discoverable.py @@ -0,0 +1,229 @@ +""" +Tests for `model_info.discoverable: false` on the model listing endpoints: +GET /v1/models (`model_list`, OpenAI and Anthropic shapes), GET /v1/models/{id} +(`model_info`), GET /v1/model/info (`model_info_v1`) and GET /model_group/info +(`model_group_info`). Flagged models drop out of the listings for callers without +the admin view and stay reachable by name. +""" + +import json + +import pytest +from starlette.requests import Request + +from litellm import Router +from litellm.proxy import proxy_server +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + +def _deployment(model_name: str, model: str = "openai/gpt-4o", **model_info): + return { + "model_name": model_name, + "litellm_params": {"model": model, "api_key": "sk-fake"}, + "model_info": {"id": f"{model_name}-id", **model_info}, + } + + +def _install_router(monkeypatch, *deployments) -> Router: + router = Router(model_list=list(deployments)) + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", router.model_list) + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "user_model", None) + return router + + +@pytest.fixture +def flagged_router(monkeypatch) -> Router: + return _install_router( + monkeypatch, + _deployment("gpt-4"), + _deployment("internal-evaluator", discoverable=False), + ) + + +@pytest.fixture +def flagged_wildcard_router(monkeypatch) -> Router: + return _install_router( + monkeypatch, + _deployment("gpt-4"), + _deployment("anthropic/*", model="anthropic/*", discoverable=False), + ) + + +@pytest.fixture +def flagged_team_router(monkeypatch) -> Router: + return _install_router( + monkeypatch, + _deployment("gpt-4"), + _deployment( + "model_name_team1_abc", team_id="team1", team_public_model_name="team-gpt", discoverable=False + ), + _deployment("model_name_team1_def", team_id="team1", team_public_model_name="team-chat"), + ) + + +@pytest.fixture +def team_admin_privileges(monkeypatch) -> None: + from litellm.proxy.management_endpoints import common_utils + + async def _is_team_admin(**kwargs) -> bool: + return True + + monkeypatch.setattr(common_utils, "_user_has_admin_privileges", _is_team_admin) + + +def _non_admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-test", user_role=LitellmUserRoles.INTERNAL_USER) + + +def _team_member(role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER) -> UserAPIKeyAuth: + return UserAPIKeyAuth( + api_key="sk-test", user_id="u", user_role=role, team_id="team1", team_models=["team-gpt", "team-chat"] + ) + + +def _admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]) + + +def _anthropic_request() -> Request: + return Request( + scope={ + "type": "http", + "method": "GET", + "path": "/v1/models", + "query_string": b"", + "headers": [(b"anthropic-version", b"2023-06-01")], + } + ) + + +async def _v1_models(user_api_key_dict: UserAPIKeyAuth, **kwargs) -> list[str]: + response = await proxy_server.model_list(user_api_key_dict=user_api_key_dict, **kwargs) + return [m["id"] for m in response["data"]] + + +async def _v1_model_info_names(user_api_key_dict: UserAPIKeyAuth, **kwargs) -> list[str]: + response = await proxy_server.model_info_v1(user_api_key_dict=user_api_key_dict, **kwargs) + return [row["model_name"] for row in json.loads(response.body)["data"]] + + +async def _model_groups(user_api_key_dict: UserAPIKeyAuth) -> list[str]: + response = await proxy_server.model_group_info(user_api_key_dict=user_api_key_dict) + return [group.model_group for group in response["data"]] + + +@pytest.mark.asyncio +async def test_v1_models_openai_shape_hides_flagged_model_from_non_admin_only(flagged_router): + assert await _v1_models(_non_admin()) == ["gpt-4"] + assert await _v1_models(_admin()) == ["gpt-4", "internal-evaluator"] + + +@pytest.mark.asyncio +async def test_v1_models_anthropic_shape_hides_flagged_model_from_non_admin_only(flagged_router): + assert await _v1_models(_non_admin(), request=_anthropic_request()) == ["gpt-4"] + assert await _v1_models(_admin(), request=_anthropic_request()) == ["gpt-4", "internal-evaluator"] + + +@pytest.mark.asyncio +async def test_v1_models_scope_expand_hides_flagged_model_from_team_admin_only(flagged_router, team_admin_privileges): + assert await _v1_models(_non_admin(), scope="expand") == ["gpt-4"] + assert await _v1_models(_admin(), scope="expand") == ["gpt-4", "internal-evaluator"] + + +@pytest.mark.asyncio +async def test_v1_models_by_id_still_serves_the_hidden_model_to_non_admin(flagged_router): + assert "internal-evaluator" not in await _v1_models(_non_admin()) + + response = await proxy_server.model_info(model_id="internal-evaluator", user_api_key_dict=_non_admin()) + assert response["id"] == "internal-evaluator" + + +@pytest.mark.asyncio +async def test_v1_models_group_with_one_discoverable_deployment_stays_listed(monkeypatch): + _install_router( + monkeypatch, + _deployment("shared", discoverable=False), + { + "model_name": "shared", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-fake"}, + "model_info": {"id": "shared-public"}, + }, + _deployment("internal-evaluator", discoverable=False), + ) + + assert await _v1_models(_non_admin()) == ["shared"] + + +@pytest.mark.asyncio +async def test_v1_models_only_an_explicit_false_hides_a_model(monkeypatch): + _install_router( + monkeypatch, + _deployment("gpt-4"), + _deployment("public-eval", discoverable=True), + _deployment("internal-evaluator", discoverable=False), + ) + + assert await _v1_models(_non_admin()) == ["gpt-4", "public-eval"] + + +@pytest.mark.asyncio +async def test_v1_model_info_hides_flagged_rows_from_non_admin_only(flagged_router): + assert await _v1_model_info_names(_non_admin()) == ["gpt-4"] + assert await _v1_model_info_names(_admin()) == ["gpt-4", "internal-evaluator"] + + +@pytest.mark.asyncio +async def test_v1_model_info_by_id_still_serves_the_hidden_row_to_non_admin(flagged_router): + assert "internal-evaluator" not in await _v1_model_info_names(_non_admin()) + + assert await _v1_model_info_names(_non_admin(), litellm_model_id="internal-evaluator-id") == [ + "internal-evaluator" + ] + + +@pytest.mark.asyncio +async def test_model_group_info_hides_flagged_group_from_non_admin_only(flagged_router): + assert await _model_groups(_non_admin()) == ["gpt-4"] + assert await _model_groups(_admin()) == ["gpt-4", "internal-evaluator"] + + +@pytest.mark.asyncio +async def test_v1_models_hides_flagged_team_model_from_its_team_member_only(flagged_team_router): + assert await _v1_models(_team_member()) == ["team-chat"] + assert set(await _v1_models(_team_member(LitellmUserRoles.PROXY_ADMIN))) >= {"team-gpt", "team-chat"} + + +@pytest.mark.asyncio +async def test_model_group_info_hides_flagged_team_model_from_its_team_member(flagged_team_router): + assert await _model_groups(_team_member()) == ["team-chat"] + + +@pytest.mark.asyncio +async def test_v1_models_hides_flagged_wildcard_expansions_from_non_admin(flagged_wildcard_router): + assert await _v1_models(_non_admin(), return_wildcard_routes=True) == ["gpt-4"] + + admin_ids = await _v1_models(_admin(), return_wildcard_routes=True) + assert "gpt-4" in admin_ids + assert any(model_id.startswith("anthropic/") for model_id in admin_ids) + + +@pytest.mark.asyncio +async def test_v1_model_info_hides_flagged_wildcard_expanded_rows_from_non_admin(flagged_wildcard_router): + assert await _v1_model_info_names(_non_admin()) == ["gpt-4"] + + admin_names = await _v1_model_info_names(_admin()) + assert "gpt-4" in admin_names + assert any(name.startswith("anthropic/") for name in admin_names) + + +@pytest.mark.asyncio +async def test_hidden_model_still_routes_for_direct_requests(flagged_router): + assert "internal-evaluator" not in await _v1_models(_non_admin()) + + deployment = flagged_router.get_available_deployment( + model="internal-evaluator", messages=[{"role": "user", "content": "hi"}] + ) + assert deployment["model_name"] == "internal-evaluator" diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 89fd9c5c9d4..62ff08230d7 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5787,12 +5787,9 @@ async def test_model_info_v1_oci_secrets_not_leaked(): from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.proxy_server import model_info_v1 - # Mock user authentication - mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) - mock_user_api_key_dict.user_id = "test-user" - mock_user_api_key_dict.api_key = "test-key" - mock_user_api_key_dict.team_models = [] - mock_user_api_key_dict.models = ["oci-grok-test"] + mock_user_api_key_dict = UserAPIKeyAuth( + user_id="test-user", api_key="test-key", team_models=[], models=["oci-grok-test"] + ) # Mock model data with OCI sensitive information mock_model_data = { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bdfd4aec316..37cfd752936 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -42027,6 +42027,8 @@ export interface components { litellm__proxy___types__ModelInfo: { /** Base Model */ base_model: ("gpt-4-1106-preview" | "gpt-4-32k" | "gpt-4" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo" | "text-embedding-ada-002") | null; + /** Discoverable */ + discoverable?: boolean | null; /** Id */ id: string | null; /** @@ -42074,6 +42076,8 @@ export interface components { * @default false */ db_model: boolean; + /** Discoverable */ + discoverable?: boolean | null; /** Enable Tag Filtering */ enable_tag_filtering?: boolean | null; /** Id */