mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
fix(proxy): apply user.models filter on /v1/model/info + /v2/model/info
PR #10 closed the discovery-vs-inference gap on /v1/models for the LiteLLM_UserTable.models ("Personal Models") field, but a live audit on the internal proxy revealed two sibling endpoints still leaked the full deployment list to users restricted by user.models: - `GET /v1/model/info` (and the `/model/info` alias) on Path B (no `litellm_model_id` query param) — exposed by litellm's own Python SDK `client.models.info()` and by any curl / ops script. The endpoint self-rolled key/team filtering via `get_key_models + get_team_models + get_complete_model_list`, bypassing `_apply_user_models_filter` entirely. - `GET /v2/model/info` on the default (flagless) branch — the endpoint returned `llm_router.model_list` verbatim, exposing every deployment's `litellm_params` (including `api_base`) to any virtual-key holder. The UI doesn't trip this (always passes `include_team_models=true`), but a bare curl did. `litellm/proxy/auth/route_checks.py:189-191` already documents the intended behaviour: elif route == "/model/info": # /model/info just shows models user has access to pass so this is a spec-vs-impl gap, not a design question. Fix: 1. `model_info_v1` (proxy_server.py): Path B replaces the self-rolled key/team/complete-list with a single call to `get_available_models_for_user(...)` — same wiring as `/v1/models` and `/model_group/info`. The downstream model_name → deployment enrichment loop is preserved unchanged. 2. `model_info_v2` (proxy_server.py): inserts a final-step `apply_user_models_filter_to_deployments` call after enrichment. Runs unconditionally — any flag combination (default, `include_team_models=true`, `user_models_only=true`, `teamId=...`) is now bounded by user.models. 3. `apply_user_models_filter_to_deployments` (utils.py): thin async wrapper that lifts `_apply_user_models_filter` (operates on `List[str]` of model names) to operate on `List[Dict]` deployment dicts. Extracts distinct `model_name` set, runs the existing filter, returns the surviving deployments preserving original order and duplicates. Tests (4 tiers, 33 new cases): - Unit (`test_proxy_utils.py`): direct `apply_user_models_filter_to_deployments` exercising master-key passthrough, empty input, narrowing, no-default-models sentinel, empty user.models passthrough, missing-model_name defense — 6 cases. - Route-level FastAPI TestClient (`test_v1_model_info_user_filter.py`): real `/v1/model/info` and `/model/info` handler → real `get_available_models_for_user` → real `_apply_user_models_filter` → mocked `get_user_object`. Covers narrowing / empty user.models / master key / no-default-models / all-proxy-models / wildcard / key vs user precedence / alias route — 8 cases. - Route-level FastAPI TestClient (`test_v2_model_info_user_filter.py`): same 8 cases on the default branch, plus 3 cases mocking `get_all_team_and_direct_access_models` to verify user.models still narrows after team-access expansion (the UI's hot path) — 11 cases total. - E2E (`e2e/cases/17`): real proxy + Postgres, dynamic users via `/user/new` + `/key/generate`, asserts against `/v1/model/info`, `/v2/model/info`, and `/v2/model/info?include_team_models=true` in lockstep. Wired into `e2e/tools/run-all-cases`. Verified on the local e2e proxy: case 15 (PR #10 regression), case 16 (budget reset), case 17 (this change) all PASS.
This commit is contained in:
parent
9fb55fe829
commit
42fe34cf4a
5 changed files with 675 additions and 0 deletions
|
|
@ -12654,6 +12654,25 @@ async def model_info_v2(
|
|||
llm_router=llm_router,
|
||||
)
|
||||
|
||||
# Defense-in-depth: bound the result by LiteLLM_UserTable.models
|
||||
# (Personal Models) so this endpoint stays consistent with
|
||||
# /v1/models and inference-time can_user_call_model regardless of
|
||||
# which flag combination the caller passed. Without this, a bare
|
||||
# `GET /v2/model/info` (neither user_models_only nor
|
||||
# include_team_models set) returns the full router model_list
|
||||
# — leaking every deployment's litellm_params to anyone
|
||||
# holding a virtual key. See BerriAI/litellm#26420.
|
||||
from litellm.proxy.utils import apply_user_models_filter_to_deployments
|
||||
|
||||
all_models = await apply_user_models_filter_to_deployments(
|
||||
deployments=all_models,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=llm_router,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
# Apply teamId filter if provided
|
||||
if teamId is not None and teamId.strip():
|
||||
all_models = await _filter_models_by_team_id(
|
||||
|
|
@ -13443,6 +13462,24 @@ async def model_info_v1(
|
|||
allowed_model_names=allowed_model_names,
|
||||
)
|
||||
|
||||
# Defense-in-depth: bound the result by LiteLLM_UserTable.models
|
||||
# (Personal Models) so /v1/model/info honors the same filter as
|
||||
# /v1/models and inference-time can_user_call_model. Upstream's
|
||||
# _get_v1_model_info_allowed_model_names only intersects key/team
|
||||
# models — user.models is applied as a second-step deployment
|
||||
# filter, matching the /v2/model/info pattern above.
|
||||
# See BerriAI/litellm#26420.
|
||||
from litellm.proxy.utils import apply_user_models_filter_to_deployments
|
||||
|
||||
all_models = await apply_user_models_filter_to_deployments(
|
||||
deployments=all_models,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=llm_router,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
# Team BYOK deployments carry an internal routing key and other teams'
|
||||
# public name/team_id/api_base; drop the ones the caller cannot access so
|
||||
# listing the full router model_list does not leak cross-team metadata.
|
||||
|
|
|
|||
|
|
@ -6530,6 +6530,57 @@ async def _apply_user_models_filter(
|
|||
)
|
||||
|
||||
|
||||
async def apply_user_models_filter_to_deployments(
|
||||
deployments: List[Dict[str, Any]],
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
llm_router: Optional["Router"],
|
||||
prisma_client: Optional["PrismaClient"],
|
||||
proxy_logging_obj: Optional["ProxyLogging"],
|
||||
user_api_key_cache: Optional["DualCache"],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Apply the `LiteLLM_UserTable.models` (Personal Models) filter to a
|
||||
deployment-shaped list (`List[Dict]` with `model_name` keys), reusing
|
||||
`_apply_user_models_filter` so the model-name-level semantics
|
||||
(no-default-models / all-proxy-models / access groups / wildcards)
|
||||
stay identical with /v1/models.
|
||||
|
||||
Used by /v1/model/info and /v2/model/info to close the
|
||||
discovery-vs-inference gap described in BerriAI/litellm#26420 —
|
||||
same fix as get_available_models_for_user() but operating on
|
||||
deployment dicts (which carry api_base, model_info.id, etc.)
|
||||
instead of bare model names.
|
||||
|
||||
Order of `deployments` is preserved; duplicates with the same
|
||||
`model_name` are kept (multiple deployments can share a name).
|
||||
"""
|
||||
if not deployments:
|
||||
return deployments
|
||||
|
||||
if llm_router is None:
|
||||
proxy_model_list: List[str] = []
|
||||
model_access_groups: Dict[str, List[str]] = {}
|
||||
else:
|
||||
proxy_model_list = llm_router.get_model_names()
|
||||
model_access_groups = llm_router.get_model_access_groups()
|
||||
|
||||
distinct_model_names = list(
|
||||
{d.get("model_name", "") for d in deployments if d.get("model_name")}
|
||||
)
|
||||
allowed_model_names = await _apply_user_models_filter(
|
||||
all_models=distinct_model_names,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_model_list=proxy_model_list,
|
||||
model_access_groups=model_access_groups,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
allowed_set = set(allowed_model_names)
|
||||
return [d for d in deployments if d.get("model_name") in allowed_set]
|
||||
|
||||
|
||||
def create_model_info_response(
|
||||
model_id: str,
|
||||
provider: str,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
"""
|
||||
Route-level tests for /v1/model/info (and the /model/info alias) —
|
||||
verify that LiteLLM_UserTable.models ("Personal Models") narrows the
|
||||
returned deployment list on Path B (no `litellm_model_id` query
|
||||
param), closing the discovery-vs-inference gap described in
|
||||
BerriAI/litellm#26420 and the follow-up audit that found it open on
|
||||
this sibling endpoint.
|
||||
|
||||
These tests exercise the real FastAPI route → real user_api_key_auth
|
||||
dependency override → real model_info_v1 handler → real
|
||||
get_available_models_for_user → real _apply_user_models_filter →
|
||||
mocked get_user_object. No real DB, no live HTTP server.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
_PROXY_MODELS = [
|
||||
{"model_name": "gpt-4", "litellm_params": {"model": "openai/gpt-4"}},
|
||||
{
|
||||
"model_name": "claude-3-opus",
|
||||
"litellm_params": {"model": "anthropic/claude-3-opus"},
|
||||
},
|
||||
{
|
||||
"model_name": "claude-3-haiku",
|
||||
"litellm_params": {"model": "anthropic/claude-3-haiku"},
|
||||
},
|
||||
{
|
||||
"model_name": "anthropic/claude-3-5-sonnet",
|
||||
"litellm_params": {"model": "anthropic/claude-3-5-sonnet"},
|
||||
},
|
||||
{
|
||||
"model_name": "anthropic/claude-3-7-sonnet",
|
||||
"litellm_params": {"model": "anthropic/claude-3-7-sonnet"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configure_router(monkeypatch):
|
||||
import litellm
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=_PROXY_MODELS,
|
||||
model_group_alias={},
|
||||
)
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
monkeypatch.setattr(ps, "llm_model_list", _PROXY_MODELS)
|
||||
monkeypatch.setattr(ps, "user_model", None)
|
||||
monkeypatch.setattr(ps, "general_settings", {})
|
||||
monkeypatch.setattr(ps, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(ps, "user_api_key_cache", DualCache())
|
||||
return router
|
||||
|
||||
|
||||
def _override_auth(user_id):
|
||||
def _auth():
|
||||
return UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id=user_id,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
models=[],
|
||||
team_id=None,
|
||||
team_models=[],
|
||||
)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = _auth
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_auth_override():
|
||||
yield
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
def _patch_user(monkeypatch, models):
|
||||
async def _fake(*args, **kwargs):
|
||||
return LiteLLM_UserTable(
|
||||
user_id="u-test",
|
||||
max_budget=None,
|
||||
user_email=None,
|
||||
models=models,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.auth.auth_checks.get_user_object",
|
||||
_fake,
|
||||
)
|
||||
|
||||
|
||||
def _model_names(resp_json):
|
||||
return sorted({item["model_name"] for item in resp_json["data"]})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/model/info Path B (no litellm_model_id) — the leak path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_v1_model_info_filters_by_user_personal_models(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Headline regression: user.models=['claude-3-opus'] must narrow the list."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["claude-3-opus"])
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-opus"]
|
||||
|
||||
|
||||
def test_v1_model_info_no_filter_when_user_models_empty(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""user.models == [] -> unrestricted, full deployment list returned."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=[])
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == sorted({m["model_name"] for m in _PROXY_MODELS})
|
||||
|
||||
|
||||
def test_v1_model_info_no_filter_when_user_id_is_none(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Master key / service account (user_id=None) -> no filter applied."""
|
||||
|
||||
async def _should_not_be_called(*args, **kwargs):
|
||||
raise AssertionError("get_user_object must not be called when user_id is None")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.auth.auth_checks.get_user_object",
|
||||
_should_not_be_called,
|
||||
)
|
||||
|
||||
_override_auth(user_id=None)
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == sorted({m["model_name"] for m in _PROXY_MODELS})
|
||||
|
||||
|
||||
def test_v1_model_info_no_default_models_returns_empty(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""`no-default-models` sentinel -> /v1/model/info returns []."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["no-default-models"])
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
def test_v1_model_info_all_proxy_models_no_filter(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""`all-proxy-models` sentinel -> user gets the full deployment list."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["all-proxy-models"])
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == sorted({m["model_name"] for m in _PROXY_MODELS})
|
||||
|
||||
|
||||
def test_v1_model_info_user_wildcard(client, configure_router, monkeypatch):
|
||||
"""user.models contains 'anthropic/*' -> only matching deployments returned."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["anthropic/*"])
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == [
|
||||
"anthropic/claude-3-5-sonnet",
|
||||
"anthropic/claude-3-7-sonnet",
|
||||
]
|
||||
|
||||
|
||||
def test_v1_model_info_user_models_takes_precedence_over_permissive_key(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Even with key.models=['all-proxy-models'], user.models still narrows.
|
||||
|
||||
Parity claim: /v1/model/info matches /v1/models which matches
|
||||
can_user_call_model at inference time.
|
||||
"""
|
||||
|
||||
def _auth_with_all_proxy():
|
||||
return UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="u-test",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
models=["all-proxy-models"],
|
||||
team_id=None,
|
||||
team_models=[],
|
||||
)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = _auth_with_all_proxy
|
||||
_patch_user(monkeypatch, models=["claude-3-haiku"])
|
||||
|
||||
resp = client.get("/v1/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-haiku"]
|
||||
|
||||
|
||||
def test_v1_model_info_alias_route_filters_identically(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Sanity: /model/info (without /v1/ prefix) shares the same handler
|
||||
and therefore the same filter behavior."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["claude-3-opus"])
|
||||
|
||||
resp = client.get("/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-opus"]
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
"""
|
||||
Route-level tests for /v2/model/info — verify that
|
||||
LiteLLM_UserTable.models ("Personal Models") narrows the returned
|
||||
deployment list on every flag combination, including the default
|
||||
(flagless) branch which previously leaked the full router.model_list.
|
||||
|
||||
Same setup pattern as test_v1_model_info_user_filter.py: real FastAPI
|
||||
route → real user_api_key_auth override → real model_info_v2 handler →
|
||||
real apply_user_models_filter_to_deployments → mocked get_user_object.
|
||||
|
||||
These tests do NOT depend on team membership; the include_team_models
|
||||
branch is exercised in case 17 (e2e) but here we focus on the
|
||||
defense-in-depth final-step filter that runs for every request shape.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
_PROXY_MODELS = [
|
||||
{"model_name": "gpt-4", "litellm_params": {"model": "openai/gpt-4"}},
|
||||
{
|
||||
"model_name": "claude-3-opus",
|
||||
"litellm_params": {"model": "anthropic/claude-3-opus"},
|
||||
},
|
||||
{
|
||||
"model_name": "claude-3-haiku",
|
||||
"litellm_params": {"model": "anthropic/claude-3-haiku"},
|
||||
},
|
||||
{
|
||||
"model_name": "anthropic/claude-3-5-sonnet",
|
||||
"litellm_params": {"model": "anthropic/claude-3-5-sonnet"},
|
||||
},
|
||||
{
|
||||
"model_name": "anthropic/claude-3-7-sonnet",
|
||||
"litellm_params": {"model": "anthropic/claude-3-7-sonnet"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configure_router(monkeypatch):
|
||||
import litellm
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=_PROXY_MODELS,
|
||||
model_group_alias={},
|
||||
)
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
monkeypatch.setattr(ps, "llm_model_list", _PROXY_MODELS)
|
||||
monkeypatch.setattr(ps, "user_model", None)
|
||||
monkeypatch.setattr(ps, "general_settings", {})
|
||||
monkeypatch.setattr(ps, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(ps, "user_api_key_cache", DualCache())
|
||||
return router
|
||||
|
||||
|
||||
def _override_auth(user_id):
|
||||
def _auth():
|
||||
return UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id=user_id,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
models=[],
|
||||
team_id=None,
|
||||
team_models=[],
|
||||
)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = _auth
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_auth_override():
|
||||
yield
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
def _patch_user(monkeypatch, models, teams=None):
|
||||
async def _fake(*args, **kwargs):
|
||||
return LiteLLM_UserTable(
|
||||
user_id="u-test",
|
||||
max_budget=None,
|
||||
user_email=None,
|
||||
models=models,
|
||||
teams=teams or [],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.auth.auth_checks.get_user_object",
|
||||
_fake,
|
||||
)
|
||||
|
||||
|
||||
def _model_names(resp_json):
|
||||
return sorted({item["model_name"] for item in resp_json["data"]})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default branch (no flags) — was leaking full router.model_list before fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_filters_by_user_models(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""user.models=['claude-3-opus'] must narrow even when caller passes no flags.
|
||||
|
||||
Pre-fix this branch returned the entire router.model_list verbatim —
|
||||
leaking every deployment's litellm_params (including api_base) to
|
||||
any virtual-key holder.
|
||||
"""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["claude-3-opus"])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-opus"]
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_no_filter_for_master_key(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Master key (user_id=None) -> no narrowing, full deployment list."""
|
||||
|
||||
async def _should_not_be_called(*args, **kwargs):
|
||||
raise AssertionError("get_user_object must not be called when user_id is None")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.auth.auth_checks.get_user_object",
|
||||
_should_not_be_called,
|
||||
)
|
||||
|
||||
_override_auth(user_id=None)
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == sorted({m["model_name"] for m in _PROXY_MODELS})
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_no_filter_when_user_models_empty(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""user.models == [] -> unrestricted, full deployment list returned."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=[])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == sorted({m["model_name"] for m in _PROXY_MODELS})
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_no_default_models_returns_empty(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""`no-default-models` sentinel -> /v2/model/info returns []."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["no-default-models"])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_all_proxy_models_no_filter(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""`all-proxy-models` sentinel -> user gets the full deployment list."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["all-proxy-models"])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == sorted({m["model_name"] for m in _PROXY_MODELS})
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_user_wildcard(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""user.models contains 'anthropic/*' -> only matching deployments returned."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["anthropic/*"])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == [
|
||||
"anthropic/claude-3-5-sonnet",
|
||||
"anthropic/claude-3-7-sonnet",
|
||||
]
|
||||
|
||||
|
||||
def test_v2_model_info_user_models_takes_precedence_over_permissive_key(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Even with key.models=['all-proxy-models'], user.models still narrows."""
|
||||
|
||||
def _auth_with_all_proxy():
|
||||
return UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="u-test",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
models=["all-proxy-models"],
|
||||
team_id=None,
|
||||
team_models=[],
|
||||
)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = _auth_with_all_proxy
|
||||
_patch_user(monkeypatch, models=["claude-3-haiku"])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-haiku"]
|
||||
|
||||
|
||||
def test_v2_model_info_default_branch_preserves_litellm_params(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Sanity: filter only drops disallowed deployments — surviving entries
|
||||
retain their litellm_params payload (model, api_base, etc.)."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["claude-3-opus"])
|
||||
|
||||
resp = client.get("/v2/model/info", headers={"Authorization": "Bearer sk-test"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["model_name"] == "claude-3-opus"
|
||||
# litellm_params is enriched but still carries the original "model" key
|
||||
assert data[0]["litellm_params"]["model"] == "anthropic/claude-3-opus"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# include_team_models=true branch — user.models must STILL narrow after the
|
||||
# endpoint expands the list via team access. This is the path the UI hits
|
||||
# (modelInfoCall in networking.tsx always passes include_team_models=true).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_v2_model_info_include_team_models_branch_still_filters_user_models(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""include_team_models=true expands the deployment list via team access
|
||||
(every model marked `access_via_team_ids=['t-a']`), simulating a user
|
||||
in a team with full proxy access. The user-level filter must still
|
||||
narrow the result to user.models = ['claude-3-opus'].
|
||||
|
||||
Without the defense-in-depth final-step filter, the endpoint would
|
||||
leak all 5 deployments.
|
||||
"""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["claude-3-opus"])
|
||||
|
||||
async def _fake_team_access(
|
||||
*, user_api_key_dict, prisma_client, llm_router, all_models
|
||||
):
|
||||
for m in all_models:
|
||||
m.setdefault("model_info", {})
|
||||
m["model_info"]["access_via_team_ids"] = ["t-a"]
|
||||
return all_models
|
||||
|
||||
monkeypatch.setattr(ps, "get_all_team_and_direct_access_models", _fake_team_access)
|
||||
|
||||
resp = client.get(
|
||||
"/v2/model/info?include_team_models=true",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-opus"]
|
||||
|
||||
|
||||
def test_v2_model_info_include_team_models_unrestricted_user_sees_team_set(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""Open user (models=[]) + include_team_models=true → no user-level
|
||||
narrowing, returns whatever the team-access path returns.
|
||||
|
||||
Verifies the defense-in-depth filter doesn't accidentally over-narrow
|
||||
when user.models is empty: it must be a pure pass-through in that case.
|
||||
"""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=[])
|
||||
|
||||
async def _fake_team_access(
|
||||
*, user_api_key_dict, prisma_client, llm_router, all_models
|
||||
):
|
||||
# Team grants access to only 2 of the 5 — keep them, drop the rest.
|
||||
allowed_via_team = {"claude-3-opus", "claude-3-haiku"}
|
||||
filtered = []
|
||||
for m in all_models:
|
||||
if m["model_name"] in allowed_via_team:
|
||||
m.setdefault("model_info", {})
|
||||
m["model_info"]["access_via_team_ids"] = ["t-a"]
|
||||
filtered.append(m)
|
||||
return filtered
|
||||
|
||||
monkeypatch.setattr(ps, "get_all_team_and_direct_access_models", _fake_team_access)
|
||||
|
||||
resp = client.get(
|
||||
"/v2/model/info?include_team_models=true",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _model_names(resp.json()) == ["claude-3-haiku", "claude-3-opus"]
|
||||
|
||||
|
||||
def test_v2_model_info_include_team_models_no_default_models_returns_empty(
|
||||
client, configure_router, monkeypatch
|
||||
):
|
||||
"""`no-default-models` sentinel must wipe the list even when a team
|
||||
granted access. Inference-time `can_user_call_model` raises on this
|
||||
sentinel; the discovery endpoint must agree."""
|
||||
_override_auth(user_id="u-test")
|
||||
_patch_user(monkeypatch, models=["no-default-models"])
|
||||
|
||||
async def _fake_team_access(
|
||||
*, user_api_key_dict, prisma_client, llm_router, all_models
|
||||
):
|
||||
for m in all_models:
|
||||
m.setdefault("model_info", {})
|
||||
m["model_info"]["access_via_team_ids"] = ["t-a"]
|
||||
return all_models
|
||||
|
||||
monkeypatch.setattr(ps, "get_all_team_and_direct_access_models", _fake_team_access)
|
||||
|
||||
resp = client.get(
|
||||
"/v2/model/info?include_team_models=true",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
|
@ -3984,6 +3984,7 @@ async def test_model_info_v1_oci_secrets_not_leaked():
|
|||
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_id = None
|
||||
mock_user_api_key_dict.team_models = []
|
||||
mock_user_api_key_dict.models = ["oci-grok-test"]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue