diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx index 8a7119aa4a3..94bfbd54426 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx @@ -14,6 +14,10 @@ vi.mock("../../../playground/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ + useModelCostMap: vi.fn().mockReturnValue({ data: null }), +})); + vi.mock("openai", () => ({ default: { OpenAI: vi.fn().mockImplementation(() => ({ @@ -97,20 +101,9 @@ describe("Fallbacks", () => { modelData: mockModelData, }; - const findDeleteButton = (container: HTMLElement) => { - const tableRows = container.querySelectorAll("tbody tr"); - if (tableRows.length === 0) return null; - const firstRow = tableRows[0]; - const actionCells = firstRow.querySelectorAll("td"); - const lastCell = actionCells[actionCells.length - 1]; - const buttons = lastCell.querySelectorAll("button"); - if (buttons.length >= 2) { - return buttons[buttons.length - 1]; - } - const clickableElements = lastCell.querySelectorAll("[class*='cursor-pointer'], button"); - return Array.from(clickableElements).find((el) => - el.className.includes("red") || el.className.includes("hover:text-red") - ) || clickableElements[clickableElements.length - 1]; + const getFirstRowDeleteButton = () => { + const deleteButtons = screen.getAllByTestId("delete-fallback-button"); + return deleteButtons.length > 0 ? deleteButtons[0] : null; }; beforeEach(() => { @@ -156,20 +149,31 @@ describe("Fallbacks", () => { await waitFor(() => { expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); - expect(screen.getByText("gpt-3.5-turbo, claude-3-opus")).toBeInTheDocument(); - expect(screen.getByText("claude-3-opus")).toBeInTheDocument(); + expect(screen.getAllByText(/gpt-3\.5-turbo/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/claude-3-opus/).length).toBeGreaterThan(0); }); }); - it("should open delete modal when delete icon is clicked", async () => { - const user = userEvent.setup(); - const { container } = render(); + it("should show delete button for each fallback row when fallbacks exist", async () => { + render(); await waitFor(() => { expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); }); - const deleteButton = findDeleteButton(container); + const deleteButtons = screen.getAllByTestId("delete-fallback-button"); + expect(deleteButtons.length).toBe(2); + }); + + it("should open delete modal when delete icon is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + }); + + const deleteButton = getFirstRowDeleteButton(); expect(deleteButton).not.toBeNull(); await user.click(deleteButton as HTMLElement); @@ -182,13 +186,13 @@ describe("Fallbacks", () => { it("should delete fallback when confirmed", async () => { const user = userEvent.setup(); - const { container } = render(); + render(); await waitFor(() => { expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); }); - const deleteButton = findDeleteButton(container); + const deleteButton = getFirstRowDeleteButton(); expect(deleteButton).not.toBeNull(); await user.click(deleteButton as HTMLElement); @@ -210,13 +214,13 @@ describe("Fallbacks", () => { it("should close delete modal when cancel is clicked", async () => { const user = userEvent.setup(); - const { container } = render(); + render(); await waitFor(() => { expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); }); - const deleteButton = findDeleteButton(container); + const deleteButton = getFirstRowDeleteButton(); expect(deleteButton).not.toBeNull(); await user.click(deleteButton as HTMLElement); @@ -237,13 +241,13 @@ describe("Fallbacks", () => { const user = userEvent.setup(); const error = new Error("Delete failed"); vi.mocked(networkingModule.setCallbacksCall).mockRejectedValueOnce(error); - const { container } = render(); + render(); await waitFor(() => { expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); }); - const deleteButton = findDeleteButton(container); + const deleteButton = getFirstRowDeleteButton(); expect(deleteButton).not.toBeNull(); await user.click(deleteButton as HTMLElement); @@ -264,13 +268,13 @@ describe("Fallbacks", () => { const user = userEvent.setup(); const error = new Error("Delete failed"); vi.mocked(networkingModule.setCallbacksCall).mockRejectedValueOnce(error); - const { container } = render(); + render(); await waitFor(() => { expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); }); - const deleteButton = findDeleteButton(container); + const deleteButton = getFirstRowDeleteButton(); expect(deleteButton).not.toBeNull(); await user.click(deleteButton as HTMLElement); @@ -296,6 +300,9 @@ describe("Fallbacks", () => { await waitFor(() => { expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument(); + expect( + screen.getByText(/No fallbacks configured. Add fallbacks to automatically try another model/), + ).toBeInTheDocument(); }); expect(screen.queryByText("gpt-4")).not.toBeInTheDocument(); @@ -309,6 +316,9 @@ describe("Fallbacks", () => { await waitFor(() => { expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument(); + expect( + screen.getByText(/No fallbacks configured. Add fallbacks to automatically try another model/), + ).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index f493cc51323..9c5933aba3a 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -1,9 +1,11 @@ -import { PlayIcon, TrashIcon } from "@heroicons/react/outline"; +import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; +import { ArrowRightIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline"; import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; -import { Tooltip } from "antd"; +import { Tooltip, Typography } from "antd"; import openai from "openai"; import React, { useEffect, useState } from "react"; import DeleteResourceModal from "../../../common_components/DeleteResourceModal"; +import { ProviderLogo } from "../../../molecules/models/ProviderLogo"; import NotificationsManager from "../../../molecules/notifications_manager"; import { getCallbacksCall, setCallbacksCall } from "../../../networking"; import AddFallbacks from "./AddFallbacks"; @@ -11,6 +13,61 @@ import AddFallbacks from "./AddFallbacks"; type FallbackEntry = { [modelName: string]: string[] }; type Fallbacks = FallbackEntry[]; +const modelCardClass = + "inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-gray-200 bg-gray-50 text-sm font-medium text-gray-800 shrink-0"; + +function renderModelNameCell( + modelName: string, + getProviderFromModel?: (modelName: string) => string, +): React.ReactNode { + const provider = getProviderFromModel?.(modelName) ?? modelName; + return ( + + + {modelName} + + ); +} + +function renderFallbacksChain( + _primaryModel: string, + fallbackModels: string[], + getProviderFromModel?: (modelName: string) => string, +): React.ReactNode { + const list = Array.isArray(fallbackModels) ? fallbackModels : []; + if (list.length === 0) return null; + + const ChainCard = ({ modelName }: { modelName: string }) => { + const provider = getProviderFromModel?.(modelName) ?? modelName; + return ( + + + {modelName} + + ); + }; + return ( + + + + + + {list.map((model, i) => ( + + {i > 0 && ( + + )} + + + ))} + + + ); +} + interface FallbacksProps { accessToken: string | null; userRole: string | null; @@ -71,6 +128,14 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID, mo const [fallbackToDelete, setFallbackToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const { data: modelCostMapData } = useModelCostMap(); + const getProviderFromModel = (model: string): string => { + if (modelCostMapData != null && typeof modelCostMapData === "object" && model in modelCostMapData) { + return modelCostMapData[model]["litellm_provider"] ?? ""; + } + return ""; + }; + useEffect(() => { if (!accessToken || !userRole || !userID) { return; @@ -177,6 +242,8 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID, mo } }; + const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0; + return ( <> = ({ accessToken, userRole, userID, mo value={routerSettings.fallbacks || []} onChange={handleFallbacksChange} /> - - - - Model Name - Fallbacks - Actions - - + {!hasFallbacks ? ( +
+ + No fallbacks configured. Add fallbacks to automatically try another model when the primary + fails. + +
+ ) : ( +
+ + + Model Name + Fallbacks + Actions + + - - {routerSettings["fallbacks"] && - routerSettings["fallbacks"].map((item: FallbackEntry, index: number) => + + {routerSettings["fallbacks"].map((item: FallbackEntry, index: number) => Object.entries(item).map(([key, value]) => ( - {key} - {Array.isArray(value) ? value.join(", ") : value} - + + {renderModelNameCell(key, getProviderFromModel)} + + + {renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)} + + = ({ accessToken, userRole, userID, mo /> - handleDeleteClick(item)} - className="cursor-pointer hover:text-red-600" - /> + onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} + className="cursor-pointer inline-flex" + > + + )), )} - -
+ + + )}