From 25d97f50c755fda196a31d435861db1d76daf751 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 17 Nov 2025 19:50:41 -0800 Subject: [PATCH] Fallbacks icon button tooltips and delete with friction (#16737) Co-authored-by: Krish Dholakia --- .../src/components/fallbacks.test.tsx | 117 +++++++++++++++ .../src/components/fallbacks.tsx | 139 +++++++++++------- .../components/templates/key_edit_view.tsx | 2 +- .../view_logs/RequestResponsePanel.tsx | 14 +- 4 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/fallbacks.test.tsx diff --git a/ui/litellm-dashboard/src/components/fallbacks.test.tsx b/ui/litellm-dashboard/src/components/fallbacks.test.tsx new file mode 100644 index 0000000000..45aa06dbaf --- /dev/null +++ b/ui/litellm-dashboard/src/components/fallbacks.test.tsx @@ -0,0 +1,117 @@ +import { render, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Fallbacks from "./fallbacks"; +import { getCallbacksCall, setCallbacksCall } from "./networking"; + +vi.mock("./networking", () => ({ + getCallbacksCall: vi.fn(), + setCallbacksCall: vi.fn(), +})); + +vi.mock("./molecules/notifications_manager", () => ({ + __esModule: true, + default: { + success: vi.fn(), + fromBackend: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + clear: vi.fn(), + }, +})); + +vi.mock("./add_fallbacks", () => ({ + __esModule: true, + default: () =>
Mock Add Fallbacks
, +})); + +vi.mock("openai", () => ({ + default: { + OpenAI: vi.fn().mockImplementation(() => ({ + chat: { + completions: { + create: vi.fn(), + }, + }, + })), + }, +})); + +// Polyfill ResizeObserver for components relying on it in tests +if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +describe("Fallbacks", () => { + const defaultProps = { + accessToken: "token", + userRole: "admin", + userID: "user-123", + modelData: { data: [] }, + }; + const mockGetCallbacksCall = vi.mocked(getCallbacksCall); + const mockSetCallbacksCall = vi.mocked(setCallbacksCall); + + beforeEach(() => { + vi.clearAllMocks(); + mockGetCallbacksCall.mockResolvedValue({ + router_settings: { + fallbacks: [], + }, + }); + }); + + it("should render an empty table with headers when access token is provided", async () => { + const { getByText } = render(); + + await waitFor(() => { + expect(getByText("Model Name")).toBeInTheDocument(); + expect(getByText("Fallbacks")).toBeInTheDocument(); + expect(getByText("Actions")).toBeInTheDocument(); + }); + }); + + it("should render fallback data when callback data is returned from network call", async () => { + const mockFallbackData = { + router_settings: { + fallbacks: [{ "xai/grok-2": ["xai/grok-4", "gpt-4"] }, { "gpt-3.5-turbo": ["gpt-4"] }], + }, + }; + + mockGetCallbacksCall.mockResolvedValue(mockFallbackData); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText("xai/grok-2")).toBeInTheDocument(); + expect(getByText("xai/grok-4, gpt-4")).toBeInTheDocument(); + expect(getByText("gpt-3.5-turbo")).toBeInTheDocument(); + expect(getByText("gpt-4")).toBeInTheDocument(); + }); + + expect(mockGetCallbacksCall).toHaveBeenCalledWith( + defaultProps.accessToken, + defaultProps.userID, + defaultProps.userRole, + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/fallbacks.tsx b/ui/litellm-dashboard/src/components/fallbacks.tsx index 1badc452ef..cacf3869b1 100644 --- a/ui/litellm-dashboard/src/components/fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/fallbacks.tsx @@ -1,22 +1,14 @@ -import React, { useState, useEffect } from "react"; -import { - Table, - TableHead, - TableRow, - TableHeaderCell, - TableCell, - TableBody, - Button, - Icon, -} from "@tremor/react"; -import { - getCallbacksCall, - setCallbacksCall, -} from "./networking"; -import { TrashIcon } from "@heroicons/react/outline"; -import AddFallbacks from "./add_fallbacks"; +import { PlayIcon, TrashIcon } from "@heroicons/react/outline"; +import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; +import { Modal, Tooltip } from "antd"; import openai from "openai"; +import React, { useEffect, useState } from "react"; +import AddFallbacks from "./add_fallbacks"; import NotificationsManager from "./molecules/notifications_manager"; +import { getCallbacksCall, setCallbacksCall } from "./networking"; + +type FallbackEntry = { [modelName: string]: string[] }; +type Fallbacks = FallbackEntry[]; interface FallbacksProps { accessToken: string | null; @@ -30,7 +22,6 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str if (isLocal != true) { console.log = function () {}; } - console.log("isLocal:", isLocal); const proxyBaseUrl = isLocal ? "http://localhost:4000" : window.location.origin; const client = new openai.OpenAI({ apiKey: accessToken, @@ -39,6 +30,8 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str }); try { + NotificationsManager.info("Testing fallback model response..."); + const response = await client.chat.completions.create({ model: selectedModel, messages: [ @@ -73,6 +66,8 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str const Fallbacks: React.FC = ({ accessToken, userRole, userID, modelData }) => { const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({}); + const [isDeleting, setIsDeleting] = useState(false); + const [fallbackToDelete, setFallbackToDelete] = useState(null); useEffect(() => { if (!accessToken || !userRole || !userID) { @@ -88,22 +83,29 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID, mo }); }, [accessToken, userRole, userID]); - const deleteFallbacks = async (key: string) => { - if (!accessToken) { + const handleDeleteClick = (fallbackEntry: FallbackEntry) => { + setFallbackToDelete(fallbackEntry); + }; + + const handleDeleteConfirm = async () => { + if (!fallbackToDelete || !accessToken) { return; } - console.log(`received key: ${key}`); - console.log(`routerSettings['fallbacks']: ${routerSettings["fallbacks"]}`); + const key = Object.keys(fallbackToDelete)[0]; + if (!key) { + return; + } + setIsDeleting(true); const updatedFallbacks = routerSettings["fallbacks"] - .map((dict: { [key: string]: any }) => { - if (key in dict) { + .map((dict: FallbackEntry) => { + if (key in dict && Array.isArray(dict[key])) { delete dict[key]; } return dict; }) - .filter((dict: { [key: string]: any }) => Object.keys(dict).length > 0); + .filter((dict: FallbackEntry) => Object.keys(dict).length > 0); const updatedSettings = { ...routerSettings, @@ -120,50 +122,83 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID, mo NotificationsManager.success("Router settings updated successfully"); } catch (error) { NotificationsManager.fromBackend("Failed to update router settings: " + error); + } finally { + setIsDeleting(false); + setFallbackToDelete(null); } }; + const handleDeleteCancel = () => { + setFallbackToDelete(null); + }; + if (!accessToken) { return null; } return ( <> - - - - Model Name - Fallbacks - - - - - {routerSettings["fallbacks"] && - routerSettings["fallbacks"].map((item: object, index: number) => - Object.entries(item).map(([key, value]) => ( - - {key} - {Array.isArray(value) ? value.join(", ") : value} - - - - - deleteFallbacks(key)} /> - - - )), - )} - -
data.model_name) : []} accessToken={accessToken} routerSettings={routerSettings} setRouterSettings={setRouterSettings} /> + + + + Model Name + Fallbacks + Actions + + + + + {routerSettings["fallbacks"] && + routerSettings["fallbacks"].map((item: FallbackEntry, index: number) => + Object.entries(item).map(([key, value]) => ( + + {key} + {Array.isArray(value) ? value.join(", ") : value} + + + testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} + className="cursor-pointer hover:text-blue-600" + /> + + + handleDeleteClick(item)} + className="cursor-pointer hover:text-red-600" + /> + + + + )), + )} + +
+ {fallbackToDelete && ( + +

Are you sure you want to delete fallback: {Object.keys(fallbackToDelete)[0]} ?

+

This action cannot be undone.

+
+ )} ); }; export default Fallbacks; - diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 756b50b3df..915e1e4624 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -517,7 +517,7 @@ export function KeyEditView({ diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestResponsePanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestResponsePanel.tsx index 8f459e76b6..407d99fdd7 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestResponsePanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestResponsePanel.tsx @@ -97,7 +97,12 @@ export function RequestResponsePanel({
             {/* {JSON.stringify(getRawRequest(), null, 2)} */}
-            
+            
           
@@ -135,7 +140,12 @@ export function RequestResponsePanel({ {hasResponse ? (
               {/* {JSON.stringify(formattedResponse(), null, 2)} */}
-              
+              
             
) : (
Response data not available