-
+
+
+
+
);
}
return (
-
- {(fields, { add, remove }) => (
- <>
- {fields.map(({ key, name: fieldName, ...restField }) => (
-
- {
- if (!value) return Promise.resolve();
- const all: (Partial | undefined)[] = form.getFieldValue(name) ?? [];
- const dupes = all.filter((entry) => entry?.key === value);
- if (dupes.length > 1) {
- return Promise.reject(new Error("Duplicate key"));
- }
- return Promise.resolve();
- },
- },
- ]}
- >
-
-
-
-
-
- remove(fieldName)}
- style={{ color: "#ef4444" }}
- />
-
- ))}
-
-
-
- >
- )}
-
+ <>
+ {fields.map((field, index) => (
+
+ }>
+ {({ ref, value, ...rest }) => (
+
+ )}
+
+ }>
+ {({ ref, value, ...rest }) => (
+
+ )}
+
+
+
+ ))}
+
+ >
);
};
diff --git a/ui/litellm-dashboard/src/components/shared/form/LabelWithHint.tsx b/ui/litellm-dashboard/src/components/shared/form/LabelWithHint.tsx
new file mode 100644
index 00000000000..475351e6272
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/form/LabelWithHint.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { CircleHelp } from "lucide-react";
+import React from "react";
+
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+
+const hintIconClassName = "size-3.5 shrink-0 cursor-help text-muted-foreground";
+
+export const labelWithHint = (label: React.ReactNode, hint: React.ReactNode): React.ReactNode => (
+ <>
+ {label}
+
+ } />
+ {hint}
+
+ >
+);
+
+export const labelWithDocsHint = (label: React.ReactNode, hint: React.ReactNode, href: string): React.ReactNode => (
+ <>
+ {label}
+
+ event.stopPropagation()}>
+
+
+ }
+ />
+ {hint}
+
+ >
+);
diff --git a/ui/litellm-dashboard/src/components/team/GuardrailsSelect.tsx b/ui/litellm-dashboard/src/components/team/GuardrailsSelect.tsx
new file mode 100644
index 00000000000..74d5048d82b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/GuardrailsSelect.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { Globe } from "lucide-react";
+import React, { useState } from "react";
+
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxCollection,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxGroup,
+ ComboboxItem,
+ ComboboxLabel,
+ ComboboxList,
+ ComboboxValue,
+ useComboboxAnchor,
+} from "@/components/ui/combobox";
+
+export interface GuardrailOption {
+ name: string;
+ disabled: boolean;
+}
+
+interface GuardrailGroup {
+ label: string;
+ icon: boolean;
+ items: GuardrailOption[];
+}
+
+interface GuardrailsSelectProps {
+ id?: string;
+ value: string[];
+ onValueChange: (value: string[]) => void;
+ globalGuardrails: readonly GuardrailOption[];
+ otherGuardrails: readonly GuardrailOption[];
+ globalGuardrailNames: ReadonlySet
;
+ placeholder?: string;
+ emptyText?: string;
+}
+
+const matchesQuery = (option: GuardrailOption, query: string): boolean =>
+ option.name.toLowerCase().includes(query.trim().toLowerCase());
+
+export const GuardrailsSelect: React.FC = ({
+ id,
+ value,
+ onValueChange,
+ globalGuardrails,
+ otherGuardrails,
+ globalGuardrailNames,
+ placeholder = "Select guardrails",
+ emptyText = "No guardrails found",
+}) => {
+ const anchor = useComboboxAnchor();
+ const [query, setQuery] = useState("");
+
+ const known = [...globalGuardrails, ...otherGuardrails];
+ const selected = value.map((name) => known.find((option) => option.name === name) ?? { name, disabled: false });
+ const grouped = globalGuardrails.length > 0 && otherGuardrails.length > 0;
+ const groups: GuardrailGroup[] = grouped
+ ? [
+ { label: "Global", icon: true, items: [...globalGuardrails] },
+ { label: "Other", icon: false, items: [...otherGuardrails] },
+ ]
+ : [{ label: "", icon: false, items: known }];
+
+ return (
+ {
+ setQuery("");
+ onValueChange(next.map((option) => option.name));
+ }}
+ inputValue={query}
+ onInputValueChange={setQuery}
+ isItemEqualToValue={(option: GuardrailOption, other: GuardrailOption) => option.name === other.name}
+ itemToStringLabel={(option: GuardrailOption) => option.name}
+ filter={matchesQuery}
+ openOnInputClick
+ >
+ } className="min-h-8 py-1 text-sm">
+
+ {(chips: GuardrailOption[]) => (
+ <>
+ {chips.map((option) => (
+
+ {globalGuardrailNames.has(option.name) && }
+ {option.name}
+
+ ))}
+
+ >
+ )}
+
+
+
+ {emptyText}
+
+ {(group: GuardrailGroup) => (
+
+ {group.label !== "" && (
+
+ {group.icon ? : null}
+ {group.label}
+
+ )}
+
+ {(option: GuardrailOption) => (
+
+ {option.name}
+
+ )}
+
+
+ )}
+
+
+
+ );
+};
+
+export default GuardrailsSelect;
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 9df13a982ed..aeba85088bb 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -122,7 +122,9 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
{JSON.stringify(initialModelAliases)}
-
+
)),
}));
@@ -658,7 +660,7 @@ describe("TeamInfoView", () => {
});
describe("settings and editing", () => {
- const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "span" });
+ const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "label" });
it("should offer the policies field and load it for a caller with the viewPolicies capability", async () => {
const user = userEvent.setup({ delay: null });
@@ -1462,8 +1464,7 @@ describe("TeamInfoView", () => {
await user.click(screen.getByLabelText(/^Guardrails/));
const listbox = await screen.findByRole("listbox", {}, { timeout: 5000 });
- // eslint-disable-next-line local/no-antd-class-selectors -- antd renders group headers outside the listbox and its popup container exposes no role or accessible name
- return listbox.closest(".ant-select-dropdown") as HTMLElement;
+ return listbox.closest('[data-slot="combobox-content"]') as HTMLElement;
};
beforeEach(() => {
@@ -1660,3 +1661,310 @@ describe("TeamInfoView - which team member fields reach the update payload depen
expect(payload.object_permission).toHaveProperty("search_tools");
});
});
+
+describe("TeamInfoView - the exact bytes the update call sends", () => {
+ const props = {
+ teamId: "123",
+ onUpdate: vi.fn(),
+ onClose: vi.fn(),
+ accessToken: "test-token",
+ is_team_admin: true,
+ is_proxy_admin: true,
+ userModels: ["gpt-4"],
+ editTeam: false,
+ };
+
+ beforeEach(seedDefaultMocks);
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const storedTeam = () =>
+ createMockTeamData({
+ models: ["gpt-4"],
+ max_budget: 100,
+ budget_duration: "1d",
+ tpm_limit: 1000,
+ rpm_limit: 1000,
+ team_member_budget_table: { max_budget: 42, budget_duration: "30d", tpm_limit: 11, rpm_limit: 22 },
+ default_team_member_models: ["gpt-4"],
+ object_permission: { search_tools: ["tool-a"], vector_stores: ["vs-1"] },
+ });
+
+ const openEditor = async (user: ReturnType) => {
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(storedTeam());
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+ };
+
+ const save = async (user: ReturnType) => {
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ return vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record;
+ };
+
+ const wireBody = (payload: Record) => JSON.parse(JSON.stringify(payload)) as Record;
+
+ const alwaysSent = {
+ team_id: "123",
+ team_alias: "Test Team",
+ models: ["gpt-4"],
+ tpm_limit: 1000,
+ rpm_limit: 1000,
+ model_tpm_limit: {},
+ model_rpm_limit: {},
+ max_budget: 100,
+ soft_budget: null,
+ budget_duration: "1d",
+ metadata: {
+ allowed_passthrough_routes: [],
+ guardrails: [],
+ opted_out_global_guardrails: [],
+ disable_global_guardrails: false,
+ soft_budget_alerting_emails: [],
+ },
+ access_group_ids: [],
+ };
+
+ const mcpPermissions = {
+ mcp_servers: [],
+ mcp_access_groups: [],
+ mcp_tool_permissions: {},
+ mcp_toolsets: [],
+ vector_stores: ["vs-1"],
+ };
+
+ it("leaves every team member key out of the request body for an untouched save with both sections closed", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ const payload = await save(user);
+
+ expect(payload).toStrictEqual({
+ ...alwaysSent,
+ team_member_budget_duration: undefined,
+ object_permission: mcpPermissions,
+ });
+ expect(wireBody(payload)).toStrictEqual({
+ ...alwaysSent,
+ object_permission: mcpPermissions,
+ });
+ });
+
+ it("resends every stored value once both sections are opened", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(screen.getByText("Team Member Settings"));
+ await screen.findByLabelText("Default Budget (USD)");
+ await user.click(screen.getByText("Search Tool Settings"));
+ await screen.findByPlaceholderText("Select search tools (optional, empty = all allowed)");
+
+ const payload = await save(user);
+
+ const expected = {
+ ...alwaysSent,
+ team_member_budget_duration: "30d",
+ team_member_budget: 42,
+ team_member_tpm_limit: 11,
+ team_member_rpm_limit: 22,
+ default_team_member_models: ["gpt-4"],
+ object_permission: { ...mcpPermissions, search_tools: ["tool-a"] },
+ };
+ expect(payload).toStrictEqual(expected);
+ expect(wireBody(payload)).toStrictEqual(expected);
+ });
+
+ it("carries every typed value to the update payload at the type and shape antd sends today", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ const alias = screen.getByLabelText("Team Name");
+ await user.clear(alias);
+ await user.type(alias, "Renamed Team");
+
+ const softBudget = screen.getByLabelText("Soft Budget (USD)");
+ await user.clear(softBudget);
+ await user.type(softBudget, "9.5");
+
+ const emails = screen.getByLabelText(/Soft Budget Alerting Emails/);
+ await user.clear(emails);
+ await user.type(emails, "a@test.com, b@test.com ");
+
+ const tpm = screen.getByLabelText("Tokens per minute Limit (TPM)");
+ await user.clear(tpm);
+ await user.type(tpm, "555");
+
+ const payload = await save(user);
+
+ expect(payload.team_alias).toBe("Renamed Team");
+ expect(payload.soft_budget).toBe("9.5");
+ expect(payload.tpm_limit).toBe("555");
+ expect((payload.metadata as Record).soft_budget_alerting_emails).toStrictEqual([
+ "a@test.com",
+ "b@test.com",
+ ]);
+ });
+
+ it("builds model_tpm_limit and model_rpm_limit from the model-specific rate limit rows", async () => {
+ const user = userEvent.setup({ delay: null });
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ models: ["gpt-4"],
+ max_budget: 100,
+ budget_duration: "1d",
+ tpm_limit: 1000,
+ rpm_limit: 1000,
+ object_permission: { vector_stores: ["vs-1"] },
+ metadata: { model_tpm_limit: { "gpt-4": 30 }, model_rpm_limit: { "gpt-4": 40 } },
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+
+ const payload = await save(user);
+
+ expect(payload.model_tpm_limit).toStrictEqual({ "gpt-4": 30 });
+ expect(payload.model_rpm_limit).toStrictEqual({ "gpt-4": 40 });
+ });
+
+ it("keeps a team member budget edited before the section is collapsed and resends it on reopen", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(screen.getByText("Team Member Settings"));
+ const budgetInput = await screen.findByLabelText("Default Budget (USD)");
+ await user.clear(budgetInput);
+ await user.type(budgetInput, "77");
+
+ await user.click(screen.getByText("Team Member Settings"));
+ await waitFor(() => expect(screen.queryByLabelText("Default Budget (USD)")).not.toBeInTheDocument());
+
+ await user.click(screen.getByText("Team Member Settings"));
+ expect(await screen.findByLabelText("Default Budget (USD)")).toHaveValue(77);
+
+ const payload = await save(user);
+ expect(payload.team_member_budget).toBe(77);
+ });
+
+ it("sends no team member key at all when the section is collapsed again after an edit", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(screen.getByText("Team Member Settings"));
+ const budgetInput = await screen.findByLabelText("Default Budget (USD)");
+ await user.clear(budgetInput);
+ await user.type(budgetInput, "77");
+
+ await user.click(screen.getByText("Team Member Settings"));
+ await waitFor(() => expect(screen.queryByLabelText("Default Budget (USD)")).not.toBeInTheDocument());
+
+ const payload = await save(user);
+
+ expect(Object.keys(wireBody(payload)).filter((key) => key.startsWith("team_member"))).toEqual([]);
+ expect(wireBody(payload)).not.toHaveProperty("default_team_member_models");
+ });
+
+ it("puts the global guardrails back on the team when the kill switch is turned off again", async () => {
+ const user = userEvent.setup({ delay: null });
+ testQueryClient.clear();
+ vi.mocked(networking.getGuardrailsList).mockResolvedValue({
+ guardrails: [
+ { guardrail_name: "always-on", litellm_params: { default_on: true } },
+ { guardrail_name: "opt-in", litellm_params: { default_on: false } },
+ ],
+ });
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ models: ["gpt-4"],
+ metadata: { guardrails: ["opt-in"], disable_global_guardrails: true },
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+
+ expect(screen.queryAllByLabelText("always-on")).toHaveLength(0);
+
+ await user.click(screen.getByRole("switch", { name: /Disable all global guardrails/ }));
+
+ expect(await screen.findAllByLabelText("always-on")).toHaveLength(1);
+
+ const payload = await save(user);
+
+ expect(payload.metadata).toStrictEqual(
+ expect.objectContaining({
+ guardrails: ["opt-in"],
+ opted_out_global_guardrails: [],
+ disable_global_guardrails: false,
+ }),
+ );
+ });
+
+ it("sends a typed model rate limit as a number", async () => {
+ const user = userEvent.setup({ delay: null });
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ models: ["gpt-4"], metadata: { model_tpm_limit: { "gpt-4": 30 } } }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+
+ const rpmInput = await screen.findByPlaceholderText("RPM Limit");
+ await user.clear(rpmInput);
+ await user.type(rpmInput, "45");
+
+ const payload = await save(user);
+
+ expect(payload.model_rpm_limit).toStrictEqual({ "gpt-4": 45 });
+ expect(payload.model_tpm_limit).toStrictEqual({ "gpt-4": 30 });
+ });
+
+ it("leaves stored policies out of the update body for a caller without the viewPolicies capability", async () => {
+ const user = userEvent.setup({ delay: null });
+ can.mockReturnValue(false);
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"], policies: ["pci"] }));
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+
+ const payload = await save(user);
+
+ expect(payload).toHaveProperty("team_alias");
+ expect(wireBody(payload)).not.toHaveProperty("policies");
+ });
+
+ it("blocks the save on an empty team name and names the rule", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.clear(screen.getByLabelText("Team Name"));
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ expect(await screen.findByText("Please input a team name")).toBeInTheDocument();
+ expect(networking.teamUpdateCall).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 0f1d22e5b9a..c20f47a155b 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -20,24 +20,32 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import type { ObjectPermission } from "@/components/object_permission_types";
import { isProxyAdminRole } from "@/utils/roles";
-import {
- EditOutlined,
- GlobalOutlined,
- InfoCircleOutlined,
- MinusCircleOutlined,
- PlusOutlined,
- SaveOutlined,
-} from "@ant-design/icons";
+import { EditOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input";
-import { Button, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tag, Tooltip } from "antd";
+import { Button as UIButton } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
+import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field";
+import { FormField } from "@/components/shared/form/FormField";
+import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint";
+import { MultiSelect } from "@/components/shared/MultiSelect";
+import { SearchSelect } from "@/components/shared/SearchSelect";
+import { useZodForm } from "@/lib/forms/useZodForm";
+import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
+import { Button, Tabs, Tooltip } from "antd";
import { toast } from "@/lib/toast";
-import { CheckIcon, ChevronDown, CopyIcon } from "lucide-react";
+import { CheckIcon, ChevronDown, CircleMinus, CopyIcon, Plus, Save } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
+import { useFieldArray } from "react-hook-form";
+import { z } from "zod/v4";
+import GuardrailsSelect from "./GuardrailsSelect";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
@@ -49,6 +57,7 @@ import {
} from "./teamModelAccess";
import MetadataKeyValueFields, {
metadataObjectToPairs,
+ metadataPairsSchema,
metadataPairsToObject,
} from "../common_components/MetadataKeyValueFields";
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
@@ -63,7 +72,7 @@ import LoggingSettingsView from "../logging_settings_view";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import { ModelSelect } from "../ModelSelect/ModelSelect";
-import { estimateRules, estimateTooltips } from "../templates/estimatedOutputTokens";
+import { estimateChecks, estimateTooltips } from "../templates/estimatedOutputTokens";
import ObjectPermissionsView from "../object_permissions_view";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
@@ -182,6 +191,201 @@ export interface TeamInfoProps {
premiumUser?: boolean;
}
+const SUPPRESSED_BY_DESCRIPTION = "";
+
+const numericInputSchema = z.union([z.string(), z.number()]).nullish();
+
+const teamUpdateFieldsSchema = z.object({
+ team_alias: z.string().min(1, "Please input a team name"),
+ models: z.array(z.string()).optional(),
+ max_budget: numericInputSchema,
+ soft_budget: numericInputSchema,
+ soft_budget_alerting_emails: z.union([z.string(), z.array(z.string())]).optional(),
+ default_team_member_models: z.array(z.string()).optional(),
+ team_member_budget: numericInputSchema,
+ team_member_budget_duration: z.string().nullish(),
+ team_member_key_duration: z.string().optional(),
+ team_member_tpm_limit: numericInputSchema,
+ team_member_rpm_limit: numericInputSchema,
+ budget_duration: z.string().nullish(),
+ tpm_limit: numericInputSchema,
+ rpm_limit: numericInputSchema,
+ modelLimits: z
+ .array(
+ z.object({
+ model: z.string().min(1, "Missing model"),
+ tpm: z.number().nullish(),
+ rpm: z.number().nullish(),
+ }),
+ )
+ .superRefine((rows, ctx) => {
+ rows.forEach((row, index) => {
+ if (row.model && rows.filter((other) => other.model === row.model).length > 1) {
+ ctx.addIssue({ code: "custom", message: "Duplicate model", path: [index, "model"] });
+ }
+ if (row.model && row.tpm == null && row.rpm == null) {
+ ctx.addIssue({ code: "custom", message: "Set at least one of TPM or RPM", path: [index, "tpm"] });
+ }
+ });
+ }),
+ default_estimated_output_tokens: numericInputSchema.refine(
+ estimateChecks.positive.isValid,
+ estimateChecks.positive.message,
+ ),
+ default_estimated_output_tokens_per_model: z
+ .string()
+ .optional()
+ .refine(estimateChecks.perModel.isValid, estimateChecks.perModel.message),
+ guardrails: z.array(z.string()).optional(),
+ disable_global_guardrails: z.boolean().optional(),
+ policies: z.array(z.string()).optional(),
+ access_group_ids: z.array(z.string()).optional(),
+ vector_stores: z.array(z.string()).optional(),
+ allowed_passthrough_routes: z.array(z.string()).optional(),
+ mcp_servers_and_groups: z
+ .object({
+ servers: z.array(z.string()),
+ accessGroups: z.array(z.string()),
+ toolsets: z.array(z.string()).optional(),
+ })
+ .optional(),
+ mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
+ agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
+ object_permission_search_tools: z.array(z.string()).optional(),
+ organization_id: z.string().nullish(),
+ logging_settings: z.array(z.unknown()).optional(),
+ secret_manager_settings: z.string().optional(),
+ metadata: metadataPairsSchema.optional(),
+});
+
+type TeamUpdateFormValues = z.infer;
+
+type TeamInfoRecord = TeamData["team_info"] & { team_member_key_duration?: string };
+
+const TEAM_MEMBER_SETTINGS_FIELDS = [
+ "default_team_member_models",
+ "team_member_budget",
+ "team_member_budget_duration",
+ "team_member_key_duration",
+ "team_member_tpm_limit",
+ "team_member_rpm_limit",
+] as const;
+const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const;
+
+const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
+ team_alias: "",
+ models: [],
+ max_budget: undefined,
+ soft_budget: undefined,
+ soft_budget_alerting_emails: "",
+ default_team_member_models: [],
+ team_member_budget: undefined,
+ team_member_budget_duration: undefined,
+ team_member_key_duration: undefined,
+ team_member_tpm_limit: undefined,
+ team_member_rpm_limit: undefined,
+ budget_duration: undefined,
+ tpm_limit: undefined,
+ rpm_limit: undefined,
+ modelLimits: [],
+ default_estimated_output_tokens: undefined,
+ default_estimated_output_tokens_per_model: "",
+ guardrails: [],
+ disable_global_guardrails: false,
+ policies: [],
+ access_group_ids: [],
+ vector_stores: [],
+ allowed_passthrough_routes: [],
+ mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
+ mcp_tool_permissions: {},
+ agents_and_groups: { agents: [], accessGroups: [] },
+ object_permission_search_tools: [],
+ organization_id: null,
+ logging_settings: [],
+ secret_manager_settings: "",
+ metadata: [],
+};
+
+const computeEffectiveGuardrails = (info: TeamInfoRecord, globalGuardrailNames: ReadonlySet): string[] => {
+ const optedOutGlobals = new Set(
+ Array.isArray(info.metadata?.opted_out_global_guardrails) ? info.metadata.opted_out_global_guardrails : [],
+ );
+ const nonGlobalOptIns: string[] = (Array.isArray(info.metadata?.guardrails) ? info.metadata.guardrails : []).filter(
+ (name: string) => !globalGuardrailNames.has(name),
+ );
+ return info.metadata?.disable_global_guardrails === true
+ ? nonGlobalOptIns
+ : [...Array.from(globalGuardrailNames).filter((name) => !optedOutGlobals.has(name)), ...nonGlobalOptIns];
+};
+
+const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]): TeamUpdateFormValues => ({
+ team_alias: info.team_alias,
+ models: info.models,
+ max_budget: info.max_budget,
+ soft_budget: info.soft_budget,
+ soft_budget_alerting_emails: Array.isArray(info.metadata?.soft_budget_alerting_emails)
+ ? info.metadata.soft_budget_alerting_emails.join(", ")
+ : "",
+ default_team_member_models: info.default_team_member_models || [],
+ team_member_budget: info.team_member_budget_table?.max_budget,
+ team_member_budget_duration: info.team_member_budget_table?.budget_duration,
+ team_member_key_duration: info.team_member_key_duration,
+ team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
+ team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
+ budget_duration: info.budget_duration,
+ 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],
+ })),
+ default_estimated_output_tokens: info.metadata?.default_estimated_output_tokens,
+ default_estimated_output_tokens_per_model: info.metadata?.default_estimated_output_tokens_per_model
+ ? JSON.stringify(info.metadata.default_estimated_output_tokens_per_model)
+ : "",
+ guardrails: effectiveGuardrails,
+ disable_global_guardrails: info.metadata?.disable_global_guardrails || false,
+ policies: info.policies || [],
+ access_group_ids: info.access_group_ids || [],
+ vector_stores: info.object_permission?.vector_stores || [],
+ allowed_passthrough_routes: info.metadata?.allowed_passthrough_routes || [],
+ mcp_servers_and_groups: {
+ servers: info.object_permission?.mcp_servers || [],
+ accessGroups: info.object_permission?.mcp_access_groups || [],
+ toolsets: info.object_permission?.mcp_toolsets || [],
+ },
+ mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {},
+ agents_and_groups: {
+ agents: info.object_permission?.agents || [],
+ accessGroups: info.object_permission?.agent_access_groups || [],
+ },
+ object_permission_search_tools: info.object_permission?.search_tools || [],
+ organization_id: info.organization_id,
+ logging_settings: info.metadata?.logging || [],
+ secret_manager_settings: info.metadata?.secret_manager_settings
+ ? JSON.stringify(info.metadata.secret_manager_settings, null, 2)
+ : "",
+ metadata: metadataObjectToPairs(info.metadata, UI_MANAGED_METADATA_KEYS),
+});
+
+const isParsableJson = (value: string | undefined): boolean => {
+ if (!value) {
+ return true;
+ }
+ try {
+ JSON.parse(value);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
const TeamInfoView: React.FC = ({
teamId,
onClose,
@@ -194,10 +398,26 @@ const TeamInfoView: React.FC = ({
premiumUser = false,
onUpdate,
}) => {
+ const teamUpdateSchema = useMemo(
+ () =>
+ teamUpdateFieldsSchema.superRefine((values, ctx) => {
+ if (!isParsableJson(values.secret_manager_settings)) {
+ ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["secret_manager_settings"] });
+ }
+ }),
+ [],
+ );
const [teamData, setTeamData] = useState(null);
const [loading, setLoading] = useState(true);
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
- const [form] = Form.useForm();
+ const form = useZodForm(teamUpdateSchema, { defaultValues: EMPTY_TEAM_UPDATE_VALUES });
+ const {
+ fields: modelLimitRows,
+ append: appendModelLimit,
+ remove: removeModelLimit,
+ } = useFieldArray({ control: form.control, name: "modelLimits" });
+ const [teamMemberSettingsOpen, setTeamMemberSettingsOpen] = useState(false);
+ const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false);
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
const [selectedEditMember, setSelectedEditMember] = useState(null);
const [isEditing, setIsEditing] = useState(false);
@@ -232,15 +452,17 @@ const TeamInfoView: React.FC = ({
// 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 killSwitchOn = Form.useWatch("disable_global_guardrails", form) as boolean | undefined;
+ const watchedModels = form.watch("models");
+ const killSwitchOn = form.watch("disable_global_guardrails");
+ const watchedMcpSelection = form.watch("mcp_servers_and_groups");
+ const watchedToolPermissions = form.watch("mcp_tool_permissions");
const availableRateLimitModels = useMemo(() => {
- const selected = selectedModelsInForm ?? teamData?.team_info?.models ?? [];
+ const selected = watchedModels ?? teamData?.team_info?.models ?? [];
if (selected.includes("all-proxy-models") || selected.includes("all-team-models")) {
return userModels;
}
return unfurlWildcardModelsInList(selected, userModels);
- }, [selectedModelsInForm, teamData, userModels]);
+ }, [watchedModels, teamData, userModels]);
const isTeamAdminFromTeamData = useMemo(
() =>
@@ -254,6 +476,37 @@ const TeamInfoView: React.FC = ({
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
+ const teamFormValues = (): TeamUpdateFormValues => {
+ const info = teamData?.team_info;
+ return info
+ ? toTeamFormValues(info, computeEffectiveGuardrails(info, globalGuardrailNames))
+ : EMPTY_TEAM_UPDATE_VALUES;
+ };
+
+ const startEditing = () => {
+ form.reset(teamFormValues());
+ setTeamMemberSettingsOpen(false);
+ setSearchToolSettingsOpen(false);
+ setIsEditing(true);
+ };
+
+ const applyKillSwitchToGuardrails = (checked: boolean) => {
+ const current = form.getValues("guardrails") ?? [];
+ const nonGlobals = current.filter((name) => !globalGuardrailNames.has(name));
+ form.setValue("guardrails", checked ? nonGlobals : [...Array.from(globalGuardrailNames), ...nonGlobals]);
+ };
+
+ const mountedUpdateValues = (values: TeamUpdateFormValues): Record => {
+ const unmounted = new Set([
+ ...(teamMemberSettingsOpen ? [] : TEAM_MEMBER_SETTINGS_FIELDS),
+ ...(canViewPolicies ? [] : ["policies"]),
+ ...(searchToolSettingsOpen ? [] : SEARCH_TOOL_SETTINGS_FIELDS),
+ ]);
+ return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key)));
+ };
+
+ const onTeamUpdateSubmit = (values: TeamUpdateFormValues) => handleTeamUpdate(mountedUpdateValues(values));
+
const fetchTeamInfo = async () => {
try {
setLoading(true);
@@ -354,7 +607,7 @@ const TeamInfoView: React.FC = ({
toast.success("Team member added successfully");
setIsAddMemberModalVisible(false);
- form.resetFields();
+ form.reset(teamFormValues());
// Fetch updated team info
const updatedTeamData = await teamInfoCall(accessToken, teamId);
@@ -678,47 +931,11 @@ const TeamInfoView: React.FC = ({
const { team_info: info } = teamData;
const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true;
- const optedOutGlobals = new Set(
- Array.isArray(info.metadata?.opted_out_global_guardrails) ? info.metadata.opted_out_global_guardrails : [],
- );
- const nonGlobalOptIns: string[] = (Array.isArray(info.metadata?.guardrails) ? info.metadata.guardrails : []).filter(
- (n: string) => !globalGuardrailNames.has(n),
- );
- const effectiveGuardrails: string[] = initialKillSwitchOn
- ? nonGlobalOptIns
- : [...Array.from(globalGuardrailNames).filter((n) => !optedOutGlobals.has(n)), ...nonGlobalOptIns];
const allGuardrails: GuardrailListItem[] = guardrailsData?.guardrails ?? [];
const globalGuardrails = allGuardrails.filter((g) => g.litellm_params?.default_on);
const otherGuardrails = allGuardrails.filter((g) => !g.litellm_params?.default_on);
- const renderGuardrailOption = (g: GuardrailListItem, disabled: boolean) => (
-
- {g.guardrail_name}
-
- );
-
- const preventTagMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
- };
-
- const renderGuardrailTag = ({ label, value, closable, onClose }: any) => {
- const isGlobal = globalGuardrailNames.has(value);
- return (
-
- {isGlobal && }
- {label}
-
- );
- };
-
const copyToClipboard = async (text: string, key: string) => {
const success = await utilCopyToClipboard(text);
if (success) {
@@ -935,7 +1152,7 @@ const TeamInfoView: React.FC = ({
icon={}
onClick={() => {
setTeamModelAliases(info.litellm_model_table?.model_aliases ?? {});
- setIsEditing(true);
+ startEditing();
}}
>
Edit Settings
@@ -946,600 +1163,608 @@ const TeamInfoView: React.FC = ({
{isEditing && isGuardrailsLoading ? (
Loading...
) : isEditing ? (
-
-
-
+
+
- Access Groups{" "}
-
-
-
-
- }
- name="access_group_ids"
- >
-
-
+
+ {({ id, value, onChange }) => (
+ onChange(next ?? null)}
+ />
+ )}
+
-
- form.setFieldValue("vector_stores", values)}
- value={form.getFieldValue("vector_stores")}
- accessToken={accessToken || ""}
- placeholder="Select vector stores"
- />
-
+
+ {({ ref, value, ...field }) => (
+
+ )}
+
-
-
-
+
+ {({ ref, value, ...field }) => (
+
+ )}
+
-
- form.setFieldValue("mcp_servers_and_groups", val)}
- value={form.getFieldValue("mcp_servers_and_groups")}
- accessToken={accessToken || ""}
- placeholder="Select MCP servers or access groups (optional)"
- allowAllProxyMcpServers={is_proxy_admin}
- />
-
+
+ Metadata
+
+
+ Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {'{"region": "us"}'}
+ .
+
+
- {/* Hidden field to register mcp_tool_permissions with the form */}
-
-
-
+
+
+ {labelWithHint(
+ "Model-Specific Rate Limits",
+ "Set per-model TPM/RPM limits that apply across the whole team.",
+ )}
+
+ {modelLimitRows.map((row, index) => (
+
+
+ {({ id, value, onChange }) => (
+ ({
+ label: model,
+ value: model,
+ }))}
+ placeholder="Select model"
+ />
+ )}
+
+
+ {({ ref, value, onChange, ...field }) => (
+ ) =>
+ onChange(event.target.value === "" ? null : Number(event.target.value))
+ }
+ placeholder="TPM Limit"
+ min={0}
+ step={1}
+ />
+ )}
+
+
+ {({ ref, value, onChange, ...field }) => (
+ ) =>
+ onChange(event.target.value === "" ? null : Number(event.target.value))
+ }
+ placeholder="RPM Limit"
+ min={0}
+ step={1}
+ />
+ )}
+
+ removeModelLimit(index)}
+ >
+
+
+
+ ))}
+ appendModelLimit({ model: "", tpm: null, rpm: null })}
+ >
+
+ Add Model Limit
+
+
+
+
+ {({ ref, value, ...field }) => (
+
+ )}
+
+
+
+ {({ ref, value, ...field }) => (
+
+ )}
+
+
+
+ Router Settings
+
+
+
+
+ {({ id, value, onChange }) => (
+ ({
+ name: g.guardrail_name,
+ disabled: Boolean(killSwitchOn),
+ }))}
+ otherGuardrails={otherGuardrails.map((g) => ({
+ name: g.guardrail_name,
+ disabled: false,
+ }))}
+ globalGuardrailNames={globalGuardrailNames}
+ />
+ )}
+
+
+
+ {({ id, value, onChange }) => (
+ {
+ onChange(checked);
+ applyKillSwitchToGuardrails(checked);
+ }}
+ />
+ )}
+
+
+ {canViewPolicies && (
+
+ {({ id, value, onChange }) => (
+ ({ value: name, label: name }))}
+ placeholder="Select or enter policies"
+ />
+ )}
+
+ )}
+
+
+ {({ value, onChange }) => (
+
+ )}
+
+
+
+ {({ value, onChange }) => (
+
+ )}
+
+
+
+ {({ value, onChange }) => (
+
+ )}
+
+
+
+ {({ value, onChange }) => (
+
+ )}
+
-
- prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups ||
- prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
- }
- >
- {() => (
form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
+ selectedServers={watchedMcpSelection?.servers || []}
+ toolPermissions={watchedToolPermissions || {}}
+ onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
/>
- )}
-
-
- form.setFieldValue("agents_and_groups", val)}
- value={form.getFieldValue("agents_and_groups")}
- accessToken={accessToken || ""}
- placeholder="Select agents or access groups (optional)"
- />
-
+
+ {({ value, onChange }) => (
+
+ )}
+
-
-
- Search Tool Settings
-
-
-
-
- form.setFieldValue("object_permission_search_tools", vals)}
- value={form.getFieldValue("object_permission_search_tools")}
- accessToken={accessToken || ""}
- placeholder="Select search tools (optional, empty = all allowed)"
- />
-
-
-
+
+ Search Tool Settings
+
+
+
+
+ {({ value, onChange }) => (
+
+ )}
+
+
+
-
-
+
+ {({ id, value, onChange }) => (
+ onChange(next === "" ? null : next)}
+ options={userOrganizations.map((org) => ({
+ value: org.organization_id ?? "",
+ label: org.organization_alias || org.organization_id || "",
+ }))}
+ placeholder="Select an organization"
+ emptyText="No matching organizations"
+ />
+ )}
+
-
- form.setFieldValue("logging_settings", values)}
- />
-
+
+ {({ value, onChange }) => (
+
+ )}
+
- {
- if (!value) {
- return Promise.resolve();
- }
- try {
- JSON.parse(value);
- return Promise.resolve();
- } catch (error) {
- return Promise.reject(new Error("Please enter valid JSON"));
- }
- },
- },
- ]}
- >
-
-
-
-
-
-
-
}
- type="primary"
- htmlType="submit"
- loading={isTeamSaving}
+
- Save Changes
-
+ {({ ref, value, ...field }) => (
+
+ )}
+
+
+
+
+
+ setIsEditing(false)}
+ disabled={isTeamSaving}
+ >
+ Cancel
+
+
+ {isTeamSaving ? : }
+ Save Changes
+
+
-
-
+
+
) : (