From eb65b8f017557698bd3f856df4222c8d61d884ef Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Thu, 5 Nov 2026 21:38:12 -0800 Subject: [PATCH] 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> --- .../components/CreateTagModal.test.tsx | 75 ++++++++++++ .../_components/components/CreateTagModal.tsx | 75 ++++++++---- .../tag-management/_components/index.tsx | 14 ++- .../_components/tagTableColumns.tsx | 2 +- .../_components/tag_info.integration.test.tsx | 108 +++++++++++++++++- .../tag-management/_components/tag_info.tsx | 104 +++++++++++------ .../src/components/tag_management/types.tsx | 10 +- ui/litellm-dashboard/src/i18n/locales/en.json | 20 +++- 8 files changed, 330 insertions(+), 78 deletions(-) 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")} -
+ @@ -157,25 +186,29 @@ const CreateTagModal: React.FC = ({ visible, onCancel, onSu /> )} - -
-

- - ), - }} - /> -

-
+ + {({ ref, value, ...field }) => } + + + + {({ ref, value, ...field }) => } + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx index b0fa159bab1..2ce4c1cd914 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx @@ -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 = ({ 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")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx index c6d9e5d4f62..f3649aa1eaf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx @@ -141,7 +141,7 @@ export const getTagTableColumns = ( cell: ({ row }) => { const description = row.original.description; return ( - + {description || "-"} ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx index 54ac909728c..d3eb7cb05c0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx @@ -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(); + + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx index 6d01c6c6768..017b73ddfe0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx @@ -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; -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): 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; } -const TagEditForm: React.FC = ({ tag, seedBudgetFields, userModels, onCancel, onSave }) => { +const TagEditForm: React.FC = ({ tag, userModels, onCancel, onSave }) => { const { t } = useTranslation(); const [budgetSectionOpen, setBudgetSectionOpen] = useState(false); const form = useZodForm(tagEditSchema, { @@ -64,18 +91,26 @@ const TagEditForm: React.FC = ({ 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 => - onSave(budgetSectionOpen ? values : { ...values, max_budget: undefined, budget_duration: undefined }); + onSave(budgetSectionOpen ? values : withoutBudgetFields(values)); + + const submitIgnoringHiddenBudgetErrors = (errors: FieldErrors): Promise | 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 ( - + {({ ref, ...field }) => } @@ -132,25 +167,25 @@ const TagEditForm: React.FC = ({ tag, seedBudgetFields, userMo )} - -
-

- - ), - }} - /> -

-
+ + {({ ref, value, ...field }) => } + + + + {({ ref, value, ...field }) => } + + @@ -222,9 +257,9 @@ const TagInfoView: React.FC = ({ 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 = ({ tagId, onClose, accessToken, setIsEditing(false)} onSave={handleSave} diff --git a/ui/litellm-dashboard/src/components/tag_management/types.tsx b/ui/litellm-dashboard/src/components/tag_management/types.tsx index dc7c7b544bc..7a856d386d2 100644 --- a/ui/litellm-dashboard/src/components/tag_management/types.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/types.tsx @@ -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; }; } diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 06f4c3b8344..919d6cd25cc 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -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 create a GitHub 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 create a GitHub 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" } } },