From f62658795aeb1202df80cf290e9525de1ea85b2c Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 12:25:48 -0700 Subject: [PATCH 01/20] 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 8faf36df4c..6b581957e9 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 3643373be6..6b4cf72fc1 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 5055a65783..2455eb495d 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 5cabe4c4a8..03a0a80bc7 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 a681e438cd..04b9a5c996 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 a4c7ae2bbb..79a55cf30f 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 02/20] 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 6b4cf72fc1..125efb03d8 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 366f659bda..a4b5e67760 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 59b09102b93060b0960de0108cb5a1e457ef9188 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 15:36:19 -0700 Subject: [PATCH 03/20] docs: add default_team_params to config reference and update examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add default_team_params to litellm_settings reference table in config_settings.md with all sub-fields documented - Update self_serve.md and msft_sso.md examples to include team_member_permissions, tpm_limit, and rpm_limit - Fix misleading comment that implied default_team_params only applies to SSO auto-created teams — it applies to all /team/new calls --- docs/my-website/docs/proxy/config_settings.md | 1 + docs/my-website/docs/proxy/self_serve.md | 25 +++++++++++++------ docs/my-website/docs/tutorials/msft_sso.md | 10 +++++--- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index cc9090c2de..5672e27fe5 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -201,6 +201,7 @@ router_settings: | enable_json_schema_validation | boolean | If true, enables json schema validation for all requests. | | enable_key_alias_format_validation | boolean | If true, validates `key_alias` format on `/key/generate` and `/key/update`. Must be 2-255 chars, start/end with alphanumeric, only allow `a-zA-Z0-9_-/.@`. Default `false`. | | disable_copilot_system_to_assistant | boolean | **DEPRECATED** - GitHub Copilot API supports system prompts. | +| default_team_params | object | Default parameters applied to every new team created via `/team/new` (including SSO auto-created teams). Only fills in fields not explicitly set in the request. Sub-fields: `max_budget` (float), `budget_duration` (string, e.g. `"30d"`), `models` (array of strings), `tpm_limit` (integer), `rpm_limit` (integer), `team_member_permissions` (array of strings, e.g. `["/team/daily/activity", "/key/generate"]`). | ### general_settings - Reference diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index b54344c1d0..7d88669bf1 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -358,10 +358,15 @@ When you connect litellm to your SSO provider, litellm can auto-create teams. Us ```yaml showLineNumbers title="Default Params for new teams" litellm_settings: - default_team_params: # Default Params to apply when litellm auto creates a team from SSO IDP provider - max_budget: 100 # Optional[float], optional): $100 budget for the team - budget_duration: 30d # Optional[str], optional): 30 days budget_duration for the team - models: ["gpt-3.5-turbo"] # Optional[List[str]], optional): models to be used by the team + default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set + max_budget: 100 # Optional[float]: $100 budget for the team + budget_duration: 30d # Optional[str]: 30 days budget_duration for the team + models: ["gpt-3.5-turbo"] # Optional[List[str]]: models to be used by the team + tpm_limit: 100000 # Optional[int]: tokens per minute limit + rpm_limit: 1000 # Optional[int]: requests per minute limit + team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members + - "/team/daily/activity" # Allow members to view team usage + - "/key/generate" # Allow members to generate API keys ``` @@ -390,10 +395,14 @@ litellm_settings: max_budget_in_team: 100 # Optional[float], optional): $100 budget for the team. Defaults to None. user_role: "user" # Optional[str], optional): "user" or "admin". Defaults to "user" - default_team_params: # Default Params to apply when litellm auto creates a team from SSO IDP provider - max_budget: 100 # Optional[float], optional): $100 budget for the team - budget_duration: 30d # Optional[str], optional): 30 days budget_duration for the team - models: ["gpt-3.5-turbo"] # Optional[List[str]], optional): models to be used by the team + default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set + max_budget: 100 # Optional[float]: $100 budget for the team + budget_duration: 30d # Optional[str]: 30 days budget_duration for the team + models: ["gpt-3.5-turbo"] # Optional[List[str]]: models to be used by the team + tpm_limit: 100000 # Optional[int]: tokens per minute limit + rpm_limit: 1000 # Optional[int]: requests per minute limit + team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members + - "/team/daily/activity" upperbound_key_generate_params: # Upperbound for /key/generate requests when self-serve flow is on diff --git a/docs/my-website/docs/tutorials/msft_sso.md b/docs/my-website/docs/tutorials/msft_sso.md index 2936f27297..d8b0b1b918 100644 --- a/docs/my-website/docs/tutorials/msft_sso.md +++ b/docs/my-website/docs/tutorials/msft_sso.md @@ -123,10 +123,12 @@ Navigate to your litellm config file and set the following params ```yaml showLineNumbers title="litellm config with default_team_params" litellm_settings: - default_team_params: # Default Params to apply when litellm auto creates a team from SSO IDP provider - max_budget: 100 # Optional[float], optional): $100 budget for the team - budget_duration: 30d # Optional[str], optional): 30 days budget_duration for the team - models: ["gpt-3.5-turbo"] # Optional[List[str]], optional): models to be used by the team + default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set + max_budget: 100 # Optional[float]: $100 budget for the team + budget_duration: 30d # Optional[str]: 30 days budget_duration for the team + models: ["gpt-3.5-turbo"] # Optional[List[str]]: models to be used by the team + team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members + - "/team/daily/activity" # Allow members to view team usage ``` ### 3.2 Auto-create a new team on LiteLLM From c19a63e2bf263f585a5e13e5073d73e20b539dc2 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 16:00:20 -0700 Subject: [PATCH 04/20] docs: clarify that models sub-field only applies to SSO auto-created teams --- docs/my-website/docs/proxy/config_settings.md | 2 +- docs/my-website/docs/proxy/self_serve.md | 4 ++-- docs/my-website/docs/tutorials/msft_sso.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 5672e27fe5..27d9aed52b 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -201,7 +201,7 @@ router_settings: | enable_json_schema_validation | boolean | If true, enables json schema validation for all requests. | | enable_key_alias_format_validation | boolean | If true, validates `key_alias` format on `/key/generate` and `/key/update`. Must be 2-255 chars, start/end with alphanumeric, only allow `a-zA-Z0-9_-/.@`. Default `false`. | | disable_copilot_system_to_assistant | boolean | **DEPRECATED** - GitHub Copilot API supports system prompts. | -| default_team_params | object | Default parameters applied to every new team created via `/team/new` (including SSO auto-created teams). Only fills in fields not explicitly set in the request. Sub-fields: `max_budget` (float), `budget_duration` (string, e.g. `"30d"`), `models` (array of strings), `tpm_limit` (integer), `rpm_limit` (integer), `team_member_permissions` (array of strings, e.g. `["/team/daily/activity", "/key/generate"]`). | +| default_team_params | object | Default parameters applied to every new team created via `/team/new` (including SSO auto-created teams). Only fills in fields not explicitly set in the request. Sub-fields: `max_budget` (float), `budget_duration` (string, e.g. `"30d"`), `tpm_limit` (integer), `rpm_limit` (integer), `team_member_permissions` (array of strings, e.g. `["/team/daily/activity", "/key/generate"]`), `models` (array of strings — only applied to SSO auto-created teams). | ### general_settings - Reference diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index 7d88669bf1..639cd05d01 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -361,7 +361,7 @@ litellm_settings: default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set max_budget: 100 # Optional[float]: $100 budget for the team budget_duration: 30d # Optional[str]: 30 days budget_duration for the team - models: ["gpt-3.5-turbo"] # Optional[List[str]]: models to be used by the team + models: ["gpt-3.5-turbo"] # Optional[List[str]]: models for the team (only applied to SSO auto-created teams) tpm_limit: 100000 # Optional[int]: tokens per minute limit rpm_limit: 1000 # Optional[int]: requests per minute limit team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members @@ -398,7 +398,7 @@ litellm_settings: default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set max_budget: 100 # Optional[float]: $100 budget for the team budget_duration: 30d # Optional[str]: 30 days budget_duration for the team - models: ["gpt-3.5-turbo"] # Optional[List[str]]: models to be used by the team + models: ["gpt-3.5-turbo"] # Optional[List[str]]: models for the team (only applied to SSO auto-created teams) tpm_limit: 100000 # Optional[int]: tokens per minute limit rpm_limit: 1000 # Optional[int]: requests per minute limit team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members diff --git a/docs/my-website/docs/tutorials/msft_sso.md b/docs/my-website/docs/tutorials/msft_sso.md index d8b0b1b918..06cc2e2aa5 100644 --- a/docs/my-website/docs/tutorials/msft_sso.md +++ b/docs/my-website/docs/tutorials/msft_sso.md @@ -126,7 +126,7 @@ litellm_settings: default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set max_budget: 100 # Optional[float]: $100 budget for the team budget_duration: 30d # Optional[str]: 30 days budget_duration for the team - models: ["gpt-3.5-turbo"] # Optional[List[str]]: models to be used by the team + models: ["gpt-3.5-turbo"] # Optional[List[str]]: models for the team (only applied to SSO auto-created teams) team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members - "/team/daily/activity" # Allow members to view team usage ``` From f0bd33486ead7ed2ac025ab3675a92969920e649 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 16:14:34 -0700 Subject: [PATCH 05/20] 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 125efb03d8..c66177aafc 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 03a0a80bc7..62a7fdb783 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 79a55cf30f..576f0a5f99 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 5a8f910fe3c35d14ff1e20c0429e652faca227ab Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 11:54:52 -0700 Subject: [PATCH 06/20] add: making organizations a select instead of read only badges --- .../src/components/team/TeamInfo.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index a4c7ae2bbb..688adb56f4 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -491,7 +491,7 @@ const TeamInfoView: React.FC = ({ ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), }, ...(values.policies?.length > 0 ? { policies: values.policies } : {}), - organization_id: values.organization_id, + organization_id: values.organization_id ?? "", }; updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); @@ -1077,8 +1077,17 @@ const TeamInfoView: React.FC = ({ /> - - + + onChange?.(val)} + disabled={disabled} + allowClear + filterOption={false} + onSearch={handleSearch} + searchValue={searchInput} + onPopupScroll={handlePopupScroll} + loading={isLoading} + notFoundContent={isLoading ? : "No teams found"} + style={{ width: "100%" }} + popupRender={(menu) => ( + <> + {menu} + {isFetchingNextPage && ( +
+ +
+ )} + + )} + > + {teams.map((team) => ( + + {team.team_alias}{" "} + ({team.team_id}) + + ))} + + ); +}; + +export default TeamMultiSelect; From 1533f6896e51c9848b2ef9b4e95f194f89a2a26f Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 14:36:46 -0700 Subject: [PATCH 09/20] fix(ui): fix imports and update placeholder for team multi select --- .../src/components/common_components/team_multi_select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx index eec08c58db..6f00a4d1f7 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx @@ -25,7 +25,7 @@ const TeamMultiSelect: React.FC = ({ disabled, organizationId, pageSize = 20, - placeholder = "Search teams by name or ID...", + placeholder = "Search teams by alias...", }) => { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { From 96b660b25766a9b390d962728f1c2db389663e29 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 15:38:16 -0700 Subject: [PATCH 10/20] fix(ui): wire team_id filter to key alias dropdown on Virtual Keys tab The Key Alias dropdown on the Virtual Keys page was showing aliases from all teams regardless of which team was selected. The team_id was never passed through the frontend chain to the backend /key/aliases endpoint. - Backend: add optional team_id query param to /key/aliases endpoint - networking.tsx: add team_id param to keyAliasesCall - useKeyAliases: accept and forward team_id to API call and query key - filter.tsx: pass allFilters context to custom filter components - PaginatedKeyAliasSelect: read Team ID from allFilters and pass to hook --- .../proxy/management_endpoints/key_management_endpoints.py | 7 +++++++ .../src/app/(dashboard)/hooks/keys/useKeyAliases.ts | 3 +++ .../PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx | 6 +++++- ui/litellm-dashboard/src/components/molecules/filter.tsx | 2 ++ ui/litellm-dashboard/src/components/networking.tsx | 2 ++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 831922ec3f..fa521ac55f 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4345,6 +4345,9 @@ async def key_aliases( search: Optional[str] = Query( None, description="Search key aliases (case-insensitive partial match)" ), + team_id: Optional[str] = Query( + None, description="Filter aliases to keys belonging to this team" + ), ) -> Dict[str, Any]: """ Lists key aliases with pagination and optional search. @@ -4420,6 +4423,10 @@ async def key_aliases( query_params.append(f"%{search}%") where_parts.append(f"key_alias ILIKE ${len(query_params)}") + if team_id: + query_params.append(team_id) + where_parts.append(f"team_id = ${len(query_params)}") + where_sql = " AND ".join(where_parts) count_sql = f'SELECT COUNT(*) AS count FROM "LiteLLM_VerificationToken" WHERE {where_sql}' diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts index f67b15f3a9..03e96fe73c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts @@ -8,6 +8,7 @@ const infiniteKeyAliasKeys = createQueryKeys("infiniteKeyAliases"); export const useInfiniteKeyAliases = ( size: number = 50, search?: string, + team_id?: string, ) => { const { accessToken } = useAuthorized(); return useInfiniteQuery({ @@ -15,6 +16,7 @@ export const useInfiniteKeyAliases = ( filters: { size, ...(search && { search }), + ...(team_id && { team_id }), }, }), queryFn: async ({ pageParam }) => { @@ -23,6 +25,7 @@ export const useInfiniteKeyAliases = ( pageParam as number, size, search, + team_id, ); }, initialPageParam: 1, diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx index 0bec77ca52..940f0b7e95 100644 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx @@ -12,6 +12,7 @@ export interface PaginatedKeyAliasSelectProps { pageSize?: number; allowClear?: boolean; disabled?: boolean; + allFilters?: { [key: string]: string }; } const SCROLL_THRESHOLD = 0.8; @@ -25,19 +26,22 @@ export const PaginatedKeyAliasSelect = ({ pageSize = 50, allowClear = true, disabled = false, + allFilters, }: PaginatedKeyAliasSelectProps) => { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { wait: DEBOUNCE_MS, }); + const teamId = allFilters?.["Team ID"] || undefined; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, - } = useInfiniteKeyAliases(pageSize, debouncedSearch || undefined); + } = useInfiniteKeyAliases(pageSize, debouncedSearch || undefined, teamId); const options = useMemo(() => { if (!data?.pages) return []; diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index dcf22293f8..34ff1983f3 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -7,6 +7,7 @@ export interface FilterOptionCustomComponentProps { value?: string; onChange: (value: string) => void; placeholder?: string; + allFilters?: { [key: string]: string }; } export interface FilterOption { @@ -209,6 +210,7 @@ const FilterComponent: React.FC = ({ value={tempValues[option.name] || undefined} onChange={(value) => handleFilterChange(option.name, value ?? "")} placeholder={`Select ${option.label || option.name}...`} + allFilters={tempValues} /> ); })() diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 2e8518d00a..33860e991d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3263,6 +3263,7 @@ export const keyAliasesCall = async ( page: number = 1, size: number = 50, search?: string, + team_id?: string, ): Promise => { /** * Get key aliases from proxy with pagination and optional search @@ -3273,6 +3274,7 @@ export const keyAliasesCall = async ( page: String(page), size: String(size), ...(search ? { search } : {}), + ...(team_id ? { team_id } : {}), }), ); let url = proxyBaseUrl ? `${proxyBaseUrl}/key/aliases` : `/key/aliases`; From 38f6c9491d602dd3b39938d1a5543806be2d2735 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 16:16:55 -0700 Subject: [PATCH 11/20] 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 a4b5e67760..a2c8c4d560 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 12/20] 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 c66177aafc..ac606a71ba 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 13/20] 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 576f0a5f99..4bb1311ebf 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 14/20] 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 4bb1311ebf..1e4345905e 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 15/20] 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 ac606a71ba..ae89f09bb4 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 a2c8c4d560..8f0a045cf8 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 From ce219fcc9623bfb9c1584a6014518f764dc2b53d Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 09:36:32 -0700 Subject: [PATCH 16/20] refactor(proxy): extract helpers to fix PLR0915 violations Extract `_apply_non_admin_alias_scope` from `key_aliases`, `_resolve_team_access_group_resources` from `team_info`, and `_enforce_list_team_v2_access` from `list_team_v2` to bring each function under ruff's 50-statement limit. No behavior changes. --- .../key_management_endpoints.py | 65 +++++--- .../management_endpoints/team_endpoints.py | 154 +++++++++++------- 2 files changed, 134 insertions(+), 85 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index fe180f1945..497ddcbbe8 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4382,6 +4382,42 @@ async def list_keys( ) +async def _apply_non_admin_alias_scope( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: Any, + query_params: List[Any], + where_parts: List[str], +) -> None: + """Append SQL scope conditions so non-admin users only see aliases for + keys they own or keys belonging to teams they are members of.""" + scope_conditions: List[str] = [] + if user_api_key_dict.user_id: + query_params.append(user_api_key_dict.user_id) + scope_conditions.append(f"user_id = ${len(query_params)}") + + # Look up the user's teams from the user table + user_teams: List[str] = [] + if user_api_key_dict.user_id: + user_row = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_api_key_dict.user_id} + ) + if user_row is not None: + user_teams = getattr(user_row, "teams", []) or [] + + if user_teams: + team_placeholders = ", ".join( + f"${len(query_params) + i + 1}" for i in range(len(user_teams)) + ) + query_params.extend(user_teams) + scope_conditions.append(f"team_id IN ({team_placeholders})") + + if scope_conditions: + where_parts.append(f"({' OR '.join(scope_conditions)})") + else: + # No user_id and no teams — return nothing + where_parts.append("FALSE") + + @router.get( "/key/aliases", tags=["key management"], @@ -4442,32 +4478,9 @@ async def key_aliases( LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, ] if not is_proxy_admin: - scope_conditions: List[str] = [] - if user_api_key_dict.user_id: - query_params.append(user_api_key_dict.user_id) - scope_conditions.append(f"user_id = ${len(query_params)}") - - # Look up the user's teams from the user table - user_teams: List[str] = [] - if user_api_key_dict.user_id: - user_row = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user_api_key_dict.user_id} - ) - if user_row is not None: - user_teams = getattr(user_row, "teams", []) or [] - - if user_teams: - team_placeholders = ", ".join( - f"${len(query_params) + i + 1}" for i in range(len(user_teams)) - ) - query_params.extend(user_teams) - scope_conditions.append(f"team_id IN ({team_placeholders})") - - if scope_conditions: - where_parts.append(f"({' OR '.join(scope_conditions)})") - else: - # No user_id and no teams — return nothing - where_parts.append("FALSE") + await _apply_non_admin_alias_scope( + user_api_key_dict, prisma_client, query_params, where_parts + ) if search: query_params.append(f"%{search}%") diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index ae89f09bb4..e153457378 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -2932,6 +2932,25 @@ async def _add_team_member_budget_table( return team_info_response_object +async def _resolve_team_access_group_resources(_team_info: Any) -> None: + """Populate access_group_models / mcp_server_ids / agent_ids on the team + info response by resolving inherited resources from its access groups.""" + if not _team_info.access_group_ids: + return + 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) + + @router.get( "/team/info", tags=["team management"], dependencies=[Depends(user_api_key_auth)] ) @@ -3043,17 +3062,7 @@ async def team_info( ) # Resolve resources inherited from access groups - 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) + await _resolve_team_access_group_resources(_team_info) response_object = TeamInfoResponseObject( team_id=team_id, @@ -3401,6 +3410,73 @@ def _convert_teams_to_response_models( return team_list +async def _enforce_list_team_v2_access( + user_api_key_dict: UserAPIKeyAuth, + user_id: Optional[str], + organization_id: Optional[str], + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> Tuple[Optional[str], Optional[List[str]]]: + """Enforce access control for list_team_v2. + + - Proxy admins and admin viewers can query any teams. + - Org admins can query teams within their organizations. + - Regular users can only query their own teams. + + Returns the (possibly overridden) user_id and org_admin_org_ids. + """ + is_proxy_admin = _user_has_admin_view(user_api_key_dict) + org_admin_org_ids: Optional[List[str]] = None + + if is_proxy_admin: + return user_id, org_admin_org_ids + + # Always check org admin status so that even own-queries see + # the full set of organisation teams, not just direct memberships. + if user_api_key_dict.user_id: + org_admin_org_ids = await _get_org_admin_org_ids( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + if org_admin_org_ids is not None: + # Org admin: validate org_id filter if provided + if organization_id and organization_id not in org_admin_org_ids: + raise HTTPException( + status_code=403, + detail={ + "error": "You can only view teams within your organizations." + }, + ) + verbose_proxy_logger.debug( + "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", + user_api_key_dict.user_id, + org_admin_org_ids, + user_id, + ) + else: + # Not an org admin — fall back to standard route check + if not allowed_route_check_inside_route( + user_api_key_dict=user_api_key_dict, requested_user_id=user_id + ): + raise HTTPException( + status_code=401, + detail={ + "error": "Only admin users can query all teams/other teams. Your user role={}".format( + user_api_key_dict.user_role + ) + }, + ) + # Regular user — auto-inject caller's user_id + if user_id is None: + user_id = user_api_key_dict.user_id + + return user_id, org_admin_org_ids + + @router.get( "/v2/team/list", tags=["team management"], @@ -3478,54 +3554,14 @@ async def list_team_v2( ) # --- Access control --- - # Proxy admins and admin viewers can query any teams. - # Org admins can query teams within their organizations. - # Regular users can only query their own teams. - is_proxy_admin = _user_has_admin_view(user_api_key_dict) - org_admin_org_ids: Optional[List[str]] = None - - if not is_proxy_admin: - # Always check org admin status so that even own-queries see - # the full set of organisation teams, not just direct memberships. - if user_api_key_dict.user_id: - org_admin_org_ids = await _get_org_admin_org_ids( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - - if org_admin_org_ids is not None: - # Org admin: validate org_id filter if provided - if organization_id and organization_id not in org_admin_org_ids: - raise HTTPException( - status_code=403, - detail={ - "error": "You can only view teams within your organizations." - }, - ) - verbose_proxy_logger.debug( - "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", - user_api_key_dict.user_id, - org_admin_org_ids, - user_id, - ) - else: - # Not an org admin — fall back to standard route check - if not allowed_route_check_inside_route( - user_api_key_dict=user_api_key_dict, requested_user_id=user_id - ): - raise HTTPException( - status_code=401, - detail={ - "error": "Only admin users can query all teams/other teams. Your user role={}".format( - user_api_key_dict.user_role - ) - }, - ) - # Regular user — auto-inject caller's user_id - if user_id is None: - user_id = user_api_key_dict.user_id + user_id, org_admin_org_ids = await _enforce_list_team_v2_access( + user_api_key_dict=user_api_key_dict, + user_id=user_id, + organization_id=organization_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) if status is not None and status != "deleted": raise HTTPException( From 866c4a25ffb5ec4a7d65e45bc950245a903e5ba9 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 09:47:34 -0700 Subject: [PATCH 17/20] test(ui): update tests to match new team_id / access-group signatures - useKeyAliases, PaginatedKeyAliasSelect: add trailing `undefined` to spy matchers for the new `team_id` param on `useInfiniteKeyAliases` and `keyAliasesCall`. - EntityUsage: mock new `TeamMultiSelect` child so QueryClientProvider is not required for team-entity tests. - ModelsCell: replace the overflow-accordion test with one that verifies the new collapse-on-`all-proxy-models` behavior (no accordion, single badge). --- .../app/(dashboard)/hooks/keys/useKeyAliases.test.ts | 10 +++++----- .../teams/components/TeamsTable/ModelsCell.test.tsx | 12 ++++++------ .../PaginatedKeyAliasSelect.test.tsx | 4 ++-- .../components/EntityUsage/EntityUsage.test.tsx | 4 ++++ 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts index b382b1f2ad..1e1190b12c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts @@ -65,7 +65,7 @@ describe("useInfiniteKeyAliases", () => { expect(result.current.isSuccess).toBe(true); }); - expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, undefined); + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, undefined, undefined); expect(result.current.data?.pages[0]).toEqual(mockPage1); }); @@ -74,7 +74,7 @@ describe("useInfiniteKeyAliases", () => { renderHook(() => useInfiniteKeyAliases(25), { wrapper }); await waitFor(() => { - expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 25, undefined); + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 25, undefined, undefined); }); }); @@ -83,7 +83,7 @@ describe("useInfiniteKeyAliases", () => { renderHook(() => useInfiniteKeyAliases(50, "my-alias"), { wrapper }); await waitFor(() => { - expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "my-alias"); + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "my-alias", undefined); }); }); @@ -145,7 +145,7 @@ describe("useInfiniteKeyAliases", () => { expect(result.current.data?.pages).toHaveLength(2); }); - expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 2, 2, undefined); + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 2, 2, undefined, undefined); expect(result.current.data?.pages[1]).toEqual(mockPage2); }); @@ -171,7 +171,7 @@ describe("useInfiniteKeyAliases", () => { rerender({ search: "search-result" }); await waitFor(() => { - expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "search-result"); + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "search-result", undefined); }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx index 747ce518cf..2b487d6532 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx @@ -125,14 +125,14 @@ describe("ModelsCell", () => { expect(screen.getByText("+2 more models")).toBeInTheDocument(); }); - it("should render 'all-proxy-models' entries in the overflow section as 'All Proxy Models' badges", () => { + it("should collapse to a single 'All Proxy Models' badge when the models list includes 'all-proxy-models'", () => { renderModelsCell(makeTeam(["m1", "m2", "m3", "all-proxy-models"])); - act(() => { - screen.getByRole("button", { name: /accordion/i }).click(); - }); - - // There should now be an "All Proxy Models" badge in the expanded section + // When all-proxy-models is present, all individual models are hidden and no accordion is shown expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + expect(screen.queryByText("m1")).not.toBeInTheDocument(); + expect(screen.queryByText("m2")).not.toBeInTheDocument(); + expect(screen.queryByText("m3")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /accordion/i })).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx index 9a3755124b..79a002cc5a 100644 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx @@ -112,7 +112,7 @@ describe("PaginatedKeyAliasSelect", () => { it("should pass pageSize to useInfiniteKeyAliases", () => { renderWithProviders(); - expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined); + expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined, undefined); }); it("should pass search to useInfiniteKeyAliases when user types", async () => { @@ -124,7 +124,7 @@ describe("PaginatedKeyAliasSelect", () => { await user.keyboard("my-alias"); await waitFor(() => { - expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias"); + expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias", undefined); }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index 5c23cf71ab..dc201cccfe 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -45,6 +45,10 @@ vi.mock("../../../EntityUsageExport", () => ({ UsageExportHeader: () =>
Usage Export Header
, })); +vi.mock("../../../common_components/team_multi_select", () => ({ + default: () =>
Team Multi Select
, +})); + // Mock useTeams hook vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(() => ({ From c495acda1b5801690d1b11c96bc22df047a19180 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 09:47:38 -0700 Subject: [PATCH 18/20] fix(ui): send null (not '') for cleared organization_id on team update AntD