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:
Kerry Lu 2026-11-05 21:38:12 -08:00 • committed by GitHub
parent f82e4c80ac
commit eb65b8f017
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 330 additions and 78 deletions

View file

@ -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 }),
);
});
});

View file

@ -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>

View file

@ -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"));

View file

@ -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>
);

View file

@ -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();

View file

@ -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}

View file

@ -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;
};
}

View file

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