feat(ui): add per-model rate limits to team edit/info views

Exposes the backend's existing model_tpm_limit/model_rpm_limit fields
(which lived in team.metadata) through a new "Model-Specific Rate Limits"
form section on the team Settings tab. Limits round-trip through the
team-update API and render on the Overview card and Settings view.

Model picker is scoped to the team's currently-selected models (unfurls
wildcards, falls back to userModels for all-proxy-models / all-team-models).
This commit is contained in:
Ryan Crabbe 2026-04-04 13:35:14 -07:00
parent 976e4c889d
commit 89f32e1d75
No known key found for this signature in database

View file

@ -17,10 +17,10 @@ import {
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { isProxyAdminRole } from "@/utils/roles";
import { EditOutlined, InfoCircleOutlined, SaveOutlined } from "@ant-design/icons";
import { EditOutlined, InfoCircleOutlined, MinusCircleOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react";
import { Button, Form, Input, message, Select, Switch, Tabs, Tooltip } from "antd";
import { Button, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Tooltip } from "antd";
import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
@ -194,6 +194,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false;
}, [teamData, userOrganizations, userId]);
// Models currently selected in the team edit form, used to scope the per-model
// rate limit dropdown to models this team actually has access to.
const selectedModelsInForm = Form.useWatch("models", form) as string[] | undefined;
const availableRateLimitModels = useMemo(() => {
const selected = selectedModelsInForm ?? teamData?.team_info?.models ?? [];
if (selected.includes("all-proxy-models") || selected.includes("all-team-models")) {
return userModels;
}
return unfurlWildcardModelsInList(selected, userModels);
}, [selectedModelsInForm, teamData, userModels]);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(
@ -466,12 +477,23 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
return v;
};
const modelTpmLimit: Record<string, number> = {};
const modelRpmLimit: Record<string, number> = {};
for (const entry of (values.modelLimits ?? []) as { model?: string; tpm?: number; rpm?: number }[]) {
if (entry?.model) {
if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm;
if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm;
}
}
const updateData: any = {
team_id: teamId,
team_alias: values.team_alias,
models: values.models,
tpm_limit: sanitizeNumeric(values.tpm_limit),
rpm_limit: sanitizeNumeric(values.rpm_limit),
model_tpm_limit: modelTpmLimit,
model_rpm_limit: modelRpmLimit,
max_budget: values.max_budget,
soft_budget: sanitizeNumeric(values.soft_budget),
budget_duration: values.budget_duration,
@ -648,6 +670,22 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Text>TPM: {info.tpm_limit || "Unlimited"}</Text>
<Text>RPM: {info.rpm_limit || "Unlimited"}</Text>
{info.max_parallel_requests && <Text>Max Parallel Requests: {info.max_parallel_requests}</Text>}
{(() => {
const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record<string, number>;
const modelRpm = (info.metadata?.model_rpm_limit ?? {}) as Record<string, number>;
const models = Array.from(new Set([...Object.keys(modelTpm), ...Object.keys(modelRpm)]));
if (models.length === 0) return null;
return (
<div className="mt-3">
<Text className="text-gray-500">Per-model limits:</Text>
{models.map((m) => (
<Text key={m} className="text-xs">
{m}: TPM {modelTpm[m] ?? "—"}, RPM {modelRpm[m] ?? "—"}
</Text>
))}
</div>
);
})()}
</div>
</Card>
@ -793,6 +831,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
models: info.models,
tpm_limit: info.tpm_limit,
rpm_limit: info.rpm_limit,
modelLimits: Array.from(
new Set([
...Object.keys(info.metadata?.model_tpm_limit ?? {}),
...Object.keys(info.metadata?.model_rpm_limit ?? {}),
]),
).map((model) => ({
model,
tpm: info.metadata?.model_tpm_limit?.[model],
rpm: info.metadata?.model_rpm_limit?.[model],
})),
max_budget: info.max_budget,
soft_budget: info.soft_budget,
budget_duration: info.budget_duration,
@ -809,7 +857,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
: "",
metadata: info.metadata
? JSON.stringify(
(({ logging, secret_manager_settings, soft_budget_alerting_emails, ...rest }) => rest)(info.metadata),
(({ logging, secret_manager_settings, soft_budget_alerting_emails, model_tpm_limit, model_rpm_limit, ...rest }) => rest)(info.metadata),
null,
2,
)
@ -934,6 +982,77 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<NumericalInput step={1} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label="Model-Specific Rate Limits"
tooltip="Set per-model TPM/RPM limits that apply across the whole team."
>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, "model"]}
rules={[
{ required: true, message: "Missing model" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all = form.getFieldValue("modelLimits") ?? [];
const dupes = all.filter(
(entry: { model?: string }) => entry?.model === value,
);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate model"));
}
return Promise.resolve();
},
},
]}
style={{ minWidth: 240 }}
>
<Select
showSearch
placeholder="Select model"
allowClear
options={availableRateLimitModels.map((m) => ({
value: m,
label: m,
}))}
/>
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add Model Limit
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
<Form.Item
label={
<span>
@ -1161,6 +1280,22 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Text className="font-medium">Rate Limits</Text>
<div>TPM: {info.tpm_limit || "Unlimited"}</div>
<div>RPM: {info.rpm_limit || "Unlimited"}</div>
{(() => {
const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record<string, number>;
const modelRpm = (info.metadata?.model_rpm_limit ?? {}) as Record<string, number>;
const models = Array.from(new Set([...Object.keys(modelTpm), ...Object.keys(modelRpm)]));
if (models.length === 0) return null;
return (
<div className="mt-2">
<Text className="text-gray-500">Per-model limits:</Text>
{models.map((m) => (
<div key={m} className="text-xs ml-2">
{m}: TPM {modelTpm[m] ?? "—"}, RPM {modelRpm[m] ?? "—"}
</div>
))}
</div>
);
})()}
</div>
<div>
<Text className="font-medium">Team Budget</Text>