mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
feat(proxy): honor model_info.discoverable on the model listing endpoints (#42825)
Some checks failed
CI Coverage / assert-ci-coverage (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Publish basedpyright base counts / publish (push) Waiting to run
Code Quality Checks / python-310-import-smoke (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / integrations (push) Waiting to run
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / mcp-integration (push) Waiting to run
Unit Tests / misc (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
VS Code Extension / vscode-extension (push) Has been cancelled
Some checks failed
CI Coverage / assert-ci-coverage (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Publish basedpyright base counts / publish (push) Waiting to run
Code Quality Checks / python-310-import-smoke (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / integrations (push) Waiting to run
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / mcp-integration (push) Waiting to run
Unit Tests / misc (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
VS Code Extension / vscode-extension (push) Has been cancelled
* 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>
This commit is contained in:
parent
ce582affaa
commit
1e5403288c
8 changed files with 533 additions and 14 deletions
|
|
@ -1133,6 +1133,7 @@ class ModelInfo(LiteLLMPydanticObjectBase):
|
|||
]
|
||||
| None
|
||||
)
|
||||
discoverable: bool | None = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=(), extra="allow")
|
||||
|
||||
|
|
|
|||
118
litellm/proxy/common_utils/discoverable_model_filter.py
Normal file
118
litellm/proxy/common_utils/discoverable_model_filter.py
Normal file
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
229
tests/test_litellm/proxy/test_model_list_discoverable.py
Normal file
229
tests/test_litellm/proxy/test_model_list_discoverable.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue