diff --git a/ui/litellm-dashboard/public/assets/logos/claude.svg b/ui/litellm-dashboard/public/assets/logos/claude.svg
index ede30652b25..17da2adfc37 100644
--- a/ui/litellm-dashboard/public/assets/logos/claude.svg
+++ b/ui/litellm-dashboard/public/assets/logos/claude.svg
@@ -1,3 +1,4 @@
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx
new file mode 100644
index 00000000000..25371abda51
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx
@@ -0,0 +1,86 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { UserInfo } from "@/components/networking";
+import { chooseSelectOption } from "../../../../../tests/test-utils";
+
+import UserBudgetDialog from "./UserBudgetDialog";
+
+const userInfo: Pick = {
+ user_id: "budget-user",
+ user_email: "budget@example.test",
+ max_budget: 12.345,
+ budget_duration: "7d",
+};
+
+const fetchMock = vi.fn();
+
+const renderDialog = () => {
+ const onClose = vi.fn();
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ return onClose;
+};
+
+describe("UserBudgetDialog", () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(Response.json({ user_id: userInfo.user_id }));
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("submits only the selected user's budget and preserves the existing reset schedule", async () => {
+ const user = userEvent.setup();
+ const onClose = renderDialog();
+ expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(12.345);
+ fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "42.765" } });
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/user/update"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ user_id: "budget-user", max_budget: 42.765 }),
+ }),
+ );
+ });
+
+ it("can remove the limit and change its reset period", async () => {
+ const user = userEvent.setup();
+ const onClose = renderDialog();
+ await user.click(screen.getByRole("checkbox", { name: "Unlimited budget" }));
+ await chooseSelectOption(user, screen.getByRole("combobox", { name: "Reset Budget" }), "monthly");
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/user/update"),
+ expect.objectContaining({
+ body: JSON.stringify({ user_id: "budget-user", max_budget: null, budget_duration: "30d" }),
+ }),
+ );
+ });
+
+ it("keeps the edited values available after an update fails so the admin can retry", async () => {
+ const user = userEvent.setup();
+ fetchMock.mockResolvedValueOnce(Response.json({ error: "Update failed" }, { status: 500 }));
+ const onClose = renderDialog();
+ fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "Save budget" })).toBeEnabled());
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(25);
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx
new file mode 100644
index 00000000000..dc430812cd6
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx
@@ -0,0 +1,107 @@
+import { useId } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { z } from "zod";
+
+import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
+import { UserInfo, userUpdateUserCall } from "@/components/networking";
+import { FormField } from "@/components/shared/form/FormField";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { FieldGroup } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { useZodForm } from "@/lib/forms/useZodForm";
+import { toast } from "@/lib/toast";
+
+import { userBudgetSchema } from "./userBudgetForm";
+
+interface UserBudgetDialogProps {
+ user: Pick;
+ accessToken: string;
+ onClose: () => void;
+}
+
+export default function UserBudgetDialog({ user, accessToken, onClose }: UserBudgetDialogProps) {
+ const formId = useId();
+ const queryClient = useQueryClient();
+ const form = useZodForm(userBudgetSchema, {
+ defaultValues: {
+ unlimited: user.max_budget == null,
+ max_budget: user.max_budget?.toString() ?? "",
+ budget_duration: user.budget_duration ?? null,
+ },
+ });
+ const unlimited = form.watch("unlimited");
+ const { isSubmitting, dirtyFields } = form.formState;
+
+ const save = async (values: z.output) => {
+ try {
+ await userUpdateUserCall(
+ accessToken,
+ {
+ user_id: user.user_id,
+ max_budget: values.unlimited ? null : Number(values.max_budget),
+ ...(dirtyFields.budget_duration ? { budget_duration: values.budget_duration } : {}),
+ },
+ null,
+ );
+ await queryClient.invalidateQueries({ queryKey: ["userList"] });
+ toast.success("Budget updated");
+ onClose();
+ } catch (error) {
+ toast.fromError(error);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts
new file mode 100644
index 00000000000..1d950d402c1
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import { userBudgetSchema } from "./userBudgetForm";
+
+describe("userBudgetSchema", () => {
+ it.each(["0", "0.001", "42.765"])("accepts a finite non-negative budget of %s", (max_budget) => {
+ expect(userBudgetSchema.safeParse({ unlimited: false, max_budget, budget_duration: "7d" }).success).toBe(true);
+ });
+
+ it.each(["", " ", "-1", "NaN", "Infinity", "12abc"])("rejects an invalid finite budget of %s", (max_budget) => {
+ expect(userBudgetSchema.safeParse({ unlimited: false, max_budget, budget_duration: null }).success).toBe(false);
+ });
+
+ it("represents unlimited as null without requiring a disabled amount", () => {
+ expect(userBudgetSchema.safeParse({ unlimited: true, max_budget: "", budget_duration: null }).success).toBe(true);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts
new file mode 100644
index 00000000000..ae96f00f80c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts
@@ -0,0 +1,16 @@
+import { z } from "zod";
+
+export const userBudgetSchema = z
+ .object({
+ unlimited: z.boolean(),
+ max_budget: z.string(),
+ budget_duration: z.string().nullable(),
+ })
+ .refine(
+ ({ unlimited, max_budget }) => {
+ const amount = Number(max_budget);
+ const validAmount = max_budget.trim() !== "" && Number.isFinite(amount) && amount >= 0;
+ return unlimited || validAmount;
+ },
+ { path: ["max_budget"], message: "Enter a non-negative budget" },
+ );
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..706a542aaed 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
@@ -81,6 +81,7 @@ const defaultProps = {
accessToken: "test-token",
token: "test-token",
userRole: "Admin",
+ isViewOnly: false,
userID: "admin-user-id",
teams: [],
};
@@ -104,6 +105,23 @@ describe("ViewUserDashboard", () => {
});
});
+ it("offers the budget quick action to a write-capable proxy admin", async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+ await user.click(await screen.findByTestId("user-actions-user-1"));
+ await user.click(await screen.findByRole("menuitem", { name: "Adjust budget" }));
+ expect(screen.getByRole("dialog", { name: "Adjust budget" })).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Unlimited budget" })).toBeChecked();
+ });
+
+ it("hides budget updates from a read-only session even when its display role is Admin", async () => {
+ const user = userEvent.setup();
+ renderDashboard({ isViewOnly: true });
+ await user.click(await screen.findByTestId("user-actions-user-1"));
+ await screen.findByTestId("user-action-edit");
+ expect(screen.queryByRole("menuitem", { name: "Adjust budget" })).not.toBeInTheDocument();
+ });
+
it("should render the ViewUserDashboard component", 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 610933e7fa0..5f07c409b5d 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
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import BulkEditUserModal from "./BulkEditUsers";
+import UserBudgetDialog from "./UserBudgetDialog";
import BulkCreateUsersButton from "@/components/bulk_create_users_button";
import { CreateUserButton } from "@/components/CreateUserButton";
import { Button } from "@/components/ui/button";
@@ -40,6 +41,7 @@ interface ViewUserDashboardProps {
accessToken: string | null;
token: string | null;
userRole: string | null;
+ isViewOnly?: boolean;
userID: string | null;
teams: any[] | null;
orgAdminOrgIds?: Array<{ organization_id: string; organization_alias: string }> | null;
@@ -55,6 +57,7 @@ const ViewUserDashboard: React.FC = ({
accessToken,
token,
userRole,
+ isViewOnly,
userID,
teams,
orgAdminOrgIds,
@@ -79,6 +82,9 @@ const ViewUserDashboard: React.FC = ({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [userToDelete, setUserToDelete] = useState(null);
+ const [budgetUser, setBudgetUser] = useState(null);
+ const canAdjustBudget = isProxyAdmin && isViewOnly === false;
+ const budgetDialogUser = canAdjustBudget ? budgetUser : null;
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState(null);
const [baseUrl, setBaseUrl] = useState(null);
@@ -315,6 +321,7 @@ const ViewUserDashboard: React.FC = ({
onUserClick={handleUserClick}
onDeleteUser={handleDelete}
onResetPassword={handleResetPassword}
+ onAdjustBudget={canAdjustBudget ? setBudgetUser : undefined}
/>
);
@@ -403,6 +410,10 @@ const ViewUserDashboard: React.FC = ({
usersTable
)}
+ {budgetDialogUser && accessToken && (
+ setBudgetUser(null)} />
+ )}
+
{/* Existing Modals */}
void;
onDeleteUser?: (user: UserInfo) => void;
onResetPassword?: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
onSortingChange?: Mock;
}
@@ -56,6 +57,7 @@ function Harness({
onUserClick = vi.fn(),
onDeleteUser = vi.fn(),
onResetPassword = vi.fn(),
+ onAdjustBudget,
onSortingChange,
}: HarnessOverrides) {
const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]);
@@ -94,6 +96,7 @@ function Harness({
onUserClick={onUserClick}
onDeleteUser={onDeleteUser}
onResetPassword={onResetPassword}
+ onAdjustBudget={onAdjustBudget}
/>
>
);
@@ -104,6 +107,24 @@ const openRowMenu = async (user: ReturnType, userId: str
};
describe("UsersTable", () => {
+ it("opens the budget editor for the selected row when the action is allowed", async () => {
+ const user = userEvent.setup();
+ const onAdjustBudget = vi.fn();
+ const selected = makeUser({ user_id: "budget-user", max_budget: 25 });
+ render();
+ await openRowMenu(user, "budget-user");
+ await user.click(await screen.findByRole("menuitem", { name: "Adjust budget" }));
+ expect(onAdjustBudget).toHaveBeenCalledWith(selected);
+ });
+
+ it("does not offer a budget update without permission", async () => {
+ const user = userEvent.setup();
+ render();
+ await openRowMenu(user, "user-1");
+ await screen.findByTestId("user-action-edit");
+ expect(screen.queryByRole("menuitem", { name: "Adjust budget" })).not.toBeInTheDocument();
+ });
+
it("renders every migrated column header", () => {
render();
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 89eb20a3201..89ccb5776fb 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
@@ -50,6 +50,7 @@ interface UsersTableProps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
}
const FILTER_LABEL_KEYS: Readonly> = {
@@ -93,6 +94,7 @@ export function UsersTable({
onUserClick,
onDeleteUser,
onResetPassword,
+ onAdjustBudget,
}: UsersTableProps) {
const { t } = useTranslation();
const [filtersOpen, setFiltersOpen] = useState(false);
@@ -105,10 +107,11 @@ export function UsersTable({
onUserClick,
onDeleteUser,
onResetPassword,
+ onAdjustBudget,
t,
};
return getUsersTableColumns(columnDeps);
- }, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, t]);
+ }, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, onAdjustBudget, t]);
const filterLabels = useMemo(
() => Object.fromEntries(Object.entries(FILTER_LABEL_KEYS).map(([columnId, key]) => [columnId, t(key)])),
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx
index 08d6750e5bb..8f7af43fd51 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx
@@ -32,9 +32,17 @@ interface UserRowActionsProps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
}
-function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
+function UserRowActions({
+ user,
+ canViewUserUsage,
+ onUserClick,
+ onDeleteUser,
+ onResetPassword,
+ onAdjustBudget,
+}: UserRowActionsProps) {
const { t } = useTranslation();
return (
@@ -50,6 +58,12 @@ function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onR
{t("users.table.rowActions.edit")}
+ {onAdjustBudget && (
+ onAdjustBudget(user)}>
+
+ {t("users.table.rowActions.adjustBudget")}
+
+ )}
onResetPassword(user.user_id)} data-testid="user-action-reset-password">
{t("users.table.rowActions.resetPassword")}
@@ -87,6 +101,7 @@ export interface UsersTableColumnsDeps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
t: TFunction;
}
@@ -97,6 +112,7 @@ export const getUsersTableColumns = ({
onUserClick,
onDeleteUser,
onResetPassword,
+ onAdjustBudget,
t,
}: UsersTableColumnsDeps): ColumnDef[] => {
const baseColumns: ColumnDef[] = [
@@ -302,6 +318,7 @@ export const getUsersTableColumns = ({
onUserClick={onUserClick}
onDeleteUser={onDeleteUser}
onResetPassword={onResetPassword}
+ onAdjustBudget={onAdjustBudget}
/>
),
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx
index 75e5f27b07b..44b214a8093 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx
@@ -5,12 +5,13 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
export default function UsersPage() {
- const { accessToken, token, userRole, userId } = useAuthorized();
+ const { accessToken, token, userRole, userId, isViewOnly } = useAuthorized();
const { data: teams } = useTeams();
return (