mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
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:
parent
0f0ffe5cd3
commit
f03cc93c66
6 changed files with 246 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue