This commit is contained in:
haider 2026-11-03 13:54:16 -05:00 • committed by GitHub
commit 974cd00d6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 129 additions and 7 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View file

@ -2257,6 +2257,7 @@ async def get_users(
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"),
models: str | None = fastapi.Query(default=None, description="Filter by comma-separated personal model grants"),
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"),
sort_by: str | None = fastapi.Query(
@ -2290,6 +2291,8 @@ async def get_users(
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.
models: Optional[str]
Filter users whose personal model grants include any of the comma-separated model ids.
page: int
The page number to return
page_size: int
@ -2352,6 +2355,12 @@ async def get_users(
"has": team # Array contains for string arrays in Prisma
}
model_ids: Final[tuple[str, ...]] = (
tuple(model_id.strip() for model_id in models.split(",") if model_id.strip()) if isinstance(models, str) else ()
)
if model_ids:
where_conditions["models"] = {"hasSome": list(model_ids)}
if sso_user_ids is not None and isinstance(sso_user_ids, str):
sso_id_list: Final = [sid.strip() for sid in sso_user_ids.split(",") if sid.strip()]
where_conditions["sso_user_id"] = {
@ -2390,6 +2399,7 @@ async def get_users(
user_ids=user_ids,
user_email=user_email,
team=team,
models=model_ids,
sso_user_ids=sso_user_ids,
organization_ids=organization_ids,
search=search,

View file

@ -26,6 +26,7 @@ async def sorted_user_ids(
user_ids: str | None,
user_email: str | None,
team: str | None,
models: Sequence[str],
sso_user_ids: str | None,
organization_ids: str | None,
search: str | None,
@ -37,7 +38,7 @@ async def sorted_user_ids(
"CASE WHEN u.metadata->'scim_active' = 'false'::jsonb THEN 1 ELSE 0 END"
if sort_by == "status"
else '(SELECT COUNT(*) FROM "LiteLLM_VerificationToken" k '
"WHERE k.user_id = u.user_id AND k.team_id IS DISTINCT FROM $11::text)"
"WHERE k.user_id = u.user_id AND k.team_id IS DISTINCT FROM $12::text)"
)
direction: Final = "DESC" if descending else "ASC"
sql: Final = f"""
@ -55,6 +56,7 @@ async def sorted_user_ids(
WHERE m.user_id = u.user_id AND m.organization_id = ANY($7::text[])))
AND ($8::text IS NULL OR u.user_id ILIKE '%' || $8 || '%'
OR u.user_email ILIKE '%' || $8 || '%')
AND ($11::text[] IS NULL OR u.models && $11::text[])
ORDER BY {expression} {direction}, u.user_id ASC
LIMIT $9 OFFSET $10
"""
@ -71,6 +73,7 @@ async def sorted_user_ids(
search or None,
take,
skip,
tuple(models) if models else None,
*((UI_SESSION_TOKEN_TEAM_ID,) if sort_by == "key_count" else ()),
)
)

View file

@ -2204,6 +2204,36 @@ def test_get_users_search_matches_user_id_or_email(mocker):
app.dependency_overrides.pop(user_api_key_auth, None)
def test_user_list_filters_personal_models_in_rows_count_and_computed_sort(mocker):
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
mock_prisma_client = mocker.MagicMock()
find_many = mocker.AsyncMock(return_value=[])
count = mocker.AsyncMock(return_value=0)
mock_prisma_client.db.litellm_usertable.find_many = find_many
mock_prisma_client.db.litellm_usertable.count = count
mock_prisma_client.db.query_raw = mocker.AsyncMock(return_value=[])
mocker.patch("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:
response = client.get("/user/list", params={"models": " model-a , model-b, ", "sort_by": "status"})
assert response.status_code == 200, response.text
assert response.json()["total"] == 0
assert count.await_args.kwargs["where"] == {"models": {"hasSome": ["model-a", "model-b"]}}
assert find_many.await_args.kwargs["where"] == {
"AND": (
{"models": {"hasSome": ["model-a", "model-b"]}},
{"user_id": {"in": ()}},
)
}
assert mock_prisma_client.db.query_raw.await_args.args[11] == ("model-a", "model-b")
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

@ -26,6 +26,7 @@ async def test_user_sort_binds_scope_search_and_page_and_excludes_ui_sessions(
user_ids=" a, b ",
user_email=search,
team="team-1",
models=("model-a", "model-b"),
sso_user_ids="sso-1, sso-2",
organization_ids="org-1, org-2",
search=search,
@ -44,9 +45,11 @@ async def test_user_sort_binds_scope_search_and_page_and_excludes_ui_sessions(
search,
25,
25,
("model-a", "model-b"),
] + ([UI_SESSION_TOKEN_TEAM_ID] if sort_by == "key_count" else [])
assert ("DESC" if descending else "ASC") + ", u.user_id ASC" in sql
assert ("k.team_id IS DISTINCT FROM $11::text" in sql) == (sort_by == "key_count")
assert "u.models && $11::text[]" in sql
assert ("k.team_id IS DISTINCT FROM $12::text" in sql) == (sort_by == "key_count")
@pytest.mark.asyncio
@ -63,9 +66,10 @@ async def test_missing_user_filters_preserve_unfiltered_query() -> None:
user_ids=None,
user_email=None,
team=None,
models=(),
sso_user_ids=None,
organization_ids=None,
search=None,
)
assert result == ()
assert prisma.db.query_raw.await_args.args[1:] == (None, None, None, None, True, (), (), None, 25, 0)
assert prisma.db.query_raw.await_args.args[1:] == (None, None, None, None, True, (), (), None, 25, 0, None)

View file

@ -9,6 +9,7 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
import ViewUserDashboard from "./view_users";
const userListCall = vi.fn();
const modelAvailableCall = vi.fn();
// Mock the networking module
vi.mock("@/components/networking", () => ({
@ -18,7 +19,7 @@ vi.mock("@/components/networking", () => ({
Admin: { ui_label: "Admin" },
User: { ui_label: "User" },
}),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
modelAvailableCall: (...args: unknown[]) => modelAvailableCall(...args),
invitationCreateCall: vi.fn().mockResolvedValue({}),
userUpdateUserCall: vi.fn().mockResolvedValue({}),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
@ -95,6 +96,7 @@ const renderDashboard = (overrides: Partial<typeof defaultProps> = {}) =>
describe("ViewUserDashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
modelAvailableCall.mockResolvedValue({ data: [{ id: "model-a" }, { id: "model-b" }] });
userListCall.mockResolvedValue({
users: [makeUser("user-1", "test@example.com")],
total: 1,
@ -292,6 +294,29 @@ describe("ViewUserDashboard", () => {
});
describe("server-side query wiring", () => {
it("filters users by selected personal models and keeps the filter while sorting", async () => {
const user = userEvent.setup();
renderDashboard();
expect(await screen.findByText("test@example.com")).toBeInTheDocument();
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByPlaceholderText("Select personal models…"));
await user.click(await screen.findByText("model-a"));
await user.keyboard("{Escape}");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => {
expect(userListCall.mock.calls.at(-1)?.[12]).toEqual(["model-a"]);
});
await user.click(screen.getByTestId("sort-header-status"));
await waitFor(() => {
const latest = userListCall.mock.calls.at(-1);
expect(latest?.[8]).toBe("status");
expect(latest?.[12]).toEqual(["model-a"]);
});
});
it("requests page 1 with the default created_at desc sort", async () => {
renderDashboard();

View file

@ -86,7 +86,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
setBaseUrl(getProxyBaseUrl());
}, []);
// Fetch available models for bulk edit
useEffect(() => {
const fetchUserModels = async () => {
try {
@ -222,6 +221,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const ssoUserIdFilter = getFilterValue("sso_user_id");
const userRoleFilter = getFilterValue("user_role");
const teamFilter = getFilterValue("team");
const modelFilter = columnFilters.find((filter) => filter.id === "models");
const personalModels = Array.isArray(modelFilter?.value)
? modelFilter.value.filter((value): value is string => typeof value === "string" && value.length > 0)
: [];
const searchFilter = searchQuery.trim() || null;
const userListQueryFilters = {
@ -232,6 +235,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
ssoUserId: ssoUserIdFilter,
role: userRoleFilter,
team: teamFilter,
models: personalModels,
sortBy,
sortOrder,
orgAdminOrgIds,
@ -255,6 +259,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
sortOrder,
orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null,
searchFilter,
personalModels,
);
},
enabled: Boolean(accessToken && token && userRole && userID),
@ -298,6 +303,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData}
possibleUIRoles={possibleUIRoles}
teams={teams}
models={userModels}
canViewUserUsage={all_admin_roles.includes(userRole ?? "")}
sorting={sorting}
onSortingChange={handleSortingChange}

View file

@ -76,6 +76,7 @@ function Harness({
isLoading={isLoading}
possibleUIRoles={possibleUIRoles}
teams={[]}
models={[]}
canViewUserUsage={canViewUserUsage}
sorting={sorting}
onSortingChange={(updater) => {

View file

@ -7,7 +7,7 @@ import {
RowSelectionState,
SortingState,
} from "@tanstack/react-table";
import { Users } from "lucide-react";
import { Info, Users } from "lucide-react";
import { useMemo, useState } from "react";
import { UserInfo } from "@/components/networking";
@ -18,6 +18,8 @@ import {
DataTableToolbar,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { CellTooltip } from "@/components/shared/table_cells";
import { Input } from "@/components/ui/input";
import { getUsersTableColumns } from "./UsersTableColumns";
@ -33,6 +35,7 @@ interface UsersTableProps {
isLoading: boolean;
possibleUIRoles: Record<string, Record<string, string>> | null;
teams: UsersTableTeamOption[] | null;
models: string[];
canViewUserUsage: boolean;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
@ -55,6 +58,7 @@ const FILTER_LABELS: Record<string, string> = {
sso_user_id: "SSO ID",
user_role: "Role",
team: "Team",
models: "Personal Models",
};
function EmptyState() {
@ -75,6 +79,7 @@ export function UsersTable({
isLoading,
possibleUIRoles,
teams,
models,
canViewUserUsage,
sorting,
onSortingChange,
@ -123,6 +128,8 @@ export function UsersTable({
[teams],
);
const modelOptions = useMemo(() => models.map((model) => ({ label: model, value: model })), [models]);
const formatFilterValue = (columnId: string, value: unknown): string => {
const raw = String(value);
if (columnId === "user_role") {
@ -209,6 +216,25 @@ export function UsersTable({
emptyText="No teams found"
/>
</DataTableFilterField>
<DataTableFilterField
label={
<span className="inline-flex items-center gap-1.5">
Personal Models
<CellTooltip
content="Filters on models granted directly to the user. It does not include access through teams, organizations, object permissions, or All Proxy Models."
trigger={<Info className="size-3.5 text-muted-foreground" aria-label="About Personal Models" />}
/>
</span>
}
>
<MultiSelect
options={modelOptions}
value={(get("models") as string[] | undefined) ?? []}
onValueChange={(value) => set("models", value)}
placeholder="Select personal models…"
emptyText="No models found"
/>
</DataTableFilterField>
</>
)}
</DataTableFilterDrawer>

View file

@ -864,6 +864,17 @@ describe("userListCall search serialization", () => {
expect(lastParams(mockFetch).has("search")).toBe(false);
expect(lastParams(mockFetch).get("user_email")).toBe("ada@example.com");
});
it("sends selected personal models as a comma-separated filter", async () => {
const mockFetch = mockOkFetch();
await Networking.userListCall("token", null, 1, 25, null, null, null, null, null, null, null, null, [
"model-a",
"model-b",
]);
expect(lastParams(mockFetch).get("models")).toBe("model-a,model-b");
});
});
describe("fetchMemoryList search serialization", () => {

View file

@ -1116,6 +1116,7 @@ export const userListCall = async (
sortOrder: "asc" | "desc" | null = null,
organizationIds: string[] | null = null,
search: string | null = null,
models: string[] | null = null,
): Promise<UserListResponse> => {
const { data } = await fetchClient.GET("/user/list", {
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
@ -1132,6 +1133,7 @@ export const userListCall = async (
sort_order: sortOrder || undefined,
organization_ids: organizationIds && organizationIds.length > 0 ? organizationIds.join(",") : undefined,
search: search || undefined,
models: models && models.length > 0 ? models.join(",") : undefined,
},
},
});

View file

@ -105,7 +105,7 @@ export function DataTableFilterDrawer<TData>({
);
}
export function DataTableFilterField({ label, children }: { label: string; children: React.ReactNode }) {
export function DataTableFilterField({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label>{label}</Label>

View file

@ -18981,6 +18981,8 @@ export interface paths {
* 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.
* models: Optional[str]
* Filter users whose personal model grants include any of the comma-separated model ids.
* page: int
* The page number to return
* page_size: int
@ -77864,6 +77866,8 @@ export interface operations {
search?: string | null;
/** @description Filter users by team id */
team?: string | null;
/** @description Filter by comma-separated personal model grants */
models?: string | null;
/** @description Page number */
page?: number;
/** @description Number of items per page */