mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
7d6781fe6a
commit
07c5908b18
9 changed files with 162 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue