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:
songkuan-zheng 2026-05-22 06:39:59 +00:00
parent 9fb55fe829
commit 42fe34cf4a
5 changed files with 675 additions and 0 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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"]

View file

@ -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"] == []

View file

@ -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"]