diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 5326074ad3c..73d6761e17f 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -77,6 +77,7 @@ from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, + UserSearchWhere, UserUpdateResult, ) from litellm.types.proxy.management_endpoints.scim_v2 import ( @@ -2079,6 +2080,10 @@ async def get_users( user_ids: str | None = fastapi.Query(default=None, description="Get list of users by user_ids"), sso_user_ids: str | None = fastapi.Query(default=None, description="Get list of users by sso_user_id"), user_email: str | None = fastapi.Query(default=None, description="Filter users by partial email match"), + search: str | None = fastapi.Query( + default=None, + description="Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive).", + ), team: str | None = fastapi.Query(default=None, description="Filter users by team id"), page: int = fastapi.Query(default=1, ge=1, description="Page number"), page_size: int = fastapi.Query(default=25, ge=1, le=100, description="Number of items per page"), @@ -2109,6 +2114,8 @@ async def get_users( Get list of users by sso_ids. Comma separated list of sso_ids. user_email: Optional[str] Filter users by partial email match + search: Optional[str] + Combined search: matches users whose user_id or user_email contains the value (case-insensitive) team: Optional[str] Filter users by team id. Will match if user has this team in their teams array. page: int @@ -2168,6 +2175,15 @@ async def get_users( "mode": "insensitive", # Case-insensitive search } + if search: + search_where: Final[UserSearchWhere] = { + "OR": ( + {"user_id": {"contains": search, "mode": "insensitive"}}, + {"user_email": {"contains": search, "mode": "insensitive"}}, + ) + } + where_conditions["OR"] = search_where["OR"] + if team is not None and isinstance(team, str): where_conditions["teams"] = { "has": team # Array contains for string arrays in Prisma diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index df0a090cdb0..6973f1d1f12 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,6 +1,8 @@ -from typing import Any, Final +from collections.abc import Mapping +from typing import Any, Final, Literal from pydantic import BaseModel, field_validator +from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( LiteLLM_UserTableWithKeyCount, @@ -9,6 +11,17 @@ from litellm.proxy._types import ( ) +class InsensitiveContains(TypedDict): + contains: ReadOnly[str] + mode: ReadOnly[Literal["insensitive"]] + + +class UserSearchWhere(TypedDict): + """Prisma filter behind `/user/list?search=`: user_id or user_email contains the term, case-insensitive.""" + + OR: ReadOnly[tuple[Mapping[Literal["user_id", "user_email"], InsensitiveContains], ...]] + + class UserListResponse(BaseModel): """ Response model for the user list endpoint diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index f231eb66a50..d02b42eb0bf 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2007,6 +2007,67 @@ async def test_get_users_user_id_partial_match(mocker): assert captured_where_conditions["user_id"]["in"] == ["user1", "user2", "user3"] +def test_get_users_search_matches_user_id_or_email(mocker): + """ + `search` ORs a case-insensitive contains match over user_id and user_email on both the rows + query and the count, while the legacy `user_email` param keeps filtering only user_email. + """ + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + searched_user_id = "a6f5c02b-0163-45ce-815f-f88d10e95686" + mock_user_row = mocker.MagicMock() + mock_user_row.user_id = searched_user_id + mock_user_row.model_dump.return_value = { + "user_id": searched_user_id, + "user_email": "search@example.com", + "user_role": "internal_user", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + find_many_wheres = [] + count_wheres = [] + + async def mock_find_many(*args, **kwargs): + find_many_wheres.append(kwargs["where"]) + return [mock_user_row] + + async def mock_count(*args, **kwargs): + count_wheres.append(kwargs["where"]) + return 1 + + async def mock_key_count(*args, **kwargs): + return 0 + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mock_prisma_client.db.litellm_usertable.count = mock_count + mock_prisma_client.db.litellm_verificationtoken.count = mock_key_count + mocker.patch( # test-quality-ok: /user/list reads prisma_client off proxy_server at call time + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + search_response = client.get("/user/list", params={"search": "A6F5C02B-0163"}) + assert search_response.status_code == 200, search_response.text + expected_or = ( + {"user_id": {"contains": "A6F5C02B-0163", "mode": "insensitive"}}, + {"user_email": {"contains": "A6F5C02B-0163", "mode": "insensitive"}}, + ) + assert find_many_wheres == [{"OR": expected_or}] + assert count_wheres == [{"OR": expected_or}] + assert [user["user_id"] for user in search_response.json()["users"]] == [searched_user_id] + assert search_response.json()["total"] == 1 + + legacy_response = client.get("/user/list", params={"user_email": "search@example.com"}) + assert legacy_response.status_code == 200, legacy_response.text + assert find_many_wheres[-1] == {"user_email": {"contains": "search@example.com", "mode": "insensitive"}} + finally: + app.dependency_overrides.pop(user_api_key_auth, None) + + def test_update_internal_user_params_reset_max_budget_with_none(): """ Test that _update_internal_user_params allows setting max_budget to None. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 095bdbf7250..6b0423067fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -323,5 +323,26 @@ describe("ViewUserDashboard", () => { expect(latest[2]).toBe(1); }); }); + + it("sends the toolbar search as the combined search param instead of user_email", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const searchedUserId = "a6f5c02b-0163-45ce-815f-f88d10e95686"; + await user.type(screen.getByPlaceholderText("Search by email or ID…"), searchedUserId); + + await waitFor(() => { + const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1]; + expect(latest[11]).toBe(searchedUserId); + }); + const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1]; + expect(latest[1]).toBeNull(); + expect(latest[4]).toBeNull(); + expect(latest[2]).toBe(1); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 7a3133840be..1ed23e7f523 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -65,7 +65,7 @@ const ViewUserDashboard: React.FC = ({ const [sorting, setSorting] = useState(DEFAULT_SORTING); const [columnFilters, setColumnFilters] = useState([]); const [searchInput, setSearchInput] = useState(""); - const [searchEmail] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); + const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); const [rowSelection, setRowSelection] = useState({}); const [selectionMode, setSelectionMode] = useState(false); @@ -222,12 +222,12 @@ const ViewUserDashboard: React.FC = ({ const ssoUserIdFilter = getFilterValue("sso_user_id"); const userRoleFilter = getFilterValue("user_role"); const teamFilter = getFilterValue("team"); - const emailFilter = searchEmail.trim() || null; + const searchFilter = searchQuery.trim() || null; const userListQueryFilters = { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, - email: emailFilter, + search: searchFilter, userId: userIdFilter, ssoUserId: ssoUserIdFilter, role: userRoleFilter, @@ -247,13 +247,14 @@ const ViewUserDashboard: React.FC = ({ userIdFilter ? [userIdFilter] : null, pagination.pageIndex + 1, pagination.pageSize, - emailFilter, + null, userRoleFilter ?? null, teamFilter ?? null, ssoUserIdFilter ?? null, sortBy, sortOrder, orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, + searchFilter, ); }, enabled: Boolean(accessToken && token && userRole && userID), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx index 26663f5d9e2..875df74652c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx @@ -158,7 +158,7 @@ export function UsersTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder="Search by email…" + searchPlaceholder="Search by email or ID…" onOpenFilters={() => setFiltersOpen(true)} filterLabels={FILTER_LABELS} formatFilterValue={formatFilterValue} diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 335a5f8b816..8dcd8c39d9d 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -815,3 +815,41 @@ describe("daily activity api_key filter", () => { expect(requestedUrl(mockFetch)).toContain("user_id="); }); }); + +describe("userListCall search serialization", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockOkFetch = () => { + const body = JSON.stringify({ users: [], total: 0, page: 1, page_size: 25, total_pages: 0 }); + const mockFetch = vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue(body) } as any); + global.fetch = mockFetch as any; + return mockFetch; + }; + + const lastParams = (mockFetch: ReturnType) => { + const [url] = mockFetch.mock.calls.at(-1) ?? []; + return new URL(url as string, "http://example.com").searchParams; + }; + + it("sends the combined search term as search, not user_email", async () => { + const mockFetch = mockOkFetch(); + + await Networking.userListCall("token", null, 1, 25, null, null, null, null, null, null, null, "a6f5c02b"); + + expect(lastParams(mockFetch).get("search")).toBe("a6f5c02b"); + expect(lastParams(mockFetch).has("user_email")).toBe(false); + }); + + it("omits search when no search term is given and keeps user_email as before", async () => { + const mockFetch = mockOkFetch(); + + await Networking.userListCall("token", null, 1, 25, "ada@example.com"); + + expect(lastParams(mockFetch).has("search")).toBe(false); + expect(lastParams(mockFetch).get("user_email")).toBe("ada@example.com"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 7e0f6c7e4f5..a40b7704674 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1033,6 +1033,7 @@ export const userListCall = async ( sortBy: string | null = null, sortOrder: "asc" | "desc" | null = null, organizationIds: string[] | null = null, + search: string | null = null, ) => { /** * Get all available teams on proxy @@ -1051,6 +1052,7 @@ export const userListCall = async ( sort_by: sortBy || undefined, sort_order: sortOrder || undefined, organization_ids: organizationIds && organizationIds.length > 0 ? organizationIds.join(",") : undefined, + search: search || undefined, }, })) as UserListResponse; return data; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 098b43f6433..8714570d5c4 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16606,6 +16606,8 @@ export interface paths { * Get list of users by sso_ids. Comma separated list of sso_ids. * user_email: Optional[str] * Filter users by partial email match + * search: Optional[str] + * Combined search: matches users whose user_id or user_email contains the value (case-insensitive) * team: Optional[str] * Filter users by team id. Will match if user has this team in their teams array. * page: int @@ -59835,6 +59837,8 @@ export interface operations { sso_user_ids?: string | null; /** @description Filter users by partial email match */ user_email?: string | null; + /** @description Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive). */ + search?: string | null; /** @description Filter users by team id */ team?: string | null; /** @description Page number */