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>
This commit is contained in:
mrinal 2026-10-01 22:40:21 +00:00
parent 0f0ffe5cd3
commit f03cc93c66
6 changed files with 246 additions and 2 deletions

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -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(<UserEditView {...defaultProps} userData={userDataWithRateLimits()} />);
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(<UserEditView {...defaultProps} userData={userDataWithRateLimits()} onSubmit={onSubmit} />);
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(<UserEditView {...defaultProps} userData={userDataWithRateLimits()} onSubmit={onSubmit} />);
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(<UserEditView {...defaultProps} userData={userDataWithRateLimits()} onSubmit={onSubmit} />);
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(<UserEditView {...defaultProps} userData={userDataWithRateLimits()} onSubmit={onSubmit} />);
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(<UserEditView {...defaultProps} userData={userDataWithRateLimits()} isBulkEdit={true} />);
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<Parameters<typeof UserEditView>[0]> = {}) => {
const onSubmit = vi.fn();

View file

@ -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<ReturnType<typeof budgetSchema>>;
@ -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 }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
</FormField>
{!isBulkEdit && (
<>
<FormField
control={form.control}
name="tpm_limit"
label={labelWithHint(
"TPM Limit",
"Applies across all keys owned by this user. Team and key limits still apply as ceilings.",
)}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Unlimited"
/>
)}
</FormField>
<FormField
control={form.control}
name="rpm_limit"
label={labelWithHint(
"RPM Limit",
"Applies across all keys owned by this user. Team and key limits still apply as ceilings.",
)}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Unlimited"
/>
)}
</FormField>
</>
)}
{/* 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 && (

View file

@ -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(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} startInEditMode />);
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,

View file

@ -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