From 963c7fb0d4a58664f40de75562e423909ccef856 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 23:16:35 -0700 Subject: [PATCH] refactor(ui): port the create key form off antd Form onto react-hook-form (#37442) * refactor(ui): port the create key form off antd Form onto react-hook-form antd hands onFinish exactly the fields mounted at submit time, so a collapsed section contributes nothing to the request while the values typed into it survive for re-expansion. react-hook-form reaches only one of those two behaviours per shouldUnregister setting, so the store is kept intact and projected down to the mounted set through an explicit mount registry. MountedFormField carries the rest of the Form.Item contract the payload depends on: defaults taken from each field's own declaration rather than a blanket empty value, and help text that replaces the rule message instead of sitting beside it. The 60-case submit differential runs unedited against the port, joined by cases for the writers outside the submit path, mounted-set validation, Enter to submit, and switch coercion. * docs(ui): state the mounted projection's static-name limit at its export The registry counts by name and the projection emits flat keys, so a Form.List row and its per-row sub-fields, whose names are generated at runtime, are never in the mounted set and go missing from the payload. That is silent and it is correct for every static field around it, so the contract belongs where the next consumer reads it. * refactor(ui): cut the mounted field's explanatory comments to the contract limit The mechanism the projection uses and the reason a helped field hides its rule message are both derivable from the code, so they belong in the pull request rather than in two places. What survives is the one thing no reader can derive: that a runtime-generated name is silently absent from the payload. * refactor(ui): drop the doc comment from MountedFormField The static-name constraint it described moves to the PR description, where it is not a second place to keep in sync with the code. --- ui/litellm-dashboard/eslint-suppressions.json | 3 - .../common_components/MountedFormField.tsx | 146 ++ .../check_openapi_schema.tsx | 129 +- .../create_key_button.integration.test.tsx | 66 + .../organisms/create_key_button.tsx | 2177 +++++++++-------- 5 files changed, 1458 insertions(+), 1063 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 3f32048cac1..b0a4d258ea8 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2332,9 +2332,6 @@ "local/filename-pascal-case": { "count": 1 }, - "local/no-complex-jsx-arrow": { - "count": 2 - }, "max-lines": { "count": 1 }, diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx new file mode 100644 index 00000000000..8a92873495c --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx @@ -0,0 +1,146 @@ +"use client"; + +import * as React from "react"; +import { + Controller, + type Control, + type ControllerProps, + type RegisterOptions, + type UseFormGetValues, +} from "react-hook-form"; + +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field"; + +export type MountedFormValues = Record; + +export interface MountRegistry { + readonly register: (name: string) => () => void; + readonly mountedNames: () => readonly string[]; +} + +export interface MountedFormContextValue { + readonly control: Control; + readonly registry: MountRegistry; +} + +const missingProvider = (): never => { + throw new Error("MountedFormField requires a MountedFormProvider ancestor"); +}; + +const MountedFormContext = React.createContext({ + get control(): Control { + return missingProvider(); + }, + registry: { + register: missingProvider, + mountedNames: missingProvider, + }, +}); + +export const MountedFormProvider = MountedFormContext.Provider; + +export const useMountRegistry = (): MountRegistry => { + const counts = React.useRef>(new Map()); + return React.useMemo( + () => ({ + register: (name: string) => { + counts.current.set(name, (counts.current.get(name) ?? 0) + 1); + return () => { + const remaining = (counts.current.get(name) ?? 0) - 1; + if (remaining > 0) { + counts.current.set(name, remaining); + } else { + counts.current.delete(name); + } + }; + }, + mountedNames: () => Array.from(counts.current.keys()), + }), + [], + ); +}; + +export const projectMountedValues = ( + registry: MountRegistry, + getValues: UseFormGetValues, +): MountedFormValues => { + const names = [...registry.mountedNames()]; + const values = getValues(names); + return Object.fromEntries(names.map((name, index) => [name, values[index]])); +}; + +export type MountedFieldControlProps = { + readonly id: string; + readonly name: string; + readonly value: unknown; + readonly onChange: (...event: unknown[]) => void; + readonly onBlur: () => void; + readonly "aria-required": "true" | undefined; + readonly "aria-invalid": "true" | undefined; + readonly "aria-describedby": string | undefined; +}; + +export interface MountedFormFieldProps { + readonly name: string; + readonly label?: React.ReactNode; + readonly help?: React.ReactNode; + readonly required?: boolean; + readonly rules?: Omit< + RegisterOptions, + "valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled" + >; + readonly defaultValue?: unknown; + readonly bare?: boolean; + readonly className?: string; + readonly children: (control: MountedFieldControlProps) => React.ReactNode; +} + +export const MountedFormField: React.FC = ({ + name, + label, + help, + required, + rules, + defaultValue, + bare, + className, + children, +}) => { + const { control, registry } = React.useContext(MountedFormContext); + React.useEffect(() => registry.register(name), [registry, name]); + + const helpId = `${name}_help`; + const hasHelp = help !== undefined && help !== null; + + const renderField: ControllerProps["render"] = ({ field, fieldState }) => { + const invalid = fieldState.error !== undefined; + const controlProps: MountedFieldControlProps = { + id: name, + name: field.name, + value: field.value, + onChange: field.onChange, + onBlur: field.onBlur, + "aria-required": required ? "true" : undefined, + "aria-invalid": invalid ? "true" : undefined, + "aria-describedby": hasHelp || invalid ? helpId : undefined, + }; + + if (bare) { + return <>{children(controlProps)}; + } + + return ( + + {label !== undefined && {label}} + {children(controlProps)} + {hasHelp ? ( + {help} + ) : ( + + )} + + ); + }; + + return ; +}; diff --git a/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx b/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx index bbe6f4f46a0..2bcd516eb7b 100644 --- a/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx +++ b/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from "react"; -import { Form, Input as AntdInput, InputNumber, Select } from "antd"; +import { Input as AntdInput, InputNumber, Select } from "antd"; import { Input } from "@/components/ui/input"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Tooltip } from "antd"; +import type { UseFormSetValue } from "react-hook-form"; import { getOpenAPISchema } from "../networking"; import { formatLabel } from "@/utils/textUtils"; +import { MountedFormField, type MountedFormValues } from "./MountedFormField"; interface SchemaProperty { type?: string; @@ -25,13 +27,13 @@ interface OpenAPISchema { interface SchemaFormFieldsProps { schemaComponent: string; excludedFields?: string[]; - form: any; + setValue: UseFormSetValue; overrideLabels?: { [key: string]: string }; overrideTooltips?: { [key: string]: string }; customValidation?: { - [key: string]: (rule: any, value: any) => Promise; + [key: string]: (rule: unknown, value: unknown) => Promise; }; - defaultValues?: { [key: string]: any }; + defaultValues?: { [key: string]: unknown }; } // Define which fields should be parsed as JSON @@ -53,6 +55,10 @@ const validateJSON = (value: string): boolean => { } }; +const isBlank = (value: unknown): boolean => value === undefined || value === null || value === ""; + +const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + const getFieldHelp = (key: string, property: SchemaProperty, type: string): string => { // Default help text based on type const defaultHelp = @@ -99,7 +105,7 @@ const getFieldHelp = (key: string, property: SchemaProperty, type: string): stri const SchemaFormFields: React.FC = ({ schemaComponent, excludedFields = [], - form, + setValue, overrideLabels = {}, overrideTooltips = {}, customValidation = {}, @@ -120,14 +126,11 @@ const SchemaFormFields: React.FC = ({ setSchemaProperties(componentSchema); - const defaultFormValues: { [key: string]: any } = {}; Object.keys(componentSchema.properties) .filter((key) => !excludedFields.includes(key) && defaultValues[key] !== undefined) .forEach((key) => { - defaultFormValues[key] = defaultValues[key]; + setValue(key, defaultValues[key]); }); - - form.setFieldsValue(defaultFormValues); } catch (error) { console.error("Schema fetch error:", error); setError(error instanceof Error ? error.message : "Failed to fetch schema"); @@ -135,7 +138,7 @@ const SchemaFormFields: React.FC = ({ }; fetchOpenAPISchema(); - }, [schemaComponent, form, excludedFields]); + }, [schemaComponent, setValue, excludedFields]); const getPropertyType = (property: SchemaProperty): string => { if (property.type) { @@ -156,22 +159,25 @@ const SchemaFormFields: React.FC = ({ const label = overrideLabels[key] || property.title || formatLabel(key); const tooltip = overrideTooltips[key] || property.description; - const rules = []; - if (isRequired) { - rules.push({ required: true, message: `${label} is required` }); - } - if (customValidation[key]) { - rules.push({ validator: customValidation[key] }); - } - if (isJSONField(key, property)) { - rules.push({ - validator: async (_: any, value: string) => { - if (value && !validateJSON(value)) { - throw new Error("Please enter valid JSON"); + const validate = { + ...(isRequired && { + required: (value: unknown) => (isBlank(value) ? `${label} is required` : true), + }), + ...(customValidation[key] && { + custom: async (value: unknown) => { + try { + await customValidation[key](null, value); + return true; + } catch (thrown) { + return messageOf(thrown); } }, - }); - } + }), + ...(isJSONField(key, property) && { + json: (value: unknown) => + value && !validateJSON(value as string) ? "Please enter valid JSON" : (true as const), + }), + }; const formLabel = tooltip ? ( @@ -184,44 +190,63 @@ const SchemaFormFields: React.FC = ({ label ); - let inputComponent; - if (isJSONField(key, property)) { - inputComponent = ; - } else if (property.enum) { - inputComponent = ( - - ); - } else if (type === "number" || type === "integer") { - inputComponent = ; - } else if (key === "duration") { - inputComponent = ; - } else { - inputComponent = ; - } - return ( - {getFieldHelp(key, property, type)}} + required={isRequired} + rules={Object.keys(validate).length > 0 ? { validate } : undefined} + defaultValue={defaultValues[key]} + help={
{getFieldHelp(key, property, type)}
} > - {inputComponent} -
+ {(control) => { + if (isJSONField(key, property)) { + return ( + + ); + } + if (property.enum) { + return ( + + ); + } + if (type === "number" || type === "integer") { + return ( + + ); + } + if (key === "duration") { + return ( + + ); + } + return ; + }} + ); }; if (error) { - return
Error: {error}
; + return
Error: {error}
; } if (!schemaProperties?.properties) { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index 9788d365fd0..3069d25a18b 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -875,4 +875,70 @@ describe("CreateKey", () => { expect(screen.queryByRole("button", { name: /Optional Settings/i })).not.toBeInTheDocument(); }); }); + + describe("writers outside the submit path", () => { + it("lets the selected user win over the search text typed into the same field", async () => { + vi.mocked(userFilterUICall).mockResolvedValue([ + { user_id: "u-77", user_email: "alice@example.com" }, + ] as unknown as Awaited>); + + await openModal(); + await userEvent.click(screen.getByRole("radio", { name: "Another User" })); + await nameTheKey(); + + await userEvent.type(antdSearchInput(await screen.findByText("Type email to search for users")), "alice"); + await userEvent.click(await screen.findByText("alice@example.com (u-77)")); + await submit(); + + expect((await createdPayload()).user_id).toBe("u-77"); + }); + + it("surfaces the required message on a field that carries no help text", async () => { + await openModal(); + await userEvent.click(screen.getByRole("radio", { name: "Another User" })); + await nameTheKey(); + await submit(); + + expect( + await screen.findByText("Please input the user ID of the user you are assigning the key to"), + ).toBeInTheDocument(); + expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled(); + }); + }); + + describe("validation follows the mounted set", () => { + it("submits an over-ceiling budget typed into a section the user closed again, omitting the key", async () => { + await openModal({ team: { team_id: "team-1", max_budget: 10 } as unknown as Team }); + await nameTheKey(); + await openSection(/Optional Settings/i); + await userEvent.type(await screen.findByLabelText(/Max Budget \(USD\)/), "50"); + await openSection(/Optional Settings/i); + await submit(); + + const payload = await createdPayload(); + expect(payload).not.toHaveProperty("max_budget"); + expect(payload.key_alias).toBe("contract-key"); + }); + }); + + describe("submit gestures", () => { + it("creates the key when Enter is pressed inside a text field", async () => { + await openModal(); + await userEvent.type(await screen.findByLabelText(/Key Name/), "enter-key{Enter}"); + + expect((await createdPayload()).key_alias).toBe("enter-key"); + }); + }); + + describe("switch coercion", () => { + it("sends enable_prompt_caching as a boolean once the switch is on", async () => { + await openModal(); + await nameTheKey(); + await openSection(/Optional Settings/i); + await userEvent.click(await screen.findByLabelText("Enable Prompt Caching")); + await submit(); + + expect((await createdPayload()).enable_prompt_caching).toBe(true); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 77ef31e0ede..ad7869202dc 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -12,22 +12,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; -import { - Button as Button2, - Form, - Input as AntdInput, - Modal, - Radio, - Select, - Switch, - Tag, - Tooltip, - Typography, -} from "antd"; +import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Button as Button2, Input as AntdInput, Modal, Radio, Select, Switch, Tag, Tooltip, Typography } from "antd"; import { ChevronDown } from "lucide-react"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { type Control, useForm, useWatch, type UseFormSetValue } from "react-hook-form"; import { rolesWithWriteAccess } from "../../utils/roles"; import AgentSelector from "../agent_management/AgentSelector"; import AccessGroupSelector from "../common_components/AccessGroupSelector"; @@ -35,6 +26,13 @@ import BudgetDurationDropdown from "../common_components/budget_duration_dropdow import SchemaFormFields from "../common_components/check_openapi_schema"; import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"; import ModelAliasManager from "../common_components/ModelAliasManager"; +import { + MountedFormField, + MountedFormProvider, + projectMountedValues, + useMountRegistry, + type MountedFormValues, +} from "../common_components/MountedFormField"; import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings"; import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; @@ -78,7 +76,46 @@ const { Option } = Select; const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-4 py-3 text-left"; const SECTION_CHEVRON_CLASS = - "size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180"; + "size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180"; + +type FieldWrite = (value: unknown) => void; + +type McpSelectorValue = { servers: string[]; accessGroups: string[]; toolsets?: string[] }; + +type AgentSelectorValue = { agents: string[]; accessGroups: string[] }; + +const isBlank = (value: unknown): boolean => value === undefined || value === null || value === ""; + +const requiredRule = (required: boolean, message: string) => ({ + validate: (value: unknown) => (required && isBlank(value) ? message : true), +}); + +const ceilingRule = (ceiling: number | null | undefined, message: (limit: number) => string) => ({ + validate: (value: unknown) => + value && ceiling !== null && ceiling !== undefined && (value as number) > ceiling ? message(ceiling) : true, +}); + +interface McpToolPermissionsFieldProps { + readonly accessToken: string; + readonly control: Control; + readonly setValue: UseFormSetValue; +} + +const McpToolPermissionsField: React.FC = ({ accessToken, control, setValue }) => { + const selection = useWatch({ control, name: "allowed_mcp_servers_and_groups" }) as { servers?: string[] } | undefined; + const toolPermissions = useWatch({ control, name: "mcp_tool_permissions" }) as Record | undefined; + + return ( +
+ s !== NO_MCP_SERVERS_SENTINEL)} + toolPermissions={toolPermissions || {}} + onChange={(toolPerms) => setValue("mcp_tool_permissions", toolPerms)} + /> +
+ ); +}; /** * Interface for pre-filling the create key form from URL parameters @@ -176,7 +213,21 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const disableCustomApiKeys = Boolean(uiSettingsData?.values?.disable_custom_api_keys); const tagOptions = tagsData ? Object.values(tagsData).map((tag) => ({ value: tag.name, label: tag.name })) : []; const queryClient = useQueryClient(); - const [form] = Form.useForm(); + const [formDefaults] = useState(() => ({ + team_id: team ? team.team_id : null, + key_type: "llm_api", + tpm_limit_type: null, + rpm_limit_type: null, + mcp_tool_permissions: {}, + duration: "", + })); + const form = useForm({ + mode: "onChange", + shouldUnregister: false, + defaultValues: formDefaults, + }); + const registry = useMountRegistry(); + const mountedForm = useMemo(() => ({ control: form.control, registry }), [form.control, registry]); const [isModalVisible, setIsModalVisible] = useState(false); const [apiKey, setApiKey] = useState(null); const [userModels, setUserModels] = useState([]); @@ -208,10 +259,10 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [routerSettingsKey, setRouterSettingsKey] = useState(0); const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]); const [selectedAgentId, setSelectedAgentId] = useState(null); - const selectedModels: string[] = Form.useWatch("models", form) ?? []; + const selectedModels: string[] = (useWatch({ control: form.control, name: "models" }) as string[] | undefined) ?? []; const handleOk = () => { setIsModalVisible(false); - form.resetFields(); + form.reset(formDefaults); setLoggingSettings([]); setDisabledCallbacks([]); setKeyType("llm_api"); @@ -233,7 +284,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setIsModalVisible(false); setApiKey(null); setSelectedCreateKeyTeam(null); - form.resetFields(); + form.reset(formDefaults); setLoggingSettings([]); setDisabledCallbacks([]); setKeyType("llm_api"); @@ -348,14 +399,14 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const selectedTeam = teams?.find((t) => t.team_id === prefillData.team_id) || null; if (selectedTeam) { setSelectedCreateKeyTeam(selectedTeam); - form.setFieldsValue({ team_id: prefillData.team_id }); + form.setValue("team_id", prefillData.team_id); } // Silently ignore invalid team_id - don't prefill with a team user doesn't have access to } // Set key alias if (prefillData.key_alias) { - form.setFieldsValue({ key_alias: prefillData.key_alias }); + form.setValue("key_alias", prefillData.key_alias); } // Defer model selection until we load the allowed model list. @@ -366,7 +417,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp // Set key type if (prefillData.key_type) { setKeyType(prefillData.key_type); - form.setFieldsValue({ key_type: prefillData.key_type }); + form.setValue("key_type", prefillData.key_type); } } } @@ -376,7 +427,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const isTeamSelectionRequired = modelsToPick.includes("no-default-models"); const isFormDisabled = isTeamSelectionRequired && !selectedCreateKeyTeam; - const handleCreate = async (formValues: Record) => { + const handleCreate = async (formValues: MountedFormValues) => { try { const input: KeyCreateInput = { formValues, @@ -425,7 +476,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setApiKey(response["key"]); toast.success("Virtual Key Created"); - form.resetFields(); + form.reset(formDefaults); setBudgetLimits([]); setTagRateLimits([]); setBudgetFallbacks({}); @@ -437,6 +488,8 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp } }; + const handleSubmit = form.handleSubmit(() => handleCreate(projectMountedValues(registry, form.getValues))); + // Fetch available models when team or auth changes. // Note: Model prefill from URL params is handled by the useEffect below, which // watches for pendingPrefillModels + modelsToPick to both be populated. @@ -446,7 +499,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const project = projects?.find((p) => p.project_id === selectedProjectId); const projectModels = project?.models ?? []; setModelsToPick(projectModels); - form.setFieldValue("models", []); + form.setValue("models", []); return; } if (userID && userRole && accessToken) { @@ -459,10 +512,10 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp } // Only clear models if we don't have pending prefill models if (!pendingPrefillModels) { - form.setFieldValue("models", []); + form.setValue("models", []); } // Clear MCP server selection when team changes (available servers may differ) - form.setFieldValue("allowed_mcp_servers_and_groups", { servers: [], accessGroups: [] }); + form.setValue("allowed_mcp_servers_and_groups", { servers: [], accessGroups: [] }); }, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole, form]); // Apply deferred model prefill once the available model list arrives. @@ -477,7 +530,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const validModels = pendingPrefillModels.filter((model) => modelsToPick.includes(model)); if (validModels.length > 0) { - form.setFieldsValue({ models: validModels }); + form.setValue("models", validModels); } setPendingPrefillModels(null); }, [pendingPrefillModels, modelsToPick, form]); @@ -492,13 +545,13 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const projectTeam = teams.find((t) => t.team_id === project.team_id) || null; if (projectTeam) { setSelectedCreateKeyTeam(projectTeam); - form.setFieldValue("team_id", projectTeam.team_id); + form.setValue("team_id", projectTeam.team_id); } }, [teams, selectedProjectId, projects]); // Add a callback function to handle user creation const handleUserCreated = (userId: string) => { - form.setFieldsValue({ user_id: userId }); + form.setValue("user_id", userId); setIsCreateUserModalVisible(false); }; @@ -537,9 +590,51 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const handleUserSelect = (_value: string, option: UserOption): void => { const selectedUser = option.user; - form.setFieldsValue({ - user_id: selectedUser.user_id, - }); + form.setValue("user_id", selectedUser.user_id); + }; + + const changeOrganization = (write: FieldWrite) => (orgId: string) => { + write(orgId); + setSelectedOrganizationId(orgId || null); + // Clear team and project when org changes + setSelectedCreateKeyTeam(null); + setSelectedProjectId(null); + form.setValue("team_id", undefined); + form.setValue("project_id", undefined); + }; + + const selectTeam = (team: Team | null) => { + setSelectedCreateKeyTeam(team); + setSelectedProjectId(null); + form.setValue("project_id", undefined); + // Auto-populate org from team for non-admin users + if (team?.organization_id) { + setSelectedOrganizationId(team.organization_id); + form.setValue("organization_id", team.organization_id); + } else if (!team) { + setSelectedOrganizationId(null); + form.setValue("organization_id", undefined); + } + }; + + const changeProject = (write: FieldWrite) => (projectId: string) => { + write(projectId); + if (!projectId) { + setSelectedProjectId(null); + setSelectedCreateKeyTeam(null); + form.setValue("team_id", undefined); + return; + } + setSelectedProjectId(projectId); + }; + + const changeKeyType = (write: FieldWrite) => (value: string) => { + write(value); + setKeyType(value); + // Clear models field and disable if management or read_only + if (value === "management" || value === "read_only") { + form.setValue("models", []); + } }; return ( @@ -550,1027 +645,1093 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp )} -
- {/* Section 1: Key Ownership */} -
-

Key Ownership

- - Owned By{" "} - - - - - } - className="mb-4" - > - setKeyOwner(e.target.value)} value={keyOwner}> - You - Service Account - {userRole === "Admin" && Another User} - - Agent New - - - + + + {/* Section 1: Key Ownership */} +
+

Key Ownership

+ + + + Owned By{" "} + + + + + + setKeyOwner(e.target.value)} value={keyOwner}> + You + Service Account + {userRole === "Admin" && Another User} + + Agent New + + + - {keyOwner === "another_user" && ( - + User ID{" "} + + + + + } + name="user_id" + className="mt-4" + required + rules={requiredRule( + keyOwner === "another_user", + `Please input the user ID of the user you are assigning the key to`, + )} + > + {(control) => ( +
+
+ setSelectedAgentId(value)} + filterOption={(input, option) => + (option?.label as string)?.toLowerCase().includes(input.toLowerCase()) + } + options={agentsList.map((a) => ({ + label: a.agent_name || a.agent_id, + value: a.agent_id, + }))} + /> +
+ This key will be used by the selected agent to make requests to LiteLLM +
+
+ )} + - User ID{" "} - + Organization{" "} + } - name="user_id" + name="organization_id" className="mt-4" - rules={[ - { - required: keyOwner === "another_user", - message: `Please input the user ID of the user you are assigning the key to`, - }, - ]} > -
-
- setSelectedAgentId(value)} - filterOption={(input, option) => - (option?.label as string)?.toLowerCase().includes(input.toLowerCase()) + } + name="team_id" + className="mt-4" + required={keyOwner === "service_account"} + rules={requiredRule(keyOwner === "service_account", "Please select a team for the service account")} + help={keyOwner === "service_account" ? "required" : ""} + > + {(control) => ( + + )} + + {enableProjectsUI && ( + + Project{" "} + + + + } - options={agentsList.map((a) => ({ - label: a.agent_name || a.agent_id, - value: a.agent_id, - }))} - /> -
- This key will be used by the selected agent to make requests to LiteLLM -
+ name="project_id" + className="mt-4" + > + {(control) => ( + + )} +
+ )} +
+ + {/* Show message when team selection is required */} + {isFormDisabled && ( +
+

+ Please select a team to continue configuring your Virtual Key. If you do not see any teams, please + contact your Proxy Admin to either provide you with access to models or to add you to a team. +

)} - - Organization{" "} - - - - - } - name="organization_id" - className="mt-4" - > - { - setSelectedOrganizationId(orgId || null); - // Clear team and project when org changes - setSelectedCreateKeyTeam(null); - setSelectedProjectId(null); - form.setFieldValue("team_id", undefined); - form.setFieldValue("project_id", undefined); - }} - /> - - - Team{" "} - - - - - } - name="team_id" - initialValue={team ? team.team_id : null} - className="mt-4" - rules={[ - { - required: keyOwner === "service_account", - message: "Please select a team for the service account", - }, - ]} - help={keyOwner === "service_account" ? "required" : ""} - > - { - setSelectedCreateKeyTeam(team); - setSelectedProjectId(null); - form.setFieldValue("project_id", undefined); - // Auto-populate org from team for non-admin users - if (team?.organization_id) { - setSelectedOrganizationId(team.organization_id); - form.setFieldValue("organization_id", team.organization_id); - } else if (!team) { - setSelectedOrganizationId(null); - form.setFieldValue("organization_id", undefined); + + {/* Section 2: Key Details */} + {!isFormDisabled && ( +
+

Key Details

+ + {keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "} + + + + } - }} - /> - - {enableProjectsUI && ( - - Project{" "} - - - - - } - name="project_id" - className="mt-4" - > - { - if (!projectId) { - setSelectedProjectId(null); - setSelectedCreateKeyTeam(null); - form.setFieldValue("team_id", undefined); - return; - } - setSelectedProjectId(projectId); - }} - /> - + name="key_alias" + required + rules={requiredRule(true, `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}`)} + help="required" + > + {(control) => } + + + + Models{" "} + + + + + } + name="models" + help={ + keyType === "management" || keyType === "read_only" + ? "Models field is disabled for this key type" + : "optional - leave empty to allow access to all models" + } + className="mt-4" + > + {(control) => ( + + )} + + + + Key Type{" "} + + + + + } + name="key_type" + className="mt-4" + > + {(control) => ( + + )} + +
)} -
- {/* Show message when team selection is required */} - {isFormDisabled && ( -
-

- Please select a team to continue configuring your Virtual Key. If you do not see any teams, please - contact your Proxy Admin to either provide you with access to models or to add you to a team. -

-
- )} - - {/* Section 2: Key Details */} - {!isFormDisabled && ( -
-

Key Details

- - {keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "} - - - - - } - name="key_alias" - rules={[ - { - required: true, - message: `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}`, - }, - ]} - help="required" - > - - - - - Models{" "} - - - - - } - name="models" - rules={[]} - help={ - keyType === "management" || keyType === "read_only" - ? "Models field is disabled for this key type" - : "optional - leave empty to allow access to all models" - } - className="mt-4" - > - - - - - Key Type{" "} - - - - - } - name="key_type" - initialValue="llm_api" - className="mt-4" - > - - -
- )} - - {/* Section 3: Optional Settings */} - {!isFormDisabled && ( -
- -

- - Optional Settings - - -

- - - Max Budget (USD){" "} - - - - - } - name="max_budget" - help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`} - rules={[ - { - validator: async (_, value) => { - if (value && team && team.max_budget !== null && value > team.max_budget) { - throw new Error( - `Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`, - ); - } - }, - }, - ]} - > - - - - Reset Budget{" "} - - - - - } - name="budget_duration" - help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} - > - form.setFieldValue("budget_duration", value)} - /> - - - Budget Windows{" "} - - - - - } - > - - - - Budget Fallbacks{" "} - - - - - } - > - - - - Tokens per minute Limit (TPM){" "} - - - - - } - name="tpm_limit" - help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`} - rules={[ - { - validator: async (_, value) => { - if (value && team && team.tpm_limit !== null && value > team.tpm_limit) { - throw new Error(`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`); - } - }, - }, - ]} - > - - - - - - - Requests per minute Limit (RPM){" "} - - - - - } - name="rpm_limit" - help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`} - rules={[ - { - validator: async (_, value) => { - if (value && team && team.rpm_limit !== null && value > team.rpm_limit) { - throw new Error(`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`); - } - }, - }, - ]} - > - - - - - - - Per-Tag Rate Limits{" "} - - - - - } - > - - - - Throttle on budget exceeded{" "} - - - - - } - name="throttle_on_budget_exceeded" - valuePropName="checked" - > - - - - Enable Prompt Caching{" "} - - - - - } - name="enable_prompt_caching" - valuePropName="checked" - > - - - - Guardrails{" "} - - e.stopPropagation()} // Prevent accordion from collapsing when clicking link - > - - - - - } - name="guardrails" - className="mt-4" - help={ - canEditGuardrails - ? "Select existing guardrails or enter new ones" - : "Premium feature - Upgrade to set guardrails by key" - } - > - ({ value: name, label: name }))} - /> - - )} - {canViewPrompts && ( - - Prompts{" "} - - e.stopPropagation()} // Prevent accordion from collapsing when clicking link - > - - - - - } - name="prompts" - className="mt-4" - help={ - premiumUser - ? "Select existing prompts or enter new ones" - : "Premium feature - Upgrade to set prompts by key" - } - > - - - + {/* Section 3: Optional Settings */} + {!isFormDisabled && ( +
+ +

- MCP Settings + Optional Settings - - + + + Max Budget (USD){" "} + + + + + } + name="max_budget" + help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`} + rules={ceilingRule( + team?.max_budget, + (limit) => `Budget cannot exceed team max budget: $${formatNumberWithCommas(limit, 4)}`, + )} + > + {(control) => ( + + )} + + + Reset Budget{" "} + + + + + } + name="budget_duration" + help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} + > + {(control) => ( + + )} + + + + + Budget Windows{" "} + + + + + + + + + + + Budget Fallbacks{" "} + + + + + + + + + Tokens per minute Limit (TPM){" "} + + + + + } + name="tpm_limit" + help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`} + rules={ceilingRule( + team?.tpm_limit, + (limit) => `TPM limit cannot exceed team TPM limit: ${limit}`, + )} + > + {(control) => ( + + )} + + + {(control) => ( + + )} + + + Requests per minute Limit (RPM){" "} + + + + + } + name="rpm_limit" + help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`} + rules={ceilingRule( + team?.rpm_limit, + (limit) => `RPM limit cannot exceed team RPM limit: ${limit}`, + )} + > + {(control) => ( + + )} + + + {(control) => ( + + )} + + + + + Per-Tag Rate Limits{" "} + + + + + + + + + Throttle on budget exceeded{" "} + + + + + } + name="throttle_on_budget_exceeded" + > + {(control) => ( + + )} + + + Enable Prompt Caching{" "} + + + + + } + name="enable_prompt_caching" + > + {(control) => ( + + )} + + + Guardrails{" "} + + e.stopPropagation()} // Prevent accordion from collapsing when clicking link + > + + + + + } + name="guardrails" + className="mt-4" + help={ + canEditGuardrails + ? "Select existing guardrails or enter new ones" + : "Premium feature - Upgrade to set guardrails by key" + } + > + {(control) => ( + ({ value: name, label: name }))} + /> )} - - - - - - - Agent Settings - - - - + )} + {canViewPrompts && ( + - Allowed Agents{" "} - - + Prompts{" "} + + e.stopPropagation()} // Prevent accordion from collapsing when clicking link + > + + } - name="allowed_agents_and_groups" - help="Select agents or access groups this key can access" + name="prompts" + className="mt-4" + help={ + premiumUser + ? "Select existing prompts or enter new ones" + : "Premium feature - Upgrade to set prompts by key" + } > - form.setFieldValue("allowed_agents_and_groups", val)} - value={form.getFieldValue("allowed_agents_and_groups")} - accessToken={accessToken} - placeholder="Select agents or access groups (optional)" + {(control) => ( + + )} + - Logging Settings + MCP Settings -
- + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + help="Select MCP servers or access groups this key can access" + > + {(control) => ( + + )} + + + {/* Hidden field to register mcp_tool_permissions with the form */} + + {(control) => } + + + + + + + + + Agent Settings + + + + + Allowed Agents{" "} + + + + + } + name="allowed_agents_and_groups" + help="Select agents or access groups this key can access" + > + {(control) => ( + + )} + + + + + {premiumUser ? ( + + + Logging Settings + + + +
+ +
+
+
+ ) : ( + + Key-level logging settings is an enterprise feature, get in touch - + + https://www.litellm.ai/enterprise + + + } + placement="top" + > +
+
+ + + Logging Settings + + + +
+ +
+
+
+
+
+
+ + )} + + + + Router Settings + + + +
+ 0 + ? { data: userModels.map((model) => ({ model_name: model })) } + : undefined + } />
- ) : ( - - Key-level logging settings is an enterprise feature, get in touch - - - https://www.litellm.ai/enterprise - - - } - placement="top" - > -
-
- - - Logging Settings - - - -
- -
-
-
-
-
-
- - )} - - - Router Settings - - - -
- 0 - ? { data: userModels.map((model) => ({ model_name: model })) } - : undefined - } - /> -
-
-
- - - - Model Aliases - - - -
-

- Create custom aliases for models that can be used in API calls. This allows you to create - shortcuts for specific models. -

- -
-
-
- - - - Key Lifecycle - - - -
- - + + Model Aliases + + + +
+

+ Create custom aliases for models that can be used in API calls. This allows you to create + shortcuts for specific models. +

+ - -
-
- - - -
- Advanced Settings - - Learn more about advanced settings in our{" "} - - documentation - - - } - > - - -
- -
- - - -
- - -
- )} +
+ + -
- - Create Key - -
- + + + Key Lifecycle + + + +
+ + {(control) => ( + + )} + +
+
+
+ + +
+ Advanced Settings + + Learn more about advanced settings in our{" "} + + documentation + + + } + > + + +
+ +
+ + + +
+ + +
+ )} + +
+ + Create Key + +
+ + {/* Add the Create User Modal */} @@ -1595,7 +1756,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp {apiKey && (
-

Save your Key

+

Save your Key

{apiKey != null ? ( ) : (