feat(ui): edit fallback chains from router settings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mubashir Osmani 2026-07-10 23:08:59 +00:00
parent eb7e4a567a
commit cc276d2891
5 changed files with 252 additions and 3 deletions

View file

@ -0,0 +1,80 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import EditFallbacks, { Fallbacks } from "./EditFallbacks";
import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
describe("EditFallbacks", () => {
const accessToken = "test-token";
const fallbackEntry = { "gpt-4": ["gpt-3.5-turbo", "claude-3-opus"] };
const value: Fallbacks = [{ "gpt-4": ["gpt-3.5-turbo", "claude-3-opus"] }, { "claude-3-opus": ["gpt-4"] }];
const setup = (overrides: Partial<React.ComponentProps<typeof EditFallbacks>> = {}) => {
const onChange = overrides.onChange ?? vi.fn().mockResolvedValue(undefined);
const onClose = overrides.onClose ?? vi.fn();
render(
<EditFallbacks
accessToken={accessToken}
fallbackEntry={fallbackEntry}
value={value}
onChange={onChange}
onClose={onClose}
{...overrides}
/>,
);
return { onChange, onClose };
};
beforeEach(() => {
vi.clearAllMocks();
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" },
{ model_group: "gemini-pro", mode: "chat" },
]);
});
it("prefills the existing fallback chain for the primary model", async () => {
setup();
await waitFor(() => {
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
expect(screen.getByText("claude-3-opus")).toBeInTheDocument();
});
});
it("removes a fallback model and saves only the edited entry", async () => {
const user = userEvent.setup();
const onChange = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
setup({ onChange, onClose });
const firstFallbackRow = (await screen.findByText("gpt-3.5-turbo")).closest("div.group") as HTMLElement;
const removeButton = within(firstFallbackRow).getByRole("button");
await user.click(removeButton);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ "gpt-4": ["claude-3-opus"] }, { "claude-3-opus": ["gpt-4"] }]);
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("blocks saving with an empty fallback chain", async () => {
const user = userEvent.setup();
const onChange = vi.fn().mockResolvedValue(undefined);
setup({ fallbackEntry: { "gpt-4": ["gpt-3.5-turbo"] }, onChange });
const row = (await screen.findByText("gpt-3.5-turbo")).closest("div.group") as HTMLElement;
await user.click(within(row).getByRole("button"));
const saveButton = screen.getByRole("button", { name: /save changes/i });
expect(saveButton).toBeDisabled();
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,111 @@
/**
* Modal for editing an existing fallback entry
* Lets the user add/remove models from a primary model's fallback chain
* Reuses FallbackGroupConfig with the primary model locked
*/
import { Button } from "antd";
import { Pencil } from "lucide-react";
import React, { useEffect, useState } from "react";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import NotificationManager from "../../../molecules/notifications_manager";
import { AddFallbacksModal } from "./AddFallbacksModal";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
export type FallbackEntry = { [modelName: string]: string[] };
export type Fallbacks = FallbackEntry[];
interface EditFallbacksProps {
accessToken: string;
fallbackEntry: FallbackEntry;
value: Fallbacks;
onChange: (fallbacks: Fallbacks) => Promise<void>;
onClose: () => void;
maxFallbacks?: number;
}
const toGroup = (entry: FallbackEntry): FallbackGroup => {
const primaryModel = Object.keys(entry)[0] ?? null;
return {
id: "edit",
primaryModel,
fallbackModels: primaryModel ? [...(entry[primaryModel] ?? [])] : [],
};
};
export default function EditFallbacks({
accessToken,
fallbackEntry,
value,
onChange,
onClose,
maxFallbacks = 10,
}: EditFallbacksProps) {
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [group, setGroup] = useState<FallbackGroup>(() => toGroup(fallbackEntry));
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const loadModels = async () => {
try {
const uniqueModels = await fetchAvailableModels(accessToken);
setAvailableModels(Array.from(new Set(uniqueModels.map((option) => option.model_group))).sort());
} catch (error) {
console.error("Error fetching model info for fallbacks:", error);
}
};
loadModels();
}, [accessToken]);
const handleSave = async () => {
const primaryModel = group.primaryModel;
if (!primaryModel) {
return;
}
if (group.fallbackModels.length === 0) {
NotificationManager.error("Add at least one fallback model, or delete the entry instead.");
return;
}
const updatedFallbacks = (value || []).map((entry) =>
primaryModel in entry ? { ...entry, [primaryModel]: group.fallbackModels } : entry,
);
setIsSaving(true);
try {
await onChange(updatedFallbacks);
NotificationManager.success(`Fallbacks for ${primaryModel} updated successfully!`);
onClose();
} catch (error) {
console.error("Error updating fallbacks:", error);
} finally {
setIsSaving(false);
}
};
return (
<AddFallbacksModal open onCancel={onClose}>
<FallbackGroupConfig
group={group}
onChange={setGroup}
availableModels={availableModels}
maxFallbacks={maxFallbacks}
disablePrimaryModel
/>
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-gray-100">
<Button type="default" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
type="default"
icon={<Pencil className="w-4 h-4" />}
onClick={handleSave}
disabled={isSaving || group.fallbackModels.length === 0}
loading={isSaving}
>
{isSaving ? "Saving Changes..." : "Save Changes"}
</Button>
</div>
</AddFallbacksModal>
);
}

View file

@ -18,9 +18,16 @@ interface FallbackGroupConfigProps {
onChange: (updatedGroup: FallbackGroup) => void;
availableModels: string[];
maxFallbacks: number;
disablePrimaryModel?: boolean;
}
export function FallbackGroupConfig({ group, onChange, availableModels, maxFallbacks }: FallbackGroupConfigProps) {
export function FallbackGroupConfig({
group,
onChange,
availableModels,
maxFallbacks,
disablePrimaryModel = false,
}: 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);
@ -70,12 +77,13 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
placeholder="Select primary model"
value={group.primaryModel}
onChange={handlePrimaryChange}
disabled={disablePrimaryModel}
showSearch
getPopupContainer={(trigger) => trigger.parentElement || document.body}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={availableModels.map((m) => ({ label: m, value: m }))}
/>
{!group.primaryModel && (
{!disablePrimaryModel && !group.primaryModel && (
<div className="mt-2 flex items-center gap-2 text-amber-600 text-xs bg-amber-50 p-2 rounded-sm">
<AlertCircle className="w-4 h-4" />
<span>Select a model to begin configuring fallbacks</span>

View file

@ -149,6 +149,24 @@ describe("Fallbacks", () => {
expect(deleteButtons.length).toBe(2);
});
it("should show an edit button for each fallback row and open the edit modal", async () => {
const user = userEvent.setup();
render(<Fallbacks {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
const editButtons = screen.getAllByTestId("edit-fallback-button");
expect(editButtons.length).toBe(2);
await user.click(editButtons[0]);
await waitFor(() => {
expect(screen.getByText("Configure Model Fallbacks")).toBeInTheDocument();
});
});
it("should open delete modal when delete icon is clicked", async () => {
const user = userEvent.setup();
render(<Fallbacks {...defaultProps} />);

View file

@ -1,5 +1,5 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { ArrowRightIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline";
import { ArrowRightIcon, PencilAltIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline";
import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
import { Tooltip, Typography } from "antd";
import openai from "openai";
@ -10,6 +10,7 @@ import NotificationsManager from "../../../molecules/notifications_manager";
import { getCallbacksCall, setCallbacksCall } from "../../../networking";
import { isProxyAdminRole } from "@/utils/roles";
import AddFallbacks from "./AddFallbacks";
import EditFallbacks from "./EditFallbacks";
type FallbackEntry = { [modelName: string]: string[] };
type Fallbacks = FallbackEntry[];
@ -119,6 +120,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
const [isDeleting, setIsDeleting] = useState(false);
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [fallbackToEdit, setFallbackToEdit] = useState<FallbackEntry | null>(null);
const { data: modelCostMapData } = useModelCostMap();
const getProviderFromModel = (model: string): string => {
@ -146,6 +148,14 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
setIsDeleteModalOpen(true);
};
const handleEditClick = (fallbackEntry: FallbackEntry) => {
setFallbackToEdit(fallbackEntry);
};
const handleEditClose = () => {
setFallbackToEdit(null);
};
const handleDeleteConfirm = async () => {
if (!fallbackToDelete || !accessToken) {
return;
@ -281,6 +291,18 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Edit fallback">
<span
data-testid="edit-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleEditClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)}
className="cursor-pointer inline-flex"
>
<Icon icon={PencilAltIcon} size="sm" className="hover:text-blue-600" />
</span>
</Tooltip>
<Tooltip title="Delete fallback">
<span
data-testid="delete-fallback-button"
@ -302,6 +324,16 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
</TableBody>
</Table>
)}
{canModify && fallbackToEdit && (
<EditFallbacks
key={Object.keys(fallbackToEdit)[0]}
accessToken={accessToken || ""}
fallbackEntry={fallbackToEdit}
value={routerSettings.fallbacks || []}
onChange={handleFallbacksChange}
onClose={handleEditClose}
/>
)}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Fallback?"