diff --git a/docs/images/personal-model-filter-after.png b/docs/images/personal-model-filter-after.png new file mode 100644 index 00000000000..79cea6b9ac6 Binary files /dev/null and b/docs/images/personal-model-filter-after.png differ diff --git a/docs/images/personal-model-filter-before.png b/docs/images/personal-model-filter-before.png new file mode 100644 index 00000000000..25e5ecd5ee3 Binary files /dev/null and b/docs/images/personal-model-filter-before.png differ diff --git a/docs/images/personal-model-filter-result.png b/docs/images/personal-model-filter-result.png new file mode 100644 index 00000000000..900c5e021fb Binary files /dev/null and b/docs/images/personal-model-filter-result.png differ diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 54a2d4062d2..b77b8bc3bed 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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, diff --git a/litellm/proxy/management_helpers/user_list_sorting.py b/litellm/proxy/management_helpers/user_list_sorting.py index d3d2e323cb0..d3211b5600c 100644 --- a/litellm/proxy/management_helpers/user_list_sorting.py +++ b/litellm/proxy/management_helpers/user_list_sorting.py @@ -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 ()), ) ) diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index 39586a1a204..9bad4ab82d4 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -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. diff --git a/tests/unit/proxy/management_helpers/test_user_list_sorting.py b/tests/unit/proxy/management_helpers/test_user_list_sorting.py index 263c1691b23..878eed4dccf 100644 --- a/tests/unit/proxy/management_helpers/test_user_list_sorting.py +++ b/tests/unit/proxy/management_helpers/test_user_list_sorting.py @@ -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) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx index d728f6473f5..c77b7f56ded 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx @@ -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 = {}) => 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(); 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 1dbab3b1803..39d95ced467 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 @@ -86,7 +86,6 @@ const ViewUserDashboard: React.FC = ({ setBaseUrl(getProxyBaseUrl()); }, []); - // Fetch available models for bulk edit useEffect(() => { const fetchUserModels = async () => { try { @@ -222,6 +221,10 @@ const ViewUserDashboard: React.FC = ({ 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 = ({ ssoUserId: ssoUserIdFilter, role: userRoleFilter, team: teamFilter, + models: personalModels, sortBy, sortOrder, orgAdminOrgIds, @@ -255,6 +259,7 @@ const ViewUserDashboard: React.FC = ({ 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 = ({ isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData} possibleUIRoles={possibleUIRoles} teams={teams} + models={userModels} canViewUserUsage={all_admin_roles.includes(userRole ?? "")} sorting={sorting} onSortingChange={handleSortingChange} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx index 85d9ef386a3..3ddf76bfe83 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx @@ -76,6 +76,7 @@ function Harness({ isLoading={isLoading} possibleUIRoles={possibleUIRoles} teams={[]} + models={[]} canViewUserUsage={canViewUserUsage} sorting={sorting} onSortingChange={(updater) => { 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 3e266ef482d..2529c3bbd2b 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 @@ -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> | null; teams: UsersTableTeamOption[] | null; + models: string[]; canViewUserUsage: boolean; sorting: SortingState; onSortingChange: OnChangeFn; @@ -55,6 +58,7 @@ const FILTER_LABELS: Record = { 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" /> + + Personal Models + } + /> + + } + > + set("models", value)} + placeholder="Select personal models…" + emptyText="No models found" + /> + )} diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 874cdb52fad..6e611c9294d 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -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", () => { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 994c2948346..c8e3ce23830 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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 => { 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, }, }, }); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx index ea986a84cc3..45b52fa3add 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx @@ -105,7 +105,7 @@ export function DataTableFilterDrawer({ ); } -export function DataTableFilterField({ label, children }: { label: string; children: React.ReactNode }) { +export function DataTableFilterField({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { return (
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 96d4c0e1bc4..0c8922b8979 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -18909,6 +18909,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 @@ -77503,6 +77505,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 */