From f62658795aeb1202df80cf290e9525de1ea85b2c Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 12:25:48 -0700 Subject: [PATCH 1/8] feat(teams): resolve access group models/MCPs/agents in team endpoints Add access_group_models, access_group_mcp_server_ids, and access_group_agent_ids to /team/info and /v2/team/list responses. These fields contain resources inherited from access groups, kept separate from direct assignments so the UI can distinguish the source. Backend: _resolve_access_group_resources() helper resolves access group resources via existing _get_*_from_access_groups() functions. UI: Teams table and detail view show direct models as blue badges and access-group-sourced models as green badges. --- litellm/proxy/_types.py | 4 + .../management_endpoints/team_endpoints.py | 54 +++++++ .../management_endpoints/team_endpoints.py | 4 + .../components/TeamsTable/ModelsCell.tsx | 142 +++++++++--------- .../components/key_team_helpers/key_list.tsx | 4 + .../src/components/team/TeamInfo.tsx | 27 +++- 6 files changed, 161 insertions(+), 74 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8faf36df4c6..6b581957e91 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3805,6 +3805,10 @@ class OrganizationMemberUpdateResponse(MemberUpdateResponse): class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): team_member_budget_table: Optional[LiteLLM_BudgetTable] = None + # Resources inherited from access groups (separate from direct assignments) + access_group_models: Optional[List[str]] = None + access_group_mcp_server_ids: Optional[List[str]] = None + access_group_agent_ids: Optional[List[str]] = None class TeamInfoResponseObject(TypedDict): diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3643373be65..6b4cf72fc11 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -62,6 +62,9 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.auth_checks import ( + _get_agent_ids_from_access_groups, + _get_mcp_server_ids_from_access_groups, + _get_models_from_access_groups, allowed_route_check_inside_route, can_org_access_model, get_org_object, @@ -3042,6 +3045,14 @@ async def team_info( team_info_response_object=_team_info, ) + # Resolve resources inherited from access groups + resolved = await _resolve_access_group_resources( + access_group_ids=_team_info.access_group_ids, + ) + _team_info.access_group_models = resolved["access_group_models"] + _team_info.access_group_mcp_server_ids = resolved["access_group_mcp_server_ids"] + _team_info.access_group_agent_ids = resolved["access_group_agent_ids"] + response_object = TeamInfoResponseObject( team_id=team_id, team_info=_team_info, @@ -3332,6 +3343,36 @@ async def _build_team_list_where_conditions( return where_conditions +async def _resolve_access_group_resources( + access_group_ids: Optional[List[str]], +) -> Dict[str, List[str]]: + """ + Resolve resources inherited from access groups. + + Returns only the access-group-sourced resources (not direct assignments). + Keeps them separate so callers can distinguish where each resource comes from. + """ + empty: Dict[str, List[str]] = { + "access_group_models": [], + "access_group_mcp_server_ids": [], + "access_group_agent_ids": [], + } + if not access_group_ids: + return empty + + return { + "access_group_models": await _get_models_from_access_groups( + access_group_ids=access_group_ids, + ), + "access_group_mcp_server_ids": await _get_mcp_server_ids_from_access_groups( + access_group_ids=access_group_ids, + ), + "access_group_agent_ids": await _get_agent_ids_from_access_groups( + access_group_ids=access_group_ids, + ), + } + + def _convert_teams_to_response_models( teams: list, use_deleted_table: bool, @@ -3558,6 +3599,19 @@ async def list_team_v2( # Convert Prisma models to response models with members_count team_list = _convert_teams_to_response_models(teams, use_deleted_table) + # Resolve resources inherited from access groups for each team + if not use_deleted_table: + for team_item in team_list: + if isinstance(team_item, TeamListItem): + resolved = await _resolve_access_group_resources( + access_group_ids=team_item.access_group_ids, + ) + team_item.access_group_models = resolved["access_group_models"] + team_item.access_group_mcp_server_ids = resolved[ + "access_group_mcp_server_ids" + ] + team_item.access_group_agent_ids = resolved["access_group_agent_ids"] + return { "teams": team_list, "total": total_count, diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 5055a65783f..2455eb495d1 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -47,6 +47,10 @@ class TeamListItem(LiteLLM_TeamTable): """A team item in the paginated list response, enriched with computed fields.""" members_count: int = 0 + # Resources inherited from access groups (separate from direct assignments) + access_group_models: Optional[List[str]] = None + access_group_mcp_server_ids: Optional[List[str]] = None + access_group_agent_ids: Optional[List[str]] = None class TeamListResponse(BaseModel): diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx index 5cabe4c4a8f..03a0a80bc71 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx @@ -1,16 +1,54 @@ import { Badge, Icon, TableCell, Text } from "@tremor/react"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Team } from "@/components/key_team_helpers/key_list"; interface ModelsCellProps { team: Team; } +interface ModelEntry { + name: string; + source: "direct" | "access_group"; +} + const ModelsCell = ({ team }: ModelsCellProps) => { const [expandedAccordion, setExpandedAccordion] = useState(false); + const modelEntries: ModelEntry[] = useMemo(() => { + const entries: ModelEntry[] = (team.models || []).map((m) => ({ + name: m, + source: "direct" as const, + })); + for (const m of team.access_group_models || []) { + entries.push({ name: m, source: "access_group" }); + } + return entries; + }, [team.models, team.access_group_models]); + + const renderBadge = (entry: ModelEntry, index: number) => { + if (entry.name === "all-proxy-models") { + return ( + + All Proxy Models + + ); + } + const displayName = getModelDisplayName(entry.name); + const truncated = displayName.length > 30 ? `${displayName.slice(0, 30)}...` : displayName; + return ( + + {truncated} + + ); + }; + return ( { whiteSpace: "pre-wrap", overflow: "hidden", }} - className={team.models.length > 3 ? "px-0" : ""} + className={modelEntries.length > 3 ? "px-0" : ""} >
- {Array.isArray(team.models) ? ( + {modelEntries.length === 0 ? ( + + All Proxy Models + + ) : (
- {team.models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {team.models.length > 3 && ( -
- { - setExpandedAccordion((prev) => !prev); - }} - /> -
- )} -
- {team.models.slice(0, 3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} - {team.models.length > 3 && !expandedAccordion && ( - - - +{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"} - - - )} - {expandedAccordion && ( -
- {team.models.slice(3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
+
+ {modelEntries.length > 3 && ( +
+ { + setExpandedAccordion((prev) => !prev); + }} + />
- - )} + )} +
+ {modelEntries.slice(0, 3).map((entry, index) => renderBadge(entry, index))} + {modelEntries.length > 3 && !expandedAccordion && ( + + + +{modelEntries.length - 3} {modelEntries.length - 3 === 1 ? "more model" : "more models"} + + + )} + {expandedAccordion && ( +
+ {modelEntries.slice(3).map((entry, index) => renderBadge(entry, index + 3))} +
+ )} +
+
- ) : null} + )}
); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a681e438cd1..04b9a5c9962 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -15,6 +15,10 @@ export interface Team { keys: KeyResponse[]; members_with_roles: Member[]; spend: number; + access_group_ids?: string[]; + access_group_models?: string[]; + access_group_mcp_server_ids?: string[]; + access_group_agent_ids?: string[]; } export interface KeyResponse { diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index a4c7ae2bbbe..79a55cf30fd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -655,16 +655,31 @@ const TeamInfoView: React.FC = ({ Models
- {info.models.length === 0 ? ( + {info.models.length === 0 && !(info.access_group_models?.length) ? ( All proxy models ) : ( - info.models.map((model, index) => ( - - {model} - - )) + <> + {info.models.map((model: string, index: number) => ( + + {model} + + ))} + {(info.access_group_models || []).map((model: string, index: number) => ( + + {model} + + ))} + )}
+ {info.access_group_models && info.access_group_models.length > 0 && ( +
+ + Direct + From access group + +
+ )}
From bbe708b093d4fc4f59f451599eb86b991dc85b91 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 14:52:32 -0700 Subject: [PATCH 2/8] perf(teams): single-pass access group resolution + asyncio.gather in list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch each access group object once and extract all 3 resource fields in a single pass instead of 3 separate calls (3N → N lookups) - Use asyncio.gather to resolve access groups across teams concurrently in list_team_v2 instead of sequential awaits - Add 5 unit tests for _resolve_access_group_resources --- .../management_endpoints/team_endpoints.py | 66 +++++-- .../test_team_endpoints.py | 163 ++++++++++++++++++ 2 files changed, 212 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 6b4cf72fc11..125efb03d8c 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -62,11 +62,9 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.auth_checks import ( - _get_agent_ids_from_access_groups, - _get_mcp_server_ids_from_access_groups, - _get_models_from_access_groups, allowed_route_check_inside_route, can_org_access_model, + get_access_object, get_org_object, get_team_object, get_user_object, @@ -3349,6 +3347,9 @@ async def _resolve_access_group_resources( """ Resolve resources inherited from access groups. + Fetches each access group object once and extracts all three resource + fields in a single pass (models, MCP servers, agents). + Returns only the access-group-sourced resources (not direct assignments). Keeps them separate so callers can distinguish where each resource comes from. """ @@ -3360,16 +3361,38 @@ async def _resolve_access_group_resources( if not access_group_ids: return empty + from litellm.proxy.proxy_server import prisma_client as _prisma_client + from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging_obj + from litellm.proxy.proxy_server import user_api_key_cache as _user_api_key_cache + + if _user_api_key_cache is None: + return empty + + models: List[str] = [] + mcp_ids: List[str] = [] + agent_ids: List[str] = [] + + for ag_id in access_group_ids: + try: + ag = await get_access_object( + access_group_id=ag_id, + prisma_client=_prisma_client, + user_api_key_cache=_user_api_key_cache, + proxy_logging_obj=_proxy_logging_obj, + ) + models.extend(getattr(ag, "access_model_names", [])) + mcp_ids.extend(getattr(ag, "access_mcp_server_ids", [])) + agent_ids.extend(getattr(ag, "access_agent_ids", [])) + except Exception: + verbose_proxy_logger.debug( + "Could not fetch access group %s for resource resolution", + ag_id, + ) + return { - "access_group_models": await _get_models_from_access_groups( - access_group_ids=access_group_ids, - ), - "access_group_mcp_server_ids": await _get_mcp_server_ids_from_access_groups( - access_group_ids=access_group_ids, - ), - "access_group_agent_ids": await _get_agent_ids_from_access_groups( - access_group_ids=access_group_ids, - ), + "access_group_models": list(set(models)), + "access_group_mcp_server_ids": list(set(mcp_ids)), + "access_group_agent_ids": list(set(agent_ids)), } @@ -3601,11 +3624,20 @@ async def list_team_v2( # Resolve resources inherited from access groups for each team if not use_deleted_table: - for team_item in team_list: - if isinstance(team_item, TeamListItem): - resolved = await _resolve_access_group_resources( - access_group_ids=team_item.access_group_ids, - ) + team_items_with_ag = [ + t for t in team_list + if isinstance(t, TeamListItem) and t.access_group_ids + ] + if team_items_with_ag: + results = await asyncio.gather( + *[ + _resolve_access_group_resources( + access_group_ids=t.access_group_ids, + ) + for t in team_items_with_ag + ] + ) + for team_item, resolved in zip(team_items_with_ag, results): team_item.access_group_models = resolved["access_group_models"] team_item.access_group_mcp_server_ids = resolved[ "access_group_mcp_server_ids" diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 366f659bdab..a4b5e677603 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -6491,3 +6491,166 @@ async def test_create_team_member_budget_table_with_duration(): assert budget_request.budget_duration == "30d" assert budget_request.max_budget == 20.0 assert result["metadata"]["team_member_budget_id"] == "budget-abc" + + +# --------------------------------------------------------------------------- +# Tests for _resolve_access_group_resources +# --------------------------------------------------------------------------- + + +class TestResolveAccessGroupResources: + """Tests for the single-pass access group resource resolution helper.""" + + @pytest.mark.asyncio + async def test_returns_empty_when_no_access_group_ids(self): + """None or empty list should return empty lists for all resource types.""" + from litellm.proxy.management_endpoints.team_endpoints import ( + _resolve_access_group_resources, + ) + + result_none = await _resolve_access_group_resources(access_group_ids=None) + assert result_none == { + "access_group_models": [], + "access_group_mcp_server_ids": [], + "access_group_agent_ids": [], + } + + result_empty = await _resolve_access_group_resources(access_group_ids=[]) + assert result_empty == { + "access_group_models": [], + "access_group_mcp_server_ids": [], + "access_group_agent_ids": [], + } + + @pytest.mark.asyncio + async def test_single_access_group(self): + """Single access group should return its resources.""" + from litellm.proxy._types import LiteLLM_AccessGroupTable + from litellm.proxy.management_endpoints.team_endpoints import ( + _resolve_access_group_resources, + ) + + fake_ag = LiteLLM_AccessGroupTable( + access_group_id="ag-1", + access_group_name="test-group", + access_model_names=["gpt-4", "claude-3"], + access_mcp_server_ids=["mcp-1"], + access_agent_ids=["agent-1", "agent-2"], + ) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_access_object", + new_callable=AsyncMock, + return_value=fake_ag, + ): + with patch( + "litellm.proxy.proxy_server.user_api_key_cache", + MagicMock(), + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1"], + ) + + assert sorted(result["access_group_models"]) == ["claude-3", "gpt-4"] + assert result["access_group_mcp_server_ids"] == ["mcp-1"] + assert sorted(result["access_group_agent_ids"]) == ["agent-1", "agent-2"] + + @pytest.mark.asyncio + async def test_multiple_access_groups_deduplicates(self): + """Multiple access groups with overlapping resources should deduplicate.""" + from litellm.proxy._types import LiteLLM_AccessGroupTable + from litellm.proxy.management_endpoints.team_endpoints import ( + _resolve_access_group_resources, + ) + + ag1 = LiteLLM_AccessGroupTable( + access_group_id="ag-1", + access_group_name="group-1", + access_model_names=["gpt-4", "claude-3"], + access_mcp_server_ids=["mcp-1"], + access_agent_ids=["agent-1"], + ) + ag2 = LiteLLM_AccessGroupTable( + access_group_id="ag-2", + access_group_name="group-2", + access_model_names=["gpt-4", "gemini"], + access_mcp_server_ids=["mcp-1", "mcp-2"], + access_agent_ids=["agent-2"], + ) + + async def fake_get_access_object(access_group_id, **kwargs): + return {"ag-1": ag1, "ag-2": ag2}[access_group_id] + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_access_object", + side_effect=fake_get_access_object, + ): + with patch( + "litellm.proxy.proxy_server.user_api_key_cache", + MagicMock(), + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1", "ag-2"], + ) + + assert sorted(result["access_group_models"]) == ["claude-3", "gemini", "gpt-4"] + assert sorted(result["access_group_mcp_server_ids"]) == ["mcp-1", "mcp-2"] + assert sorted(result["access_group_agent_ids"]) == ["agent-1", "agent-2"] + + @pytest.mark.asyncio + async def test_missing_access_group_skipped(self): + """If an access group doesn't exist, it should be skipped gracefully.""" + from litellm.proxy._types import LiteLLM_AccessGroupTable + from litellm.proxy.management_endpoints.team_endpoints import ( + _resolve_access_group_resources, + ) + + ag1 = LiteLLM_AccessGroupTable( + access_group_id="ag-1", + access_group_name="group-1", + access_model_names=["gpt-4"], + access_mcp_server_ids=[], + access_agent_ids=[], + ) + + async def fake_get_access_object(access_group_id, **kwargs): + if access_group_id == "ag-1": + return ag1 + raise HTTPException(status_code=404, detail="Not found") + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_access_object", + side_effect=fake_get_access_object, + ): + with patch( + "litellm.proxy.proxy_server.user_api_key_cache", + MagicMock(), + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1", "ag-missing"], + ) + + assert result["access_group_models"] == ["gpt-4"] + assert result["access_group_mcp_server_ids"] == [] + assert result["access_group_agent_ids"] == [] + + @pytest.mark.asyncio + async def test_returns_empty_when_cache_unavailable(self): + """If user_api_key_cache is None, should return empty results.""" + from litellm.proxy.management_endpoints.team_endpoints import ( + _resolve_access_group_resources, + ) + + with patch( + "litellm.proxy.proxy_server.user_api_key_cache", + None, + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1"], + ) + + assert result == { + "access_group_models": [], + "access_group_mcp_server_ids": [], + "access_group_agent_ids": [], + } From f0bd33486ead7ed2ac025ab3675a92969920e649 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 16:14:34 -0700 Subject: [PATCH 3/8] fix: lazy import get_access_object to break cyclic import + short-circuit all-proxy-models display - Remove get_access_object from module-level import in team_endpoints.py and use a lazy _get_access_object wrapper to avoid cyclic dependency - Add _prisma_client is None early-exit guard in _resolve_access_group_resources - Short-circuit UI to show "All Proxy Models" when team.models is empty or contains "all-proxy-models", skipping access group model resolution --- .../proxy/management_endpoints/team_endpoints.py | 16 ++++++++++++++-- .../teams/components/TeamsTable/ModelsCell.tsx | 7 +++++-- .../src/components/team/TeamInfo.tsx | 4 ++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 125efb03d8c..c66177aafcf 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -64,7 +64,6 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import ( allowed_route_check_inside_route, can_org_access_model, - get_access_object, get_org_object, get_team_object, get_user_object, @@ -111,6 +110,16 @@ from litellm.types.proxy.management_endpoints.team_endpoints import ( router = APIRouter() +def _get_access_object(*args, **kwargs): + """ + Lazily import and delegate to `get_access_object` from + `litellm.proxy.auth.auth_checks` to avoid module-level cyclic imports. + """ + from litellm.proxy.auth.auth_checks import get_access_object as _inner_get_access_object + + return _inner_get_access_object(*args, **kwargs) + + class TeamMemberBudgetHandler: """Helper class to handle team member budget, RPM, and TPM limit operations""" @@ -3368,13 +3377,16 @@ async def _resolve_access_group_resources( if _user_api_key_cache is None: return empty + if _prisma_client is None: + return empty + models: List[str] = [] mcp_ids: List[str] = [] agent_ids: List[str] = [] for ag_id in access_group_ids: try: - ag = await get_access_object( + ag = await _get_access_object( access_group_id=ag_id, prisma_client=_prisma_client, user_api_key_cache=_user_api_key_cache, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx index 03a0a80bc71..62a7fdb783f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx @@ -16,8 +16,11 @@ interface ModelEntry { const ModelsCell = ({ team }: ModelsCellProps) => { const [expandedAccordion, setExpandedAccordion] = useState(false); + const isAllModels = !team.models || team.models.length === 0 || team.models.includes("all-proxy-models"); + const modelEntries: ModelEntry[] = useMemo(() => { - const entries: ModelEntry[] = (team.models || []).map((m) => ({ + if (isAllModels) return []; + const entries: ModelEntry[] = team.models.map((m) => ({ name: m, source: "direct" as const, })); @@ -25,7 +28,7 @@ const ModelsCell = ({ team }: ModelsCellProps) => { entries.push({ name: m, source: "access_group" }); } return entries; - }, [team.models, team.access_group_models]); + }, [team.models, team.access_group_models, isAllModels]); const renderBadge = (entry: ModelEntry, index: number) => { if (entry.name === "all-proxy-models") { diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 79a55cf30fd..576f0a5f99c 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -655,7 +655,7 @@ const TeamInfoView: React.FC = ({ Models
- {info.models.length === 0 && !(info.access_group_models?.length) ? ( + {info.models.length === 0 || info.models.includes("all-proxy-models") ? ( All proxy models ) : ( <> @@ -672,7 +672,7 @@ const TeamInfoView: React.FC = ({ )}
- {info.access_group_models && info.access_group_models.length > 0 && ( + {info.models.length > 0 && !info.models.includes("all-proxy-models") && info.access_group_models && info.access_group_models.length > 0 && (
Direct From 38f6c9491d602dd3b39938d1a5543806be2d2735 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 16:16:55 -0700 Subject: [PATCH 4/8] fix(tests): correct mock targets in TestResolveAccessGroupResources Three tests were patching the non-existent `get_access_object` instead of `_get_access_object` (the lazy-import wrapper), causing AttributeError. Also added missing `prisma_client` mock so tests get past the early-exit guard and actually exercise the resolution logic. --- .../test_team_endpoints.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index a4b5e677603..a2c8c4d560c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -6539,7 +6539,7 @@ class TestResolveAccessGroupResources: ) with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_access_object", + "litellm.proxy.management_endpoints.team_endpoints._get_access_object", new_callable=AsyncMock, return_value=fake_ag, ): @@ -6547,9 +6547,13 @@ class TestResolveAccessGroupResources: "litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1"], - ) + with patch( + "litellm.proxy.proxy_server.prisma_client", + MagicMock(), + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1"], + ) assert sorted(result["access_group_models"]) == ["claude-3", "gpt-4"] assert result["access_group_mcp_server_ids"] == ["mcp-1"] @@ -6582,16 +6586,20 @@ class TestResolveAccessGroupResources: return {"ag-1": ag1, "ag-2": ag2}[access_group_id] with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_access_object", + "litellm.proxy.management_endpoints.team_endpoints._get_access_object", side_effect=fake_get_access_object, ): with patch( "litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1", "ag-2"], - ) + with patch( + "litellm.proxy.proxy_server.prisma_client", + MagicMock(), + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1", "ag-2"], + ) assert sorted(result["access_group_models"]) == ["claude-3", "gemini", "gpt-4"] assert sorted(result["access_group_mcp_server_ids"]) == ["mcp-1", "mcp-2"] @@ -6619,16 +6627,20 @@ class TestResolveAccessGroupResources: raise HTTPException(status_code=404, detail="Not found") with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_access_object", + "litellm.proxy.management_endpoints.team_endpoints._get_access_object", side_effect=fake_get_access_object, ): with patch( "litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1", "ag-missing"], - ) + with patch( + "litellm.proxy.proxy_server.prisma_client", + MagicMock(), + ): + result = await _resolve_access_group_resources( + access_group_ids=["ag-1", "ag-missing"], + ) assert result["access_group_models"] == ["gpt-4"] assert result["access_group_mcp_server_ids"] == [] From ea32cb58a8a9cc12eeef9f6e5db6ff1f0666b112 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 16:20:54 -0700 Subject: [PATCH 5/8] fix: use direct attribute access with or [] fallback in _resolve_access_group_resources Replace getattr(ag, "field", []) with ag.field or [] for cleaner access and safe handling if a field is None. --- litellm/proxy/management_endpoints/team_endpoints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index c66177aafcf..ac606a71ba6 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3392,9 +3392,9 @@ async def _resolve_access_group_resources( user_api_key_cache=_user_api_key_cache, proxy_logging_obj=_proxy_logging_obj, ) - models.extend(getattr(ag, "access_model_names", [])) - mcp_ids.extend(getattr(ag, "access_mcp_server_ids", [])) - agent_ids.extend(getattr(ag, "access_agent_ids", [])) + models.extend(ag.access_model_names or []) + mcp_ids.extend(ag.access_mcp_server_ids or []) + agent_ids.extend(ag.access_agent_ids or []) except Exception: verbose_proxy_logger.debug( "Could not fetch access group %s for resource resolution", From 3bdd04250784a10ce031c35c0cd50e7f03001243 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 16:49:55 -0700 Subject: [PATCH 6/8] fix(ui): remove model source legend from team detail view The blue/green color distinction is self-explanatory; the legend added visual clutter without providing enough value. --- ui/litellm-dashboard/src/components/team/TeamInfo.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 576f0a5f99c..4bb1311ebf6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -672,14 +672,6 @@ const TeamInfoView: React.FC = ({ )}
- {info.models.length > 0 && !info.models.includes("all-proxy-models") && info.access_group_models && info.access_group_models.length > 0 && ( -
- - Direct - From access group - -
- )}
From bb03a11d7c43dd36c6a2b075afd780670b11a42e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 16:59:47 -0700 Subject: [PATCH 7/8] fix(ui): add missing access_group fields to TeamData.team_info type The TeamData interface was missing access_group_models, access_group_mcp_server_ids, and access_group_agent_ids fields, causing a TypeScript build failure. --- ui/litellm-dashboard/src/components/team/TeamInfo.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 4bb1311ebf6..1e4345905ed 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -95,6 +95,9 @@ export interface TeamData { } | null; created_at: string; access_group_ids?: string[]; + access_group_models?: string[]; + access_group_mcp_server_ids?: string[]; + access_group_agent_ids?: string[]; guardrails?: string[]; policies?: string[]; object_permission?: { From 93369bf60d7ad87f33c56ca66ce20a3994419e8d Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 17:13:56 -0700 Subject: [PATCH 8/8] perf(teams): batch-fetch access groups in single DB query Replace per-ID _resolve_access_group_resources loop with a single find_many call that deduplicates IDs across all teams. Removes the N+1 query pattern on cold cache for the team list endpoint. --- .../management_endpoints/team_endpoints.py | 129 ++++------- .../test_team_endpoints.py | 219 +++++++----------- 2 files changed, 135 insertions(+), 213 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index ac606a71ba6..ae89f09bb44 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -110,16 +110,6 @@ from litellm.types.proxy.management_endpoints.team_endpoints import ( router = APIRouter() -def _get_access_object(*args, **kwargs): - """ - Lazily import and delegate to `get_access_object` from - `litellm.proxy.auth.auth_checks` to avoid module-level cyclic imports. - """ - from litellm.proxy.auth.auth_checks import get_access_object as _inner_get_access_object - - return _inner_get_access_object(*args, **kwargs) - - class TeamMemberBudgetHandler: """Helper class to handle team member budget, RPM, and TPM limit operations""" @@ -3053,12 +3043,17 @@ async def team_info( ) # Resolve resources inherited from access groups - resolved = await _resolve_access_group_resources( - access_group_ids=_team_info.access_group_ids, - ) - _team_info.access_group_models = resolved["access_group_models"] - _team_info.access_group_mcp_server_ids = resolved["access_group_mcp_server_ids"] - _team_info.access_group_agent_ids = resolved["access_group_agent_ids"] + if _team_info.access_group_ids: + ag_lookup = await _batch_resolve_access_group_resources(_team_info.access_group_ids) + models, mcp_ids, agent_ids = set(), set(), set() + for ag_id in _team_info.access_group_ids: + if ag_id in ag_lookup: + models.update(ag_lookup[ag_id]["models"]) + mcp_ids.update(ag_lookup[ag_id]["mcp_server_ids"]) + agent_ids.update(ag_lookup[ag_id]["agent_ids"]) + _team_info.access_group_models = list(models) + _team_info.access_group_mcp_server_ids = list(mcp_ids) + _team_info.access_group_agent_ids = list(agent_ids) response_object = TeamInfoResponseObject( team_id=team_id, @@ -3350,62 +3345,34 @@ async def _build_team_list_where_conditions( return where_conditions -async def _resolve_access_group_resources( - access_group_ids: Optional[List[str]], -) -> Dict[str, List[str]]: +async def _batch_resolve_access_group_resources( + all_access_group_ids: List[str], +) -> Dict[str, Dict[str, List[str]]]: """ - Resolve resources inherited from access groups. + Batch-fetch access groups in a single DB query and return a per-group + resource map. - Fetches each access group object once and extracts all three resource - fields in a single pass (models, MCP servers, agents). - - Returns only the access-group-sourced resources (not direct assignments). - Keeps them separate so callers can distinguish where each resource comes from. + Returns {ag_id: {"models": [...], "mcp_server_ids": [...], "agent_ids": [...]}}. + Missing/invalid groups are silently omitted. """ - empty: Dict[str, List[str]] = { - "access_group_models": [], - "access_group_mcp_server_ids": [], - "access_group_agent_ids": [], - } - if not access_group_ids: - return empty - from litellm.proxy.proxy_server import prisma_client as _prisma_client - from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging_obj - from litellm.proxy.proxy_server import user_api_key_cache as _user_api_key_cache - if _user_api_key_cache is None: - return empty + if not all_access_group_ids or _prisma_client is None: + return {} - if _prisma_client is None: - return empty + unique_ids = list(set(all_access_group_ids)) + rows = await _prisma_client.db.litellm_accessgrouptable.find_many( + where={"access_group_id": {"in": unique_ids}}, + ) - models: List[str] = [] - mcp_ids: List[str] = [] - agent_ids: List[str] = [] - - for ag_id in access_group_ids: - try: - ag = await _get_access_object( - access_group_id=ag_id, - prisma_client=_prisma_client, - user_api_key_cache=_user_api_key_cache, - proxy_logging_obj=_proxy_logging_obj, - ) - models.extend(ag.access_model_names or []) - mcp_ids.extend(ag.access_mcp_server_ids or []) - agent_ids.extend(ag.access_agent_ids or []) - except Exception: - verbose_proxy_logger.debug( - "Could not fetch access group %s for resource resolution", - ag_id, - ) - - return { - "access_group_models": list(set(models)), - "access_group_mcp_server_ids": list(set(mcp_ids)), - "access_group_agent_ids": list(set(agent_ids)), - } + result: Dict[str, Dict[str, List[str]]] = {} + for row in rows: + result[row.access_group_id] = { + "models": list(row.access_model_names or []), + "mcp_server_ids": list(row.access_mcp_server_ids or []), + "agent_ids": list(row.access_agent_ids or []), + } + return result def _convert_teams_to_response_models( @@ -3634,27 +3601,29 @@ async def list_team_v2( # Convert Prisma models to response models with members_count team_list = _convert_teams_to_response_models(teams, use_deleted_table) - # Resolve resources inherited from access groups for each team + # Resolve resources inherited from access groups (single batch query) if not use_deleted_table: team_items_with_ag = [ t for t in team_list if isinstance(t, TeamListItem) and t.access_group_ids ] if team_items_with_ag: - results = await asyncio.gather( - *[ - _resolve_access_group_resources( - access_group_ids=t.access_group_ids, - ) - for t in team_items_with_ag - ] - ) - for team_item, resolved in zip(team_items_with_ag, results): - team_item.access_group_models = resolved["access_group_models"] - team_item.access_group_mcp_server_ids = resolved[ - "access_group_mcp_server_ids" - ] - team_item.access_group_agent_ids = resolved["access_group_agent_ids"] + all_ag_ids = [ + ag_id + for t in team_items_with_ag + for ag_id in (t.access_group_ids or []) + ] + ag_lookup = await _batch_resolve_access_group_resources(all_ag_ids) + for team_item in team_items_with_ag: + models, mcp_ids, agent_ids = set(), set(), set() + for ag_id in (team_item.access_group_ids or []): + if ag_id in ag_lookup: + models.update(ag_lookup[ag_id]["models"]) + mcp_ids.update(ag_lookup[ag_id]["mcp_server_ids"]) + agent_ids.update(ag_lookup[ag_id]["agent_ids"]) + team_item.access_group_models = list(models) + team_item.access_group_mcp_server_ids = list(mcp_ids) + team_item.access_group_agent_ids = list(agent_ids) return { "teams": team_list, diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index a2c8c4d560c..8f0a045cf86 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -6494,175 +6494,128 @@ async def test_create_team_member_budget_table_with_duration(): # --------------------------------------------------------------------------- -# Tests for _resolve_access_group_resources +# Tests for _batch_resolve_access_group_resources # --------------------------------------------------------------------------- -class TestResolveAccessGroupResources: - """Tests for the single-pass access group resource resolution helper.""" +class TestBatchResolveAccessGroupResources: + """Tests for the batch access group resource resolution helper.""" @pytest.mark.asyncio - async def test_returns_empty_when_no_access_group_ids(self): - """None or empty list should return empty lists for all resource types.""" + async def test_returns_empty_when_no_ids(self): + """Empty list should return empty dict.""" from litellm.proxy.management_endpoints.team_endpoints import ( - _resolve_access_group_resources, + _batch_resolve_access_group_resources, ) - result_none = await _resolve_access_group_resources(access_group_ids=None) - assert result_none == { - "access_group_models": [], - "access_group_mcp_server_ids": [], - "access_group_agent_ids": [], - } - - result_empty = await _resolve_access_group_resources(access_group_ids=[]) - assert result_empty == { - "access_group_models": [], - "access_group_mcp_server_ids": [], - "access_group_agent_ids": [], - } + assert await _batch_resolve_access_group_resources([]) == {} @pytest.mark.asyncio async def test_single_access_group(self): """Single access group should return its resources.""" - from litellm.proxy._types import LiteLLM_AccessGroupTable from litellm.proxy.management_endpoints.team_endpoints import ( - _resolve_access_group_resources, + _batch_resolve_access_group_resources, ) - fake_ag = LiteLLM_AccessGroupTable( - access_group_id="ag-1", - access_group_name="test-group", - access_model_names=["gpt-4", "claude-3"], - access_mcp_server_ids=["mcp-1"], - access_agent_ids=["agent-1", "agent-2"], - ) + fake_row = MagicMock() + fake_row.access_group_id = "ag-1" + fake_row.access_model_names = ["gpt-4", "claude-3"] + fake_row.access_mcp_server_ids = ["mcp-1"] + fake_row.access_agent_ids = ["agent-1", "agent-2"] - with patch( - "litellm.proxy.management_endpoints.team_endpoints._get_access_object", - new_callable=AsyncMock, - return_value=fake_ag, - ): - with patch( - "litellm.proxy.proxy_server.user_api_key_cache", - MagicMock(), - ): - with patch( - "litellm.proxy.proxy_server.prisma_client", - MagicMock(), - ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1"], - ) + fake_prisma = MagicMock() + fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[fake_row]) - assert sorted(result["access_group_models"]) == ["claude-3", "gpt-4"] - assert result["access_group_mcp_server_ids"] == ["mcp-1"] - assert sorted(result["access_group_agent_ids"]) == ["agent-1", "agent-2"] + with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma): + result = await _batch_resolve_access_group_resources(["ag-1"]) + + assert sorted(result["ag-1"]["models"]) == ["claude-3", "gpt-4"] + assert result["ag-1"]["mcp_server_ids"] == ["mcp-1"] + assert sorted(result["ag-1"]["agent_ids"]) == ["agent-1", "agent-2"] @pytest.mark.asyncio - async def test_multiple_access_groups_deduplicates(self): - """Multiple access groups with overlapping resources should deduplicate.""" - from litellm.proxy._types import LiteLLM_AccessGroupTable + async def test_multiple_access_groups(self): + """Multiple access groups returned in a single query.""" from litellm.proxy.management_endpoints.team_endpoints import ( - _resolve_access_group_resources, + _batch_resolve_access_group_resources, ) - ag1 = LiteLLM_AccessGroupTable( - access_group_id="ag-1", - access_group_name="group-1", - access_model_names=["gpt-4", "claude-3"], - access_mcp_server_ids=["mcp-1"], - access_agent_ids=["agent-1"], - ) - ag2 = LiteLLM_AccessGroupTable( - access_group_id="ag-2", - access_group_name="group-2", - access_model_names=["gpt-4", "gemini"], - access_mcp_server_ids=["mcp-1", "mcp-2"], - access_agent_ids=["agent-2"], - ) + row1 = MagicMock() + row1.access_group_id = "ag-1" + row1.access_model_names = ["gpt-4"] + row1.access_mcp_server_ids = ["mcp-1"] + row1.access_agent_ids = ["agent-1"] - async def fake_get_access_object(access_group_id, **kwargs): - return {"ag-1": ag1, "ag-2": ag2}[access_group_id] + row2 = MagicMock() + row2.access_group_id = "ag-2" + row2.access_model_names = ["gemini"] + row2.access_mcp_server_ids = ["mcp-2"] + row2.access_agent_ids = ["agent-2"] - with patch( - "litellm.proxy.management_endpoints.team_endpoints._get_access_object", - side_effect=fake_get_access_object, - ): - with patch( - "litellm.proxy.proxy_server.user_api_key_cache", - MagicMock(), - ): - with patch( - "litellm.proxy.proxy_server.prisma_client", - MagicMock(), - ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1", "ag-2"], - ) + fake_prisma = MagicMock() + fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[row1, row2]) - assert sorted(result["access_group_models"]) == ["claude-3", "gemini", "gpt-4"] - assert sorted(result["access_group_mcp_server_ids"]) == ["mcp-1", "mcp-2"] - assert sorted(result["access_group_agent_ids"]) == ["agent-1", "agent-2"] + with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma): + result = await _batch_resolve_access_group_resources(["ag-1", "ag-2"]) + + assert result["ag-1"]["models"] == ["gpt-4"] + assert result["ag-2"]["models"] == ["gemini"] @pytest.mark.asyncio - async def test_missing_access_group_skipped(self): - """If an access group doesn't exist, it should be skipped gracefully.""" - from litellm.proxy._types import LiteLLM_AccessGroupTable + async def test_missing_access_group_omitted(self): + """If an access group doesn't exist in DB, it's simply not in the result.""" from litellm.proxy.management_endpoints.team_endpoints import ( - _resolve_access_group_resources, + _batch_resolve_access_group_resources, ) - ag1 = LiteLLM_AccessGroupTable( - access_group_id="ag-1", - access_group_name="group-1", - access_model_names=["gpt-4"], - access_mcp_server_ids=[], - access_agent_ids=[], - ) + row1 = MagicMock() + row1.access_group_id = "ag-1" + row1.access_model_names = ["gpt-4"] + row1.access_mcp_server_ids = [] + row1.access_agent_ids = [] - async def fake_get_access_object(access_group_id, **kwargs): - if access_group_id == "ag-1": - return ag1 - raise HTTPException(status_code=404, detail="Not found") + fake_prisma = MagicMock() + fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[row1]) - with patch( - "litellm.proxy.management_endpoints.team_endpoints._get_access_object", - side_effect=fake_get_access_object, - ): - with patch( - "litellm.proxy.proxy_server.user_api_key_cache", - MagicMock(), - ): - with patch( - "litellm.proxy.proxy_server.prisma_client", - MagicMock(), - ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1", "ag-missing"], - ) + with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma): + result = await _batch_resolve_access_group_resources(["ag-1", "ag-missing"]) - assert result["access_group_models"] == ["gpt-4"] - assert result["access_group_mcp_server_ids"] == [] - assert result["access_group_agent_ids"] == [] + assert "ag-1" in result + assert "ag-missing" not in result @pytest.mark.asyncio - async def test_returns_empty_when_cache_unavailable(self): - """If user_api_key_cache is None, should return empty results.""" + async def test_returns_empty_when_prisma_unavailable(self): + """If prisma_client is None, should return empty dict.""" from litellm.proxy.management_endpoints.team_endpoints import ( - _resolve_access_group_resources, + _batch_resolve_access_group_resources, ) - with patch( - "litellm.proxy.proxy_server.user_api_key_cache", - None, - ): - result = await _resolve_access_group_resources( - access_group_ids=["ag-1"], - ) + with patch("litellm.proxy.proxy_server.prisma_client", None): + result = await _batch_resolve_access_group_resources(["ag-1"]) - assert result == { - "access_group_models": [], - "access_group_mcp_server_ids": [], - "access_group_agent_ids": [], - } + assert result == {} + + @pytest.mark.asyncio + async def test_deduplicates_input_ids(self): + """Duplicate IDs in input should result in a single DB lookup.""" + from litellm.proxy.management_endpoints.team_endpoints import ( + _batch_resolve_access_group_resources, + ) + + row1 = MagicMock() + row1.access_group_id = "ag-1" + row1.access_model_names = ["gpt-4"] + row1.access_mcp_server_ids = [] + row1.access_agent_ids = [] + + fake_find_many = AsyncMock(return_value=[row1]) + fake_prisma = MagicMock() + fake_prisma.db.litellm_accessgrouptable.find_many = fake_find_many + + with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma): + result = await _batch_resolve_access_group_resources(["ag-1", "ag-1", "ag-1"]) + + # Should have been called with deduplicated list + call_args = fake_find_many.call_args + assert len(call_args.kwargs["where"]["access_group_id"]["in"]) == 1 + assert "ag-1" in result