From afebf559aafe1cc5765b9031a056f44cdd30601e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 11 Feb 2026 16:20:56 -0800 Subject: [PATCH 1/2] fix auto router description textinput --- .../add_model/RouterConfigBuilder.test.tsx | 296 ++++++++++++++++++ .../add_model/RouterConfigBuilder.tsx | 277 ++++++++++++++++ .../add_model/add_auto_router_tab.tsx | 2 +- .../add_model/router_config_builder.tsx | 281 ----------------- .../edit_auto_router_modal.tsx | 2 +- 5 files changed, 575 insertions(+), 283 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx delete mode 100644 ui/litellm-dashboard/src/components/add_model/router_config_builder.tsx diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx new file mode 100644 index 00000000000..fb2f15c621a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx @@ -0,0 +1,296 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import RouterConfigBuilder from "./RouterConfigBuilder"; + +const MOCK_MODEL_INFO = [ + { model_group: "gpt-4", mode: "chat" }, + { model_group: "gpt-3.5-turbo", mode: "chat" }, + { model_group: "claude-3-opus", mode: "chat" }, +]; + +describe("RouterConfigBuilder", () => { + it("should render", () => { + render(); + + expect(screen.getByText("Routes Configuration")).toBeInTheDocument(); + }); + + it("should display Add Route button", () => { + render(); + + expect(screen.getByRole("button", { name: /add route/i })).toBeInTheDocument(); + }); + + it("should show empty state when no routes are configured", () => { + render(); + + expect(screen.getByText(/no routes configured/i)).toBeInTheDocument(); + }); + + it("should add a route when Add Route is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /add route/i })); + + expect(screen.getByText("Route 1: Unnamed")).toBeInTheDocument(); + }); + + it("should call onChange when a route is added", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /add route/i })); + + expect(onChange).toHaveBeenCalledWith({ + routes: [ + expect.objectContaining({ + name: "", + utterances: [], + description: "", + score_threshold: 0.5, + }), + ], + }); + }); + + it("should initialize routes from value prop", async () => { + const value = { + routes: [ + { + name: "gpt-4", + utterances: ["hello", "hi"], + description: "For greetings", + score_threshold: 0.7, + }, + ], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + }); + + it("should support both name and model fields in value prop", async () => { + const value = { + routes: [{ model: "gpt-3.5-turbo", utterances: [], description: "", score_threshold: 0.5 }], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-3.5-turbo")).toBeInTheDocument(); + }); + }); + + it("should remove a route when delete button is clicked", async () => { + const user = userEvent.setup(); + const value = { + routes: [ + { + name: "gpt-4", + utterances: [], + description: "", + score_threshold: 0.5, + }, + ], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + + const deleteButton = screen.getByRole("button", { name: "delete" }); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.queryByText("Route 1: gpt-4")).not.toBeInTheDocument(); + expect(screen.getByText(/no routes configured/i)).toBeInTheDocument(); + }); + }); + + it("should call onChange when route is removed", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const value = { + routes: [ + { + name: "gpt-4", + utterances: [], + description: "", + score_threshold: 0.5, + }, + ], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + + const deleteButton = screen.getByRole("button", { name: "delete" }); + await user.click(deleteButton); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ routes: [] }); + }); + }); + + + + + it("should update route when description is changed", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const value = { + routes: [ + { + name: "gpt-4", + utterances: [], + description: "", + score_threshold: 0.5, + }, + ], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + + const descriptionInput = screen.getByPlaceholderText("Describe when this route should be used..."); + await user.type(descriptionInput, "For code generation"); + + await waitFor(() => { + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0].routes[0].description).toBe("For code generation"); + }); + }); + + it("should update route when score threshold is changed", async () => { + const onChange = vi.fn(); + const value = { + routes: [ + { + name: "gpt-4", + utterances: [], + description: "", + score_threshold: 0.5, + }, + ], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + + const scoreInput = screen.getByRole("spinbutton"); + fireEvent.change(scoreInput, { target: { value: "0.9" } }); + + await waitFor(() => { + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0].routes[0].score_threshold).toBe(0.9); + }); + }); + + it("should add multiple routes", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /add route/i })); + await user.click(screen.getByRole("button", { name: /add route/i })); + + expect(screen.getByText("Route 1: Unnamed")).toBeInTheDocument(); + expect(screen.getByText("Route 2: Unnamed")).toBeInTheDocument(); + }); + + it("should toggle JSON preview visibility", async () => { + const user = userEvent.setup(); + const { container } = render(); + + expect(screen.getByText("JSON Preview")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show" })).toBeInTheDocument(); + expect(container.querySelector("pre")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show" })); + + expect(screen.getByRole("button", { name: "Hide" })).toBeInTheDocument(); + expect(container.querySelector("pre")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Hide" })); + + expect(screen.getByRole("button", { name: "Show" })).toBeInTheDocument(); + expect(container.querySelector("pre")).not.toBeInTheDocument(); + }); + + it("should display JSON preview with route data when routes exist", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "Show" })); + + const preElement = container.querySelector("pre"); + expect(preElement).toBeInTheDocument(); + expect(preElement?.textContent).toContain("gpt-4"); + expect(preElement?.textContent).toContain("hello"); + expect(preElement?.textContent).toContain("0.8"); + }); + + it("should display model selector with options from modelInfo", async () => { + const value = { + routes: [ + { name: "", utterances: [], description: "", score_threshold: 0.5 }, + ], + }; + render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: Unnamed")).toBeInTheDocument(); + }); + + expect(screen.getByText("Model")).toBeInTheDocument(); + const comboboxes = screen.getAllByRole("combobox"); + expect(comboboxes.length).toBeGreaterThan(0); + }); + + it("should clear routes when value prop changes to empty", async () => { + const value = { + routes: [ + { + name: "gpt-4", + utterances: [], + description: "", + score_threshold: 0.5, + }, + ], + }; + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText(/no routes configured/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx new file mode 100644 index 00000000000..645fae01725 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx @@ -0,0 +1,277 @@ +import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from "@ant-design/icons"; +import { Select as AntdSelect, Button, Card, Collapse, Divider, Empty, Flex, Input, InputNumber, Space, Tooltip, Typography } from "antd"; +import React, { useEffect, useState } from "react"; +import { ModelGroup } from "../playground/llm_calls/fetch_models"; + +const { Text } = Typography; + +const { TextArea } = Input; + +interface Route { + id: string; + model: string; + utterances: string[]; + description: string; + score_threshold: number; +} + +interface SavedRoute { + id?: string; + name?: string; + model?: string; + utterances?: string[]; + description?: string; + score_threshold?: number; +} + +interface RouterConfig { + routes?: SavedRoute[]; +} + +interface RouterConfigBuilderProps { + modelInfo: ModelGroup[]; + value?: RouterConfig; + onChange?: (config: any) => void; +} + +const RouterConfigBuilder: React.FC = ({ modelInfo, value, onChange }) => { + const [routes, setRoutes] = useState([]); + const [showJsonPreview, setShowJsonPreview] = useState(false); + const [expandedRoutes, setExpandedRoutes] = useState([]); + + // Initialize routes from value prop - preserve existing route IDs to avoid focus loss when parent re-renders + useEffect(() => { + const routesFromValue = value?.routes; + if (routesFromValue) { + const routeIds: string[] = []; + setRoutes((prevRoutes) => { + const initializedRoutes = routesFromValue.map((route: SavedRoute, index: number) => { + const existingRoute = prevRoutes[index]; + const id = existingRoute?.id || route.id || `route-${index}-${Date.now()}`; + routeIds.push(id); + return { + id, + model: route.name || route.model || "", // handle both 'name' and 'model' fields + utterances: route.utterances || [], + description: route.description || "", + score_threshold: route.score_threshold || 0.5, + }; + }); + return initializedRoutes; + }); + setExpandedRoutes(routeIds); + } else { + setRoutes([]); + setExpandedRoutes([]); + } + }, [value]); + + // Handle adding a new route + const addRoute = () => { + const newRouteId = `route-${Date.now()}`; + const newRoute: Route = { + id: newRouteId, + model: "", + utterances: [], + description: "", + score_threshold: 0.5, + }; + const updatedRoutes = [...routes, newRoute]; + setRoutes(updatedRoutes); + updateConfig(updatedRoutes); + // Automatically expand the new route + setExpandedRoutes((prev) => [...prev, newRouteId]); + }; + + // Handle removing a route + const removeRoute = (routeId: string) => { + const updatedRoutes = routes.filter((route) => route.id !== routeId); + setRoutes(updatedRoutes); + updateConfig(updatedRoutes); + // Remove from expanded routes as well + setExpandedRoutes((prev) => prev.filter((id) => id !== routeId)); + }; + + // Handle updating a route + const updateRoute = (routeId: string, field: keyof Route, value: any) => { + const updatedRoutes = routes.map((route) => (route.id === routeId ? { ...route, [field]: value } : route)); + setRoutes(updatedRoutes); + updateConfig(updatedRoutes); + }; + + // Update the overall configuration + const updateConfig = (updatedRoutes: Route[]) => { + const config = { + routes: updatedRoutes.map((route) => ({ + name: route.model, + utterances: route.utterances, + description: route.description, + score_threshold: route.score_threshold, + })), + }; + onChange?.(config); + }; + + // Handle utterances change (convert textarea string to array) + const handleUtterancesChange = (routeId: string, utterancesText: string) => { + const utterancesArray = utterancesText + .split("\n") + .map((line) => line.trim()) // Only trims leading/trailing whitespace, preserves internal spaces + .filter((line) => line.length > 0); + updateRoute(routeId, "utterances", utterancesArray); + }; + + // Prepare model options for dropdowns + const modelOptions = modelInfo.map((model) => ({ + value: model.model_group, + label: model.model_group, + })); + + const generateConfig = () => { + return { + routes: routes.map((route) => ({ + name: route.model, + utterances: route.utterances, + description: route.description, + score_threshold: route.score_threshold, + })), + }; + }; + + return ( +
+ + + Routes Configuration + + + + + + + + {/* Routes */} + {routes.length === 0 ? ( + + + + ) : ( + setExpandedRoutes(Array.isArray(keys) ? keys : [keys].filter(Boolean))} + style={{ width: "100%" }} + items={routes.map((route, index) => ({ + key: route.id, + label: ( + + Route {index + 1}: {route.model || "Unnamed"} + + ), + extra: ( +