mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 16:24:46 +00:00
Fallbacks icon button tooltips and delete with friction (#16737)
Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
This commit is contained in:
co-authored by
Krish Dholakia
parent
11f009a5a4
commit
25d97f50c7
@@ -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: () => <div>Mock Add Fallbacks</div>,
|
||||
}));
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<FallbacksProps> = ({ accessToken, userRole, userID, modelData }) => {
|
||||
const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
@@ -88,22 +83,29 @@ const Fallbacks: React.FC<FallbacksProps> = ({ 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<FallbacksProps> = ({ 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 (
|
||||
<>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Model Name</TableHeaderCell>
|
||||
<TableHeaderCell>Fallbacks</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{routerSettings["fallbacks"] &&
|
||||
routerSettings["fallbacks"].map((item: object, index: number) =>
|
||||
Object.entries(item).map(([key, value]) => (
|
||||
<TableRow key={index.toString() + key}>
|
||||
<TableCell>{key}</TableCell>
|
||||
<TableCell>{Array.isArray(value) ? value.join(", ") : value}</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => testFallbackModelResponse(key, accessToken)}>Test Fallback</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Icon icon={TrashIcon} size="sm" onClick={() => deleteFallbacks(key)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<AddFallbacks
|
||||
models={modelData?.data ? modelData.data.map((data: any) => data.model_name) : []}
|
||||
accessToken={accessToken}
|
||||
routerSettings={routerSettings}
|
||||
setRouterSettings={setRouterSettings}
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Model Name</TableHeaderCell>
|
||||
<TableHeaderCell>Fallbacks</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{routerSettings["fallbacks"] &&
|
||||
routerSettings["fallbacks"].map((item: FallbackEntry, index: number) =>
|
||||
Object.entries(item).map(([key, value]) => (
|
||||
<TableRow key={index.toString() + key}>
|
||||
<TableCell>{key}</TableCell>
|
||||
<TableCell>{Array.isArray(value) ? value.join(", ") : value}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Test fallback">
|
||||
<Icon
|
||||
icon={PlayIcon}
|
||||
size="sm"
|
||||
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
|
||||
className="cursor-pointer hover:text-blue-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete fallback">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(item)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{fallbackToDelete && (
|
||||
<Modal
|
||||
title="Delete Fallback"
|
||||
open={fallbackToDelete !== null}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
confirmLoading={isDeleting}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<p>Are you sure you want to delete fallback: {Object.keys(fallbackToDelete)[0]} ?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Fallbacks;
|
||||
|
||||
|
||||
@@ -517,7 +517,7 @@ export function KeyEditView({
|
||||
<KeyLifecycleSettings
|
||||
form={form}
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}q
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
/>
|
||||
|
||||
@@ -97,7 +97,12 @@ export function RequestResponsePanel({
|
||||
<div className="p-4 overflow-auto max-h-96 w-full max-w-full box-border">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all w-full max-w-full overflow-hidden break-words">
|
||||
{/* {JSON.stringify(getRawRequest(), null, 2)} */}
|
||||
<JsonView data={getRawRequest()} shouldExpandNode={allExpanded} style={defaultStyles} clickToExpandNode={true} />
|
||||
<JsonView
|
||||
data={getRawRequest()}
|
||||
shouldExpandNode={allExpanded}
|
||||
style={defaultStyles}
|
||||
clickToExpandNode={true}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +140,12 @@ export function RequestResponsePanel({
|
||||
{hasResponse ? (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all w-full max-w-full overflow-hidden break-words">
|
||||
{/* {JSON.stringify(formattedResponse(), null, 2)} */}
|
||||
<JsonView data={formattedResponse()} shouldExpandNode={allExpanded} style={defaultStyles} clickToExpandNode={true} />
|
||||
<JsonView
|
||||
data={formattedResponse()}
|
||||
shouldExpandNode={allExpanded}
|
||||
style={defaultStyles}
|
||||
clickToExpandNode={true}
|
||||
/>
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm italic text-center py-4">Response data not available</div>
|
||||
|
||||
Reference in New Issue
Block a user