diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 063aee5f08f..655552e4131 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1052,9 +1052,6 @@ "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 5 } @@ -1095,9 +1092,6 @@ "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 2 }, @@ -1112,9 +1106,6 @@ "no-nested-ternary": { "count": 4 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1122,9 +1113,6 @@ "src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx": { "local/no-complex-jsx-arrow": { "count": 2 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx": { @@ -1132,21 +1120,11 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/compareUI/components/MessageInput.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": { "no-restricted-imports": { "count": 2 } }, - "src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": { "local/no-complex-jsx-arrow": { "count": 2 @@ -3531,6 +3509,14 @@ "count": 1 } }, + "src/components/ui/slider.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-nested-ternary": { + "count": 1 + } + }, "src/components/ui/switch.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx new file mode 100644 index 00000000000..e30f87115cf --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx @@ -0,0 +1,320 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AgentBuilderView from "./AgentBuilderView"; +import type { AgentModel } from "../../llm_calls/fetch_agents"; + +const modelCreateCall = vi.fn().mockResolvedValue({ model_id: "id-new" }); +const modelPatchUpdateCall = vi.fn().mockResolvedValue({}); +const modelDeleteCall = vi.fn().mockResolvedValue({}); +const keyCreateCall = vi.fn().mockResolvedValue({ key: "sk-agent-key" }); +const fetchMCPServers = vi.fn().mockResolvedValue([]); +const fetchAvailableAgentModels = vi.fn(); +const fetchAvailableModels = vi.fn().mockResolvedValue([{ model_group: "gpt-4o" }, { model_group: "claude-sonnet-4" }]); + +vi.mock("@/components/networking", () => ({ + proxyBaseUrl: "https://proxy.example.com", + modelCreateCall: (...args: unknown[]) => modelCreateCall(...args), + modelPatchUpdateCall: (...args: unknown[]) => modelPatchUpdateCall(...args), + modelDeleteCall: (...args: unknown[]) => modelDeleteCall(...args), + keyCreateCall: (...args: unknown[]) => keyCreateCall(...args), + fetchMCPServers: (...args: unknown[]) => fetchMCPServers(...args), +})); + +vi.mock("../../llm_calls/fetch_agents", () => ({ + fetchAvailableAgentModels: (...args: unknown[]) => fetchAvailableAgentModels(...args), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: (...args: unknown[]) => fetchAvailableModels(...args), +})); + +vi.mock("@/components/CodeBlock", () => ({ + default: ({ code }: { code: string }) =>
{code}
, +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +const StatefulPanel = ({ label }: { label: string }) => { + const [draft, setDraft] = useState(""); + return setDraft(event.target.value)} />; +}; + +vi.mock("./ChatUI", () => ({ + default: () => , +})); + +vi.mock("../complianceUI/ComplianceUI", () => ({ + default: () => , +})); + +const AGENTS: AgentModel[] = [ + { + model_name: "support-agent", + litellm_params: { model: "litellm_agent/gpt-4o", litellm_system_prompt: "Be helpful.", temperature: 0.3 }, + model_info: { id: "agent-1" }, + }, + { + model_name: "research-agent", + litellm_params: { model: "litellm_agent/claude-sonnet-4" }, + model_info: { id: "agent-2" }, + }, +]; + +const props = { + accessToken: "sk-access", + token: "tok", + userID: "u1", + userRole: "Admin", +}; + +const controlUnder = (label: string): HTMLElement => + within(screen.getByText(label).parentElement!).getByRole("combobox"); + +const renderView = () => render(); + +const waitForRoster = async () => { + await screen.findByRole("button", { name: "support-agent litellm_agent" }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + fetchAvailableAgentModels.mockResolvedValue(AGENTS); + fetchAvailableModels.mockResolvedValue([{ model_group: "gpt-4o" }, { model_group: "claude-sonnet-4" }]); + fetchMCPServers.mockResolvedValue([]); + modelCreateCall.mockResolvedValue({ model_id: "id-new" }); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +}); + +describe("AgentBuilderView", () => { + it("asks the visitor to sign in when there is no session", () => { + render(); + + expect(screen.getByText("Sign in to use Agent Builder.")).toBeInTheDocument(); + expect(fetchAvailableAgentModels).not.toHaveBeenCalled(); + }); + + it("marks itself busy while the roster loads", async () => { + let release: (agents: AgentModel[]) => void = () => {}; + fetchAvailableAgentModels.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + renderView(); + + expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + + release(AGENTS); + await waitForRoster(); + expect(document.querySelector('[aria-busy="true"]')).not.toBeInTheDocument(); + }); + + it("lists every agent and opens the first one's configuration", async () => { + renderView(); + await waitForRoster(); + + expect(screen.getByRole("button", { name: "research-agent litellm_agent" })).toBeInTheDocument(); + expect(screen.getByText("Agent Builder")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByDisplayValue("support-agent")).toBeInTheDocument()); + expect(screen.getByDisplayValue("Be helpful.")).toBeInTheDocument(); + expect(screen.getByDisplayValue("0.3")).toBeInTheDocument(); + }); + + it("loads the configuration of whichever agent is picked", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: "research-agent litellm_agent" })); + + await waitFor(() => expect(screen.getByDisplayValue("research-agent")).toBeInTheDocument()); + }); + + it("offers a blank draft and a save control for a new agent", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + + expect(screen.getByRole("button", { name: /Save Agent/i })).toBeInTheDocument(); + expect(screen.getByDisplayValue("You are a helpful assistant.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Update Agent/i })).not.toBeInTheDocument(); + }); + + it("creates the agent under the litellm_agent prefix", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + await user.type(screen.getByPlaceholderText("My Agent"), "billing-agent"); + await user.click(screen.getByRole("button", { name: /Save Agent/i })); + + await waitFor(() => expect(modelCreateCall).toHaveBeenCalled()); + const payload = modelCreateCall.mock.calls[0][1]; + expect(payload.model_name).toBe("billing-agent"); + expect(payload.litellm_params.model).toBe("litellm_agent/gpt-4o"); + }); + + it("will not save a draft without a name", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + await user.click(screen.getByRole("button", { name: /Save Agent/i })); + + expect(modelCreateCall).not.toHaveBeenCalled(); + }); + + it("updates the selected agent through its model id", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getByRole("button", { name: /Update Agent/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(modelPatchUpdateCall.mock.calls[0][2]).toBe("agent-1"); + }); + + it("deletes only after the warning is confirmed", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]); + + expect(await screen.findByText(/Are you sure you want to delete "support-agent"/)).toBeInTheDocument(); + expect(modelDeleteCall).not.toHaveBeenCalled(); + + const confirmations = screen.getAllByRole("button", { name: /Delete$/ }); + await user.click(confirmations[confirmations.length - 1]); + + await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("sk-access", "agent-1")); + }); + + it("abandons the delete when the warning is dismissed", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]); + await screen.findByText(/Are you sure you want to delete "support-agent"/); + await user.click(screen.getAllByRole("button", { name: /Cancel$/ }).at(-1)!); + + await waitFor(() => + expect(screen.queryByText(/Are you sure you want to delete "support-agent"/)).not.toBeInTheDocument(), + ); + expect(modelDeleteCall).not.toHaveBeenCalled(); + }); + + it("shows a ready-to-run curl example on the Connect tab", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Connect/i })); + + const snippet = await screen.findByTestId("code-block"); + expect(snippet).toHaveTextContent("https://proxy.example.com/v1/chat/completions"); + expect(snippet).toHaveTextContent('"model": "support-agent"'); + }); + + it("mints a key scoped to the selected agent", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Connect/i })); + await user.click(await screen.findByRole("button", { name: /Create key for this agent/i })); + + await waitFor(() => expect(keyCreateCall).toHaveBeenCalled()); + expect(keyCreateCall.mock.calls[0][2].models).toEqual(["support-agent"]); + expect(await screen.findByTestId("code-block")).toHaveTextContent("Bearer sk-agent-key"); + }); + + it("keeps a tab's own state alive while the user works in another tab", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Chat/i })); + const scratch = await screen.findByLabelText("chat scratch"); + await user.type(scratch, "half a thought"); + expect(scratch).toHaveValue("half a thought"); + + await user.click(screen.getByRole("tab", { name: /Configure/i })); + await screen.findByDisplayValue("support-agent"); + + await user.click(screen.getByRole("tab", { name: /Chat/i })); + expect(await screen.findByLabelText("chat scratch")).toHaveValue("half a thought"); + }); + + it("keeps the batch tab's state alive across a round trip too", async () => { + const user = userEvent.setup(); + renderView(); + await waitForRoster(); + + await user.click(screen.getByRole("tab", { name: /Batch Test/i })); + await user.type(await screen.findByLabelText("batch scratch"), "seven cases"); + + await user.click(screen.getByRole("tab", { name: /Connect/i })); + await screen.findByTestId("code-block"); + + await user.click(screen.getByRole("tab", { name: /Batch Test/i })); + expect(await screen.findByLabelText("batch scratch")).toHaveValue("seven cases"); + }); + + it("attaches the MCP servers the agent should reach", async () => { + const user = userEvent.setup(); + fetchMCPServers.mockResolvedValue([{ server_id: "srv-1", alias: "github", server_name: "github-mcp" }]); + renderView(); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + + await user.click(controlUnder("MCP servers")); + const options = await screen.findAllByText("github"); + await user.click(options[options.length - 1]); + await user.keyboard("{Escape}"); + + await user.click(screen.getByRole("button", { name: /Update Agent/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(modelPatchUpdateCall.mock.calls[0][1].litellm_params.tools).toEqual([ + { type: "mcp", server_label: "litellm", server_url: "litellm_proxy/mcp/github", require_approval: "never" }, + ]); + }); + + it("warns that the builder is experimental", async () => { + renderView(); + await waitForRoster(); + + expect(screen.getByText(/Agent Builder is experimental/)).toBeInTheDocument(); + expect(within(screen.getByText(/Agent Builder is experimental/)).getByRole("link")).toHaveAttribute( + "href", + "mailto:product@berri.ai", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx index d4333b95c62..5a86b2d27f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx @@ -1,16 +1,24 @@ "use client"; -import { - CommentOutlined, - DeleteOutlined, - ExperimentOutlined, - LinkOutlined, - PlusOutlined, - RobotOutlined, - SaveOutlined, -} from "@ant-design/icons"; -import { Button, Input, Modal, Select, Spin, Tabs } from "antd"; +import { Bot, FlaskConical, Link as LinkIcon, MessageSquare, Plus, Save, Trash2 } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { useVisitedTabs } from "@/hooks/useVisitedTabs"; import CodeBlock from "@/components/CodeBlock"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { @@ -27,8 +35,6 @@ import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_m import ComplianceUI from "../complianceUI/ComplianceUI"; import ChatUI from "./ChatUI"; -const { TextArea } = Input; - export interface AgentBuilderViewProps { accessToken: string | null; token: string | null; @@ -45,6 +51,8 @@ export interface AgentBuilderViewProps { const NEW_AGENT_ID = "__new__"; +type AgentTab = "configure" | "chat" | "test" | "connect"; + function getConnectTabBaseUrl( proxySettings: AgentBuilderViewProps["proxySettings"], customProxyBaseUrl?: string, @@ -116,7 +124,7 @@ function ConnectTabContent({ Create a virtual key that can only call this agent. The key will be scoped to you (user_id) and restricted to the model {agentName}.

- {disabledPersonalKeyCreation && ( @@ -190,7 +198,12 @@ export default function AgentBuilderView({ const [modelGroups, setModelGroups] = useState([]); const [loadingAgents, setLoadingAgents] = useState(true); const [selectedId, setSelectedId] = useState(null); - const [activeTab, setActiveTab] = useState<"configure" | "chat" | "test" | "connect">("configure"); + const [activeTab, setActiveTab] = useState("configure"); + const { onTabChange, hasVisited } = useVisitedTabs("configure"); + const goToTab = (tab: AgentTab) => { + setActiveTab(tab); + onTabChange(tab); + }; const [creatingKey, setCreatingKey] = useState(false); const [createdKeyValue, setCreatedKeyValue] = useState(null); @@ -207,6 +220,7 @@ export default function AgentBuilderView({ const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); const effectiveApiKey = apiKey || accessToken || ""; const selectedAgent = @@ -314,7 +328,7 @@ export default function AgentBuilderView({ setDraftTemperature(0.7); setDraftMaxTokens(4096); setDraftTools([]); - setActiveTab("configure"); + goToTab("configure"); }; const handleSaveAgent = async () => { @@ -344,7 +358,7 @@ export default function AgentBuilderView({ ? list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim()) : list.find((a) => a.model_name === draftName.trim()); setSelectedId(created ? getAgentSelectionKey(created) : list[0] ? getAgentSelectionKey(list[0]) : null); - setActiveTab("chat"); + goToTab("chat"); } catch (e) { NotificationsManager.fromBackend("Failed to save agent"); } finally { @@ -411,27 +425,24 @@ export default function AgentBuilderView({ const handleDeleteAgent = () => { if (!selectedAgent || !selectedAgentModelId || !accessToken) return; - Modal.confirm({ - title: "Delete agent", - content: `Are you sure you want to delete "${selectedAgent.model_name}"? This cannot be undone.`, - okText: "Delete", - okType: "danger", - cancelText: "Cancel", - onOk: async () => { - setDeleting(true); - try { - await modelDeleteCall(accessToken, selectedAgentModelId); - NotificationsManager.success("Agent deleted"); - const list = await loadAgents(); - const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId); - setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null); - } catch (e) { - NotificationsManager.fromBackend("Failed to delete agent"); - } finally { - setDeleting(false); - } - }, - }); + setConfirmingDelete(true); + }; + + const handleConfirmDelete = async () => { + if (!selectedAgent || !selectedAgentModelId || !accessToken) return; + setDeleting(true); + try { + await modelDeleteCall(accessToken, selectedAgentModelId); + NotificationsManager.success("Agent deleted"); + const list = await loadAgents(); + const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId); + setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null); + } catch (e) { + NotificationsManager.fromBackend("Failed to delete agent"); + } finally { + setDeleting(false); + setConfirmingDelete(false); + } }; if (!accessToken || !userID || !userRole) { @@ -446,13 +457,8 @@ export default function AgentBuilderView({
Agent Builder {isNewAgent ? ( - ) : ( @@ -460,7 +466,7 @@ export default function AgentBuilderView({ )}
- + Agent Builder is experimental and may change or be removed without notice. We’d love your feedback—email us at{" "} @@ -477,12 +483,14 @@ export default function AgentBuilderView({
Agents -
{loadingAgents ? ( -
- +
+
) : ( <> @@ -509,7 +517,7 @@ export default function AgentBuilderView({ onClick={handleAddAgent} className="mb-1 w-full rounded-md border border-dashed border-gray-300 px-3 py-2 text-left text-sm text-gray-500 hover:border-blue-400 hover:bg-blue-50/50 hover:text-gray-700" > - New agent + New agent )} @@ -526,227 +534,230 @@ export default function AgentBuilderView({ {(selectedId !== null || isNewAgent) && ( <> setActiveTab(k as "configure" | "chat" | "test" | "connect")} - className="flex-1 overflow-hidden [&_.ant-tabs-content]:h-full [&_.ant-tabs-tabpane]:h-full [&_.ant-tabs-nav]:pl-4" - items={[ - { - key: "configure", - label: ( - - Configure - - ), - children: ( -
- {isNewAgent || selectedAgent ? ( -
- {!selectedAgentModelId && selectedAgent && ( -
- This agent cannot be updated or deleted here (missing model id). Manage it from Models - & Endpoints. -
- )} -
- - setDraftName(e.target.value)} - placeholder="My Agent" - /> -
-
- -