From 295bfe072f65e8fbf853468565cb979d209ae117 Mon Sep 17 00:00:00 2001 From: Adam Kirstein Date: Wed, 13 May 2026 12:53:10 -0700 Subject: [PATCH] fix(proxy): resolve team access-group models in /v1/models, strip sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- litellm/proxy/utils.py | 103 +++++ .../test_get_available_models_for_user.py | 386 ++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 tests/test_litellm/proxy/test_get_available_models_for_user.py diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 559d5c99b9d..26a823c5880 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -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, diff --git a/tests/test_litellm/proxy/test_get_available_models_for_user.py b/tests/test_litellm/proxy/test_get_available_models_for_user.py new file mode 100644 index 00000000000..8f74898d716 --- /dev/null +++ b/tests/test_litellm/proxy/test_get_available_models_for_user.py @@ -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()