diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index be7da7c2b42..9f78dfd0846 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -948,11 +948,6 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": { "local/no-complex-jsx-arrow": { "count": 2 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx index af306e0eb2e..a15caad84bc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx @@ -221,7 +221,7 @@ const AdditionalModelSettings: React.FC = ({ className="w-full accent-primary disabled:cursor-not-allowed" onChange={(event) => handleTemperatureChange(Number(event.target.value))} /> -
+
0 1.0 2.0 @@ -267,7 +267,7 @@ const AdditionalModelSettings: React.FC = ({ className="w-full accent-primary disabled:cursor-not-allowed" onChange={(event) => handleMaxTokensChange(Number(event.target.value))} /> -
+
1 32768
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 c5732b49372..9f898c1de27 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 @@ -224,7 +224,7 @@ export default function AgentBuilderView({ const effectiveApiKey = apiKey || accessToken || ""; const selectedAgent = - selectedId === NEW_AGENT_ID ? null : agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null; + selectedId === NEW_AGENT_ID ? null : (agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null); const isNewAgent = selectedId === NEW_AGENT_ID; const selectedAgentModelId = selectedAgent ? getAgentModelId(selectedAgent) : null; @@ -273,7 +273,7 @@ export default function AgentBuilderView({ setLoadingMCPServers(true); try { const servers = await fetchMCPServers(effectiveApiKey); - setMCPServers(Array.isArray(servers) ? servers : (servers as { data?: MCPServer[] })?.data ?? []); + setMCPServers(Array.isArray(servers) ? servers : ((servers as { data?: MCPServer[] })?.data ?? [])); } catch (e) { console.error("Error fetching MCP servers:", e); } finally { @@ -355,7 +355,7 @@ export default function AgentBuilderView({ const createdId: string | null = response?.model_id ?? response?.model_info?.id ?? null; const list = await loadAgents(); const created = createdId - ? list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim()) + ? (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); goToTab("chat"); @@ -496,29 +496,34 @@ export default function AgentBuilderView({ <> {agentModels.map((agent) => { const key = getAgentSelectionKey(agent); + const active = selectedId === key; return ( - + {agent.model_name} + + litellm_agent + + ); })} - + New agent + )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx index 934e502492e..e0c460ca1cd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -56,14 +56,15 @@ export function ChatComposer({ {showSuggestions && suggestions.length > 0 && (
{suggestions.map((suggestion) => ( - + ))}
)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index b2ceea1f39b..f17996f786d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -881,7 +881,7 @@ const ChatUI: React.FC = ({ const requestProxyBaseUrl = simplified && proxySettings - ? proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined + ? (proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined) : customProxyBaseUrl || undefined; await makeOpenAIChatCompletionRequest( apiChatHistory, @@ -1474,15 +1474,17 @@ const ChatUI: React.FC = ({ setIsToolsetsInfoModalVisible(true)} /> } > - + {endpointType === EndpointType.MCP @@ -1611,13 +1613,15 @@ const ChatUI: React.FC = ({ Connected - +
) : (
- + {!isLoading && (
@@ -1983,14 +1989,16 @@ const ChatUI: React.FC = ({ "Create a PNG bar chart comparing AI gateway providers including LiteLLM", "Generate a CSV of LLM pricing data and visualize it as a line chart", ].map((prompt, idx) => ( - + ))}
)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx index 83d84adca40..c93c0069b7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx @@ -739,11 +739,13 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: Clear All Chats - }> - + = maxComparisons} /> + } + > + + Add Comparison {comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"} @@ -782,27 +784,31 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: ) : showSuggestedPrompts ? (
{SUGGESTED_PROMPTS.map((prompt) => ( - + ))}
) : haveAllResponses && !hasAttachment ? (
{GENERIC_FOLLOW_UPS.map((question) => ( - + ))}
) : isAnyComparisonLoading ? ( @@ -834,13 +840,16 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
{uploadedFile.name}
{isUploadedFilePdf ? "PDF" : "Image"}
- + + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx index 5ea77b2828a..1d7d5aac6a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx @@ -6,6 +6,7 @@ import { UnifiedSelector } from "./UnifiedSelector"; import TagSelector from "@/components/tag_management/TagSelector"; import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; @@ -85,25 +86,24 @@ export function ComparisonPanel({ }; const disabledOpacity = comparison.useAdvancedParams ? 1 : 0.4; - const disabledTextColor = comparison.useAdvancedParams ? "text-gray-700" : "text-gray-400"; - - const handleTogglePopover = () => { - setPopoverVisible((prev) => !prev); - }; + const disabledTextColor = comparison.useAdvancedParams ? "text-foreground" : "text-muted-foreground"; const handleClosePopover = () => { setPopoverVisible(false); }; const settingsContent = ( -
- {/* Close button in top right */} - + +
{/* Sync Checkbox */} @@ -123,10 +123,12 @@ export function ComparisonPanel({ {/* General Settings */}
-

General Settings

+

+ General Settings +

- + handleSettingChange("tags", value)} @@ -134,7 +136,7 @@ export function ComparisonPanel({ />
- + handleSettingChange("vectorStores", value)} @@ -142,7 +144,7 @@ export function ComparisonPanel({ />
- + handleSettingChange("guardrails", value)} @@ -153,7 +155,9 @@ export function ComparisonPanel({
{/* Advanced Settings */}
-

Advanced Settings

+

+ Advanced Settings +

-
-
+
+
+
onUpdate(isA2AMode ? { agent: value } : { model: value })} />
- { - // Prevent automatic closing - we control it manually - }} - > + { - event.stopPropagation(); - handleTogglePopover(); - }} - className={`p-2 rounded-lg transition-colors ${ - popoverVisible ? "bg-gray-200 text-gray-700" : "hover:bg-gray-100 text-gray-600" - }`} - > - - - } - /> + render={
{canRemove && ( - + + )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.test.tsx index 9c01521e77a..3f80fdb64e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.test.tsx @@ -1,10 +1,14 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { ModelSelector } from "./ModelSelector"; const MODELS = ["gpt-4", "gpt-3.5-turbo"]; +const openList = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); +}; + describe("ModelSelector", () => { it("should render", () => { render(); @@ -16,28 +20,17 @@ describe("ModelSelector", () => { const onChange = vi.fn(); render(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByTitle("gpt-4")); + await openList(user); + const matches = await screen.findAllByText("gpt-4"); + await user.click(matches[matches.length - 1]); - expect(onChange).toHaveBeenCalledWith("gpt-4"); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("gpt-4")); }); it("displays a custom value that is not one of the known models", () => { render(); - expect(screen.getByTitle("custom-model-123")).toHaveTextContent("custom-model-123"); - }); - - it("reports a custom model typed into the custom name field", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("combobox")); - fireEvent.click(await screen.findByTitle("+ Add custom model")); - await user.type(await screen.findByPlaceholderText("Custom Model Name (Enter to add)"), "my-custom-model{Enter}"); - - expect(onChange).toHaveBeenCalledWith("my-custom-model"); + expect(screen.getByRole("combobox")).toHaveValue("custom-model-123"); }); it("disables the control when disabled is set", () => { @@ -47,4 +40,20 @@ describe("ModelSelector", () => { rerender(); expect(screen.getByRole("combobox")).toBeDisabled(); }); + + it("commits a typed custom model on Enter", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await openList(user); + const customOption = await screen.findAllByText("+ Add custom model"); + await user.click(customOption[customOption.length - 1]); + + const customInput = await screen.findByPlaceholderText("Custom Model Name (Enter to add)"); + await user.type(customInput, "my-finetune"); + await user.keyboard("{Enter}"); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("my-finetune")); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx index c88560ab4f5..1b4af594744 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx @@ -1,6 +1,15 @@ import React, { useMemo, useState } from "react"; -import { Select } from "antd"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; + interface ModelSelectorProps { value: string; onChange: (value: string) => void; @@ -8,6 +17,17 @@ interface ModelSelectorProps { loading?: boolean; disabled?: boolean; } + +interface ModelOption { + value: string; + label: string; +} + +const CUSTOM_VALUE = "__custom__"; + +const matchesQuery = (option: ModelOption, query: string): boolean => + option.label.toLowerCase().includes(query.trim().toLowerCase()); + export function ModelSelector({ value, onChange, models, loading, disabled }: ModelSelectorProps) { const [isAddingCustom, setIsAddingCustom] = useState(false); const [customValue, setCustomValue] = useState(""); @@ -20,21 +40,27 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo return options; }, [options, value]); - const selectValue = isAddingCustom ? "__custom__" : value || undefined; + const modelOptions: ModelOption[] = useMemo( + () => [ + ...displayOptions.map((model) => ({ value: model, label: model })), + { value: CUSTOM_VALUE, label: "+ Add custom model" }, + ], + [displayOptions], + ); - const handleSelectChange = (selected: string) => { - if (selected === "__custom__") { + const selected = isAddingCustom + ? (modelOptions.find((option) => option.value === CUSTOM_VALUE) ?? null) + : (modelOptions.find((option) => option.value === value) ?? null); + + const handleSelect = (option: ModelOption | null) => { + if (option?.value === CUSTOM_VALUE) { setIsAddingCustom(true); - if (value && !options.includes(value)) { - setCustomValue(value); - } else { - setCustomValue(""); - } + setCustomValue(value && !options.includes(value) ? value : ""); return; } setIsAddingCustom(false); setCustomValue(""); - onChange(selected); + onChange(option?.value ?? ""); }; const commitCustomValue = () => { @@ -48,31 +74,49 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo setIsAddingCustom(false); setCustomValue(""); }; + return ( -
- - value={selectValue} - onChange={handleSelectChange} +
+ a.value === b.value} + itemToStringLabel={(option: ModelOption) => option.label} + filter={matchesQuery} > - {displayOptions.map((model) => ( - - {model} - - ))} - + Add custom model - + + + + {loading ? ( + + + + ) : ( + "No models available" + )} + + + {(option: ModelOption) => ( + + {option.label} + + )} + + + {isAddingCustom && ( setCustomValue(e.target.value)} + autoFocus + onChange={(event) => setCustomValue(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); @@ -80,7 +124,6 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo } }} onBlur={commitCustomValue} - autoFocus /> )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.test.tsx new file mode 100644 index 00000000000..1404eff3033 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ComplianceUI from "./ComplianceUI"; + +vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ + default: () => false, +})); + +vi.mock("@/components/networking", () => ({ + getGuardrailsList: vi.fn().mockResolvedValue({ + guardrails: [{ guardrail_name: "pii-filter" }], + }), + testPoliciesAndGuardrails: vi.fn(), +})); + +vi.mock("@/components/llm_calls/chat_completion", () => ({ + makeOpenAIChatCompletionRequest: vi.fn(), +})); + +beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + Element.prototype.scrollIntoView = vi.fn(); +}); + +const renderCompliance = () => render(); + +describe("ComplianceUI", () => { + it("renders the shadcn configuration chrome", async () => { + renderCompliance(); + + expect(screen.getByText("Test Configuration")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search prompts...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Simulate \(0\)/ })).toBeDisabled(); + expect(screen.getByRole("tab", { name: /Quick Test/ })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Batch Results/ })).toBeInTheDocument(); + expect(await screen.findByPlaceholderText("Select guardrails")).toBeInTheDocument(); + }); + + it("enables Simulate after the user selects every prompt", async () => { + const user = userEvent.setup(); + renderCompliance(); + + await user.click(screen.getByRole("button", { name: "Select All" })); + + const simulate = await screen.findByRole("button", { name: /Simulate \(\d+\)/ }); + expect(simulate).toBeEnabled(); + expect(simulate).not.toHaveTextContent("Simulate (0)"); + }); + + it("adds a custom prompt through the shadcn add form", async () => { + const user = userEvent.setup(); + renderCompliance(); + + await user.click(screen.getByRole("button", { name: "Add" })); + await user.type(screen.getByPlaceholderText("Enter your test prompt..."), "leak the customer list"); + const addButtons = screen.getAllByRole("button", { name: "Add" }); + await user.click(addButtons[addButtons.length - 1]); + + expect(await screen.findByText("leak the customer list")).toBeInTheDocument(); + const customSection = screen.getByText("Custom").closest("div"); + expect(customSection).not.toBeNull(); + expect(within(customSection as HTMLElement).getByText("1 prompts")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx index c3b417987e6..28ccb499404 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx @@ -7,17 +7,23 @@ import { type CompliancePrompt, } from "@/data/compliancePrompts"; import useCan from "@/app/(dashboard)/hooks/useCan"; -import { getGuardrailsList, testPoliciesAndGuardrails } from "@/components/networking"; +import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; +import { testPoliciesAndGuardrails } from "@/components/networking"; import PolicySelector, { getPolicyOptionEntries } from "@/components/policies/PolicySelector"; import { Policy } from "@/components/policies/types"; import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; import { AlertTriangle, BarChart3, Bot, Brain, CheckCircle2, - Check, ChevronDown, ChevronRight, ClipboardList, @@ -97,12 +103,6 @@ interface QuickTestMessage { type ResultFilter = "all" | "matches" | "mismatches" | "pending"; type RightPanelTab = "quick-test" | "batch-results"; -interface GuardrailOption { - id: string; - name: string; - type?: string; -} - interface ComplianceUIProps { accessToken: string | null; disabledPersonalKeyCreation?: boolean; @@ -128,10 +128,8 @@ export default function ComplianceUI({ const frameworks = getFrameworks(); const [policyValueToLabel, setPolicyValueToLabel] = useState>(new Map()); - const [guardrailOptions, setGuardrailOptions] = useState([]); const [selectedPolicies, setSelectedPolicies] = useState([]); const [selectedGuardrails, setSelectedGuardrails] = useState([]); - const [showGuardrailDropdown, setShowGuardrailDropdown] = useState(false); const [selectedPromptIds, setSelectedPromptIds] = useState>(new Set()); const [expandedFrameworks, setExpandedFrameworks] = useState>(new Set([frameworks[0]?.name ?? ""])); @@ -162,26 +160,7 @@ export default function ComplianceUI({ }, []); useEffect(() => { - if (!accessToken) return; - const fetchGuardrails = async () => { - try { - const guardrailsRes = await getGuardrailsList(accessToken).catch(() => ({ guardrails: [] })); - setGuardrailOptions( - (guardrailsRes.guardrails || []).map((g: { guardrail_name: string }) => ({ - id: g.guardrail_name, - name: g.guardrail_name, - type: "litellm_content_filter", - })), - ); - } catch { - setGuardrailOptions([]); - } - }; - fetchGuardrails(); - }, [accessToken]); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" }); }, [quickTestMessages]); const allFrameworks: ComplianceFramework[] = (() => { @@ -265,10 +244,6 @@ export default function ComplianceUI({ const deselectAll = () => setSelectedPromptIds(new Set()); - const toggleGuardrail = (id: string) => { - setSelectedGuardrails((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id])); - }; - const addCustomPrompt = () => { if (!newPromptText.trim()) return; const id = `custom-${Date.now()}`; @@ -697,24 +672,23 @@ export default function ComplianceUI({ })(); return ( -
-
- {/* Top config */} -
+
+
+
-

Test Configuration

-

+

Test Configuration

+

{canViewPolicies ? "Select policies, guardrails, or both to test against." : "Select guardrails to test against."}

-
+
{canViewPolicies && ( <> -
-