fix(ui): render fallback targets with parameter overrides

This commit is contained in:
Tanishq 2026-10-25 19:25:46 +05:30
parent dddbaa803d
commit 1942148f89
4 changed files with 122 additions and 44 deletions

View file

@ -14,13 +14,16 @@ import { AddFallbacksModal } from "./AddFallbacksModal";
import { FallbackGroup } from "./FallbackGroupConfig";
import { FallbackSelectionForm } from "./FallbackSelectionForm";
export type FallbackTarget = string | { model: string; [parameter: string]: unknown };
export type ConfiguredFallbackEntry = { [modelName: string]: FallbackTarget[] };
export type ConfiguredFallbacks = ConfiguredFallbackEntry[];
export type FallbackEntry = { [modelName: string]: string[] };
export type Fallbacks = FallbackEntry[];
interface AddFallbacksProps {
accessToken: string;
value?: Fallbacks; // Current fallbacks value from form
onChange?: (fallbacks: Fallbacks) => Promise<void>; // Callback to update form value
value?: ConfiguredFallbacks; // Current fallbacks value from form
onChange?: (fallbacks: ConfiguredFallbacks) => Promise<void>; // Callback to update form value
disabledReason?: string; // When set, the button is disabled and this explains why
}

View file

@ -12,6 +12,7 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { toast } from "@/lib/toast";
import { AddFallbacksModal } from "./AddFallbacksModal";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
import type { ConfiguredFallbacks } from "./AddFallbacks";
export type FallbackEntry = { [modelName: string]: string[] };
export type Fallbacks = FallbackEntry[];
@ -19,8 +20,8 @@ export type Fallbacks = FallbackEntry[];
interface EditFallbacksProps {
accessToken: string;
fallbackEntry: FallbackEntry;
value: Fallbacks;
onChange: (fallbacks: Fallbacks) => Promise<void>;
value: ConfiguredFallbacks;
onChange: (fallbacks: ConfiguredFallbacks) => Promise<void>;
onClose: () => void;
maxFallbacks?: number;
}

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
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 Fallbacks from "./Fallbacks";
@ -125,6 +125,72 @@ describe("Fallbacks", () => {
});
});
describe("fallbacks with parameter overrides", () => {
const override = {
model: "overridden-backup",
reasoning_effort: "none",
api_key: "private-fallback-key",
extra_headers: { Authorization: "private-authorization" },
};
const configuredFallbacks = [{ primary: ["plain-backup", override] }, { "editable-primary": ["plain-backup"] }];
beforeEach(() => {
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValue({
router_settings: { fallbacks: configuredFallbacks },
});
});
it("displays mixed targets without exposing override credentials or opening the string-only editor", async () => {
const user = userEvent.setup();
renderWithQueryClient(<Fallbacks {...defaultProps} />);
const row = await screen.findByRole("row", { name: /primary.*overridden-backup/ });
expect(within(row).getByText("plain-backup")).toBeInTheDocument();
expect(within(row).getByText("overridden-backup")).toBeInTheDocument();
expect(within(row).getByText("Overrides")).toBeInTheDocument();
expect(screen.queryByText(override.api_key)).not.toBeInTheDocument();
expect(screen.queryByText(override.extra_headers.Authorization)).not.toBeInTheDocument();
const edit = within(row).getByTestId("edit-fallback-button");
expect(edit).toHaveAttribute("aria-disabled", "true");
await user.click(edit);
expect(screen.queryByText("Configure Model Fallbacks")).not.toBeInTheDocument();
});
it("preserves parameter overrides when saving another fallback's models", async () => {
const user = userEvent.setup();
renderWithQueryClient(<Fallbacks {...defaultProps} />);
const row = await screen.findByRole("row", { name: /editable-primary/ });
await user.click(within(row).getByTestId("edit-fallback-button"));
await user.click(await screen.findByRole("button", { name: "Save Changes" }));
await waitFor(() =>
expect(networkingModule.setCallbacksCall).toHaveBeenCalledWith(mockAccessToken, {
router_settings: { fallbacks: configuredFallbacks },
}),
);
expect(screen.getByText("overridden-backup")).toBeInTheDocument();
});
it("preserves parameter overrides when deleting another fallback", async () => {
const user = userEvent.setup();
renderWithQueryClient(<Fallbacks {...defaultProps} />);
const row = await screen.findByRole("row", { name: /editable-primary/ });
await user.click(within(row).getByTestId("delete-fallback-button"));
await user.click(screen.getByRole("button", { name: "Delete", exact: true }));
await waitFor(() =>
expect(networkingModule.setCallbacksCall).toHaveBeenCalledWith(mockAccessToken, {
router_settings: { fallbacks: [configuredFallbacks[0]] },
}),
);
expect(screen.getByText("overridden-backup")).toBeInTheDocument();
expect(screen.queryByText("editable-primary")).not.toBeInTheDocument();
});
});
it("should not render when accessToken is null", () => {
const { container } = renderWithQueryClient(<Fallbacks {...defaultProps} accessToken={null} />);
expect(container).toBeEmptyDOMElement();

View file

@ -16,60 +16,60 @@ import {
isOwnedByConfig,
} from "../../../router_settings/ConfigOwned";
import { isProxyAdminRole } from "@/utils/roles";
import AddFallbacks from "./AddFallbacks";
import EditFallbacks from "./EditFallbacks";
type FallbackEntry = { [modelName: string]: string[] };
type Fallbacks = FallbackEntry[];
import AddFallbacks, {
type ConfiguredFallbackEntry as FallbackEntry,
type ConfiguredFallbacks as Fallbacks,
type FallbackTarget,
} from "./AddFallbacks";
import EditFallbacks, { type FallbackEntry as EditableFallbackEntry } from "./EditFallbacks";
const modelCardClass =
"inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-border bg-muted text-sm font-medium text-foreground shrink-0";
"inline-flex max-w-full items-center gap-2 px-2.5 py-1 rounded-md border border-border bg-muted text-sm font-medium text-foreground shrink-0";
const iconWrapperClass = "inline-flex shrink-0 items-center justify-center px-1.5 py-1.5";
function renderModelNameCell(modelName: string, getProviderFromModel?: (modelName: string) => string): React.ReactNode {
function renderModelNameCell(
modelName: string,
getProviderFromModel?: (modelName: string) => string,
hasOverrides = false,
): React.ReactNode {
const provider = getProviderFromModel?.(modelName) ?? modelName;
return (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{modelName}</span>
<span className="min-w-0 break-words">{modelName}</span>
{hasOverrides && <span className="text-xs text-muted-foreground">Overrides</span>}
</span>
);
}
function renderFallbacksChain(
_primaryModel: string,
fallbackModels: string[],
fallbackModels: FallbackTarget[],
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 className="break-words">{modelName}</span>
</span>
);
};
return (
<span className="grid grid-cols-[auto_1fr] items-start gap-x-2 w-full min-w-0">
<span className="grid grid-cols-[auto_minmax(min-content,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-info" aria-hidden>
<ArrowRight 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 && (
<span className={`${iconWrapperClass} text-muted-foreground`}>
<ArrowRight className="h-3 w-3 shrink-0" />
</span>
)}
<ChainCard modelName={model} />
</React.Fragment>
))}
{list.map((model, i) => {
const modelName = typeof model === "string" ? model : model.model;
return (
<React.Fragment key={`${i}-${modelName}`}>
{i > 0 && (
<span className={`${iconWrapperClass} text-muted-foreground`}>
<ArrowRight className="h-3 w-3 shrink-0" />
</span>
)}
{renderModelNameCell(modelName, getProviderFromModel, typeof model !== "string")}
</React.Fragment>
);
})}
</span>
</span>
);
@ -137,7 +137,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 [fallbackToEdit, setFallbackToEdit] = useState<EditableFallbackEntry | null>(null);
const [routerSettingsSource, setRouterSettingsSource] = useState<RouterSettingsSource | null>(null);
const { data: modelCostMapData } = useModelCostMap();
@ -169,8 +169,9 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
setIsDeleteModalOpen(true);
};
const handleEditClick = (fallbackEntry: FallbackEntry) => {
setFallbackToEdit(fallbackEntry);
const handleEditClick = (primaryModel: string, fallbackModels: FallbackTarget[]) => {
if (!fallbackModels.every((model): model is string => typeof model === "string")) return;
setFallbackToEdit({ [primaryModel]: fallbackModels });
};
const handleEditClose = () => {
@ -270,8 +271,10 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
const ownedByConfig = routerSettingsSource !== null && isOwnedByConfig(routerSettingsSource, "fallbacks");
const writeHint = fallbacksWriteHint(routerSettingsSource, ownedByConfig);
const canWrite = canModify && writeHint === undefined;
const writeActionClass = (hoverClass: string) =>
canWrite ? `${iconWrapperClass} cursor-pointer ${hoverClass}` : `${iconWrapperClass} cursor-not-allowed opacity-50`;
const writeActionClass = (hoverClass: string, enabled = canWrite) =>
enabled ? `${iconWrapperClass} cursor-pointer ${hoverClass}` : `${iconWrapperClass} cursor-not-allowed opacity-50`;
const canEdit = (models: FallbackTarget[]) =>
canWrite && Array.isArray(models) && models.every((model) => typeof model === "string");
return (
<TooltipProvider>
@ -335,16 +338,21 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
data-testid="edit-fallback-button"
role="button"
tabIndex={0}
aria-disabled={!canWrite}
onClick={() => canWrite && handleEditClick(item)}
onKeyDown={(e) => canWrite && e.key === "Enter" && handleEditClick(item)}
className={writeActionClass("hover:text-info")}
aria-disabled={!canEdit(value)}
onClick={() => canEdit(value) && handleEditClick(key, value)}
onKeyDown={(e) => canEdit(value) && e.key === "Enter" && handleEditClick(key, value)}
className={writeActionClass("hover:text-info", canEdit(value))}
/>
}
>
<Pencil className="h-5 w-5 shrink-0" />
</TooltipTrigger>
<TooltipContent>{writeHint ?? "Edit fallback"}</TooltipContent>
<TooltipContent>
{writeHint ??
(canEdit(value)
? "Edit fallback"
: "Edit parameter overrides through the API or config file")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger