mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
fallback-display: updated fallback display table to use arrows and card structure for better visibility.
This commit is contained in:
parent
4555ed37c5
commit
aad90ede43
2 changed files with 148 additions and 51 deletions
|
|
@ -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(<Fallbacks {...defaultProps} />);
|
||||
it("should show delete button for each fallback row when fallbacks exist", async () => {
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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(<Fallbacks {...defaultProps} />);
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span className={modelCardClass}>
|
||||
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
|
||||
<span>{modelName}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={modelCardClass}>
|
||||
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
|
||||
<span>{modelName}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<span className="grid grid-cols-[auto_1fr] items-start gap-x-2 w-full min-w-0">
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-8 h-8 shrink-0 self-start text-blue-600"
|
||||
aria-hidden
|
||||
>
|
||||
<ArrowRightIcon className="w-5 h-5 stroke-[2.5]" />
|
||||
</span>
|
||||
<span className="flex flex-wrap items-start gap-1 min-w-0">
|
||||
{list.map((model, i) => (
|
||||
<React.Fragment key={model}>
|
||||
{i > 0 && (
|
||||
<Icon icon={ArrowRightIcon} size="xs" className="shrink-0 text-gray-400" />
|
||||
)}
|
||||
<ChainCard modelName={model} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface FallbacksProps {
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
|
|
@ -71,6 +128,14 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
|
|||
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(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<FallbacksProps> = ({ accessToken, userRole, userID, mo
|
|||
}
|
||||
};
|
||||
|
||||
const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddFallbacks
|
||||
|
|
@ -185,23 +252,34 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
|
|||
value={routerSettings.fallbacks || []}
|
||||
onChange={handleFallbacksChange}
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Model Name</TableHeaderCell>
|
||||
<TableHeaderCell>Fallbacks</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{!hasFallbacks ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-6 text-center">
|
||||
<Typography.Text type="secondary">
|
||||
No fallbacks configured. Add fallbacks to automatically try another model when the primary
|
||||
fails.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<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) =>
|
||||
<TableBody>
|
||||
{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>
|
||||
<TableCell className="align-top">
|
||||
{renderModelNameCell(key, getProviderFromModel)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Tooltip title="Test fallback">
|
||||
<Icon
|
||||
icon={PlayIcon}
|
||||
|
|
@ -211,19 +289,28 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
|
|||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete fallback">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
<span
|
||||
data-testid="delete-fallback-button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleDeleteClick(item)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
|
||||
className="cursor-pointer inline-flex"
|
||||
>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
className="hover:text-red-600"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Fallback?"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue