diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index d2ce79580da..23f5f18cab9 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = ({ 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 = ({ return v; }; + const modelTpmLimit: Record = {}; + const modelRpmLimit: Record = {}; + 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 = ({ TPM: {info.tpm_limit || "Unlimited"} RPM: {info.rpm_limit || "Unlimited"} {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} + {(() => { + const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record; + const modelRpm = (info.metadata?.model_rpm_limit ?? {}) as Record; + const models = Array.from(new Set([...Object.keys(modelTpm), ...Object.keys(modelRpm)])); + if (models.length === 0) return null; + return ( +
+ Per-model limits: + {models.map((m) => ( + + {m}: TPM {modelTpm[m] ?? "—"}, RPM {modelRpm[m] ?? "—"} + + ))} +
+ ); + })()} @@ -793,6 +831,16 @@ const TeamInfoView: React.FC = ({ 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 = ({ : "", 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 = ({ + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + { + 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 }} + > +