mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat: filter internal users by personal model grants
This commit is contained in:
parent
60fc121ce8
commit
0635f7f3f1
15 changed files with 129 additions and 7 deletions
BIN
docs/images/personal-model-filter-after.png
Normal file
BIN
docs/images/personal-model-filter-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/images/personal-model-filter-before.png
Normal file
BIN
docs/images/personal-model-filter-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/images/personal-model-filter-result.png
Normal file
BIN
docs/images/personal-model-filter-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ()),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ function Harness({
|
|||
isLoading={isLoading}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
teams={[]}
|
||||
models={[]}
|
||||
canViewUserUsage={canViewUserUsage}
|
||||
sorting={sorting}
|
||||
onSortingChange={(updater) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue