From f03cc93c661c63ac4335dfd502319cb02a72dc78 Mon Sep 17 00:00:00 2001 From: mrinal Date: Thu, 1 Oct 2026 22:40:21 +0000 Subject: [PATCH] feat(ui): edit user tpm and rpm limits from the user edit form Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/userRateLimitPayload.test.ts | 47 ++++++++++ .../users/_components/userRateLimitPayload.ts | 17 ++++ .../users/_components/user_edit_view.test.tsx | 92 +++++++++++++++++++ .../users/_components/user_edit_view.tsx | 75 ++++++++++++++- .../view_users/user_info_view.test.tsx | 13 +++ .../_components/view_users/user_info_view.tsx | 4 + 6 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/userRateLimitPayload.ts 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