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" } } },