mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
fix(proxy): resolve team access-group models in /v1/models, strip sentinel
`get_available_models_for_user` in `litellm/proxy/utils.py` powers the
OpenAI-compatible `/v1/models`, `/v1/models/{id}`, and `/model_group/info`
endpoints. It read `team.models[]` but never resolved
`team.access_group_ids[]` against the DB-backed `LiteLLM_AccessGroupTable`,
so a team configured as `models=["no-default-models"]` plus one or more
Unified Access Groups returned only the `no-default-models` sentinel —
OpenWebUI then showed an empty model dropdown for those teams.
The parallel admin path (`_add_access_group_models_to_team_models` in
`proxy_server.py`) already does the resolution correctly for
`/team/info` and `/v2/team/list`, but emits deployment ids. The
OpenAI-format path needs model names, so this adds a sibling helper
`_resolve_team_access_group_model_names` next to its only caller in
`utils.py` (avoiding the `utils.py -> proxy_server.py` circular-import
trap, and keeping `model_checks.py` sync-only as today).
The skip rules mirror the admin helper: teams whose `models[]` is empty
or contains `all-proxy-models` already see everything, so access-group
resolution is bypassed to avoid double-counting.
Additionally, the `SpecialModelNames.no_default_models` sentinel is now
stripped from `team_models` before `get_complete_model_list`, so it can
never surface as a selectable model id in `/v1/models` responses (the
user path in `auth_checks.py` already handled this; the team path did
not).
Tests: 11 new tests in `tests/test_litellm/proxy/test_get_available_models_for_user.py`
covering the pure resolver (happy path, skip rules, dedupe, deleted
access group) and end-to-end behavior (original bug, direct + access
group merge, sentinel-only team, explicit `team_id` param, no-team
caller).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7af0f05b71
commit
295bfe072f
2 changed files with 489 additions and 0 deletions
|
|
@ -99,6 +99,7 @@ from litellm.proxy._types import (
|
|||
CallInfo,
|
||||
LiteLLM_VerificationTokenView,
|
||||
Member,
|
||||
SpecialModelNames,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
|
@ -5810,6 +5811,66 @@ def construct_database_url_from_env_vars() -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
async def _resolve_team_access_group_model_names(
|
||||
team_objects: List[Any],
|
||||
prisma_client: "PrismaClient",
|
||||
) -> List[str]:
|
||||
"""
|
||||
Resolve model names reachable via ``team.access_group_ids[]`` against the
|
||||
DB-backed ``LiteLLM_AccessGroupTable``.
|
||||
|
||||
Sibling of ``_add_access_group_models_to_team_models`` in ``proxy_server.py``:
|
||||
the admin helper returns deployment ids (for ``/team/info`` / ``/v2/team/list``),
|
||||
while this helper returns model names (for the OpenAI-compatible
|
||||
``/v1/models`` path).
|
||||
|
||||
Skip rules mirror the admin helper:
|
||||
- teams with no ``access_group_ids`` contribute nothing.
|
||||
- teams whose ``models`` is empty or contains ``all-proxy-models`` already
|
||||
see every model on the proxy; access-group resolution is skipped to avoid
|
||||
double-counting.
|
||||
|
||||
Returns deduped model names in encounter order. The
|
||||
``no-default-models`` sentinel is never emitted (callers are still
|
||||
responsible for stripping it from any caller-supplied list — see the
|
||||
sentinel filter in ``get_available_models_for_user``).
|
||||
"""
|
||||
eligible_teams: List[Any] = []
|
||||
all_access_group_ids: set = set()
|
||||
for team in team_objects:
|
||||
if not getattr(team, "access_group_ids", None):
|
||||
continue
|
||||
team_models_raw = getattr(team, "models", None) or []
|
||||
if (
|
||||
not team_models_raw
|
||||
or SpecialModelNames.all_proxy_models.value in team_models_raw
|
||||
):
|
||||
continue
|
||||
eligible_teams.append(team)
|
||||
all_access_group_ids.update(team.access_group_ids)
|
||||
|
||||
if not eligible_teams:
|
||||
return []
|
||||
|
||||
access_group_rows = await prisma_client.db.litellm_accessgrouptable.find_many(
|
||||
where={"access_group_id": {"in": list(all_access_group_ids)}}
|
||||
)
|
||||
ag_to_model_names: Dict[str, List[str]] = {
|
||||
row.access_group_id: row.access_model_names or [] for row in access_group_rows
|
||||
}
|
||||
|
||||
seen: set = set()
|
||||
resolved: List[str] = []
|
||||
for team in eligible_teams:
|
||||
for ag_id in team.access_group_ids or []:
|
||||
for model_name in ag_to_model_names.get(ag_id, []):
|
||||
if model_name in seen:
|
||||
continue
|
||||
seen.add(model_name)
|
||||
resolved.append(model_name)
|
||||
return resolved
|
||||
|
||||
|
||||
async def get_available_models_for_user(
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
llm_router: Optional["Router"],
|
||||
|
|
@ -5867,6 +5928,7 @@ async def get_available_models_for_user(
|
|||
|
||||
# Get team models
|
||||
team_models: List[str] = user_api_key_dict.team_models
|
||||
team_object: Optional[Any] = None
|
||||
|
||||
# If specific team_id is provided, validate and get team models
|
||||
if team_id and prisma_client and proxy_logging_obj and user_api_key_cache:
|
||||
|
|
@ -5889,6 +5951,47 @@ async def get_available_models_for_user(
|
|||
include_model_access_groups=include_model_access_groups,
|
||||
)
|
||||
|
||||
# Resolve models reachable via team.access_group_ids[] (DB-backed Unified
|
||||
# Access Groups). Without this, a team configured as
|
||||
# ``models=["no-default-models"]`` plus one or more access groups returns
|
||||
# only the sentinel from /v1/models, even though the access groups grant
|
||||
# real models. Mirrors _add_access_group_models_to_team_models, but emits
|
||||
# model names instead of deployment ids (which is what /v1/models needs).
|
||||
if (
|
||||
team_object is None
|
||||
and user_api_key_dict.team_id
|
||||
and prisma_client is not None
|
||||
and user_api_key_cache is not None
|
||||
and proxy_logging_obj is not None
|
||||
):
|
||||
try:
|
||||
team_object = await get_team_object(
|
||||
team_id=user_api_key_dict.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception:
|
||||
team_object = None
|
||||
|
||||
if team_object is not None and prisma_client is not None:
|
||||
access_group_model_names = await _resolve_team_access_group_model_names(
|
||||
team_objects=[team_object],
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
for model_name in access_group_model_names:
|
||||
if model_name not in team_models:
|
||||
team_models.append(model_name)
|
||||
|
||||
# Strip the ``no-default-models`` sentinel before passing to
|
||||
# get_complete_model_list. The sentinel is an internal marker meaning
|
||||
# "this team has no direct models"; it must never appear in the
|
||||
# OpenAI-format response (otherwise OpenWebUI shows it as a selectable
|
||||
# model id). Matches the user-path treatment in auth_checks.py.
|
||||
team_models = [
|
||||
m for m in team_models if m != SpecialModelNames.no_default_models.value
|
||||
]
|
||||
|
||||
# Get complete model list
|
||||
all_models = get_complete_model_list(
|
||||
key_models=key_models,
|
||||
|
|
|
|||
386
tests/test_litellm/proxy/test_get_available_models_for_user.py
Normal file
386
tests/test_litellm/proxy/test_get_available_models_for_user.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""
|
||||
Tests for ``get_available_models_for_user`` resolving DB-backed Unified
|
||||
Access Groups and stripping the ``no-default-models`` sentinel.
|
||||
|
||||
Regression coverage for the bug where a team configured as
|
||||
``models=["no-default-models"]`` plus one or more
|
||||
``team.access_group_ids[]`` returned only the sentinel from ``/v1/models``
|
||||
(OpenWebUI then rendered an empty model dropdown), even though
|
||||
``LiteLLM_AccessGroupTable`` resolution was correct on the admin-side
|
||||
``/team/info`` / ``/v2/team/list`` endpoints.
|
||||
|
||||
See ``access-group-models-v1-models-fix.md`` for the full incident write-up.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTableCachedObj,
|
||||
SpecialModelNames,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.utils import (
|
||||
_resolve_team_access_group_model_names,
|
||||
get_available_models_for_user,
|
||||
)
|
||||
|
||||
|
||||
def _make_team_cached_obj(
|
||||
*,
|
||||
team_id: str = "team_alpha",
|
||||
models: Optional[List[str]] = None,
|
||||
access_group_ids: Optional[List[str]] = None,
|
||||
) -> LiteLLM_TeamTableCachedObj:
|
||||
return LiteLLM_TeamTableCachedObj(
|
||||
team_id=team_id,
|
||||
models=models if models is not None else [],
|
||||
access_group_ids=access_group_ids,
|
||||
)
|
||||
|
||||
|
||||
def _make_access_group_row(group_id: str, model_names: List[str]):
|
||||
row = MagicMock()
|
||||
row.access_group_id = group_id
|
||||
row.access_model_names = model_names
|
||||
return row
|
||||
|
||||
|
||||
def _make_prisma_client_with_ag_rows(rows: List):
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=rows)
|
||||
return prisma
|
||||
|
||||
|
||||
def _make_user_api_key_dict(
|
||||
*,
|
||||
team_id: Optional[str] = None,
|
||||
team_models: Optional[List[str]] = None,
|
||||
) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-test",
|
||||
models=[],
|
||||
team_id=team_id,
|
||||
team_models=team_models or [],
|
||||
)
|
||||
|
||||
|
||||
def _patched_get_team_object(team_obj):
|
||||
"""Returns an AsyncMock that ignores extra kwargs and returns ``team_obj``."""
|
||||
|
||||
async def _impl(*_args, **_kwargs):
|
||||
return team_obj
|
||||
|
||||
return _impl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_team_access_group_model_names — pure helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_returns_access_group_model_names():
|
||||
"""Happy path: team has access groups, helper returns their model names."""
|
||||
team = _make_team_cached_obj(
|
||||
models=["no-default-models"], access_group_ids=["ag-1"]
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["gpt-4o", "claude-opus-4-5"])]
|
||||
)
|
||||
|
||||
result = await _resolve_team_access_group_model_names(
|
||||
team_objects=[team], prisma_client=prisma
|
||||
)
|
||||
|
||||
assert result == ["gpt-4o", "claude-opus-4-5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_skips_teams_with_empty_models():
|
||||
"""
|
||||
A team with ``models=[]`` already sees every proxy model; resolving its
|
||||
access groups would double-count. Mirror ``_add_access_group_models_to_team_models``.
|
||||
"""
|
||||
team = _make_team_cached_obj(models=[], access_group_ids=["ag-1"])
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["gpt-4o"])]
|
||||
)
|
||||
|
||||
result = await _resolve_team_access_group_model_names(
|
||||
team_objects=[team], prisma_client=prisma
|
||||
)
|
||||
|
||||
assert result == []
|
||||
prisma.db.litellm_accessgrouptable.find_many.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_skips_teams_with_all_proxy_models_sentinel():
|
||||
"""Same skip rule for the explicit ``all-proxy-models`` marker."""
|
||||
team = _make_team_cached_obj(
|
||||
models=[SpecialModelNames.all_proxy_models.value],
|
||||
access_group_ids=["ag-1"],
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["gpt-4o"])]
|
||||
)
|
||||
|
||||
result = await _resolve_team_access_group_model_names(
|
||||
team_objects=[team], prisma_client=prisma
|
||||
)
|
||||
|
||||
assert result == []
|
||||
prisma.db.litellm_accessgrouptable.find_many.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_deduplicates_across_access_groups():
|
||||
"""Two access groups that share a model are deduped, order preserved."""
|
||||
team = _make_team_cached_obj(
|
||||
models=["no-default-models"], access_group_ids=["ag-1", "ag-2"]
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[
|
||||
_make_access_group_row("ag-1", ["gpt-4o", "claude-opus-4-5"]),
|
||||
_make_access_group_row("ag-2", ["claude-opus-4-5", "mistral-large"]),
|
||||
]
|
||||
)
|
||||
|
||||
result = await _resolve_team_access_group_model_names(
|
||||
team_objects=[team], prisma_client=prisma
|
||||
)
|
||||
|
||||
assert result == ["gpt-4o", "claude-opus-4-5", "mistral-large"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_handles_deleted_access_group():
|
||||
"""Missing rows contribute nothing; no error."""
|
||||
team = _make_team_cached_obj(
|
||||
models=["no-default-models"], access_group_ids=["ag-1", "ag-deleted"]
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["gpt-4o"])]
|
||||
)
|
||||
|
||||
result = await _resolve_team_access_group_model_names(
|
||||
team_objects=[team], prisma_client=prisma
|
||||
)
|
||||
|
||||
assert result == ["gpt-4o"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_available_models_for_user — end-to-end behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_default_models_team_resolves_access_group_models():
|
||||
"""
|
||||
The original bug: ``models=["no-default-models"]`` plus an access group
|
||||
returned just the sentinel. Now it returns the access-group models with
|
||||
the sentinel stripped.
|
||||
"""
|
||||
team = _make_team_cached_obj(
|
||||
models=["no-default-models"], access_group_ids=["ag-1"]
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["gpt-4o", "claude-opus-4-5"])]
|
||||
)
|
||||
|
||||
user_api_key_dict = _make_user_api_key_dict(
|
||||
team_id=team.team_id, team_models=["no-default-models"]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_object",
|
||||
new=_patched_get_team_object(team),
|
||||
):
|
||||
result = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=None,
|
||||
general_settings={},
|
||||
user_model=None,
|
||||
prisma_client=prisma,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
team_id=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
)
|
||||
|
||||
assert SpecialModelNames.no_default_models.value not in result
|
||||
assert set(result) == {"gpt-4o", "claude-opus-4-5"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_and_access_group_models_are_merged_and_deduped():
|
||||
"""Direct ``team.models[]`` and access-group-resolved names union together."""
|
||||
team = _make_team_cached_obj(models=["gpt-4o"], access_group_ids=["ag-1"])
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["claude-opus-4-5", "gpt-4o"])]
|
||||
)
|
||||
|
||||
user_api_key_dict = _make_user_api_key_dict(
|
||||
team_id=team.team_id, team_models=["gpt-4o"]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_object",
|
||||
new=_patched_get_team_object(team),
|
||||
):
|
||||
result = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=None,
|
||||
general_settings={},
|
||||
user_model=None,
|
||||
prisma_client=prisma,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
team_id=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
)
|
||||
|
||||
assert result.count("gpt-4o") == 1
|
||||
assert set(result) == {"gpt-4o", "claude-opus-4-5"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentinel_only_team_without_access_groups_returns_empty():
|
||||
"""
|
||||
A team with ``models=["no-default-models"]`` and no access groups must
|
||||
return an empty list — never the literal sentinel string, which OpenWebUI
|
||||
would otherwise render as a selectable model id.
|
||||
"""
|
||||
team = _make_team_cached_obj(models=["no-default-models"], access_group_ids=None)
|
||||
prisma = _make_prisma_client_with_ag_rows([])
|
||||
|
||||
user_api_key_dict = _make_user_api_key_dict(
|
||||
team_id=team.team_id, team_models=["no-default-models"]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_object",
|
||||
new=_patched_get_team_object(team),
|
||||
):
|
||||
result = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=None,
|
||||
general_settings={},
|
||||
user_model=None,
|
||||
prisma_client=prisma,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
team_id=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
)
|
||||
|
||||
assert SpecialModelNames.no_default_models.value not in result
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_team_id_param_resolves_access_groups():
|
||||
"""
|
||||
The ``team_id`` kwarg path (used by admin/UI flows that pin a team
|
||||
explicitly) must resolve access groups too.
|
||||
"""
|
||||
team = _make_team_cached_obj(
|
||||
models=["no-default-models"], access_group_ids=["ag-1"]
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows(
|
||||
[_make_access_group_row("ag-1", ["gpt-4o"])]
|
||||
)
|
||||
|
||||
user_api_key_dict = _make_user_api_key_dict(team_id=None, team_models=[])
|
||||
|
||||
async def _noop_validate_membership(*_args, **_kwargs):
|
||||
return True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_object",
|
||||
new=_patched_get_team_object(team),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.validate_membership",
|
||||
new=_noop_validate_membership,
|
||||
),
|
||||
):
|
||||
result = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=None,
|
||||
general_settings={},
|
||||
user_model=None,
|
||||
prisma_client=prisma,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
team_id=team.team_id,
|
||||
user_api_key_cache=MagicMock(),
|
||||
)
|
||||
|
||||
assert set(result) == {"gpt-4o"}
|
||||
assert SpecialModelNames.no_default_models.value not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_access_group_does_not_leak_sentinel():
|
||||
"""
|
||||
Edge case from the field: a team references an access group that has been
|
||||
deleted from ``LiteLLM_AccessGroupTable``. Resolution returns no rows; the
|
||||
sentinel must still be stripped so /v1/models returns an empty list, not
|
||||
``["no-default-models"]``.
|
||||
"""
|
||||
team = _make_team_cached_obj(
|
||||
models=["no-default-models"], access_group_ids=["ag-deleted"]
|
||||
)
|
||||
prisma = _make_prisma_client_with_ag_rows([])
|
||||
|
||||
user_api_key_dict = _make_user_api_key_dict(
|
||||
team_id=team.team_id, team_models=["no-default-models"]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_object",
|
||||
new=_patched_get_team_object(team),
|
||||
):
|
||||
result = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=None,
|
||||
general_settings={},
|
||||
user_model=None,
|
||||
prisma_client=prisma,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
team_id=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_with_no_team_skips_access_group_resolution():
|
||||
"""
|
||||
Callers without any team (no ``team_id`` kwarg, no
|
||||
``user_api_key_dict.team_id``) skip the DB resolution entirely — they have
|
||||
no team-scoped access groups to look up.
|
||||
"""
|
||||
user_api_key_dict = _make_user_api_key_dict(team_id=None, team_models=[])
|
||||
prisma = _make_prisma_client_with_ag_rows([])
|
||||
|
||||
result = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=None,
|
||||
general_settings={},
|
||||
user_model=None,
|
||||
prisma_client=prisma,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
team_id=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
prisma.db.litellm_accessgrouptable.find_many.assert_not_called()
|
||||
Loading…
Add table
Reference in a new issue