mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
feat(ui): set tpm_limit and rpm_limit on tags from the Admin UI (#50314)
* feat(ui): let admins set tpm_limit and rpm_limit on tags The tag create modal and edit form said tag TPM/RPM limits were unsupported and never sent them, while the proxy has enforced tag-level rpm_limit and tpm_limit since #41807. Replace the notice with TPM Limit and RPM Limit inputs wired to /tag/new and /tag/update, validated as nonnegative integers, with a cleared limit sent as null so the proxy drops it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): prefill tag budget and limit fields from the Edit Tag button The edit form only seeded max_budget and budget_duration when opened from the row's Edit action, so the Edit Tag button inside the tag view showed empty budget and limit inputs and a cleared field could not be sent as null. Seed from the loaded tag on both paths Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): accept null budget fields when editing a tag A tag whose budget row has max_budget or a limit set to null failed the edit form's zod schema with 'expected string, received null' once the fields were prefilled. Treat null like an empty input and type Tag's budget fields as nullable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): accept a null description when editing a tag A tag created without a description comes back from /tag/info with description null, which the edit form's zod schema rejected with 'expected string, received null' on save. Allow null and type Tag.description as nullable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): ignore invalid limits left in a collapsed tag budget section An invalid value typed in Budget & Rate Limits and then hidden by collapsing the section blocked Create Tag and Save Changes with the error out of sight. When the section is collapsed and the only errors are on its fields, drop them and submit the visible values, which already discards the hidden budget fields Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): name the expected payload so the inline-object lint budget holds Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f82e4c80ac
commit
eb65b8f017
8 changed files with 330 additions and 78 deletions
|
|
@ -61,4 +61,79 @@ describe("CreateTagModal", () => {
|
|||
// Form validation should prevent submission
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits TPM and RPM limits typed in the budget section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tag Name"), { target: { value: "limited-tag" } });
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
fireEvent.change(await screen.findByLabelText("TPM Limit"), { target: { value: "50000" } });
|
||||
fireEvent.change(screen.getByLabelText("RPM Limit"), { target: { value: "120" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Create Tag/i }));
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tag_name: "limited-tag", tpm_limit: "50000", rpm_limit: "120" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops TPM and RPM limits typed in a budget section that was collapsed again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tag Name"), { target: { value: "limited-tag" } });
|
||||
const toggle = () => screen.getByRole("button", { name: /Budget & Rate Limits/ });
|
||||
await user.click(toggle());
|
||||
fireEvent.change(await screen.findByLabelText("RPM Limit"), { target: { value: "120" } });
|
||||
await user.click(toggle());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Create Tag/i }));
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tag_name: "limited-tag", rpm_limit: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a fractional TPM limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tag Name"), { target: { value: "limited-tag" } });
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
fireEvent.change(await screen.findByLabelText("TPM Limit"), { target: { value: "2.5" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Create Tag/i }));
|
||||
|
||||
expect(await screen.findByText("Enter a whole number of 0 or more")).toBeInTheDocument();
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no longer tells users that tag rate limits are unsupported", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
await screen.findByLabelText("TPM Limit");
|
||||
|
||||
expect(screen.queryByText(/not currently supported/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /GitHub issue/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still creates the tag when an invalid limit was left behind in a collapsed budget section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tag Name"), { target: { value: "limited-tag" } });
|
||||
const toggle = () => screen.getByRole("button", { name: /Budget & Rate Limits/ });
|
||||
await user.click(toggle());
|
||||
fireEvent.change(await screen.findByLabelText("TPM Limit"), { target: { value: "-1" } });
|
||||
await user.click(toggle());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Create Tag/i }));
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tag_name: "limited-tag", tpm_limit: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { ChevronRight, CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FieldErrors } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
|
|
@ -38,16 +39,38 @@ interface ModelInfo {
|
|||
};
|
||||
}
|
||||
|
||||
const optionalLimit = z
|
||||
.string()
|
||||
.refine((value) => value === "" || (Number.isInteger(Number(value)) && Number(value) >= 0), {
|
||||
error: () => i18n.t("tagManagement.createModal.validation.limitNonnegativeInteger"),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const createTagShape = {
|
||||
tag_name: z.string().min(1, { error: () => i18n.t("tagManagement.createModal.validation.nameRequired") }),
|
||||
description: z.string().optional(),
|
||||
allowed_llms: z.array(z.string()).optional(),
|
||||
max_budget: z.string().optional(),
|
||||
budget_duration: z.string().optional(),
|
||||
tpm_limit: optionalLimit,
|
||||
rpm_limit: optionalLimit,
|
||||
};
|
||||
|
||||
const createTagSchema = z.object(createTagShape);
|
||||
|
||||
const budgetFieldNames = ["max_budget", "budget_duration", "tpm_limit", "rpm_limit"] as const;
|
||||
|
||||
const withoutBudgetFields = (values: CreateTagFormValues): CreateTagFormValues => ({
|
||||
...values,
|
||||
max_budget: undefined,
|
||||
budget_duration: undefined,
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
});
|
||||
|
||||
const onlyBudgetFieldErrors = (errors: FieldErrors<CreateTagFormValues>): boolean =>
|
||||
Object.keys(errors).every((name) => (budgetFieldNames as readonly string[]).includes(name));
|
||||
|
||||
export type CreateTagFormValues = z.output<typeof createTagSchema>;
|
||||
|
||||
interface CreateTagModalProps {
|
||||
|
|
@ -69,11 +92,17 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
}));
|
||||
|
||||
const handleFinish = (values: CreateTagFormValues) => {
|
||||
onSubmit(budgetSectionOpen ? values : { ...values, max_budget: undefined, budget_duration: undefined });
|
||||
onSubmit(budgetSectionOpen ? values : withoutBudgetFields(values));
|
||||
form.reset();
|
||||
setBudgetSectionOpen(false);
|
||||
};
|
||||
|
||||
const handleInvalid = (errors: FieldErrors<CreateTagFormValues>) => {
|
||||
if (budgetSectionOpen || !onlyBudgetFieldErrors(errors)) return;
|
||||
form.clearErrors([...budgetFieldNames]);
|
||||
handleFinish(form.getValues());
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
onCancel();
|
||||
|
|
@ -85,7 +114,7 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
<DialogHeader>
|
||||
<DialogTitle>{t("tagManagement.createModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(handleFinish)} noValidate>
|
||||
<form onSubmit={form.handleSubmit(handleFinish, handleInvalid)} noValidate>
|
||||
<TooltipProvider>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="tag_name" label={t("tagManagement.createModal.fields.name")}>
|
||||
|
|
@ -157,25 +186,29 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="tagManagement.createModal.rateLimitsNotice"
|
||||
components={{
|
||||
issue: (
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tpm_limit"
|
||||
label={labelWithHint(
|
||||
t("tagManagement.createModal.fields.tpmLimit"),
|
||||
t("tagManagement.createModal.fields.tpmLimitHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={1} min={0} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rpm_limit"
|
||||
label={labelWithHint(
|
||||
t("tagManagement.createModal.fields.rpmLimit"),
|
||||
t("tagManagement.createModal.fields.rpmLimitHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={1} min={0} />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Tag } from "@/components/tag_management/types";
|
|||
import TagTable from "./TagTable";
|
||||
import { toast } from "@/lib/toast";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import CreateTagModal from "./components/CreateTagModal";
|
||||
import CreateTagModal, { CreateTagFormValues } from "./components/CreateTagModal";
|
||||
|
||||
interface ModelInfo {
|
||||
model_name: string;
|
||||
|
|
@ -21,6 +21,9 @@ interface ModelInfo {
|
|||
};
|
||||
}
|
||||
|
||||
const numericPayload = (value: string | undefined): number | undefined =>
|
||||
value === undefined || value === "" ? undefined : Number(value);
|
||||
|
||||
interface TagProps {
|
||||
accessToken: string | null;
|
||||
userID: string | null;
|
||||
|
|
@ -62,17 +65,16 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
setLastRefreshed(currentDate.toLocaleString());
|
||||
};
|
||||
|
||||
const handleCreate = async (formValues: any) => {
|
||||
const handleCreate = async (formValues: CreateTagFormValues) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await tagCreateCall(accessToken, {
|
||||
name: formValues.tag_name,
|
||||
description: formValues.description,
|
||||
models: formValues.allowed_llms,
|
||||
max_budget: formValues.max_budget,
|
||||
soft_budget: formValues.soft_budget,
|
||||
tpm_limit: formValues.tpm_limit,
|
||||
rpm_limit: formValues.rpm_limit,
|
||||
max_budget: numericPayload(formValues.max_budget),
|
||||
tpm_limit: numericPayload(formValues.tpm_limit),
|
||||
rpm_limit: numericPayload(formValues.rpm_limit),
|
||||
budget_duration: formValues.budget_duration,
|
||||
});
|
||||
toast.success(t("tagManagement.page.toasts.created"));
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export const getTagTableColumns = (
|
|||
cell: ({ row }) => {
|
||||
const description = row.original.description;
|
||||
return (
|
||||
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description}>
|
||||
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description ?? undefined}>
|
||||
{description || "-"}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ describe("TagInfoView save payload", () => {
|
|||
description: "original description",
|
||||
models: ["model-1", "model-2"],
|
||||
max_budget: 150.75,
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 60,
|
||||
budget_duration: "7d",
|
||||
};
|
||||
|
||||
|
|
@ -163,14 +163,114 @@ describe("TagInfoView save payload", () => {
|
|||
description: "original description",
|
||||
models: ["model-1", "model-2"],
|
||||
max_budget: 150.75,
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 60,
|
||||
budget_duration: "7d",
|
||||
};
|
||||
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expected);
|
||||
});
|
||||
|
||||
it("sends the edited TPM and RPM limits as integers", async () => {
|
||||
const { user } = await renderEditor();
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
expect(await screen.findByLabelText("TPM Limit")).toHaveValue(1000);
|
||||
expect(screen.getByLabelText("RPM Limit")).toHaveValue(60);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("TPM Limit"), { target: { value: "50000" } });
|
||||
fireEvent.change(screen.getByLabelText("RPM Limit"), { target: { value: "120" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
const expected = { tpm_limit: 50000, rpm_limit: 120, max_budget: 10, budget_duration: "7d" };
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("sends null for a cleared TPM limit so the proxy drops it", async () => {
|
||||
const { user } = await renderEditor();
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("TPM Limit"), { target: { value: "" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
expect.objectContaining({ tpm_limit: null, rpm_limit: 60 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a fractional or negative RPM limit", async () => {
|
||||
const { user } = await renderEditor();
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("RPM Limit"), { target: { value: "1.5" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
expect(await screen.findByText("Enter a whole number of 0 or more")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("RPM Limit"), { target: { value: "-1" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
expect(await screen.findByText("Enter a whole number of 0 or more")).toBeInTheDocument();
|
||||
expect(mockTagUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the current limits when editing from the Edit Tag button rather than the row action", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagInfoView tagId="prod-tag" onClose={vi.fn()} accessToken="sk-test" is_admin editTag={false} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Tag" }));
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
expect(await screen.findByLabelText("TPM Limit")).toHaveValue(1000);
|
||||
expect(screen.getByLabelText("RPM Limit")).toHaveValue(60);
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(10);
|
||||
});
|
||||
|
||||
it("saves a tag whose budget row has null fields instead of failing validation on the nulls", async () => {
|
||||
mockTagInfoCall.mockResolvedValue({
|
||||
"prod-tag": {
|
||||
...tag,
|
||||
litellm_budget_table: { max_budget: null, budget_duration: null, tpm_limit: 1000, rpm_limit: null },
|
||||
},
|
||||
});
|
||||
const { user } = await renderEditor();
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("RPM Limit"), { target: { value: "30" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(screen.queryByText(/Invalid input/)).not.toBeInTheDocument();
|
||||
const expected = { max_budget: null, tpm_limit: 1000, rpm_limit: 30, budget_duration: null };
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("saves a tag that has no description instead of failing validation on the null", async () => {
|
||||
mockTagInfoCall.mockResolvedValue({ "prod-tag": { ...tag, description: null } });
|
||||
const { user } = await renderEditor();
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("RPM Limit"), { target: { value: "30" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(screen.queryByText(/Invalid input/)).not.toBeInTheDocument();
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
expect.objectContaining({ description: null, rpm_limit: 30 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still saves when an invalid limit was left behind in a collapsed budget section", async () => {
|
||||
const { user } = await renderEditor();
|
||||
const toggle = () => screen.getByRole("button", { name: /Budget & Rate Limits/ });
|
||||
|
||||
await user.click(toggle());
|
||||
fireEvent.change(await screen.findByLabelText("RPM Limit"), { target: { value: "1.5" } });
|
||||
await user.click(toggle());
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
const expected = { name: "prod-tag", rpm_limit: undefined, tpm_limit: undefined, max_budget: undefined };
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("leaves the tag untouched and returns to the detail view when Cancel is clicked", async () => {
|
||||
const { user } = await renderEditor();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import type { FieldErrors } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { fetchUserModels } from "@/components/organisms/create_key_button";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
|
|
@ -25,38 +26,64 @@ import { useZodForm } from "@/lib/forms/useZodForm";
|
|||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, ChevronRight, CopyIcon } from "lucide-react";
|
||||
|
||||
const isBlank = (value: string | number | null | undefined): value is "" | null | undefined =>
|
||||
value == null || value === "";
|
||||
const isNonnegativeNumber = (value: string | number): boolean => Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||
const isNonnegativeInteger = (value: string | number): boolean => Number.isInteger(Number(value)) && Number(value) >= 0;
|
||||
|
||||
const optionalLimit = z
|
||||
.union([z.string(), z.number()])
|
||||
.nullish()
|
||||
.refine((value) => isBlank(value) || isNonnegativeInteger(value), {
|
||||
error: () => i18n.t("tagManagement.editForm.validation.limitNonnegativeInteger"),
|
||||
});
|
||||
|
||||
const tagEditShape = {
|
||||
name: z.string().min(1, { error: () => i18n.t("tagManagement.editForm.validation.nameRequired") }),
|
||||
description: z.string().optional(),
|
||||
description: z.string().nullish(),
|
||||
models: z.array(z.string()).optional(),
|
||||
max_budget: z
|
||||
.union([z.string(), z.number()])
|
||||
.refine((value) => value === "" || (Number.isFinite(Number(value)) && Number(value) >= 0), {
|
||||
.nullish()
|
||||
.refine((value) => isBlank(value) || isNonnegativeNumber(value), {
|
||||
error: () => i18n.t("tagManagement.editForm.validation.budgetNonnegative"),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
budget_duration: z.string().nullish(),
|
||||
tpm_limit: optionalLimit,
|
||||
rpm_limit: optionalLimit,
|
||||
};
|
||||
|
||||
const tagEditSchema = z.object(tagEditShape);
|
||||
|
||||
type TagEditFormValues = z.output<typeof tagEditSchema>;
|
||||
|
||||
const budgetPayload = (value: TagEditFormValues["max_budget"]): number | null | undefined => {
|
||||
const budgetFieldNames = ["max_budget", "budget_duration", "tpm_limit", "rpm_limit"] as const;
|
||||
|
||||
const withoutBudgetFields = (values: TagEditFormValues): TagEditFormValues => ({
|
||||
...values,
|
||||
max_budget: undefined,
|
||||
budget_duration: undefined,
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
});
|
||||
|
||||
const onlyBudgetFieldErrors = (errors: FieldErrors<TagEditFormValues>): boolean =>
|
||||
Object.keys(errors).every((name) => (budgetFieldNames as readonly string[]).includes(name));
|
||||
|
||||
const numericPayload = (value: string | number | null | undefined): number | null | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "") return null;
|
||||
if (isBlank(value)) return null;
|
||||
return Number(value);
|
||||
};
|
||||
|
||||
interface TagEditFormProps {
|
||||
tag: Tag;
|
||||
seedBudgetFields: boolean;
|
||||
userModels: string[];
|
||||
onCancel: () => void;
|
||||
onSave: (values: TagEditFormValues) => Promise<void>;
|
||||
}
|
||||
|
||||
const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userModels, onCancel, onSave }) => {
|
||||
const TagEditForm: React.FC<TagEditFormProps> = ({ tag, userModels, onCancel, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const [budgetSectionOpen, setBudgetSectionOpen] = useState(false);
|
||||
const form = useZodForm(tagEditSchema, {
|
||||
|
|
@ -64,18 +91,26 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
name: tag.name,
|
||||
description: tag.description,
|
||||
models: tag.models,
|
||||
max_budget: seedBudgetFields ? tag.litellm_budget_table?.max_budget : undefined,
|
||||
budget_duration: seedBudgetFields ? tag.litellm_budget_table?.budget_duration : undefined,
|
||||
max_budget: tag.litellm_budget_table?.max_budget,
|
||||
budget_duration: tag.litellm_budget_table?.budget_duration,
|
||||
tpm_limit: tag.litellm_budget_table?.tpm_limit,
|
||||
rpm_limit: tag.litellm_budget_table?.rpm_limit,
|
||||
},
|
||||
});
|
||||
|
||||
const submitVisibleValues = (values: TagEditFormValues): Promise<void> =>
|
||||
onSave(budgetSectionOpen ? values : { ...values, max_budget: undefined, budget_duration: undefined });
|
||||
onSave(budgetSectionOpen ? values : withoutBudgetFields(values));
|
||||
|
||||
const submitIgnoringHiddenBudgetErrors = (errors: FieldErrors<TagEditFormValues>): Promise<void> | undefined => {
|
||||
if (budgetSectionOpen || !onlyBudgetFieldErrors(errors)) return undefined;
|
||||
form.clearErrors([...budgetFieldNames]);
|
||||
return submitVisibleValues(form.getValues());
|
||||
};
|
||||
|
||||
const modelOptions = userModels.map((modelId) => ({ label: getModelDisplayName(modelId), value: modelId }));
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(submitVisibleValues)} noValidate>
|
||||
<form onSubmit={form.handleSubmit(submitVisibleValues, submitIgnoringHiddenBudgetErrors)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label={t("tagManagement.editForm.fields.name")}>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} />}
|
||||
|
|
@ -132,25 +167,25 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
<BudgetDurationDropdown id={id} value={value ?? null} onChange={onChange} />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="tagManagement.editForm.rateLimitsNotice"
|
||||
components={{
|
||||
issue: (
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tpm_limit"
|
||||
label={t("tagManagement.editForm.fields.tpmLimit")}
|
||||
description={t("tagManagement.editForm.fields.tpmLimitDescription")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={1} min={0} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rpm_limit"
|
||||
label={t("tagManagement.editForm.fields.rpmLimit")}
|
||||
description={t("tagManagement.editForm.fields.rpmLimitDescription")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={1} min={0} />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
|
|
@ -222,9 +257,9 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
name: values.name,
|
||||
description: values.description,
|
||||
models: values.models,
|
||||
max_budget: budgetPayload(values.max_budget),
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
max_budget: numericPayload(values.max_budget),
|
||||
tpm_limit: numericPayload(values.tpm_limit),
|
||||
rpm_limit: numericPayload(values.rpm_limit),
|
||||
budget_duration: values.budget_duration,
|
||||
});
|
||||
toast.success(t("tagManagement.info.toasts.updated"));
|
||||
|
|
@ -279,7 +314,6 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
<CardContent>
|
||||
<TagEditForm
|
||||
tag={tagDetails}
|
||||
seedBudgetFields={editTag}
|
||||
userModels={userModels}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSave={handleSave}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { components } from "@/lib/http/schema";
|
|||
|
||||
export interface Tag {
|
||||
name: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
models: string[]; // model IDs
|
||||
model_info?: { [key: string]: string }; // maps model_id to model_name
|
||||
created_at: string;
|
||||
|
|
@ -10,12 +10,12 @@ export interface Tag {
|
|||
created_by?: string;
|
||||
updated_by?: string;
|
||||
litellm_budget_table?: {
|
||||
max_budget?: number;
|
||||
max_budget?: number | null;
|
||||
soft_budget?: number;
|
||||
tpm_limit?: number;
|
||||
rpm_limit?: number;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
max_parallel_requests?: number;
|
||||
budget_duration?: string;
|
||||
budget_duration?: string | null;
|
||||
model_max_budget?: any;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6694,13 +6694,17 @@
|
|||
"maxBudget": "Max Budget (USD)",
|
||||
"maxBudgetHint": "Maximum amount in USD this tag can spend. When reached, requests with this tag will be blocked",
|
||||
"resetBudget": "Reset Budget",
|
||||
"resetBudgetHint": "How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours"
|
||||
"resetBudgetHint": "How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours",
|
||||
"tpmLimit": "TPM Limit",
|
||||
"tpmLimitHint": "Maximum tokens per minute across every key and team that sends this tag. Requests over the limit get a 429",
|
||||
"rpmLimit": "RPM Limit",
|
||||
"rpmLimitHint": "Maximum requests per minute across every key and team that sends this tag. Requests over the limit get a 429"
|
||||
},
|
||||
"budgetSection": "Budget & Rate Limits (Optional)",
|
||||
"rateLimitsNotice": "TPM/RPM limits for tags are not currently supported. If you need this feature, please <issue>create a GitHub issue</issue>.",
|
||||
"submit": "Create Tag",
|
||||
"validation": {
|
||||
"nameRequired": "Please input a tag name"
|
||||
"nameRequired": "Please input a tag name",
|
||||
"limitNonnegativeInteger": "Enter a whole number of 0 or more"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
|
|
@ -6742,15 +6746,19 @@
|
|||
"maxBudget": "Max Budget (USD)",
|
||||
"maxBudgetDescription": "Maximum amount in USD this tag can spend",
|
||||
"resetBudget": "Reset Budget",
|
||||
"resetBudgetDescription": "How often the budget should reset"
|
||||
"resetBudgetDescription": "How often the budget should reset",
|
||||
"tpmLimit": "TPM Limit",
|
||||
"tpmLimitDescription": "Maximum tokens per minute for this tag, shared across all keys and teams",
|
||||
"rpmLimit": "RPM Limit",
|
||||
"rpmLimitDescription": "Maximum requests per minute for this tag, shared across all keys and teams"
|
||||
},
|
||||
"budgetSection": "Budget & Rate Limits",
|
||||
"rateLimitsNotice": "TPM/RPM limits for tags are not currently supported. If you need this feature, please <issue>create a GitHub issue</issue>.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes",
|
||||
"validation": {
|
||||
"nameRequired": "Please input a tag name",
|
||||
"budgetNonnegative": "Enter a nonnegative budget"
|
||||
"budgetNonnegative": "Enter a nonnegative budget",
|
||||
"limitNonnegativeInteger": "Enter a whole number of 0 or more"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue