diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.test.ts
new file mode 100644
index 00000000000..c64f6e6a88a
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { isValidRateLimitInput, rateLimitUpdate } from "./userRateLimitPayload";
+
+describe("rateLimitUpdate", () => {
+ it("omits an untouched input when the stored value is null", () => {
+ expect(rateLimitUpdate(undefined, null)).toBeUndefined();
+ });
+
+ it.each([500, "500"])("omits an unchanged stored limit from input %s", (input) => {
+ expect(rateLimitUpdate(input, 500)).toBeUndefined();
+ });
+
+ it.each(["", null])("sends null when a stored limit is deliberately cleared with %s", (input) => {
+ expect(rateLimitUpdate(input, 500)).toBeNull();
+ });
+
+ it("sends a new limit entered as a string", () => {
+ expect(rateLimitUpdate("100", null)).toBe(100);
+ });
+
+ it("preserves zero as a changed limit", () => {
+ expect(rateLimitUpdate("0", 500)).toBe(0);
+ });
+
+ it("omits a whitespace-only input when no limit was stored", () => {
+ expect(rateLimitUpdate(" ", null)).toBeUndefined();
+ });
+});
+
+describe("isValidRateLimitInput", () => {
+ it.each([
+ ["empty string", ""],
+ ["null", null],
+ ["undefined", undefined],
+ ["whitespace", " "],
+ ["string zero", "0"],
+ ["number zero", 0],
+ ["integer string", "12"],
+ ["integer", 12],
+ ])("accepts %s", (_label, value) => {
+ expect(isValidRateLimitInput(value)).toBe(true);
+ });
+
+ it.each(["1.5", "-1", "abc", "1e400"])("rejects %s", (value) => {
+ expect(isValidRateLimitInput(value)).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.ts
new file mode 100644
index 00000000000..82c0fb3bf88
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.ts
@@ -0,0 +1,17 @@
+export const rateLimitUpdate = (
+ input: string | number | null | undefined,
+ stored: number | null | undefined,
+): number | null | undefined => {
+ const isNullishInput = input === null || input === undefined;
+ const isBlankString = typeof input === "string" && input.trim() === "";
+ const normalized = isNullishInput || isBlankString ? null : Number(input);
+ return normalized === (stored ?? null) ? undefined : normalized;
+};
+
+export const isValidRateLimitInput = (value: string | number | null | undefined): boolean => {
+ if (value === "" || value === null || value === undefined) {
+ return true;
+ }
+ const number = Number(value);
+ return Number.isFinite(number) && Number.isInteger(number) && number >= 0;
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx
index 2571eb344f5..4a3c165b015 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx
@@ -457,6 +457,98 @@ describe("UserEditView", () => {
expect(checkbox).toBeChecked();
});
});
+
+ describe("user rate limits", () => {
+ const userDataWithRateLimits = () => ({
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ tpm_limit: 100000,
+ rpm_limit: null,
+ },
+ });
+
+ it("seeds the TPM and RPM inputs from the selected user", async () => {
+ renderWithProviders();
+
+ expect(await screen.findByRole("spinbutton", { name: /tpm limit/i })).toHaveValue(100000);
+ expect(await screen.findByRole("spinbutton", { name: /rpm limit/i })).toHaveValue(null);
+ });
+
+ it("omits unchanged rate limits from the submit payload", async () => {
+ const onSubmit = vi.fn();
+ renderWithProviders();
+
+ await userEvent.click(await screen.findByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled();
+ });
+ expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("tpm_limit");
+ expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("rpm_limit");
+ });
+
+ it("sends null only for a deliberately cleared TPM limit", async () => {
+ const onSubmit = vi.fn();
+ renderWithProviders();
+
+ fireEvent.change(await screen.findByRole("spinbutton", { name: /tpm limit/i }), {
+ target: { value: "" },
+ });
+ await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled();
+ });
+ expect(onSubmit.mock.calls[0][0].tpm_limit).toBeNull();
+ expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("rpm_limit");
+ });
+
+ it("submits a new RPM limit as a number", async () => {
+ const onSubmit = vi.fn();
+ renderWithProviders();
+
+ fireEvent.change(await screen.findByRole("spinbutton", { name: /rpm limit/i }), {
+ target: { value: "1" },
+ });
+ await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled();
+ });
+ expect(onSubmit.mock.calls[0][0].rpm_limit).toBe(1);
+ expect(typeof onSubmit.mock.calls[0][0].rpm_limit).toBe("number");
+ });
+
+ it.each(["-1", "1.5"])("rejects an invalid TPM limit of %s", async (value) => {
+ const onSubmit = vi.fn();
+ renderWithProviders();
+
+ fireEvent.change(await screen.findByRole("spinbutton", { name: /tpm limit/i }), {
+ target: { value },
+ });
+ const submitButton = screen.getByRole("button", { name: /save changes/i }) as HTMLButtonElement;
+ const form = submitButton.form;
+ if (!form) {
+ throw new Error("User edit form was not rendered");
+ }
+ fireEvent.submit(form);
+
+ expect(
+ await screen.findByText("Enter a non-negative whole number, or leave empty for unlimited"),
+ ).toBeInTheDocument();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("hides both rate-limit inputs in bulk edit mode", async () => {
+ renderWithProviders();
+
+ await screen.findByRole("button", { name: /save changes/i });
+ expect(screen.queryByRole("spinbutton", { name: /tpm limit/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("spinbutton", { name: /rpm limit/i })).not.toBeInTheDocument();
+ });
+ });
+
describe("submit payload parity", () => {
const submittedPayload = async (props: Partial[0]> = {}) => {
const onSubmit = vi.fn();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
index b7a3486c78e..f30ba1ccb5f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
@@ -5,6 +5,7 @@ import BudgetDurationDropdown from "@/components/common_components/budget_durati
import { ModelMaxBudget, ModelMaxBudgetField } from "@/components/key_team_helpers/ModelMaxBudgetEditor";
import { modelMaxBudgetUpdate } from "@/components/key_team_helpers/modelMaxBudgetPayload";
import { useSeededState } from "@/components/key_team_helpers/useSeededState";
+import { isValidRateLimitInput, rateLimitUpdate } from "./userRateLimitPayload";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
@@ -67,6 +68,14 @@ const budgetSchema = (unlimitedBudget: boolean) =>
(value) => unlimitedBudget || (value !== "" && value !== null && value !== undefined),
"Please enter a budget or select Unlimited Budget",
),
+ tpm_limit: z
+ .union([z.string(), z.number()])
+ .nullish()
+ .refine(isValidRateLimitInput, "Enter a non-negative whole number, or leave empty for unlimited"),
+ rpm_limit: z
+ .union([z.string(), z.number()])
+ .nullish()
+ .refine(isValidRateLimitInput, "Enter a non-negative whole number, or leave empty for unlimited"),
});
type UserEditFormValues = z.infer>;
@@ -92,7 +101,14 @@ const toFormValues = (
const maxBudget = userData.user_info?.max_budget;
const isUnlimited = maxBudget === null || maxBudget === undefined;
return {
- ...(isBulkEdit ? {} : { user_id: userData.user_id, user_email: userData.user_info?.user_email }),
+ ...(isBulkEdit
+ ? {}
+ : {
+ user_id: userData.user_id,
+ user_email: userData.user_info?.user_email,
+ tpm_limit: userData.user_info?.tpm_limit ?? "",
+ rpm_limit: userData.user_info?.rpm_limit ?? "",
+ }),
user_alias: userData.user_info?.user_alias,
user_role: userData.user_info?.user_role,
models: userData.user_info?.models || [],
@@ -171,11 +187,16 @@ export function UserEditView({
return;
}
+ const { tpm_limit: tpmLimitInput, rpm_limit: rpmLimitInput, ...formValues } = values;
const modelBudgets = modelMaxBudgetUpdate(modelMaxBudget, userData.user_info?.model_max_budget);
+ const tpmLimit = rateLimitUpdate(tpmLimitInput, isBulkEdit ? undefined : userData.user_info?.tpm_limit);
+ const rpmLimit = rateLimitUpdate(rpmLimitInput, isBulkEdit ? undefined : userData.user_info?.rpm_limit);
onSubmit({
- ...values,
+ ...formValues,
...("metadata" in values ? { metadata: metadata.value } : {}),
...(modelBudgets !== undefined && { model_max_budget: modelBudgets }),
+ ...(tpmLimit !== undefined && { tpm_limit: tpmLimit }),
+ ...(rpmLimit !== undefined && { rpm_limit: rpmLimit }),
max_budget:
unlimitedBudget || values.max_budget === "" || values.max_budget === undefined ? null : values.max_budget,
});
@@ -293,6 +314,56 @@ export function UserEditView({
{({ id, value, onChange }) => }
+ {!isBulkEdit && (
+ <>
+
+ {({ ref, value, onChange, ...control }) => (
+ onChange(event.target.value)}
+ onWheel={(event) => event.currentTarget.blur()}
+ placeholder="Unlimited"
+ />
+ )}
+
+
+
+ {({ ref, value, onChange, ...control }) => (
+ onChange(event.target.value)}
+ onWheel={(event) => event.currentTarget.blur()}
+ placeholder="Unlimited"
+ />
+ )}
+
+ >
+ )}
+
{/* Bulk edit forwards a fixed field list and has no single stored budget to
diff against, so the editor would silently discard whatever was typed. */}
{!isBulkEdit && (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
index 0d8505ffbc8..1b062c3d05f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
@@ -130,6 +130,19 @@ describe("UserInfoView", () => {
expect(aliases.length).toBeGreaterThan(0);
});
+ it("seeds the user rate limits when opening the edit form", async () => {
+ mockUserGetInfoV2.mockResolvedValue({
+ ...MOCK_USER_DATA,
+ tpm_limit: 100000,
+ rpm_limit: null,
+ });
+
+ render();
+
+ expect(await screen.findByRole("spinbutton", { name: /tpm limit/i })).toHaveValue(100000);
+ expect(await screen.findByRole("spinbutton", { name: /rpm limit/i })).toHaveValue(null);
+ });
+
it("should render overview spend and budget with two decimal places", async () => {
mockUserGetInfoV2.mockResolvedValue({
...MOCK_USER_DATA,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
index c95badc587a..2056142b50a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
@@ -341,6 +341,8 @@ export default function UserInfoView({
user_alias: formValues.user_alias ?? userData.user_alias,
models: formValues.models ?? userData.models,
max_budget: formValues.max_budget === undefined ? userData.max_budget : formValues.max_budget,
+ tpm_limit: formValues.tpm_limit === undefined ? userData.tpm_limit : formValues.tpm_limit,
+ rpm_limit: formValues.rpm_limit === undefined ? userData.rpm_limit : formValues.rpm_limit,
budget_duration:
formValues.budget_duration === undefined ? userData.budget_duration : formValues.budget_duration,
metadata: formValues.metadata ?? userData.metadata,
@@ -401,6 +403,8 @@ export default function UserInfoView({
user_role: userData.user_role,
models: userData.models,
max_budget: userData.max_budget,
+ tpm_limit: userData.tpm_limit,
+ rpm_limit: userData.rpm_limit,
budget_duration: userData.budget_duration,
metadata: userData.metadata,
// Without these the per-model budget editor mounts empty and a save