From 1942148f89b566d47a24887a025eefc8fea376d7 Mon Sep 17 00:00:00 2001 From: Tanishq <74068132+tanishqbaweja@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:25:46 +0530 Subject: [PATCH] fix(ui): render fallback targets with parameter overrides --- .../RouterSettings/Fallbacks/AddFallbacks.tsx | 7 +- .../Fallbacks/EditFallbacks.tsx | 5 +- .../Fallbacks/Fallbacks.test.tsx | 68 ++++++++++++++- .../RouterSettings/Fallbacks/Fallbacks.tsx | 86 ++++++++++--------- 4 files changed, 122 insertions(+), 44 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index 6ad7624ec60..9a3e12cbe1e 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -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; // Callback to update form value + value?: ConfiguredFallbacks; // Current fallbacks value from form + onChange?: (fallbacks: ConfiguredFallbacks) => Promise; // Callback to update form value disabledReason?: string; // When set, the button is disabled and this explains why } diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx index 3893a2f47ad..6ee79d57949 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx @@ -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; + value: ConfiguredFallbacks; + onChange: (fallbacks: ConfiguredFallbacks) => Promise; onClose: () => void; maxFallbacks?: number; } diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx index 7a7554139b2..d268c6878ec 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); expect(container).toBeEmptyDOMElement(); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index 03fd1d43510..be0f8b9cfde 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -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 ( - {modelName} + {modelName} + {hasOverrides && Overrides} ); } 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 ( - - - {modelName} - - ); - }; return ( - + - {list.map((model, i) => ( - - {i > 0 && ( - - - - )} - - - ))} + {list.map((model, i) => { + const modelName = typeof model === "string" ? model : model.model; + return ( + + {i > 0 && ( + + + + )} + {renderModelNameCell(modelName, getProviderFromModel, typeof model !== "string")} + + ); + })} ); @@ -137,7 +137,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) const [isDeleting, setIsDeleting] = useState(false); const [fallbackToDelete, setFallbackToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [fallbackToEdit, setFallbackToEdit] = useState(null); + const [fallbackToEdit, setFallbackToEdit] = useState(null); const [routerSettingsSource, setRouterSettingsSource] = useState(null); const { data: modelCostMapData } = useModelCostMap(); @@ -169,8 +169,9 @@ const Fallbacks: React.FC = ({ 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 = ({ 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 ( @@ -335,16 +338,21 @@ const Fallbacks: React.FC = ({ 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))} /> } > - {writeHint ?? "Edit fallback"} + + {writeHint ?? + (canEdit(value) + ? "Edit fallback" + : "Edit parameter overrides through the API or config file")} +