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.
This commit is contained in:
ryan-crabbe-berri
2026-05-12 11:14:29 -07:00
committed by GitHub
parent b39cac9382
commit 9c4faeabc9
4 changed files with 141 additions and 14 deletions
@@ -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,
@@ -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 <user's teams>` 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):
"""
@@ -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,
@@ -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<TeamProps> = ({
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState<Organization | null>(null);
const [filters, setFilters] = useState<FilterState>({
team_id: "",
team_alias: "",
search: "",
organization_id: "",
sort_by: "created_at",
sort_order: "desc",
@@ -215,7 +213,7 @@ const Teams: React.FC<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
<Input
prefix={<SearchIcon size={16} />}
suffix={isSearching ? <AntDLoadingSpinner size="small" /> : null}
placeholder="Search teams by name..."
placeholder="Search teams by name or ID..."
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
style={{ maxWidth: 400 }}