Fallbacks icon button tooltips and delete with friction (#16737)

Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
This commit is contained in:
yuneng-jiang
2025-11-17 19:50:41 -08:00
committed by GitHub
co-authored by Krish Dholakia
parent 11f009a5a4
commit 25d97f50c7
4 changed files with 217 additions and 55 deletions
@@ -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>