From 9c4faeabc98aea4ac86fd533b823efbb35db79fb Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 12 May 2026 11:14:29 -0700 Subject: [PATCH] feat(ui): search teams by team ID alongside name (#27684) * feat(ui): search teams by team ID alongside name The Teams page search box only matched team_alias, so pasting a team UUID returned zero results. Detect when the input is a full UUID and route it to the team_id filter instead; otherwise keep the existing alias substring search. Placeholder now reads "Search teams by name or ID...". Resolves LIT-2648 * refactor: search teams via backend OR clause, drop client-side UUID detection Adds a `search` query param to /v2/team/list that ORs across team_id (exact) and team_alias (case-insensitive contains), so the search box sends one param regardless of input format. Removes the isLikelyTeamId helper and the client-side branching it fed. --- .../management_endpoints/team_endpoints.py | 12 ++ .../test_team_endpoints.py | 115 ++++++++++++++++++ .../app/(dashboard)/hooks/teams/useTeams.ts | 3 + .../src/components/OldTeams.tsx | 25 ++-- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 259624f1e1..65bcca23c3 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3805,6 +3805,7 @@ async def _build_team_list_where_conditions( organization_id: Optional[str], user_id: Optional[str], use_deleted_table: bool, + search: Optional[str] = None, org_admin_org_ids: Optional[List[str]] = None, user_api_key_cache: Optional[Any] = None, proxy_logging_obj: Optional[Any] = None, @@ -3826,6 +3827,12 @@ async def _build_team_list_where_conditions( "mode": "insensitive", # Case-insensitive search } + if search: + where_conditions["OR"] = [ + {"team_id": search}, + {"team_alias": {"contains": search, "mode": "insensitive"}}, + ] + if organization_id: where_conditions["organization_id"] = organization_id elif org_admin_org_ids is not None: @@ -4019,6 +4026,10 @@ async def list_team_v2( default=None, description="Only return teams which this 'team_alias' belongs to. Supports partial matching.", ), + search: Optional[str] = fastapi.Query( + default=None, + description="Combined search: matches teams whose 'team_id' equals the value OR whose 'team_alias' contains it (case-insensitive).", + ), page: int = fastapi.Query( default=1, description="Page number for pagination", ge=1 ), @@ -4104,6 +4115,7 @@ async def list_team_v2( organization_id=organization_id, user_id=user_id, use_deleted_table=use_deleted_table, + search=search, org_admin_org_ids=org_admin_org_ids, user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, 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 e668672dd2..5c7bbc46c9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -3140,6 +3140,121 @@ async def test_list_team_v2_with_invalid_status(): assert "deleted" in str(exc_info.value.detail) +@pytest.mark.asyncio +async def test_list_team_v2_search_builds_or_clause(): + """ + `search` should be passed as a Prisma OR across team_id (exact) and + team_alias (case-insensitive contains), so the UI can hit a single + backend filter with either a UUID or a name fragment. + """ + from unittest.mock import AsyncMock, Mock, patch + + from fastapi import Request + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 + + mock_request = Mock(spec=Request) + mock_admin = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client: + mock_db = Mock() + mock_prisma_client.db = mock_db + mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + mock_db.litellm_teamtable.count = AsyncMock(return_value=0) + + await list_team_v2( + http_request=mock_request, + user_id=None, + organization_id=None, + team_id=None, + team_alias=None, + search="platform", + user_api_key_dict=mock_admin, + page=1, + page_size=10, + status=None, + ) + + find_many_kwargs = mock_db.litellm_teamtable.find_many.call_args.kwargs + assert find_many_kwargs["where"] == { + "OR": [ + {"team_id": "platform"}, + {"team_alias": {"contains": "platform", "mode": "insensitive"}}, + ] + } + + +@pytest.mark.asyncio +async def test_list_team_v2_search_composes_with_user_id_filter(): + """ + For non-admin users, `search` must compose with the membership filter: + the resulting where clause should AND `team_id IN ` with + the search OR clause, so users still only see their own teams. + """ + from datetime import datetime + from unittest.mock import AsyncMock, Mock, patch + + from fastapi import Request + + from litellm.proxy._types import ( + LiteLLM_UserTable, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 + + mock_request = Mock(spec=Request) + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="member_user" + ) + + mock_user = LiteLLM_UserTable( + user_id="member_user", + teams=["team_a", "team_b"], + organization_memberships=[], + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new=AsyncMock(return_value=mock_user), + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints._get_org_admin_org_ids", + new=AsyncMock(return_value=None), + ), + ): + mock_db = Mock() + mock_prisma.db = mock_db + mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + mock_db.litellm_teamtable.count = AsyncMock(return_value=0) + + await list_team_v2( + http_request=mock_request, + user_id="member_user", + organization_id=None, + team_id=None, + team_alias=None, + search="team_a", + user_api_key_dict=mock_user_api_key_dict, + page=1, + page_size=10, + status=None, + ) + + find_many_kwargs = mock_db.litellm_teamtable.find_many.call_args.kwargs + where = find_many_kwargs["where"] + assert where["OR"] == [ + {"team_id": "team_a"}, + {"team_alias": {"contains": "team_a", "mode": "insensitive"}}, + ] + assert where["team_id"] == {"in": ["team_a", "team_b"]} + + @pytest.mark.asyncio async def test_team_member_delete_cleans_membership(mock_db_client, mock_admin_auth): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index f74a71e901..b25b6ce393 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -29,6 +29,7 @@ export interface TeamListCallOptions { organizationID?: string | null; teamID?: string | null; team_alias?: string | null; + search?: string | null; userID?: string | null; sortBy?: string | null; sortOrder?: string | null; @@ -52,6 +53,7 @@ export const teamListCall = async ( team_id: options.teamID, organization_id: options.organizationID, team_alias: options.team_alias, + search: options.search, user_id: options.userID, page, page_size: pageSize, @@ -178,6 +180,7 @@ const deletedTeamListCall = async ( team_id: options.teamID, organization_id: options.organizationID, team_alias: options.team_alias, + search: options.search, user_id: options.userID, page, page_size: pageSize, diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index abc26c4cd4..b9305e4723 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -80,8 +80,7 @@ interface TeamProps { } interface FilterState { - team_id: string; - team_alias: string; + search: string; organization_id: string; sort_by: string; sort_order: "asc" | "desc"; @@ -200,8 +199,7 @@ const Teams: React.FC = ({ const [currentOrg, setCurrentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); const [filters, setFilters] = useState({ - team_id: "", - team_alias: "", + search: "", organization_id: "", sort_by: "created_at", sort_order: "desc", @@ -215,7 +213,7 @@ const Teams: React.FC = ({ sortBy?: string; sortOrder?: string; organizationID?: string; - teamAlias?: string; + search?: string; } = {}) => { if (!accessToken) return; const page = opts.page ?? currentPage; @@ -223,7 +221,7 @@ const Teams: React.FC = ({ const sortBy = opts.sortBy ?? filters.sort_by; const sortOrder = opts.sortOrder ?? filters.sort_order; const organizationID = opts.organizationID ?? filters.organization_id; - const teamAlias = opts.teamAlias ?? filters.team_alias; + const search = opts.search ?? filters.search; setIsLoading(true); setFetchError(null); @@ -234,7 +232,7 @@ const Teams: React.FC = ({ size, { organizationID: organizationID || null, - team_alias: teamAlias || null, + search: search || null, userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, sortBy: sortBy || null, sortOrder: sortOrder || null, @@ -632,9 +630,9 @@ const Teams: React.FC = ({ setIsSearching(true); searchDebounceRef.current = setTimeout(async () => { try { - setFilters((prev) => ({ ...prev, team_alias: value })); + setFilters((prev) => ({ ...prev, search: value })); setCurrentPage(1); - await fetchTeamsV2({ page: 1, teamAlias: value }); + await fetchTeamsV2({ page: 1, search: value }); } finally { setIsSearching(false); } @@ -653,7 +651,7 @@ const Teams: React.FC = ({ pageSize, { organizationID: newFilters.organization_id || null, - team_alias: newFilters.team_alias || null, + search: newFilters.search || null, userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, sortBy: newFilters.sort_by || null, sortOrder: newFilters.sort_order || null, @@ -670,15 +668,14 @@ const Teams: React.FC = ({ if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); setIsSearching(false); const resetFilters: FilterState = { - team_id: "", - team_alias: "", + search: "", organization_id: "", sort_by: "created_at", sort_order: "desc", }; setFilters(resetFilters); setCurrentPage(1); - fetchTeamsV2({ page: 1, organizationID: "", teamAlias: "", sortBy: "created_at", sortOrder: "desc" }); + fetchTeamsV2({ page: 1, organizationID: "", search: "", sortBy: "created_at", sortOrder: "desc" }); }; const { token } = theme.useToken(); @@ -945,7 +942,7 @@ const Teams: React.FC = ({ } suffix={isSearching ? : null} - placeholder="Search teams by name..." + placeholder="Search teams by name or ID..." onChange={(e) => handleSearchChange(e.target.value)} allowClear style={{ maxWidth: 400 }}