diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx new file mode 100644 index 00000000000..e0152358f8c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx @@ -0,0 +1,436 @@ +"use client"; + +import * as React from "react"; +import { ChevronRight, CircleHelp } from "lucide-react"; +import { + Controller, + useFormContext, + type FieldPath, + type RegisterOptions, + type ControllerProps, + type ControllerRenderProps, +} from "react-hook-form"; + +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "@/components/ui/combobox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/shared/form/field"; + +export interface AgentSkillFormValue { + id?: string; + name?: string; + description?: string; + tags?: string[]; + examples?: string[]; +} + +export interface StaticHeaderFormValue { + header?: string; + value?: string; +} + +export interface McpServerSelection { + servers?: string[]; + accessGroups?: string[]; + toolsets?: string[]; +} + +export type AgentFormFieldValue = + | string + | number + | boolean + | string[] + | AgentSkillFormValue[] + | StaticHeaderFormValue[] + | McpServerSelection + | Record + | null + | undefined; + +export interface AgentFormValues { + [credentialKey: string]: AgentFormFieldValue; + agent_name?: string; + name?: string; + display_name?: string; + description?: string; + url?: string; + version?: string; + protocolVersion?: string; + skills?: AgentSkillFormValue[]; + streaming?: boolean; + pushNotifications?: boolean; + stateTransitionHistory?: boolean; + iconUrl?: string; + documentationUrl?: string; + supportsAuthenticatedExtendedCard?: boolean; + model?: string; + make_public?: boolean; + cost_per_query?: string | number; + input_cost_per_token?: string | number; + output_cost_per_token?: string | number; + static_headers?: StaticHeaderFormValue[]; + extra_headers?: string[]; + tpm_limit?: number | null; + rpm_limit?: number | null; + session_tpm_limit?: number | null; + session_rpm_limit?: number | null; + team_id?: string; + guardrails?: string[]; + entitlement_models?: string[]; + entitlement_agents?: string[]; + allowed_mcp_servers_and_groups?: McpServerSelection; + mcp_tool_permissions?: Record; + defaultInputModes?: string[]; + defaultOutputModes?: string[]; + enable_tracing?: boolean; +} + +export type AgentFieldName = FieldPath; + +export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + +export type AgentFieldControlProps = ControllerRenderProps & { + id: string; + "aria-invalid": true | undefined; + "aria-describedby": string | undefined; +}; + +export interface AgentRequestPayload { + [key: string]: unknown; + agent_name?: string; + agent_card_params?: Record; + litellm_params?: Record; + object_permission?: Record; +} + +interface AgentFormFieldProps { + name: AgentFieldName; + label?: React.ReactNode; + description?: React.ReactNode; + defaultValue?: AgentFormFieldValue; + rules?: Omit, "valueAsNumber" | "valueAsDate" | "setValueAs">; + className?: string; + children: (control: AgentFieldControlProps) => React.ReactNode; +} + +export const AgentFormField = ({ + name, + label, + description, + defaultValue, + rules, + className, + children, +}: AgentFormFieldProps) => { + const { control } = useFormContext(); + const reactId = React.useId(); + const controlId = `${reactId}-control`; + const descriptionId = `${reactId}-description`; + const errorId = `${reactId}-error`; + + const renderField: ControllerProps["render"] = ({ field, fieldState }) => { + const invalid = fieldState.error !== undefined; + const describedBy = + [description !== undefined ? descriptionId : undefined, invalid ? errorId : undefined] + .filter((id): id is string => id !== undefined) + .join(" ") || undefined; + + return ( + + {label !== undefined && {label}} + {children({ + ...field, + id: controlId, + "aria-invalid": invalid || undefined, + "aria-describedby": describedBy, + })} + {description !== undefined && {description}} + + + ); + }; + + return ; +}; + +export interface CollapsiblePanelsState { + readonly openPanels: readonly string[]; + readonly mountedPanels: readonly string[]; + readonly toggle: (panelKey: string) => void; +} + +export const useCollapsiblePanels = (initiallyOpen: readonly string[]): CollapsiblePanelsState => { + const [openPanels, setOpenPanels] = React.useState(initiallyOpen); + const [mountedPanels, setMountedPanels] = React.useState(initiallyOpen); + + const toggle = React.useCallback((panelKey: string) => { + setOpenPanels((current) => + current.includes(panelKey) ? current.filter((key) => key !== panelKey) : [...current, panelKey], + ); + setMountedPanels((current) => (current.includes(panelKey) ? current : [...current, panelKey])); + }, []); + + return { openPanels, mountedPanels, toggle }; +}; + +interface AgentFormPanelProps { + panelKey: string; + title: string; + panels: CollapsiblePanelsState; + children: React.ReactNode; +} + +export const AgentFormPanel = ({ panelKey, title, panels, children }: AgentFormPanelProps) => ( + panels.toggle(panelKey)} + className="border-b border-border last:border-b-0" + > + + + {title} + + + {panels.mountedPanels.includes(panelKey) && {children}} + + +); + +export const omitFieldValues = (values: AgentFormValues, names: readonly string[]): AgentFormValues => + Object.fromEntries(Object.entries(values).filter(([key]) => !names.includes(key))); + +interface AgentNumberInputProps extends Omit { + value: AgentFormFieldValue; + onChange: (value: number | null) => void; + inputRef: AgentFieldControlProps["ref"]; + min?: number; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export const AgentNumberInput = ({ value, onChange, onBlur, inputRef, min, ...props }: AgentNumberInputProps) => ( + event.currentTarget.blur()} + onChange={(event) => { + const raw = event.target.valueAsNumber; + onChange(Number.isNaN(raw) ? null : raw); + }} + onBlur={() => { + if (min !== undefined && typeof value === "number" && value < min) onChange(min); + onBlur(); + }} + /> +); + +export interface AgentSelectOption { + label: string; + value: string; +} + +const matchesQuery = (option: AgentSelectOption, query: string): boolean => + option.label.toLowerCase().includes(query.trim().toLowerCase()); + +const TAG_SEPARATOR = ","; + +interface AgentTagsInputProps { + id: string; + options?: readonly AgentSelectOption[]; + value: string[]; + onValueChange: (value: string[]) => void; + placeholder?: string; + emptyText?: string; + "aria-invalid"?: true | undefined; + "aria-describedby"?: string | undefined; +} + +export const AgentTagsInput = ({ + id, + options = [], + value, + onValueChange, + placeholder, + emptyText = "No matching options", + ...props +}: AgentTagsInputProps) => { + const anchor = useComboboxAnchor(); + const [query, setQuery] = React.useState(""); + const pendingRef = React.useRef(""); + + const selected = value.map((tag) => options.find((option) => option.value === tag) ?? { label: tag, value: tag }); + const pending = query.trim(); + const items = + pending.length > 0 && !options.some((option) => option.value === pending) + ? [{ label: pending, value: pending }, ...options] + : [...options]; + + const setPending = (next: string) => { + pendingRef.current = next; + setQuery(next); + }; + + const addTags = (tags: readonly string[]) => { + const additions = tags + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag, index, all) => all.indexOf(tag) === index && !value.includes(tag)); + if (additions.length > 0) onValueChange([...value, ...additions]); + }; + + const handleInputValueChange = (next: string, details: { reason: string }) => { + if (details.reason === "input-clear") { + const committed = pendingRef.current; + setPending(""); + addTags([committed]); + return; + } + const parts = next.split(TAG_SEPARATOR); + setPending(parts[parts.length - 1] ?? ""); + addTags(parts.slice(0, -1)); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" || event.currentTarget.getAttribute("aria-activedescendant")) return; + event.preventDefault(); + const committed = pendingRef.current; + setPending(""); + addTags([committed]); + }; + + return ( + { + setPending(""); + onValueChange(next.map((option) => option.value)); + }} + inputValue={query} + onInputValueChange={handleInputValueChange} + isItemEqualToValue={(option: AgentSelectOption, other: AgentSelectOption) => option.value === other.value} + itemToStringLabel={(option: AgentSelectOption) => option.label} + filter={matchesQuery} + openOnInputClick + > + } className="min-h-8 py-1 text-sm"> + + {(chips: AgentSelectOption[]) => ( + <> + {chips.map((option) => ( + + {option.label} + + ))} + + + )} + + + + {emptyText} + + {(option: AgentSelectOption) => ( + + {option.label} + + )} + + + + ); +}; + +interface AgentMultiSelectProps { + id: string; + options: readonly AgentSelectOption[]; + value: string[]; + onValueChange: (value: string[]) => void; + placeholder?: string; + emptyText?: string; + "aria-invalid"?: true | undefined; + "aria-describedby"?: string | undefined; +} + +export const AgentMultiSelect = ({ + id, + options, + value, + onValueChange, + placeholder, + emptyText = "No matching options", + ...props +}: AgentMultiSelectProps) => { + const anchor = useComboboxAnchor(); + const items = [...options]; + const selected = value.map((item) => items.find((option) => option.value === item) ?? { label: item, value: item }); + + return ( + onValueChange(next.map((option) => option.value))} + isItemEqualToValue={(option: AgentSelectOption, other: AgentSelectOption) => option.value === other.value} + itemToStringLabel={(option: AgentSelectOption) => option.label} + filter={matchesQuery} + openOnInputClick + > + } className="min-h-8 py-1 text-sm"> + + {(chips: AgentSelectOption[]) => ( + <> + {chips.map((option) => ( + + {option.label} + + ))} + + + )} + + + + {emptyText} + + {(option: AgentSelectOption) => ( + + {option.label} + + )} + + + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx new file mode 100644 index 00000000000..b411649ea72 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx @@ -0,0 +1,354 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import AddAgentForm from "./add_agent_form"; +import * as networking from "@/components/networking"; +import type { AgentCreateInfo } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + createAgentCall: vi.fn(), + getAgentCreateMetadata: vi.fn(), + getAgentsList: vi.fn(), + keyCreateForAgentCall: vi.fn(), + keyListCall: vi.fn(), + keyUpdateCall: vi.fn(), + modelAvailableCall: vi.fn(), +})); + +vi.mock("./agent_card_discovery", () => ({ default: () =>
})); +vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ default: () =>
})); +vi.mock("@/components/mcp_server_management/MCPToolPermissions", () => ({ default: () =>
})); +vi.mock("@/components/guardrails/GuardrailSelector", () => ({ default: () =>
})); +vi.mock("@/components/common_components/team_dropdown", () => ({ default: () =>
})); + +const a2aInfo: AgentCreateInfo = { + agent_type: "a2a", + agent_type_display_name: "A2A Agent", + description: "Agent-to-agent protocol", + logo_url: "/ui/assets/logos/a2a_agent.png", + credential_fields: [], + use_a2a_form_fields: true, +}; + +const langgraphInfo: AgentCreateInfo = { + agent_type: "langgraph", + agent_type_display_name: "LangGraph", + description: "LangGraph platform", + logo_url: "/ui/assets/logos/langgraph.png", + use_a2a_form_fields: false, + litellm_params_template: { custom_llm_provider: "langgraph" }, + model_template: "langgraph/{assistant_id}", + credential_fields: [ + { key: "api_base", label: "API Base", field_type: "text", required: true, placeholder: "https://host" }, + { key: "assistant_id", label: "Assistant ID", field_type: "text", required: true, default_value: "" }, + { key: "api_key", label: "API Key", field_type: "password", required: false }, + ], +}; + +const renderForm = () => + render(); + +const panel = (name: RegExp) => screen.getByRole("button", { name }); + +const openAgentTypeMenu = async (user: ReturnType) => { + await user.click(screen.getAllByRole("combobox")[0]); +}; + +const createdPayload = () => vi.mocked(networking.createAgentCall).mock.calls[0][1] as Record; + +const goToLastStepAndCreate = async (user: ReturnType) => { + await user.click(await screen.findByRole("button", { name: /^Next/ })); + await user.click(await screen.findByRole("button", { name: /^Next/ })); + await user.click(await screen.findByRole("button", { name: /^Next/ })); + await user.click(await screen.findByRole("button", { name: /Create Agent/ })); + await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1)); +}; + +const selectAgentType = async (user: ReturnType, label: string) => { + await openAgentTypeMenu(user); + await user.click(await screen.findByText(label)); +}; + +describe("AddAgentForm submit payload", () => { + beforeEach(() => { + vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([a2aInfo, langgraphInfo]); + vi.mocked(networking.getAgentsList) + .mockReset() + .mockResolvedValue({ agents: [{ agent_id: "sub-1", agent_name: "Sub Agent One" }] }); + vi.mocked(networking.keyListCall).mockReset().mockResolvedValue({ keys: [] }); + vi.mocked(networking.modelAvailableCall) + .mockReset() + .mockResolvedValue({ data: [{ id: "gpt-4o" }] }); + vi.mocked(networking.createAgentCall) + .mockReset() + .mockResolvedValue({ agent_id: "agent-1", agent_name: "created-agent" } as never); + vi.mocked(networking.keyCreateForAgentCall) + .mockReset() + .mockResolvedValue({ key: "sk-new" } as never); + vi.mocked(networking.keyUpdateCall) + .mockReset() + .mockResolvedValue({} as never); + }); + + it("sends every a2a field the user filled across all collapsible panels", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "support-agent"); + await user.type(screen.getByLabelText("Display Name"), "Support Agent"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "answers questions"); + await user.type(screen.getByLabelText("URL"), "http://localhost:9999/"); + await user.clear(screen.getByLabelText("Version")); + await user.type(screen.getByLabelText("Version"), "2.0.0"); + + await user.click(panel(/Skills/)); + await user.click(screen.getByRole("button", { name: /Add Skill/ })); + await user.type(await screen.findByLabelText("Skill ID"), "hello"); + await user.type(screen.getByLabelText("Skill Name"), "Hello"); + await user.type(screen.getByPlaceholderText("What this skill does"), "greets"); + await user.type(screen.getByLabelText("Tags"), "greeting,polite"); + await user.type(screen.getByLabelText("Examples"), "say hi"); + await user.click(screen.getByLabelText("Agent Name")); + + await user.click(panel(/Capabilities/)); + await user.click(await screen.findByRole("switch", { name: "Streaming" })); + await user.click(screen.getByRole("switch", { name: "Push Notifications" })); + + await user.click(panel(/Optional Settings/)); + await user.type(await screen.findByLabelText("Icon URL"), "https://example.com/icon.png"); + + await user.click(panel(/Cost Configuration/)); + await user.type(await screen.findByLabelText("Cost Per Query ($)"), "0.25"); + await user.type(screen.getByLabelText("Input Cost Per Token ($)"), "0.000002"); + + await user.click(panel(/LiteLLM Parameters/)); + await user.type(await screen.findByLabelText("Model (Optional)"), "gpt-4o"); + await user.click(screen.getByRole("switch", { name: "Make Public" })); + + await user.click(panel(/Authentication Headers/)); + await user.click(await screen.findByRole("button", { name: /Add Static Header/ })); + await user.type(await screen.findByPlaceholderText("Header name (e.g. Authorization)"), "X-Tenant"); + await user.type(screen.getByPlaceholderText("Value (e.g. Bearer token123)"), "acme"); + await user.type(screen.getByLabelText("Forward Client Headers"), "x-api-key,"); + await user.click(screen.getByLabelText("Agent Name")); + + await goToLastStepAndCreate(user); + + expect(createdPayload()).toEqual({ + agent_name: "support-agent", + agent_card_params: { + protocolVersion: "1.0", + name: "Support Agent", + description: "answers questions", + url: "http://localhost:9999/", + version: "2.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { streaming: true, pushNotifications: true }, + skills: [ + { + id: "hello", + name: "Hello", + description: "greets", + tags: ["greeting", "polite"], + examples: ["say hi"], + }, + ], + iconUrl: "https://example.com/icon.png", + }, + litellm_params: { + model: "gpt-4o", + make_public: true, + cost_per_query: 0.25, + input_cost_per_token: 0.000002, + }, + static_headers: { "X-Tenant": "acme" }, + extra_headers: ["x-api-key"], + }); + }); + + it("keeps a value typed into a panel the user collapsed again", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "collapsed-agent"); + await user.type(screen.getByLabelText("Display Name"), "Collapsed"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d"); + + await user.click(panel(/Cost Configuration/)); + await user.type(await screen.findByLabelText("Cost Per Query ($)"), "0.75"); + await user.click(panel(/Cost Configuration/)); + + await goToLastStepAndCreate(user); + + expect(createdPayload().litellm_params).toEqual({ cost_per_query: 0.75 }); + }); + + it("restores what was typed when a collapsed panel is expanded again", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.click(panel(/Cost Configuration/)); + await user.type(await screen.findByLabelText("Cost Per Query ($)"), "0.75"); + await user.click(panel(/Cost Configuration/)); + await user.click(panel(/Cost Configuration/)); + + expect(await screen.findByLabelText("Cost Per Query ($)")).toHaveValue(0.75); + }); + + it("blocks the first step until the required agent name is filled", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + expect(await screen.findByText("Please enter a unique agent name")).toBeInTheDocument(); + expect(screen.getByLabelText("Agent Name")).toBeInTheDocument(); + }); + + it("sends the custom agent shape when the custom type is picked", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + await openAgentTypeMenu(user); + await user.click(await screen.findByText("Custom / Other")); + + await user.type(await screen.findByLabelText("Agent Name"), "my-custom-agent"); + await user.type(screen.getByPlaceholderText("Describe what this agent does\u2026"), "custom thing"); + + await goToLastStepAndCreate(user); + + expect(createdPayload()).toEqual({ + agent_name: "my-custom-agent", + agent_card_params: { + protocolVersion: "1.0", + name: "my-custom-agent", + description: "custom thing", + url: "", + version: "1.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { streaming: false }, + skills: [], + }, + }); + }); + + it("sends credential fields and the model template for a dynamic agent type", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + await selectAgentType(user, "LangGraph"); + + await user.type(await screen.findByLabelText("Agent Name"), "lg-agent"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "graph agent"); + await user.type(screen.getByLabelText("API Base"), "https://lg.example.com"); + await user.type(screen.getByLabelText("Assistant ID"), "asst_1"); + await user.type(screen.getByLabelText("API Key"), "secret-value"); + + await goToLastStepAndCreate(user); + + expect(createdPayload()).toEqual({ + agent_name: "lg-agent", + agent_card_params: { + protocolVersion: "1.0", + name: "lg-agent", + description: "graph agent", + url: "https://lg.example.com", + version: "1.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { streaming: true }, + skills: [ + { + id: "chat", + name: "Chat", + description: "General chat capability", + tags: ["chat", "conversation"], + }, + ], + }, + litellm_params: { + custom_llm_provider: "langgraph", + api_base: "https://lg.example.com", + assistant_id: "asst_1", + api_key: "secret-value", + model: "langgraph/asst_1", + }, + }); + }); + + it("resets to the agent type that was selected before the switch", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await screen.findByLabelText("Agent Name"); + expect(screen.getByLabelText("Version")).toHaveValue("1.0.0"); + + await openAgentTypeMenu(user); + await user.click(await screen.findByText("Custom / Other")); + await screen.findByPlaceholderText("e.g. my-custom-agent"); + + await selectAgentType(user, "A2A Agent"); + + expect(await screen.findByLabelText("Version")).toHaveValue(""); + }); + + it("sends entitlements and rate limits gathered on the later steps", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "entitled-agent"); + await user.type(screen.getByLabelText("Display Name"), "Entitled"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d"); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + await user.type(await screen.findByLabelText("Allowed Models"), "gpt-4o,"); + await user.keyboard("{Escape}"); + await user.click(screen.getByLabelText("Allowed Agents (Sub-Agents)")); + await user.click(await screen.findByTitle("Sub Agent One")); + await user.keyboard("{Escape}"); + await user.click(screen.getByText(/Configure which models, agents, and MCP tools/)); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + await user.click((await screen.findAllByRole("switch"))[1]); + await user.type(screen.getByLabelText("TPM Limit"), "1000"); + await user.type(screen.getByLabelText("Session RPM Limit"), "20"); + await user.click(screen.getByRole("button", { name: /^Next/ })); + + await user.click(await screen.findByRole("button", { name: /Create Agent/ })); + await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1)); + + const payload = createdPayload(); + expect(payload.tpm_limit).toBe(1000); + expect(payload.session_rpm_limit).toBe(20); + expect(payload.object_permission).toEqual({ models: ["gpt-4o"], agents: ["sub-1"] }); + expect(payload.litellm_params).toEqual({ require_trace_id_on_calls_by_agent: true }); + }); + + it("creates a key named after the agent once the agent is created", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderForm(); + + await user.type(await screen.findByLabelText("Agent Name"), "keyed-agent"); + await user.type(screen.getByLabelText("Display Name"), "Keyed"); + await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d"); + + await goToLastStepAndCreate(user); + + expect(networking.keyCreateForAgentCall).toHaveBeenCalledWith( + "tok", + "agent-1", + "keyed-agent-key", + [], + undefined, + null, + ); + expect(await screen.findByText("Agent Created!")).toBeInTheDocument(); + expect(within(screen.getByText("Agent Created!").parentElement!).getByText("created-agent")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 7bd6b68a26d..da98eb8a352 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -1,10 +1,20 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber } from "antd"; +import { Form, Modal, Select, Steps, Tag } from "antd"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; import { toast } from "@/lib/toast"; import { Logo } from "@/components/molecules/logo/Logo"; import { Button } from "@tremor/react"; -import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined } from "@ant-design/icons"; import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay"; +import { Button as ShadButton } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { createAgentCall, getAgentCreateMetadata, @@ -23,7 +33,19 @@ import AgentFormFields from "./agent_form_fields"; import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery"; import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils"; import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; -import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { AGENT_FORM_CONFIG, getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; +import { + AgentFormField, + AgentFormValues, + AgentMultiSelect, + AgentNumberInput, + AgentRequestPayload, + AgentTagsInput, + McpServerSelection, + labelWithHint, + useCollapsiblePanels, +} from "./AgentFormKit"; import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; @@ -32,6 +54,17 @@ const { Step } = Steps; const CUSTOM_AGENT_TYPE = "custom"; +const SHARED_INITIAL_VALUES: AgentFormValues = { + allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, + mcp_tool_permissions: {}, + entitlement_models: [], + entitlement_agents: [], + guardrails: [], +}; + +const buildInitialValues = (agentType: string): AgentFormValues => + agentType === "a2a" ? { ...getDefaultFormValues(), ...SHARED_INITIAL_VALUES } : { ...SHARED_INITIAL_VALUES }; + interface AddAgentFormProps { visible: boolean; onClose: () => void; @@ -42,7 +75,8 @@ interface AddAgentFormProps { const AddAgentForm: React.FC = ({ visible, onClose, accessToken, onSuccess, teams }) => { const { userId, userRole } = useAuthorized(); - const [form] = Form.useForm(); + const form = useForm({ defaultValues: buildInitialValues("a2a") }); + const panels = useCollapsiblePanels([AGENT_FORM_CONFIG.basic.key]); const [currentStep, setCurrentStep] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [agentType, setAgentType] = useState("a2a"); @@ -52,7 +86,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const [keyAssignOption, setKeyAssignOption] = useState<"create_new" | "existing_key" | "skip">("create_new"); const [newKeyName, setNewKeyName] = useState(""); const [newKeyModels, setNewKeyModels] = useState([]); - const [existingKeys, setExistingKeys] = useState([]); + const [existingKeys, setExistingKeys] = useState<{ token: string; key_alias?: string }[]>([]); const [selectedExistingKey, setSelectedExistingKey] = useState(null); const [loadingKeys, setLoadingKeys] = useState(false); const [availableModels, setAvailableModels] = useState([]); @@ -71,14 +105,10 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const [maxIterations, setMaxIterations] = useState(null); const [maxBudgetPerSession, setMaxBudgetPerSession] = useState(null); - // Latest upstream card selection from auto-discovery (skills, capabilities, - // name, description). Dynamic agent forms don't render Form.Items for those - // fields, so we overlay this onto agent_card_params at submit. const [appliedDiscoveredSelection, setAppliedDiscoveredSelection] = useState( null, ); - // Fetch agent type metadata on mount useEffect(() => { const fetchMetadata = async () => { try { @@ -142,7 +172,12 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok .then((response) => { if (cancelled) return; const agents = response?.agents ?? []; - setAvailableAgents(agents.map((a: any) => ({ agent_id: a.agent_id, agent_name: a.agent_name }))); + setAvailableAgents( + agents.map((a: { agent_id: string; agent_name: string }) => ({ + agent_id: a.agent_id, + agent_name: a.agent_name, + })), + ); }) .catch((error) => { if (!cancelled) console.error("Error fetching agents:", error); @@ -159,7 +194,9 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok // Watch every form field so we can recompute the discovery plan whenever // the user types into a relevant credential field below. - const watchedFormValues = Form.useWatch([], form); + const watchedFormValues = useWatch({ control: form.control }); + const mcpSelection = useWatch({ control: form.control, name: "allowed_mcp_servers_and_groups" }); + const mcpToolPermissions = useWatch({ control: form.control, name: "mcp_tool_permissions" }); // Build the discovery plan for the proxy. Different agent runtimes publish // their cards at different URL shapes: @@ -177,25 +214,22 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok ); const handleNext = async () => { - try { - if (currentStep === 0) { - await form.validateFields(); - const agentName = form.getFieldValue("agent_name"); - if (agentName && !newKeyName) { - setNewKeyName(`${agentName}-key`); - } + if (currentStep === 0) { + const isValid = await form.trigger(); + if (!isValid) return; + const agentName = form.getValues("agent_name"); + if (agentName && !newKeyName) { + setNewKeyName(`${agentName}-key`); } - setCurrentStep((s) => s + 1); - } catch { - // validation failed — stay on current step } + setCurrentStep((s) => s + 1); }; const handleBack = () => { setCurrentStep((s) => Math.max(0, s - 1)); }; - const buildAgentData = (values: any) => { + const buildAgentData = (values: AgentFormValues): AgentRequestPayload | null => { if (agentType === CUSTOM_AGENT_TYPE) { return { agent_name: values.agent_name, @@ -213,29 +247,34 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok }; } - let agentData: Record; if (agentType === "a2a") { - agentData = buildAgentDataFromForm(values); - } else if (selectedAgentTypeInfo?.use_a2a_form_fields) { - agentData = buildAgentDataFromForm(values); - if (selectedAgentTypeInfo.litellm_params_template) { - agentData.litellm_params = { - ...agentData.litellm_params, - ...selectedAgentTypeInfo.litellm_params_template, - }; - } - for (const field of selectedAgentTypeInfo.credential_fields) { - const value = values[field.key]; - if (value && field.include_in_litellm_params !== false) { - agentData.litellm_params[field.key] = value; - } - } - } else if (selectedAgentTypeInfo) { - agentData = buildDynamicAgentData(values, selectedAgentTypeInfo); - } else { - return null; + return overlayDiscoveredCardParams(buildAgentDataFromForm(values), appliedDiscoveredSelection?.selected_card); } + if (!selectedAgentTypeInfo) return null; + + if (!selectedAgentTypeInfo.use_a2a_form_fields) { + return overlayDiscoveredCardParams( + buildDynamicAgentData(values, selectedAgentTypeInfo), + appliedDiscoveredSelection?.selected_card, + ); + } + + const agentData: AgentRequestPayload = buildAgentDataFromForm(values); + if (selectedAgentTypeInfo.litellm_params_template) { + agentData.litellm_params = { + ...agentData.litellm_params, + ...selectedAgentTypeInfo.litellm_params_template, + }; + } + const credentialParams = Object.fromEntries( + selectedAgentTypeInfo.credential_fields + .filter((field) => values[field.key] && field.include_in_litellm_params !== false) + .map((field) => [field.key, values[field.key]]), + ); + if (Object.keys(credentialParams).length > 0) { + agentData.litellm_params = { ...agentData.litellm_params, ...credentialParams }; + } return overlayDiscoveredCardParams(agentData, appliedDiscoveredSelection?.selected_card); }; @@ -247,8 +286,12 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok setIsSubmitting(true); try { - await form.validateFields(); - const values = { ...form.getFieldsValue(true) }; + const isValid = await form.trigger(); + if (!isValid) { + setIsSubmitting(false); + return; + } + const values = form.getValues(); const agentData = buildAgentData(values); if (!agentData) { toast.error("Failed to build agent data"); @@ -257,52 +300,35 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok } // Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions) - const mcpServersAndGroups = values.allowed_mcp_servers_and_groups; - const mcpToolPermissions = values.mcp_tool_permissions || {}; - const entitlementModels = values.entitlement_models || []; - const entitlementAgents = values.entitlement_agents || []; - const hasObjectPermission = - mcpServersAndGroups?.servers?.length > 0 || - mcpServersAndGroups?.accessGroups?.length > 0 || - Object.keys(mcpToolPermissions).length > 0 || - entitlementModels.length > 0 || - entitlementAgents.length > 0; - if (hasObjectPermission) { - agentData.object_permission = {}; - if (mcpServersAndGroups?.servers?.length > 0) { - agentData.object_permission.mcp_servers = mcpServersAndGroups.servers; - } - if (mcpServersAndGroups?.accessGroups?.length > 0) { - agentData.object_permission.mcp_access_groups = mcpServersAndGroups.accessGroups; - } - if (Object.keys(mcpToolPermissions).length > 0) { - agentData.object_permission.mcp_tool_permissions = mcpToolPermissions; - } - if (entitlementModels.length > 0) { - agentData.object_permission.models = entitlementModels; - } - if (entitlementAgents.length > 0) { - agentData.object_permission.agents = entitlementAgents; - } + const mcpServersAndGroups = values.allowed_mcp_servers_and_groups ?? {}; + const toolPermissions = values.mcp_tool_permissions ?? {}; + const entitlementModels = values.entitlement_models ?? []; + const entitlementAgents = values.entitlement_agents ?? []; + const objectPermission: Record = { + ...(mcpServersAndGroups.servers?.length ? { mcp_servers: mcpServersAndGroups.servers } : {}), + ...(mcpServersAndGroups.accessGroups?.length ? { mcp_access_groups: mcpServersAndGroups.accessGroups } : {}), + ...(Object.keys(toolPermissions).length ? { mcp_tool_permissions: toolPermissions } : {}), + ...(entitlementModels.length ? { models: entitlementModels } : {}), + ...(entitlementAgents.length ? { agents: entitlementAgents } : {}), + }; + if (Object.keys(objectPermission).length > 0) { + agentData.object_permission = objectPermission; } // Wire trace-id flags and budget controls into agent litellm_params (before create call) if (requireTraceIdInbound || requireTraceIdOutbound) { - if (!agentData.litellm_params) agentData.litellm_params = {}; - if (requireTraceIdInbound) { - agentData.litellm_params.require_trace_id_on_calls_to_agent = true; - } - if (requireTraceIdOutbound) { - agentData.litellm_params.require_trace_id_on_calls_by_agent = true; - if (maxIterations) agentData.litellm_params.max_iterations = maxIterations; - if (maxBudgetPerSession) agentData.litellm_params.max_budget_per_session = maxBudgetPerSession; - } + agentData.litellm_params = { + ...agentData.litellm_params, + ...(requireTraceIdInbound ? { require_trace_id_on_calls_to_agent: true } : {}), + ...(requireTraceIdOutbound ? { require_trace_id_on_calls_by_agent: true } : {}), + ...(requireTraceIdOutbound && maxIterations ? { max_iterations: maxIterations } : {}), + ...(requireTraceIdOutbound && maxBudgetPerSession ? { max_budget_per_session: maxBudgetPerSession } : {}), + }; } - const selectedGuardrails = values.guardrails || []; + const selectedGuardrails = values.guardrails ?? []; if (selectedGuardrails.length > 0) { - if (!agentData.litellm_params) agentData.litellm_params = {}; - agentData.litellm_params.guardrails = selectedGuardrails; + agentData.litellm_params = { ...agentData.litellm_params, guardrails: selectedGuardrails }; } const selectedTeamId = values.team_id || null; @@ -351,7 +377,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok }; const handleClose = () => { - form.resetFields(); + form.reset(buildInitialValues(agentType)); setAgentType("a2a"); setCurrentStep(0); setKeyAssignOption("create_new"); @@ -371,130 +397,127 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const renderEntitlementsStep = () => (
-

+

Configure which models, agents, and MCP tools this agent is allowed to use. Leave fields empty to allow all (subject to key/team permissions).

- Allowed Models} - name="entitlement_models" - tooltip="Restrict which models this agent can call. Leave empty to allow all." - > - - ((option?.label as string) ?? "").toLowerCase().includes(input.toLowerCase()) - } - options={availableAgents.map((a) => ({ - label: a.agent_name, - value: a.agent_id, - }))} - /> - - - - - - Allowed MCP Servers{" "} - + + {({ id, value, onChange }) => ( + ({ label: getModelDisplayName(m), value: m }))} /> - - } - name="allowed_mcp_servers_and_groups" - initialValue={{ servers: [], accessGroups: [] }} - > - - form.setFieldValue("allowed_mcp_servers_and_groups", val) - } - value={form.getFieldValue("allowed_mcp_servers_and_groups") || { servers: [], accessGroups: [] }} - accessToken={accessToken ?? ""} - placeholder="Select MCP servers or access groups (optional)" - /> - - - - prev.allowed_mcp_servers_and_groups !== curr.allowed_mcp_servers_and_groups || - prev.mcp_tool_permissions !== curr.mcp_tool_permissions - } - > - {() => ( -
- + + + {({ id, value, onChange }) => ( + ({ label: a.agent_name, value: a.agent_id }))} + /> + )} + + + + + + {({ value, onChange }) => ( + ) => - form.setFieldsValue({ mcp_tool_permissions: toolPerms }) - } + placeholder="Select MCP servers or access groups (optional)" /> -
- )} -
+ )} + + + +
+ ) => form.setValue("mcp_tool_permissions", toolPerms)} + /> +
); + const rateLimitField = (name: keyof AgentFormValues & string, label: string, placeholder: string) => ( + + {({ value, onChange, ref, ...control }) => ( + + )} + + ); + const renderObservabilityStep = () => (
-

Tracing

+

Tracing

- + Require x-litellm-trace-id on calls TO this agent -

+

Only accept this agent being invoked with a trace-id (e.g. when used as a sub-agent).

- +
- + Require x-litellm-trace-id on calls BY this agent -

+

Requires LLM/MCP calls made by this agent to include x-litellm-trace-id for session tracking.

{ + onCheckedChange={(checked) => { setRequireTraceIdOutbound(checked); if (!checked) { setMaxIterations(null); @@ -506,96 +529,99 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
- +
-

Budgets & Rate Limits

+

Budgets & Rate Limits

{!requireTraceIdOutbound && ( -
+
Enable "Require x-litellm-trace-id on calls BY this agent" in Tracing to configure budgets and rate limits.
)} -
Session Budgets
+
Session Budgets
-
- - + Max Iterations + setMaxIterations(val)} + value={maxIterations ?? ""} + onChange={(event) => + setMaxIterations(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber) + } + onBlur={() => setMaxIterations((current) => (current !== null && current < 1 ? 1 : current))} /> -

Hard cap on LLM calls per session

-
-
- - Hard cap on LLM calls per session

+ + + Max Budget Per Session ($) + setMaxBudgetPerSession(val)} + value={maxBudgetPerSession ?? ""} + onChange={(event) => + setMaxBudgetPerSession(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber) + } + onBlur={() => + setMaxBudgetPerSession((current) => (current !== null && current < 0.01 ? 0.01 : current)) + } /> -

Max spend per trace before returning 429

-
+

Max spend per trace before returning 429

+
- + -
Agent Rate Limits
-

Global rate limits applied across all callers of this agent.

+
Agent Rate Limits
+

Global rate limits applied across all callers of this agent.

- - - - - - + {rateLimitField("tpm_limit", "TPM Limit", "e.g. 100000")} + {rateLimitField("rpm_limit", "RPM Limit", "e.g. 100")}
-
Per-Session Rate Limits
-

+

Per-Session Rate Limits
+

Rate limits per session (x-litellm-trace-id). Each session gets its own counters.

- - - - - - + {rateLimitField("session_tpm_limit", "Session TPM Limit", "e.g. 10000")} + {rateLimitField("session_rpm_limit", "Session RPM Limit", "e.g. 20")}
- +
-

Guardrails

-

+

Guardrails

+

Apply guardrails to this agent. Selected guardrails will run on all calls made by this agent.

- - form.setFieldsValue({ guardrails: selected })} - /> - + + {({ value, onChange }) => ( + + )} +
); const handleAgentTypeChange = (value: string) => { setAgentType(value); - form.resetFields(); + form.reset(buildInitialValues(agentType)); // Discovery selections are tied to a specific agent type's URL shape; // switching types invalidates them. setAppliedDiscoveredSelection(null); @@ -604,12 +630,6 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok // Apply a discovered agent card to the form so the rest of Step 1 (skills, // capabilities, name, description, URL) reflects what the user picked. The // proxy re-applies its own merge at registration; we only seed defaults here. - // - // AntD's `setFieldsValue` silently ignores keys whose Form.Item isn't - // registered, so this is safe across all agent types — A2A forms pick up - // every field below; LangGraph and other dynamic forms only pick up the - // shared ones (`agent_name`, `description`, plus any credential field whose - // key looks URL-ish). const handleApplyDiscoveredCard = (selection: DiscoveredAgentCardSelection | null) => { setAppliedDiscoveredSelection(selection); if (!selection) return; @@ -622,10 +642,14 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok examples: s.examples ?? [], })); - const currentAgentName = form.getFieldValue("agent_name"); + const currentAgentName = form.getValues("agent_name"); const seededAgentName = currentAgentName || selected_card.name || selected_card.provider?.organization || ""; - const fieldsToSet: Record = { + const urlCredentialKeys = (selectedAgentTypeInfo?.credential_fields ?? []) + .map((f) => f.key) + .filter((key) => /(^|_)(url|api_base|endpoint)$/i.test(key)); + + const fieldsToSet: AgentFormValues = { agent_name: seededAgentName, name: selected_card.name, description: selected_card.description, @@ -636,20 +660,13 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok skills, iconUrl: selected_card.iconUrl, documentationUrl: selected_card.documentationUrl, + ...Object.fromEntries(urlCredentialKeys.map((key) => [key, upstream_url])), }; - // For dynamic agent types (e.g. LangGraph), the URL lives in a - // type-specific credential field. Match on common naming variants so the - // user doesn't have to re-paste the URL they already typed above. - const urlCredentialKeys = (selectedAgentTypeInfo?.credential_fields ?? []) - .map((f) => f.key) - .filter((key) => /(^|_)(url|api_base|endpoint)$/i.test(key)); - for (const key of urlCredentialKeys) { - fieldsToSet[key] = upstream_url; + for (const [key, value] of Object.entries(fieldsToSet)) { + form.setValue(key, value); } - form.setFieldsValue(fieldsToSet); - if (!newKeyName && seededAgentName) { setNewKeyName(`${seededAgentName}-key`); } @@ -663,7 +680,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const renderConfigureStep = () => ( <> Agent Type} + label={Agent Type} required tooltip="Select the type of agent you want to create" > @@ -676,25 +693,29 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok dropdownRender={(menu) => ( <> {menu} - +
-
Not listed?
+
+ Not listed? +
handleAgentTypeChange(CUSTOM_AGENT_TYPE)} > - +
- Custom / Other + Custom / Other GENERIC
-
- For agents that don't follow a standard protocol — just needs a virtual key +
+ For agents that don't follow a standard protocol, just needs a virtual key
@@ -708,16 +729,16 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok value={info.agent_type} label={
- + {info.agent_type_display_name}
} >
- +
{info.agent_type_display_name}
- {info.description &&
{info.description}
} + {info.description &&
{info.description}
}
@@ -727,49 +748,77 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
{agentType === CUSTOM_AGENT_TYPE ? ( -
- - - - - - -
+ + + {({ value, onChange, ref, ...control }) => ( + + )} + + + {({ value, onChange, ref, ...control }) => ( +