Merge pull request #19673 from BerriAI/litellm_ui_router_fallbacks_02

[Feature] UI - Fallbacks: New Add Fallbacks Modal
This commit is contained in:
yuneng-jiang 2026-01-23 14:33:30 -08:00 committed by GitHub
commit 22a268c544
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1342 additions and 401 deletions

View file

@ -0,0 +1,309 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AddFallbacks, { Fallbacks } from "./AddFallbacks";
import * as fetchModelsModule from "../../../playground/llm_calls/fetch_models";
vi.mock("../../../playground/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
message: {
error: vi.fn(),
},
};
});
vi.mock("./FallbackSelectionForm", () => ({
FallbackSelectionForm: ({ groups, onGroupsChange }: any) => {
const handleUpdateGroup = () => {
if (groups.length > 0) {
const updatedGroups = groups.map((group: any, index: number) => {
if (index === 0 && !group.primaryModel) {
return {
...group,
primaryModel: "gpt-4",
fallbackModels: ["gpt-3.5-turbo"],
};
}
return group;
});
onGroupsChange(updatedGroups);
}
};
return (
<div data-testid="fallback-selection-form">
<button onClick={handleUpdateGroup} data-testid="update-group-button">
Update Group
</button>
<div data-testid="groups-count">{groups.length}</div>
{groups.map((group: any) => (
<div key={group.id} data-testid={`group-${group.id}`}>
Primary: {group.primaryModel || "None"}, Fallbacks: {group.fallbackModels.length}
</div>
))}
</div>
);
},
}));
describe("AddFallbacks", () => {
const mockOnChange = vi.fn();
const mockAccessToken = "test-token";
const mockModelGroups = [
{ model_group: "gpt-4", mode: "chat" },
{ model_group: "gpt-3.5-turbo", mode: "chat" },
{ model_group: "claude-3-opus", mode: "chat" },
];
const defaultProps = {
accessToken: mockAccessToken,
value: [] as Fallbacks,
onChange: mockOnChange,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue(mockModelGroups);
});
it("should render the component", () => {
render(<AddFallbacks {...defaultProps} />);
expect(screen.getByRole("button", { name: /add fallbacks/i })).toBeInTheDocument();
});
it("should open modal when Add Fallbacks button is clicked", async () => {
const user = userEvent.setup();
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
it("should fetch available models when modal opens", async () => {
const user = userEvent.setup();
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(fetchModelsModule.fetchAvailableModels).toHaveBeenCalledWith(mockAccessToken);
});
});
it("should close modal when Cancel button is clicked", async () => {
const user = userEvent.setup();
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("should show error when saving incomplete groups", async () => {
const user = userEvent.setup();
const antd = await import("antd");
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await waitFor(() => {
const saveButton = screen.getByRole("button", { name: /save all configurations/i });
expect(saveButton).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save all configurations/i });
await user.click(saveButton);
await waitFor(() => {
expect(antd.message.error).toHaveBeenCalled();
});
});
it("should show error message when saving incomplete groups", async () => {
const user = userEvent.setup();
const antd = await import("antd");
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save all configurations/i });
await user.click(saveButton);
await waitFor(() => {
expect(antd.message.error).toHaveBeenCalled();
});
});
it("should call onChange with new fallbacks when Save is clicked with valid configuration", async () => {
const user = userEvent.setup();
mockOnChange.mockResolvedValue(undefined);
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTestId("fallback-selection-form")).toBeInTheDocument();
});
const updateGroupButton = screen.getByTestId("update-group-button");
await user.click(updateGroupButton);
await waitFor(() => {
expect(screen.getByText(/Primary: gpt-4/i)).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save all configurations/i });
expect(saveButton).not.toBeDisabled();
await user.click(saveButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
const callArgs = mockOnChange.mock.calls[0][0];
expect(callArgs).toHaveLength(1);
expect(callArgs[0]).toHaveProperty("gpt-4");
expect(callArgs[0]["gpt-4"]).toContain("gpt-3.5-turbo");
});
});
it("should append new fallbacks to existing value", async () => {
const user = userEvent.setup();
const existingFallbacks: Fallbacks = [{ "existing-model": ["fallback-1"] }];
mockOnChange.mockResolvedValue(undefined);
render(<AddFallbacks {...defaultProps} value={existingFallbacks} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTestId("fallback-selection-form")).toBeInTheDocument();
});
const updateGroupButton = screen.getByTestId("update-group-button");
await user.click(updateGroupButton);
await waitFor(() => {
expect(screen.getByText(/Primary: gpt-4/i)).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save all configurations/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
const callArgs = mockOnChange.mock.calls[0][0];
expect(callArgs).toHaveLength(2);
expect(callArgs[0]).toEqual({ "existing-model": ["fallback-1"] });
expect(callArgs[1]).toHaveProperty("gpt-4");
});
});
it("should reset form state when modal is closed", async () => {
const user = userEvent.setup();
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByTestId("fallback-selection-form")).toBeInTheDocument();
});
});
it("should handle onChange error gracefully", async () => {
const user = userEvent.setup();
const error = new Error("Save failed");
mockOnChange.mockRejectedValue(error);
render(<AddFallbacks {...defaultProps} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTestId("fallback-selection-form")).toBeInTheDocument();
});
const updateGroupButton = screen.getByTestId("update-group-button");
await user.click(updateGroupButton);
await waitFor(() => {
expect(screen.getByText(/Primary: gpt-4/i)).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save all configurations/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it("should not call onChange when onChange prop is not provided", async () => {
const user = userEvent.setup();
render(<AddFallbacks accessToken={mockAccessToken} value={[]} />);
const addButton = screen.getByRole("button", { name: /add fallbacks/i });
await user.click(addButton);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,169 @@
/**
* Parent component for adding fallbacks to the proxy router config
* Handles value/onChange logic and form submission
* Works with forms - reads from and writes to router_settings.fallbacks
*/
import { Button as TremorButton } from "@tremor/react";
import { Button, message } from "antd";
import React, { useEffect, useState } from "react";
import NotificationManager from "../../../molecules/notifications_manager";
import { fetchAvailableModels, ModelGroup } from "../../../playground/llm_calls/fetch_models";
import { AddFallbacksModal } from "./AddFallbacksModal";
import { FallbackGroup } from "./FallbackGroupConfig";
import { FallbackSelectionForm } from "./FallbackSelectionForm";
export type FallbackEntry = { [modelName: string]: string[] };
export type Fallbacks = FallbackEntry[];
interface AddFallbacksProps {
models?: string[];
accessToken: string;
value?: Fallbacks; // Current fallbacks value from form
onChange?: (fallbacks: Fallbacks) => Promise<void>; // Callback to update form value
}
export default function AddFallbacks({
models,
accessToken,
value = [],
onChange,
}: AddFallbacksProps) {
const [isModalVisible, setIsModalVisible] = useState(false);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [modalKey, setModalKey] = useState(0); // Key to force remount of form when modal opens
const [isSaving, setIsSaving] = useState(false);
const [groups, setGroups] = useState<FallbackGroup[]>([
{
id: "1",
primaryModel: null,
fallbackModels: [],
},
]);
// Reset groups state and increment modal key when modal opens
useEffect(() => {
if (isModalVisible) {
setGroups([
{
id: "1",
primaryModel: null,
fallbackModels: [],
},
]);
setModalKey((prev) => prev + 1); // Force remount of form
}
}, [isModalVisible]);
useEffect(() => {
const loadModels = async () => {
try {
const uniqueModels = await fetchAvailableModels(accessToken);
console.log("Fetched models for fallbacks:", uniqueModels);
setModelInfo(uniqueModels);
} catch (error) {
console.error("Error fetching model info for fallbacks:", error);
}
};
if (isModalVisible) {
loadModels();
}
}, [accessToken, isModalVisible]);
const availableModels = Array.from(new Set(modelInfo.map((option) => option.model_group))).sort();
const handleCancel = () => {
setIsModalVisible(false);
// Reset to initial state
setGroups([
{
id: "1",
primaryModel: null,
fallbackModels: [],
},
]);
};
const handleSaveAll = async () => {
// Validation
const invalidGroups = groups.filter(
(g) => !g.primaryModel || g.fallbackModels.length === 0,
);
if (invalidGroups.length > 0) {
message.error(
`Please complete configuration for all groups. ${invalidGroups.length} group(s) incomplete.`,
);
return;
}
// Create fallback objects in the format expected by the API
const newFallbacks = groups.map((g) => ({
[g.primaryModel!]: g.fallbackModels,
}));
// Get current fallbacks from form value, or an empty array if it's null/undefined
const currentFallbacks = value || [];
// Add new fallbacks to the current fallbacks
const updatedFallbacks = [...currentFallbacks, ...newFallbacks];
// Call onChange to update the form value and wait for it to complete
if (onChange) {
setIsSaving(true);
try {
await onChange(updatedFallbacks);
NotificationManager.success(`${groups.length} fallback configuration(s) added successfully!`);
handleCancel();
} catch (error) {
// Error handling is done in handleFallbacksChange, so we don't need to show another notification here
console.error("Error saving fallbacks:", error);
} finally {
setIsSaving(false);
}
} else {
NotificationManager.fromBackend("onChange callback not provided");
}
};
return (
<div>
<TremorButton
className="mx-auto"
onClick={() => setIsModalVisible(true)}
icon={() => <span className="mr-1">+</span>}
>
Add Fallbacks
</TremorButton>
<AddFallbacksModal open={isModalVisible} onCancel={handleCancel}>
<FallbackSelectionForm
key={modalKey}
groups={groups}
onGroupsChange={setGroups}
availableModels={availableModels}
maxFallbacks={5}
maxGroups={5}
/>
{/* Footer with Cancel and Save buttons */}
{groups.length > 0 && (
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-gray-100">
<Button
type="default"
onClick={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
type="default"
onClick={handleSaveAll}
disabled={groups.length === 0 || isSaving}
loading={isSaving}
>
{isSaving ? "Saving Configuration..." : "Save All Configurations"}
</Button>
</div>
)}
</AddFallbacksModal>
</div>
);
}

View file

@ -0,0 +1,56 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AddFallbacksModal } from "./AddFallbacksModal";
describe("AddFallbacksModal", () => {
const mockOnCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the modal when open is true", () => {
render(
<AddFallbacksModal open={true} onCancel={mockOnCancel}>
<div>Test Content</div>
</AddFallbacksModal>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Configure Model Fallbacks")).toBeInTheDocument();
expect(screen.getByText("Manage multiple fallback chains for different models (up to 5 groups at a time)")).toBeInTheDocument();
expect(screen.getByText("Test Content")).toBeInTheDocument();
});
it("should not render the modal when open is false", () => {
render(
<AddFallbacksModal open={false} onCancel={mockOnCancel}>
<div>Test Content</div>
</AddFallbacksModal>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("should render children content when modal is open", () => {
render(
<AddFallbacksModal open={true} onCancel={mockOnCancel}>
<div data-testid="child-content">Child Component</div>
</AddFallbacksModal>,
);
expect(screen.getByTestId("child-content")).toBeInTheDocument();
expect(screen.getByText("Child Component")).toBeInTheDocument();
});
it("should display the correct title and description", () => {
render(
<AddFallbacksModal open={true} onCancel={mockOnCancel}>
<div>Content</div>
</AddFallbacksModal>,
);
expect(screen.getByText("Configure Model Fallbacks")).toBeInTheDocument();
expect(screen.getByText(/Manage multiple fallback chains/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,52 @@
/**
* Modal wrapper for the fallback selection form
* Handles modal visibility and layout, but delegates content to children
*/
import { Modal } from "antd";
import { ArrowRight } from "lucide-react";
import React from "react";
interface AddFallbacksModalProps {
open: boolean;
onCancel: () => void;
children: React.ReactNode;
}
export function AddFallbacksModal({
open,
onCancel,
children,
}: AddFallbacksModalProps) {
return (
<Modal
title={
<div className="pb-4 border-b border-gray-100">
<div className="flex items-center gap-2 text-gray-800">
<div className="p-2 bg-indigo-50 rounded-lg">
<ArrowRight className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-bold m-0">Configure Model Fallbacks</h2>
<p className="text-sm text-gray-500 font-normal m-0">
Manage multiple fallback chains for different models (up to 5 groups at a time)
</p>
</div>
</div>
</div>
}
open={open}
width={900}
footer={null}
onCancel={onCancel}
maskClosable={false}
className="top-8"
styles={{
body: { padding: "24px" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
<div className="mt-6">{children}</div>
</Modal>
);
}

View file

@ -0,0 +1,208 @@
/**
* Component for configuring a single fallback group
* Handles primary model selection and fallback chain configuration
*/
import { Select, Tooltip } from "antd";
import { AlertCircle, ArrowDown, X } from "lucide-react";
import React from "react";
export interface FallbackGroup {
id: string;
primaryModel: string | null;
fallbackModels: string[];
}
interface FallbackGroupConfigProps {
group: FallbackGroup;
onChange: (updatedGroup: FallbackGroup) => void;
availableModels: string[];
maxFallbacks: number;
}
export function FallbackGroupConfig({
group,
onChange,
availableModels,
maxFallbacks,
}: FallbackGroupConfigProps) {
// Filter available options for fallbacks (exclude primary only, allow already selected to be shown for deselection)
const availableFallbackOptions = availableModels.filter(
(m) => m !== group.primaryModel,
);
const handlePrimaryChange = (value: string) => {
let newFallbacks = [...group.fallbackModels];
// Remove from fallbacks if it was there
if (newFallbacks.includes(value)) {
newFallbacks = newFallbacks.filter((m) => m !== value);
}
onChange({
...group,
primaryModel: value,
fallbackModels: newFallbacks,
});
};
const handleFallbackSelect = (values: string[]) => {
// Limit to maxFallbacks
const limitedValues = values.slice(0, maxFallbacks);
onChange({
...group,
fallbackModels: limitedValues,
});
};
const removeFallback = (indexToRemove: number) => {
const newFallbacks = group.fallbackModels.filter((_, index) => index !== indexToRemove);
onChange({
...group,
fallbackModels: newFallbacks,
});
};
const canAddMoreFallbacks = group.fallbackModels.length < maxFallbacks;
return (
<div className="flex flex-col gap-8 py-4">
{/* Primary Model Section */}
<div className="relative">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Primary Model <span className="text-red-500">*</span>
</label>
<Select
className="w-full h-12"
size="large"
placeholder="Select primary model"
value={group.primaryModel}
onChange={handlePrimaryChange}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={availableModels.map((m) => ({ label: m, value: m }))}
/>
{!group.primaryModel && (
<div className="mt-2 flex items-center gap-2 text-amber-600 text-xs bg-amber-50 p-2 rounded">
<AlertCircle className="w-4 h-4" />
<span>Select a model to begin configuring fallbacks</span>
</div>
)}
</div>
{/* Visual Connection */}
<div className="flex items-center justify-center -my-4 z-10">
<div className="bg-indigo-50 text-indigo-500 px-4 py-1 rounded-full text-xs font-bold border border-indigo-100 flex items-center gap-2 shadow-sm">
<ArrowDown className="w-4 h-4" />
IF FAILS, TRY...
</div>
</div>
{/* Fallback Models Section */}
<div
className={`transition-opacity duration-300 ${!group.primaryModel ? "opacity-50 pointer-events-none" : "opacity-100"}`}
>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Fallback Chain <span className="text-red-500">*</span>
<span className="text-xs text-gray-500 font-normal ml-2">
(Max {maxFallbacks} fallbacks at a time)
</span>
</label>
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
{/* Add Fallback Input */}
<div className="mb-4">
<Select
mode="multiple"
className="w-full"
size="large"
placeholder={
canAddMoreFallbacks
? "Select fallback models to add..."
: `Maximum ${maxFallbacks} fallbacks reached`
}
value={group.fallbackModels}
onChange={handleFallbackSelect}
disabled={!group.primaryModel}
options={availableFallbackOptions.map((m) => ({
label: m,
value: m,
}))}
optionRender={(option, info) => {
const isSelected = group.fallbackModels.includes(option.value as string);
const orderIndex = isSelected
? group.fallbackModels.indexOf(option.value as string) + 1
: null;
return (
<div className="flex items-center gap-2">
{isSelected && orderIndex !== null && (
<span className="flex items-center justify-center w-5 h-5 rounded bg-indigo-100 text-indigo-600 text-xs font-bold">
{orderIndex}
</span>
)}
<span>{option.label}</span>
</div>
);
}}
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<Tooltip
styles={{ root: { pointerEvents: "none" } }}
title={omittedValues.map(({ value }) => value).join(", ")}
>
<span>+{omittedValues.length} more</span>
</Tooltip>
)}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
<p className="text-xs text-gray-500 mt-1 ml-1">
{canAddMoreFallbacks
? `Search and select multiple models. Selected models will appear below in order. (${group.fallbackModels.length}/${maxFallbacks} used)`
: `Maximum ${maxFallbacks} fallbacks reached. Remove some to add more.`}
</p>
</div>
{/* Fallback List */}
<div className="space-y-2 min-h-[100px]">
{group.fallbackModels.length === 0 ? (
<div className="h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-400">
<span className="text-sm">No fallback models selected</span>
<span className="text-xs mt-1">Add models from the dropdown above</span>
</div>
) : (
group.fallbackModels.map((modelValue, index) => {
return (
<div
key={`${modelValue}-${index}`}
className="group flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200 hover:border-indigo-300 hover:shadow-sm transition-all"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-6 h-6 rounded bg-gray-100 text-gray-400 group-hover:text-indigo-500 group-hover:bg-indigo-50">
<span className="text-xs font-bold">{index + 1}</span>
</div>
<div>
<span className="font-medium text-gray-800">{modelValue}</span>
</div>
</div>
<button
type="button"
onClick={() => removeFallback(index)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 p-1"
>
<X className="w-4 h-4" />
</button>
</div>
);
})
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,132 @@
/**
* Form component for selecting and configuring fallback groups
* Manages groups state internally, but does not handle submission
* Decoupled from form submission logic
*/
import { Button } from "@tremor/react";
import { message, Tabs } from "antd";
import { Plus } from "lucide-react";
import React, { useEffect, useState } from "react";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
interface FallbackSelectionFormProps {
groups: FallbackGroup[];
onGroupsChange: (groups: FallbackGroup[]) => void;
availableModels: string[];
maxFallbacks?: number;
maxGroups?: number;
}
export function FallbackSelectionForm({
groups,
onGroupsChange,
availableModels,
maxFallbacks = 5,
maxGroups = 5,
}: FallbackSelectionFormProps) {
const [activeKey, setActiveKey] = useState(groups.length > 0 ? groups[0].id : "1");
// Reset activeKey when groups change (e.g., when modal reopens)
useEffect(() => {
if (groups.length > 0) {
// If current activeKey doesn't exist in groups, reset to first group
const activeKeyExists = groups.some((g) => g.id === activeKey);
if (!activeKeyExists) {
setActiveKey(groups[0].id);
}
} else {
// If groups is empty, reset activeKey
setActiveKey("1");
}
}, [groups]);
const handleAddGroup = () => {
if (groups.length >= maxGroups) {
return;
}
const newId = Date.now().toString();
const newGroups = [
...groups,
{
id: newId,
primaryModel: null,
fallbackModels: [],
},
];
onGroupsChange(newGroups);
setActiveKey(newId);
};
const handleRemoveGroup = (targetId: string) => {
if (groups.length === 1) {
message.warning("At least one group is required");
return;
}
const newGroups = groups.filter((g) => g.id !== targetId);
onGroupsChange(newGroups);
if (activeKey === targetId && newGroups.length > 0) {
setActiveKey(newGroups[newGroups.length - 1].id);
}
};
const handleGroupUpdate = (updatedGroup: FallbackGroup) => {
const newGroups = groups.map((g) => (g.id === updatedGroup.id ? updatedGroup : g));
onGroupsChange(newGroups);
};
// Generate tab items
const items = groups.map((group, index) => {
const label = group.primaryModel
? group.primaryModel
: `Group ${index + 1}`;
return {
key: group.id,
label: label,
closable: groups.length > 1, // Only allow closing if there's more than 1 group
children: (
<FallbackGroupConfig
group={group}
onChange={handleGroupUpdate}
availableModels={availableModels}
maxFallbacks={maxFallbacks}
/>
),
};
});
if (groups.length === 0) {
return (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed border-gray-300">
<p className="text-gray-500 mb-4">No fallback groups configured</p>
<Button
variant="primary"
onClick={handleAddGroup}
icon={() => <Plus className="w-4 h-4" />}
>
Create First Group
</Button>
</div>
);
}
return (
<Tabs
type="editable-card"
activeKey={activeKey}
onChange={setActiveKey}
onEdit={(targetKey, action) => {
if (action === "add") handleAddGroup();
else if (action === "remove" && groups.length > 1) {
handleRemoveGroup(targetKey as string);
}
}}
items={items}
className="fallback-tabs"
tabBarStyle={{
marginBottom: 0,
}}
hideAdd={groups.length >= maxGroups}
/>
);
}

View file

@ -0,0 +1,372 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Fallbacks from "./fallbacks";
import * as networkingModule from "../../../networking";
import * as fetchModelsModule from "../../../playground/llm_calls/fetch_models";
vi.mock("../../../networking", () => ({
getCallbacksCall: vi.fn(),
setCallbacksCall: vi.fn(),
}));
vi.mock("../../../playground/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
vi.mock("openai", () => ({
default: {
OpenAI: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: vi.fn(),
},
},
})),
},
}));
vi.mock("../../../common_components/DeleteResourceModal", () => ({
__esModule: true,
default: ({ isOpen, onOk, onCancel, title, message, resourceInformation, confirmLoading }: any) => {
if (!isOpen) return null;
return (
<div data-testid="delete-modal">
<div>{title}</div>
<div>{message}</div>
{resourceInformation?.map((info: any, idx: number) => (
<div key={idx}>
{info.label}: {info.value}
</div>
))}
<button onClick={onCancel} disabled={confirmLoading}>
Cancel
</button>
<button onClick={onOk} disabled={confirmLoading}>
Delete
</button>
</div>
);
},
}));
vi.mock("./AddFallbacks", () => ({
__esModule: true,
default: ({ value, onChange }: any) => {
const handleClick = async () => {
if (onChange) {
try {
const newFallbacks = [...(value || []), { "test-model": ["test-fallback"] }];
await onChange(newFallbacks);
} catch (error) {
// Error is handled by the component
}
}
};
return (
<button onClick={handleClick} data-testid="add-fallbacks-button">
Add Fallbacks
</button>
);
},
}));
describe("Fallbacks", () => {
const mockAccessToken = "test-token";
const mockUserRole = "Admin";
const mockUserID = "user-123";
const mockModelData = {
data: [
{ model_name: "gpt-4" },
{ model_name: "gpt-3.5-turbo" },
{ model_name: "claude-3-opus" },
],
};
const mockRouterSettings = {
fallbacks: [
{ "gpt-4": ["gpt-3.5-turbo", "claude-3-opus"] },
{ "claude-3-opus": ["gpt-4"] },
],
};
const defaultProps = {
accessToken: mockAccessToken,
userRole: mockUserRole,
userID: mockUserID,
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];
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValue({
router_settings: mockRouterSettings,
});
vi.mocked(networkingModule.setCallbacksCall).mockResolvedValue(undefined);
vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue([
{ model_group: "gpt-4", mode: "chat" },
{ model_group: "gpt-3.5-turbo", mode: "chat" },
{ model_group: "claude-3-opus", mode: "chat" },
]);
});
it("should render the component", async () => {
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument();
});
});
it("should not render when accessToken is null", () => {
const { container } = render(<Fallbacks {...defaultProps} accessToken={null} />);
expect(container.firstChild).toBeNull();
});
it("should fetch router settings on mount", async () => {
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(networkingModule.getCallbacksCall).toHaveBeenCalledWith(
mockAccessToken,
mockUserID,
mockUserRole,
);
});
});
it("should display fallback entries in table", async () => {
render(<Fallbacks {...defaultProps} />);
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();
});
});
it("should open delete modal when delete icon is clicked", async () => {
const user = userEvent.setup();
const { container } = render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
const deleteButton = findDeleteButton(container);
expect(deleteButton).not.toBeNull();
await user.click(deleteButton as HTMLElement);
await waitFor(() => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
expect(screen.getByText("Delete Fallback?")).toBeInTheDocument();
});
});
it("should delete fallback when confirmed", async () => {
const user = userEvent.setup();
const { container } = render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
const deleteButton = findDeleteButton(container);
expect(deleteButton).not.toBeNull();
await user.click(deleteButton as HTMLElement);
await waitFor(() => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
await user.click(confirmButton);
await waitFor(() => {
expect(networkingModule.setCallbacksCall).toHaveBeenCalled();
const callArgs = networkingModule.setCallbacksCall.mock.calls[0];
expect(callArgs[0]).toBe(mockAccessToken);
expect(callArgs[1].router_settings.fallbacks).toHaveLength(1);
});
});
it("should close delete modal when cancel is clicked", async () => {
const user = userEvent.setup();
const { container } = render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
const deleteButton = findDeleteButton(container);
expect(deleteButton).not.toBeNull();
await user.click(deleteButton as HTMLElement);
await waitFor(() => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
await waitFor(() => {
expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
});
});
it("should show error notification on delete failure", async () => {
const user = userEvent.setup();
const error = new Error("Delete failed");
vi.mocked(networkingModule.setCallbacksCall).mockRejectedValueOnce(error);
const { container } = render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
const deleteButton = findDeleteButton(container);
expect(deleteButton).not.toBeNull();
await user.click(deleteButton as HTMLElement);
await waitFor(() => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
await user.click(confirmButton);
await waitFor(() => {
expect(networkingModule.setCallbacksCall).toHaveBeenCalled();
});
});
it("should handle delete error gracefully", async () => {
const user = userEvent.setup();
const error = new Error("Delete failed");
vi.mocked(networkingModule.setCallbacksCall).mockRejectedValueOnce(error);
const { container } = render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
const deleteButton = findDeleteButton(container);
expect(deleteButton).not.toBeNull();
await user.click(deleteButton as HTMLElement);
await waitFor(() => {
expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
await user.click(confirmButton);
await waitFor(() => {
expect(networkingModule.setCallbacksCall).toHaveBeenCalled();
expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
});
});
it("should handle empty fallbacks array", async () => {
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValueOnce({
router_settings: { fallbacks: [] },
});
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument();
});
expect(screen.queryByText("gpt-4")).not.toBeInTheDocument();
});
it("should handle router settings without fallbacks property", async () => {
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValueOnce({
router_settings: {},
});
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument();
});
});
it("should remove model_group_retry_policy from router settings", async () => {
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValueOnce({
router_settings: {
...mockRouterSettings,
model_group_retry_policy: { some: "policy" },
},
});
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(networkingModule.getCallbacksCall).toHaveBeenCalled();
});
});
it("should update fallbacks when AddFallbacks onChange is called", async () => {
const user = userEvent.setup();
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument();
});
const addButton = screen.getByTestId("add-fallbacks-button");
await user.click(addButton);
await waitFor(() => {
expect(networkingModule.setCallbacksCall).toHaveBeenCalled();
});
});
it("should handle fallbacks change error and refetch", async () => {
const user = userEvent.setup();
const error = new Error("Update failed");
vi.mocked(networkingModule.setCallbacksCall).mockRejectedValueOnce(error);
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValue({
router_settings: mockRouterSettings,
});
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("add-fallbacks-button")).toBeInTheDocument();
});
const addButton = screen.getByTestId("add-fallbacks-button");
await user.click(addButton);
await waitFor(() => {
expect(networkingModule.setCallbacksCall).toHaveBeenCalled();
});
await waitFor(
() => {
expect(networkingModule.getCallbacksCall).toHaveBeenCalledTimes(2);
},
{ timeout: 3000 },
);
});
});

View file

@ -3,10 +3,10 @@ import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow
import { Tooltip } from "antd";
import openai from "openai";
import React, { useEffect, useState } from "react";
import AddFallbacks from "./add_fallbacks";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import NotificationsManager from "./molecules/notifications_manager";
import { getCallbacksCall, setCallbacksCall } from "./networking";
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
import NotificationsManager from "../../../molecules/notifications_manager";
import { getCallbacksCall, setCallbacksCall } from "../../../networking";
import AddFallbacks from "./AddFallbacks";
type FallbackEntry = { [modelName: string]: string[] };
type Fallbacks = FallbackEntry[];
@ -21,7 +21,7 @@ interface FallbacksProps {
async function testFallbackModelResponse(selectedModel: string, accessToken: string) {
const isLocal = process.env.NODE_ENV === "development";
if (isLocal != true) {
console.log = function () {};
console.log = function () { };
}
const proxyBaseUrl = isLocal ? "http://localhost:4000" : window.location.origin;
const client = new openai.OpenAI({
@ -142,13 +142,48 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
return null;
}
const handleFallbacksChange = async (fallbacks: Fallbacks): Promise<void> => {
if (!accessToken) {
return;
}
const updatedSettings = {
...routerSettings,
fallbacks: fallbacks,
};
const payload = {
router_settings: updatedSettings,
};
try {
await setCallbacksCall(accessToken, payload);
// Update UI only after successful API call
setRouterSettings(updatedSettings);
} catch (error) {
// Revert on error by refetching from server
NotificationsManager.fromBackend("Failed to update router settings: " + error);
if (accessToken && userRole && userID) {
getCallbacksCall(accessToken, userID, userRole).then((data) => {
let router_settings = data.router_settings;
if ("model_group_retry_policy" in router_settings) {
delete router_settings["model_group_retry_policy"];
}
setRouterSettings(router_settings);
});
}
// Re-throw error so caller can handle it
throw error;
}
};
return (
<>
<AddFallbacks
models={modelData?.data ? modelData.data.map((data: any) => data.model_name) : []}
accessToken={accessToken}
routerSettings={routerSettings}
setRouterSettings={setRouterSettings}
accessToken={accessToken || ""}
value={routerSettings.fallbacks || []}
onChange={handleFallbacksChange}
/>
<Table>
<TableHead>

View file

@ -1,47 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AddFallbacks from "./add_fallbacks";
vi.mock("./networking", () => ({
setCallbacksCall: vi.fn(),
}));
vi.mock("./playground/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(() =>
Promise.resolve([
{ model_group: "gpt-4" },
{ model_group: "gpt-3.5-turbo" },
{ model_group: "claude-3-opus" },
{ model_group: "claude-3-sonnet" },
]),
),
}));
vi.mock("./molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),
},
}));
describe("AddFallbacks", () => {
const mockAccessToken = "test-token";
const mockRouterSettings = { fallbacks: [] };
const mockSetRouterSettings = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the component", () => {
render(
<AddFallbacks
accessToken={mockAccessToken}
routerSettings={mockRouterSettings}
setRouterSettings={mockSetRouterSettings}
/>,
);
expect(screen.getByRole("button", { name: /Add Fallbacks/i })).toBeInTheDocument();
});
});

View file

@ -1,250 +0,0 @@
/**
* Modal to add fallbacks to the proxy router config
*/
import { Button } from "@tremor/react";
import { Form, Modal, Select } from "antd";
import React, { useEffect, useState } from "react";
import NotificationManager from "./molecules/notifications_manager";
import { setCallbacksCall } from "./networking";
import { fetchAvailableModels, ModelGroup } from "./playground/llm_calls/fetch_models";
interface AddFallbacksProps {
models?: string[];
accessToken: string;
routerSettings: { [key: string]: any };
setRouterSettings: React.Dispatch<React.SetStateAction<{ [key: string]: any }>>;
}
const AddFallbacks: React.FC<AddFallbacksProps> = ({ models, accessToken, routerSettings, setRouterSettings }) => {
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedModel, setSelectedModel] = useState("");
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [selectedFallbacks, setSelectedFallbacks] = useState<string[]>([]);
useEffect(() => {
const loadModels = async () => {
try {
const uniqueModels = await fetchAvailableModels(accessToken);
console.log("Fetched models for fallbacks:", uniqueModels);
setModelInfo(uniqueModels);
} catch (error) {
console.error("Error fetching model info for fallbacks:", error);
}
};
loadModels();
}, [accessToken]);
const handleOk = () => {
setIsModalVisible(false);
form.resetFields();
setSelectedFallbacks([]);
setSelectedModel("");
};
const handleCancel = () => {
setIsModalVisible(false);
form.resetFields();
setSelectedFallbacks([]);
setSelectedModel("");
};
const updateFallbacks = (formValues: Record<string, any>) => {
// Print the received value
console.log(formValues);
// Extract model_name and models from formValues
const { model_name, models } = formValues;
// Create new fallback
const newFallback = { [model_name]: models };
// Get current fallbacks, or an empty array if it's null
const currentFallbacks = routerSettings.fallbacks || [];
// Add new fallback to the current fallbacks
const updatedFallbacks = [...currentFallbacks, newFallback];
// Create a new routerSettings object with updated fallbacks
const updatedRouterSettings = { ...routerSettings, fallbacks: updatedFallbacks };
// Print updated routerSettings
console.log(updatedRouterSettings);
const payload = {
router_settings: updatedRouterSettings,
};
try {
setCallbacksCall(accessToken, payload);
// Update routerSettings state
setRouterSettings(updatedRouterSettings);
} catch (error) {
NotificationManager.fromBackend("Failed to update router settings: " + error);
}
NotificationManager.success("router settings updated successfully");
setIsModalVisible(false);
form.resetFields();
setSelectedFallbacks([]);
setSelectedModel("");
};
return (
<div>
<Button className="mx-auto" onClick={() => setIsModalVisible(true)} icon={() => <span className="mr-1">+</span>}>
Add Fallbacks
</Button>
<Modal
title={
<div className="pb-4 border-b border-gray-100">
<h2 className="text-xl font-semibold text-gray-900">Add Fallbacks</h2>
</div>
}
open={isModalVisible}
width={900}
footer={null}
onOk={handleOk}
onCancel={handleCancel}
className="top-8"
styles={{
body: { padding: "24px" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
<div className="mt-6">
<div className="mb-6">
<p className="text-gray-600">
Configure fallback models to improve reliability. When the primary model fails or is unavailable, requests
will automatically route to the specified fallback models in order.
</p>
</div>
<Form form={form} onFinish={updateFallbacks} layout="vertical" className="space-y-6">
<div className="grid grid-cols-1 gap-6">
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Primary Model <span className="text-red-500">*</span>
</span>
}
name="model_name"
rules={[{ required: true, message: "Please select the primary model that needs fallbacks" }]}
className="!mb-0"
>
<Select
placeholder="Select the model that needs fallback protection"
value={selectedModel || undefined}
onChange={(value: string) => {
setSelectedModel(value);
// Remove the selected model from fallbacks if it was selected
const updatedFallbacks = selectedFallbacks.filter((model) => model !== value);
setSelectedFallbacks(updatedFallbacks);
form.setFieldValue("models", updatedFallbacks);
form.setFieldValue("model_name", value);
}}
showSearch
allowClear
style={{ width: "100%" }}
>
{Array.from(new Set(modelInfo.map((option) => option.model_group))).map(
(model: string, index: number) => (
<Select.Option key={index} value={model}>
{model}
</Select.Option>
),
)}
</Select>
<p className="text-sm text-gray-500 mt-1">This is the primary model that users will request</p>
</Form.Item>
<div className="border-t border-gray-200 my-6"></div>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Fallback Models (select multiple) <span className="text-red-500">*</span>
</span>
}
name="models"
rules={[{ required: true, message: "Please select at least one fallback model" }]}
className="!mb-0"
>
<div className="space-y-3">
{/* Show selected models in order */}
{selectedFallbacks.length > 0 && (
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50">
<p className="text-sm font-medium text-gray-700 mb-2">Fallback Order:</p>
<div className="flex flex-wrap gap-2">
{selectedFallbacks.map((model, index) => (
<div
key={model}
className="flex items-center bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
>
<span className="font-medium mr-2">{index + 1}.</span>
<span>{model}</span>
<button
type="button"
onClick={() => {
const newFallbacks = selectedFallbacks.filter((m) => m !== model);
setSelectedFallbacks(newFallbacks);
form.setFieldValue("models", newFallbacks);
}}
className="ml-2 text-blue-600 hover:text-blue-800"
>
×
</button>
</div>
))}
</div>
</div>
)}
{/* Model selector */}
<Select
placeholder="Add a fallback model"
value={undefined}
onChange={(value: string) => {
if (value && !selectedFallbacks.includes(value)) {
const newFallbacks = [...selectedFallbacks, value];
setSelectedFallbacks(newFallbacks);
form.setFieldValue("models", newFallbacks);
}
}}
showSearch
allowClear
style={{ width: "100%" }}
>
{Array.from(new Set(modelInfo.map((option) => option.model_group)))
.filter((data: string) => data !== selectedModel && !selectedFallbacks.includes(data))
.sort()
.map((model: string) => (
<Select.Option key={model} value={model}>
{model}
</Select.Option>
))}
</Select>
</div>
<p className="text-sm text-gray-500 mt-1">
<strong>Order matters:</strong> Models will be tried in the order shown above (1st, 2nd, 3rd, etc.)
</p>
</Form.Item>
</div>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
<Button variant="primary" type="submit">
Add Fallbacks
</Button>
</div>
</Form>
</div>
</Modal>
</div>
);
};
export default AddFallbacks;

View file

@ -1,95 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { 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("./add_fallbacks", () => ({
__esModule: true,
default: () => <div>Mock Add Fallbacks</div>,
}));
vi.mock("openai", () => ({
default: {
OpenAI: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: vi.fn().mockResolvedValue({
model: "test-model",
}),
},
},
})),
},
}));
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: [],
},
});
mockSetCallbacksCall.mockResolvedValue({});
});
it("should render", async () => {
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("columnheader", { name: "Model Name" })).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);
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("xai/grok-2")).toBeInTheDocument();
expect(screen.getByText("xai/grok-4, gpt-4")).toBeInTheDocument();
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
expect(screen.getByText("gpt-4")).toBeInTheDocument();
});
expect(mockGetCallbacksCall).toHaveBeenCalledWith(
defaultProps.accessToken,
defaultProps.userID,
defaultProps.userRole,
);
});
it("should render AddFallbacks component", async () => {
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Mock Add Fallbacks")).toBeInTheDocument();
});
});
it("should not render when access token is not provided", () => {
const { container } = render(<Fallbacks {...defaultProps} accessToken={null} />);
expect(container.firstChild).toBeNull();
});
});

View file

@ -22,7 +22,7 @@ import { InputNumber } from "antd";
import { TrashIcon, CheckCircleIcon } from "@heroicons/react/outline";
import RouterSettings from "./router_settings";
import Fallbacks from "./fallbacks";
import Fallbacks from "./Settings/RouterSettings/Fallbacks/Fallbacks";
interface GeneralSettingsPageProps {
accessToken: string | null;
userRole: string | null;