diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx new file mode 100644 index 00000000000..f546b58215a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ChatComposer } from "./ChatComposer"; + +const renderComposer = (props: Partial> = {}) => + render( + , + ); + +const addonOf = (container: HTMLElement) => + container.querySelector("[data-slot=input-group-addon]") as HTMLElement; + +describe("ChatComposer", () => { + it("should submit on Enter", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("should not submit on Shift+Enter", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", shiftKey: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should not submit while an IME composition is active", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", isComposing: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should not submit on Enter or click when submitDisabled", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit, submitDisabled: true }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" }); + fireEvent.click(screen.getByTestId("chat-send-button")); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should submit when the send button is clicked", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.click(screen.getByTestId("chat-send-button")); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("should swap send for a stop button that cancels while loading", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + renderComposer({ onSubmit, onCancel, isLoading: true }); + + expect(screen.queryByTestId("chat-send-button")).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("chat-stop-button")); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should render suggestions only when asked and report the chosen one", () => { + const onSuggestionSelect = vi.fn(); + const { rerender } = renderComposer({ suggestions: ["Summarize this"], onSuggestionSelect }); + + expect(screen.queryByTestId("chat-suggested-actions")).not.toBeInTheDocument(); + + rerender( + , + ); + fireEvent.click(screen.getByText("Summarize this")); + + expect(onSuggestionSelect).toHaveBeenCalledWith("Summarize this"); + }); + + it("should not nest a form inside the composer when body renders one", () => { + const { container } = renderComposer({ + body: ( +
+ +
+ ), + }); + + expect(container.querySelectorAll("form")).toHaveLength(1); + expect(screen.getByTestId("body-form")).toBeInTheDocument(); + }); + + it("should focus the message box, not a tool input, when the toolbar gap is clicked", () => { + const { container } = renderComposer({ + tools: , + }); + + fireEvent.click(addonOf(container)); + + expect(document.activeElement).toBe(screen.getByTestId("chat-composer-input")); + }); +}); 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 new file mode 100644 index 00000000000..cc541c939ad --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import { ArrowUp, Code2, Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; + +interface ChatComposerProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onCancel?: () => void; + placeholder: string; + disabled?: boolean; + isLoading?: boolean; + submitDisabled?: boolean; + tools?: React.ReactNode; + body?: React.ReactNode; + suggestions?: string[]; + showSuggestions?: boolean; + onSuggestionSelect?: (suggestion: string) => void; + className?: string; +} + +export function ChatComposer({ + value, + onChange, + onSubmit, + onCancel, + placeholder, + disabled = false, + isLoading = false, + submitDisabled = false, + tools, + body, + suggestions = [], + showSuggestions = false, + onSuggestionSelect, + className, +}: ChatComposerProps) { + const submitIfAllowed = () => { + if (!submitDisabled && !isLoading) { + onSubmit(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + submitIfAllowed(); + } + }; + + return ( +
+ {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} + +
+ + {body ? ( +
{body}
+ ) : ( + onChange(event.target.value)} + onKeyDown={handleKeyDown} + /> + )} + + +
{tools}
+ + {isLoading && onCancel ? ( + + + + ) : ( + + + + )} +
+
+
+
+ ); +} + +interface CodeInterpreterToggleProps { + enabled: boolean; + onToggle: () => void; +} + +export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) { + return ( + + + } + > + + + + {enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"} + + + ); +} + +export default ChatComposer; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index 32f1013f386..de079cdebc9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -118,6 +118,74 @@ describe("ChatUI", () => { }); }); + it("shows only endpoint-compatible models when chat endpoint is selected", async () => { + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ + { model_group: "ChatModel", mode: "chat" }, + { model_group: "SpeechModel", mode: "audio_speech" }, + { model_group: "ImageModel", mode: "image_generation" }, + { model_group: "ResponsesModel", mode: "responses" }, + { model_group: "RealtimeModel", mode: "realtime" }, + { model_group: "NoModeModel" }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await selectComboboxOption("Select an endpoint", "/v1/chat/completions"); + await openComboboxByPlaceholder("Select a Model"); + + await waitFor(() => { + expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0); + expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0); + expect(screen.queryByText("SpeechModel")).toBeNull(); + expect(screen.queryByText("ImageModel")).toBeNull(); + expect(screen.queryByText("ResponsesModel")).toBeNull(); + expect(screen.queryByText("RealtimeModel")).toBeNull(); + }); + }); + + it("shows only realtime models when realtime endpoint is selected", async () => { + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ + { model_group: "ChatModel", mode: "chat" }, + { model_group: "RealtimeModel", mode: "realtime" }, + { model_group: "NoModeModel" }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await selectComboboxOption("Select an endpoint", "/v1/realtime"); + await openComboboxByPlaceholder("Select a Model"); + + await waitFor(() => { + expect(screen.getAllByText("RealtimeModel").length).toBeGreaterThan(0); + expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0); + expect(screen.queryByText("ChatModel")).toBeNull(); + }); + }); + it("should show 'Enter custom model' option in model selector", async () => { render( { expect(screen.getByPlaceholderText("Select an endpoint")).toHaveValue("/v1/responses"); }); - it("should still switch endpoint when the picked model cannot be served by it", async () => { + it("should not offer a model the selected endpoint cannot serve", async () => { (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ + { model_group: "ChatModel", mode: "chat" }, { model_group: "SpeechModel", mode: "audio_speech" }, ]); @@ -447,9 +516,12 @@ describe("ChatUI", () => { }); await selectComboboxOption("Select an endpoint", "/v1/responses"); - await selectComboboxOption("Select a Model", "SpeechModel"); + await openComboboxByPlaceholder("Select a Model"); - expect(screen.getByPlaceholderText("Select an endpoint")).toHaveValue("/v1/audio/speech"); + await waitFor(() => { + expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0); + }); + expect(screen.queryByText("SpeechModel")).toBeNull(); }); it("should attach an audio file dropped on the transcription upload area", async () => { 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 9ef44589240..e81b86d4b8b 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 @@ -1,7 +1,6 @@ "use client"; import { - ArrowUp, Bot, Code2, Database, @@ -48,11 +47,13 @@ import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api import { makeInteractionsRequest } from "../../llm_calls/interactions_api"; import AdditionalModelSettings from "./AdditionalModelSettings"; import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants"; +import ChatComposer, { CodeInterpreterToggle } from "./ChatComposer"; import ChatImageUpload from "./ChatImageUpload"; import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils"; import CodeInterpreterTool from "./CodeInterpreterTool"; import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets"; import EndpointSelector from "./EndpointSelector"; +import { filterModelsForEndpoint, isModelCompatibleWithEndpoint } from "./EndpointUtils"; import FilePreviewCard from "./FilePreviewCard"; import ChatMessageBubble from "./ChatMessageBubble"; import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay"; @@ -72,7 +73,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { @@ -507,14 +507,6 @@ const ChatUI: React.FC = ({ } }, [chatHistory]); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); // Prevent default to avoid newline - handleSendMessage(); - } - // If Shift+Enter is pressed, the default behavior (inserting a newline) will occur - }; - const handleCancelRequest = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -1150,27 +1142,12 @@ const ChatUI: React.FC = ({ NotificationsManager.success("Chat history cleared."); }; - const currentEndpointServes = (mode: string): boolean => { - const modelEndpoint = getEndpointType(mode); - if ( - endpointType === EndpointType.RESPONSES || - endpointType === EndpointType.ANTHROPIC_MESSAGES || - endpointType === EndpointType.INTERACTIONS - ) { - return modelEndpoint === endpointType || modelEndpoint === EndpointType.CHAT; - } - if (endpointType === EndpointType.IMAGE_EDITS) { - return modelEndpoint === endpointType || modelEndpoint === EndpointType.IMAGE; - } - return modelEndpoint === endpointType; - }; - const onModelChange = (value: string) => { setSelectedModel(value); setShowCustomModelInput(value === "custom"); const model = modelInfo.find((option) => option.model_group === value); - if (model?.mode && !currentEndpointServes(model.mode)) { + if (model?.mode && !isModelCompatibleWithEndpoint(model, endpointType as EndpointType)) { setEndpointType(getEndpointType(model.mode)); } }; @@ -1189,11 +1166,17 @@ const ChatUI: React.FC = ({ }; const supportsStreamingToggle = endpointType === EndpointType.CHAT || endpointType === EndpointType.RESPONSES; + const modelsForEndpoint = useMemo( + () => filterModelsForEndpoint(modelInfo, endpointType as EndpointType), + [modelInfo, endpointType], + ); let modelEmptyText = "No models available for this key"; if (modelLoadError) { modelEmptyText = "Unable to load models for this key"; } else if (apiKeySource === "custom" && !apiKey.trim()) { modelEmptyText = "Enter a Virtual Key to load models"; + } else if (modelInfo.length > 0 && modelsForEndpoint.length === 0) { + modelEmptyText = "No models available for this endpoint"; } const inputPlaceholder = @@ -1423,7 +1406,7 @@ const ChatUI: React.FC = ({ onValueChange={onModelChange} options={[ { value: "custom", label: "Enter custom model" }, - ...modelInfo.map((model) => ({ + ...modelsForEndpoint.map((model) => ({ value: model.model_group, label: model.model_group, sublabel: model.mode ? `Mode: ${model.mode}` : undefined, @@ -2005,27 +1988,24 @@ const ChatUI: React.FC = ({ )} - {chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP && ( -
- {(endpointType === EndpointType.A2A_AGENTS + ( - - ))} -
- )} - -
-
-
+ } + onSuggestionSelect={setInputMessage} + tools={ + <> {endpointType === EndpointType.RESPONSES && !responsesUploadedImage && ( = ({ /> )} {endpointType === EndpointType.RESPONSES && ( - - { - codeInterpreter.toggle(); - if (!codeInterpreter.enabled) { - NotificationsManager.success("Code Interpreter enabled!"); - } - }} - /> + { + codeInterpreter.toggle(); + if (!codeInterpreter.enabled) { + NotificationsManager.success("Code Interpreter enabled!"); } - > - - - - {codeInterpreter.enabled - ? "Code Interpreter enabled (click to disable)" - : "Enable Code Interpreter"} - - + }} + /> )} -
- - {endpointType === EndpointType.MCP && + + } + body={ + endpointType === EndpointType.MCP && selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && - selectedMCPDirectTool ? ( -
- {(() => { + selectedMCPDirectTool + ? (() => { const rawSel = selectedMCPServers[0]; let toolPool: MCPTool[] = []; if (rawSel.startsWith("toolset:")) { @@ -2106,45 +2063,10 @@ const ChatUI: React.FC = ({ Loading tool schema...
); - })()} -
- ) : ( -