mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(proxy): scope team BYOK models by key team_id in /model/info (#31009)
GET /model/info returned an empty list for a team key whose team only has team-scoped BYOK deployments, even though /v1/models and a master key both returned them. _get_caller_byok_team_scope resolved the caller's allowed teams only from user_api_key_dict.user_id and the bound user's team memberships. A team or service key has user_id=None, so the helper returned an empty set and _byok_row_outside_caller_teams then dropped every team BYOK row This includes the key's own team_id in the allowed-team set across every non-admin branch, since a team key is authoritatively scoped to its team regardless of whether a bound user is resolvable or a formal member of that team Completes the work in #30025, which aligned /v1/model/info with router deployments but missed the team-key case in the scope helper it introduced
This commit is contained in:
parent
8bc18388e3
commit
ce4111b800
4 changed files with 214 additions and 9 deletions
|
|
@ -11581,9 +11581,12 @@ async def _get_caller_byok_team_scope(
|
|||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||
):
|
||||
return None
|
||||
key_team_scope: set[str] = (
|
||||
{user_api_key_dict.team_id} if user_api_key_dict.team_id else set()
|
||||
)
|
||||
user_id = user_api_key_dict.user_id
|
||||
if user_id is None:
|
||||
return set()
|
||||
return key_team_scope
|
||||
try:
|
||||
user_row = await UserRepository(prisma_client).table.find_unique(
|
||||
where={"user_id": user_id}
|
||||
|
|
@ -11591,12 +11594,12 @@ async def _get_caller_byok_team_scope(
|
|||
except Exception:
|
||||
verbose_proxy_logger.exception(
|
||||
"Failed to look up caller teams while scoping BYOK search; "
|
||||
"defaulting to no team access."
|
||||
"defaulting to key team scope only."
|
||||
)
|
||||
return set()
|
||||
return key_team_scope
|
||||
if user_row is None:
|
||||
return set()
|
||||
return set(user_row.teams or [])
|
||||
return key_team_scope
|
||||
return key_team_scope | set(user_row.teams or [])
|
||||
|
||||
|
||||
def _byok_row_outside_caller_teams(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Pins (PR2):
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -128,6 +128,104 @@ def test_v1_model_info_no_model_list_error(client, auth_as, null_router, path):
|
|||
assert "LLM Model List not loaded" in response.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /model/info — team BYOK scoping (issue #30983)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BYOK_TEAM_ID = "team-abc"
|
||||
_BYOK_PUBLIC_NAME = "my-byok-gpt-4"
|
||||
_BYOK_INTERNAL_NAME = f"model_name_{_BYOK_TEAM_ID}_0123456789abcdef"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def byok_team_router(monkeypatch):
|
||||
"""Router holding one team-scoped BYOK deployment for team `team-abc`.
|
||||
|
||||
Mirrors how a team's own-key BYOK model lives in the router: the routing
|
||||
key is an internal mangled name while the public name lives in
|
||||
`model_info.team_public_model_name`.
|
||||
"""
|
||||
byok_deployment = {
|
||||
"model_name": _BYOK_INTERNAL_NAME,
|
||||
"litellm_params": {"model": "openai/gpt-4"},
|
||||
"model_info": {
|
||||
"id": "byok-deployment-id",
|
||||
"db_model": True,
|
||||
"team_id": _BYOK_TEAM_ID,
|
||||
"team_public_model_name": _BYOK_PUBLIC_NAME,
|
||||
},
|
||||
}
|
||||
|
||||
router = MagicMock()
|
||||
router.model_list = [byok_deployment]
|
||||
router.get_model_list_from_model_alias = MagicMock(return_value=[])
|
||||
router.get_model_names = MagicMock(return_value=[])
|
||||
router.get_model_access_groups = MagicMock(return_value={})
|
||||
router.get_model_ids = MagicMock(return_value=[])
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(proxy_server, "llm_model_list", [byok_deployment])
|
||||
monkeypatch.setattr(proxy_server, "user_model", None)
|
||||
yield router
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"])
|
||||
def test_model_info_team_key_sees_own_byok_model(client, auth_as, byok_team_router, mock_prisma, monkeypatch, path):
|
||||
"""Regression for #30983: a team key (user_id=None) must see its own
|
||||
team's BYOK model under the public name.
|
||||
|
||||
Before the fix `_get_caller_byok_team_scope` keyed only off the bound
|
||||
user's team memberships, returned an empty set for a team key, and the
|
||||
BYOK row was dropped -> `{"data": []}`.
|
||||
"""
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", mock_prisma)
|
||||
mock_prisma.db.litellm_usertable.find_unique.return_value = None
|
||||
|
||||
with auth_as(
|
||||
role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id=None,
|
||||
team_id=_BYOK_TEAM_ID,
|
||||
team_models=[_BYOK_PUBLIC_NAME],
|
||||
):
|
||||
response = client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
surfaced_names = [m.get("model_name") for m in data]
|
||||
assert _BYOK_PUBLIC_NAME in surfaced_names
|
||||
assert _BYOK_INTERNAL_NAME not in surfaced_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"])
|
||||
def test_model_info_team_key_cannot_see_other_teams_byok_model(
|
||||
client, auth_as, byok_team_router, mock_prisma, monkeypatch, path
|
||||
):
|
||||
"""A team key for a different team must NOT see team-abc's BYOK row.
|
||||
|
||||
Guards the fix from over-broadening into a cross-team metadata leak.
|
||||
"""
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", mock_prisma)
|
||||
mock_prisma.db.litellm_usertable.find_unique.return_value = None
|
||||
|
||||
with auth_as(
|
||||
role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id=None,
|
||||
team_id="other-team",
|
||||
team_models=[_BYOK_PUBLIC_NAME],
|
||||
):
|
||||
response = client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
surfaced_names = [m.get("model_name") for m in data]
|
||||
assert _BYOK_PUBLIC_NAME not in surfaced_names
|
||||
assert _BYOK_INTERNAL_NAME not in surfaced_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /model_group/info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -316,8 +316,10 @@ async def test_model_info_v1_unrestricted_key_hides_other_team_byok(monkeypatch)
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_info_v1_service_key_hides_all_team_byok(monkeypatch):
|
||||
"""A key without a resolvable user (e.g. CI/service token) sees only
|
||||
global deployments, never any team-scoped BYOK rows."""
|
||||
"""A key with no resolvable user and no team (e.g. a CI/service token
|
||||
created outside any team) sees only global deployments, never team-scoped
|
||||
BYOK rows. A team-scoped key does see its own team's rows (issue #30983),
|
||||
pinned by the /model/info route tests."""
|
||||
team_row = _team_row()
|
||||
other_team_row = _other_team_row()
|
||||
global_row = {
|
||||
|
|
@ -343,7 +345,7 @@ async def test_model_info_v1_service_key_hides_all_team_byok(monkeypatch):
|
|||
caller = UserAPIKeyAuth(
|
||||
user_id=None,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
team_id="team-abc-123",
|
||||
team_id=None,
|
||||
models=[],
|
||||
team_models=[],
|
||||
)
|
||||
|
|
@ -352,6 +354,106 @@ async def test_model_info_v1_service_key_hides_all_team_byok(monkeypatch):
|
|||
assert [m["model_info"]["id"] for m in resp["data"]] == ["global-id-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"find_unique",
|
||||
[
|
||||
AsyncMock(return_value=MagicMock(teams=[])),
|
||||
AsyncMock(return_value=None),
|
||||
AsyncMock(side_effect=RuntimeError("db down")),
|
||||
],
|
||||
ids=["user-not-in-team", "user-row-missing", "user-lookup-error"],
|
||||
)
|
||||
async def test_model_info_v1_team_key_sees_own_byok_regardless_of_user_lookup(
|
||||
monkeypatch, find_unique
|
||||
):
|
||||
"""A team-scoped key sees its own team's BYOK rows even when the bound user
|
||||
is not a member of that team, has no DB row, or the lookup errors; the
|
||||
key's team_id is authoritative (issue #30983). Other teams' rows stay
|
||||
hidden."""
|
||||
global_row = {
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "gpt-4"},
|
||||
"model_info": {"id": "global-id-1", "db_model": False},
|
||||
}
|
||||
router = MagicMock()
|
||||
router.model_list = [_team_row(), _other_team_row(), global_row]
|
||||
router.get_model_names.return_value = ["gpt-4"]
|
||||
router.get_model_access_groups.return_value = {}
|
||||
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_usertable.find_unique = find_unique
|
||||
|
||||
async def _populate(**kwargs):
|
||||
return kwargs["all_models"]
|
||||
|
||||
monkeypatch.setattr(ps, "user_model", None)
|
||||
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
monkeypatch.setattr(ps, "prisma_client", prisma_client)
|
||||
monkeypatch.setattr(ps, "_populate_team_access_on_models", _populate)
|
||||
monkeypatch.setattr(
|
||||
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
|
||||
)
|
||||
|
||||
caller = UserAPIKeyAuth(
|
||||
user_id="user-1",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
team_id="team-abc-123",
|
||||
models=[],
|
||||
team_models=[],
|
||||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_info"]["id"] for m in resp["data"]] == ["byok-id-1", "global-id-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_info_v1_user_team_membership_grants_byok(monkeypatch):
|
||||
"""A user's own team memberships still grant that team's BYOK rows, unioned
|
||||
with any team the key itself is scoped to."""
|
||||
global_row = {
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "gpt-4"},
|
||||
"model_info": {"id": "global-id-1", "db_model": False},
|
||||
}
|
||||
router = MagicMock()
|
||||
router.model_list = [_team_row(), _other_team_row(), global_row]
|
||||
router.get_model_names.return_value = ["gpt-4"]
|
||||
router.get_model_access_groups.return_value = {}
|
||||
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=MagicMock(teams=["team-other"])
|
||||
)
|
||||
|
||||
async def _populate(**kwargs):
|
||||
return kwargs["all_models"]
|
||||
|
||||
monkeypatch.setattr(ps, "user_model", None)
|
||||
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
monkeypatch.setattr(ps, "prisma_client", prisma_client)
|
||||
monkeypatch.setattr(ps, "_populate_team_access_on_models", _populate)
|
||||
monkeypatch.setattr(
|
||||
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
|
||||
)
|
||||
|
||||
caller = UserAPIKeyAuth(
|
||||
user_id="user-2",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
team_id=None,
|
||||
models=[],
|
||||
team_models=[],
|
||||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_info"]["id"] for m in resp["data"]] == [
|
||||
"byok-id-other",
|
||||
"global-id-1",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_info_v1_populates_access_via_team_ids(monkeypatch):
|
||||
"""`/v1/model/info` must populate access_via_team_ids when the DB is connected."""
|
||||
|
|
|
|||
|
|
@ -1348,6 +1348,7 @@ async def test_apply_search_filter_scopes_byok_to_caller_teams():
|
|||
non_admin = MagicMock(spec=UserAPIKeyAuth)
|
||||
non_admin.user_role = LitellmUserRoles.INTERNAL_USER
|
||||
non_admin.user_id = "user-mine"
|
||||
non_admin.team_id = None
|
||||
|
||||
filtered, total_count = await _apply_search_filter_to_models(
|
||||
all_models=[caller_team_byok, other_team_byok, public_model],
|
||||
|
|
@ -1381,6 +1382,7 @@ async def test_apply_search_filter_scopes_byok_to_caller_teams():
|
|||
admin = MagicMock(spec=UserAPIKeyAuth)
|
||||
admin.user_role = LitellmUserRoles.PROXY_ADMIN
|
||||
admin.user_id = "admin-1"
|
||||
admin.team_id = None
|
||||
|
||||
filtered_admin, _ = await _apply_search_filter_to_models(
|
||||
all_models=[caller_team_byok, other_team_byok, public_model],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue