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:
Adam Kirstein 2026-05-13 12:53:10 -07:00
parent 7af0f05b71
commit 295bfe072f
2 changed files with 489 additions and 0 deletions

View file

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

View 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()