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