fix(ui): let the Internal Users search box match user_id as well as email

GET /user/list gains an optional search query param that ORs a case-insensitive contains match over user_id and user_email. The Users page search box now sends that param and reads "Search by email or ID…", the way the Teams page already searches by name or ID. Every existing /user/list param keeps its meaning and the Filters drawer is untouched

Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
This commit is contained in:
ryan-crabbe-berri 2026-09-03 11:27:56 -07:00
parent 7d6781fe6a
commit 07c5908b18
9 changed files with 162 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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);
});
});
});

View file

@ -65,7 +65,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [searchInput, setSearchInput] = useState("");
const [searchEmail] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [selectionMode, setSelectionMode] = useState(false);
@ -222,12 +222,12 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
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<ViewUserDashboardProps> = ({
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),

View file

@ -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}

View file

@ -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<typeof vi.fn>) => {
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");
});
});

View file

@ -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;

View file

@ -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 */