diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.test.tsx
index 42e0ee6e74e..52e2d458767 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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 }),
+ );
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx
index 520cd0e7a3a..1ef098d7eb3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx
@@ -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): boolean =>
+ Object.keys(errors).every((name) => (budgetFieldNames as readonly string[]).includes(name));
+
export type CreateTagFormValues = z.output;
interface CreateTagModalProps {
@@ -69,11 +92,17 @@ const CreateTagModal: React.FC = ({ 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) => {
+ if (budgetSectionOpen || !onlyBudgetFieldErrors(errors)) return;
+ form.clearErrors([...budgetFieldNames]);
+ handleFinish(form.getValues());
+ };
+
const handleCancel = () => {
form.reset();
onCancel();
@@ -85,7 +114,7 @@ const CreateTagModal: React.FC = ({ visible, onCancel, onSu
{t("tagManagement.createModal.title")}
-