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 ? ( ) : (