mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
fix(ui): render fallback targets with parameter overrides
This commit is contained in:
parent
dddbaa803d
commit
1942148f89
4 changed files with 122 additions and 44 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue