From 3fdacfa6f95d3d82f44b974a4f056a1f7a381175 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 13 Aug 2026 12:40:42 -0700 Subject: [PATCH] fix(ui): restore playground model filtering by endpoint (#36130) * fix(ui): restore playground model filtering by endpoint Bring back the prior Chat model dropdown filter (including chat models on responses/anthropic/interactions and image models on image_edits), and map mode realtime so the realtime endpoint only lists compatible models * fix(ui): exclude unknown model modes from playground endpoint filters Modes outside ModelMode (batch, rerank, ocr, etc.) must not collapse to chat-compatible, or conversational endpoints surface unusable models * feat(ui): add shared vercel-style playground chat composer (#36131) * feat(ui): adopt vercel-style chat composer for playground Replace the compact single-line input with a PromptInput-style composer: taller auto-growing textarea, rounded card shell, footer tools, and stop button while a request is in flight * style(ui): strengthen playground chat composer border and shadow Make the shared chat input stand out with a fuller border, layered shadow, and a slightly stronger focus ring * fix(ui): size chat composer textarea with CSS field-sizing Drop direct el.style.height mutation in favor of field-sizing:content * fix(ui): keep the chat composer out of a nested form and focus its textarea The composer wrapped everything in a native form, so MCP mode nested Ant Design's tool-arguments form inside it, which is invalid HTML and let Enter hit either form. The footer also relied on InputGroupAddon focusing the first input in the group, which is the hidden file input from the attach controls rather than the message textarea. Drop the outer form and submit from the send button directly, and have the addon focus the element marked as the group's control. * refactor(ui): reuse the endpoint compatibility check when a model is picked The endpoint guard added upstream duplicated the compatibility families this PR introduces, so point it at isModelCompatibleWithEndpoint instead. Filtering also means an incompatible model is no longer offered for an endpoint, so the test that picked one now asserts it is absent. * fix(ui): match the image-edit model mode the backend actually sends model_prices_and_context_window.json labels these models image_edit, but the mode enum spelled it image_edits, so once unknown modes started being filtered out every image-edit model vanished from the playground, /v1/images/edits included. The endpoint key keeps its own spelling. The compatibility tests stubbed getEndpointType with a hand-written map that repeated the same wrong spelling, which is how this stayed hidden, so they now run against the real mapping. --- .../components/chat_ui/ChatComposer.test.tsx | 122 ++++++++++++ .../components/chat_ui/ChatComposer.tsx | 173 ++++++++++++++++++ .../components/chat_ui/ChatUI.test.tsx | 78 +++++++- .../playground/components/chat_ui/ChatUI.tsx | 166 +++++------------ .../components/chat_ui/EndpointUtils.test.tsx | 101 +++++++--- .../components/chat_ui/EndpointUtils.tsx | 43 +++-- .../chat_ui/mode_endpoint_mapping.tsx | 5 +- .../src/components/ui/input-group.tsx | 2 +- 8 files changed, 521 insertions(+), 169 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx 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 8e242e6ec35..f288f0841f1 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 f3d79dd3a2f..2fda11acd4b 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 { @@ -506,14 +506,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(); @@ -1149,27 +1141,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)); } }; @@ -1188,11 +1165,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 = @@ -1422,7 +1405,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, @@ -2004,27 +1987,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:")) { @@ -2105,45 +2062,10 @@ const ChatUI: React.FC = ({ Loading tool schema...
); - })()} -
- ) : ( -