diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.test.tsx index f847d1e1b9f..057b9a5bd7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.test.tsx @@ -10,6 +10,8 @@ const modelPatchUpdateCall = vi.fn(); const modelDeleteCall = vi.fn(); const invalidate = vi.fn().mockResolvedValue(undefined); const useFusionRouters = vi.fn(); +const toastSuccess = vi.fn(); +const toastFromError = vi.fn(); vi.mock("@/components/networking", () => ({ modelCreateCall: (...args: unknown[]) => modelCreateCall(...args), @@ -58,9 +60,21 @@ vi.mock("@/components/common_components/team_dropdown", () => ({ ), })); -vi.mock("@/components/common_components/DeleteResourceModal", () => ({ default: () => null })); +vi.mock("@/components/common_components/DeleteResourceModal", () => ({ + default: ({ title, message, onOk }: { title: string; message: string; onOk: () => void }) => ( +
+

{message}

+ +
+ ), +})); vi.mock("@/lib/toast", () => ({ - toast: { success: vi.fn(), fromError: vi.fn() }, + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + fromError: (...args: unknown[]) => toastFromError(...args), + }, })); const existingDeployment = { @@ -101,6 +115,7 @@ describe("FusionModelsPanel", () => { useFusionRouters.mockReturnValue({ data: [], isLoading: false }); modelCreateCall.mockResolvedValue({}); modelPatchUpdateCall.mockResolvedValue({}); + modelDeleteCall.mockResolvedValue({}); }); it("creates an auto Fusion model with an ordinary model/new payload", async () => { @@ -137,6 +152,25 @@ describe("FusionModelsPanel", () => { }); }); + it("keeps the form open and shows the backend error when creation fails", async () => { + modelCreateCall.mockRejectedValue(new Error("backend unavailable")); + const user = userEvent.setup(); + renderPanel(); + + await user.click(screen.getByRole("button", { name: "Add Fusion Model" })); + await user.type(screen.getByLabelText("Model name"), "fusion/coding"); + await user.click(screen.getByRole("button", { name: "Choose panel models" })); + await user.click(screen.getByLabelText("Outer model")); + await user.click(await screen.findByRole("option", { name: "outer" }, { timeout: 5000 })); + await user.click(screen.getByRole("button", { name: "Load search tools" })); + await user.click(screen.getByRole("button", { name: "Create Fusion Model" })); + + expect(await screen.findByText("backend unavailable")).toBeVisible(); + expect(screen.getByRole("dialog", { name: "Add Fusion Model" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Create Fusion Model" })).toBeEnabled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + it("shows readable, full-width deliberation presets", async () => { const user = userEvent.setup(); renderPanel(); @@ -200,4 +234,32 @@ describe("FusionModelsPanel", () => { "fusion-id", ); }); + + it("deletes a stored Fusion model and refreshes the list", async () => { + useFusionRouters.mockReturnValue({ data: [existingDeployment], isLoading: false }); + const user = userEvent.setup(); + renderPanel(); + + await user.click(screen.getByRole("button", { name: "Delete fusion/existing" })); + expect(screen.getByRole("dialog", { name: "Delete Fusion Model" })).toHaveTextContent("fusion/existing"); + await user.click(screen.getByRole("button", { name: "Confirm delete" })); + + await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "fusion-id")); + await waitFor(() => expect(invalidate).toHaveBeenCalled()); + expect(toastSuccess).toHaveBeenCalledWith("Deleted Fusion model: fusion/existing"); + }); + + it("keeps the delete dialog open when deletion fails", async () => { + useFusionRouters.mockReturnValue({ data: [existingDeployment], isLoading: false }); + modelDeleteCall.mockRejectedValue(new Error("backend unavailable")); + const user = userEvent.setup(); + renderPanel(); + + await user.click(screen.getByRole("button", { name: "Delete fusion/existing" })); + await user.click(screen.getByRole("button", { name: "Confirm delete" })); + + await waitFor(() => expect(toastFromError).toHaveBeenCalled()); + expect(screen.getByRole("dialog", { name: "Delete Fusion Model" })).toBeVisible(); + expect(invalidate).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.test.ts index 68af9c7d4a1..e73dc92607f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.test.ts @@ -74,6 +74,15 @@ describe("Fusion model configuration", () => { ).toMatchObject({ search_tool_name: "web-search", max_tool_calls: 4 }); }); + it("rejects non-finite and fractional numeric settings before sending them", () => { + expect(fusionConfigError(validValue({ panel_timeout_seconds: Number.NaN }), false)).toMatch(/timeout/); + expect(fusionConfigError(validValue({ max_candidate_chars: 1000.5 }), false)).toMatch(/Candidate limit/); + expect(fusionConfigError(validValue({ temperature: Number.NaN }), false)).toMatch(/temperature/); + expect(fusionConfigError(validValue({ web_access_enabled: true, search_tool_name: " " }), false)).toMatch( + /Search Tool/, + ); + }); + it("includes team scope only when required", () => { expect(fusionModelPayload(validValue({ team_id: "team-1" }), true).model_info).toEqual({ team_id: "team-1" }); expect(fusionConfigError(validValue(), true)).toBe("Select a team to continue."); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.ts index 626c73ec71c..e4534e86b27 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/fusionModelConfig.ts @@ -47,7 +47,13 @@ const numberOr = (value: unknown, fallback: number): number => const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); const webAccessConfigError = (value: FusionFormValue): string | null => - value.web_access_enabled && !value.search_tool_name ? "Select a Search Tool or turn Web access off." : null; + value.web_access_enabled && !value.search_tool_name.trim() ? "Select a Search Tool or turn Web access off." : null; + +const isFiniteNumberInRange = (value: number, minimum: number, maximum: number): boolean => + Number.isFinite(value) && value >= minimum && value <= maximum; + +const isIntegerInRange = (value: number, minimum: number, maximum: number): boolean => + Number.isInteger(value) && value >= minimum && value <= maximum; export const parseFusionConfig = (value: unknown): FusionRouterConfigValue => { const config = asRecord(value); @@ -77,26 +83,24 @@ export const parseFusionConfig = (value: unknown): FusionRouterConfigValue => { export const fusionConfigError = (value: FusionFormValue, requiresTeamScope: boolean): string | null => { if (!value.model_name.trim()) return "Fusion model name is required."; if (requiresTeamScope && !value.team_id) return "Select a team to continue."; - if (!value.outer_model) return "Select the outer model."; + if (!value.outer_model.trim()) return "Select the outer model."; if (value.panel_models.length < 1) return "Select at least one panel model."; if (value.panel_models.length > 8) return "A Fusion panel can contain at most eight models."; const webAccessError = webAccessConfigError(value); if (webAccessError) return webAccessError; - if (value.panel_timeout_seconds <= 0 || value.panel_timeout_seconds > 600) { + if (!isFiniteNumberInRange(value.panel_timeout_seconds, Number.MIN_VALUE, 600)) { return "Panel and analyst timeout must be between 1 and 600 seconds."; } - if (value.max_candidate_chars < 1000 || value.max_candidate_chars > 50000) { + if (!isIntegerInRange(value.max_candidate_chars, 1000, 50000)) { return "Candidate limit must be between 1,000 and 50,000 characters."; } - if ( - !Number.isInteger(value.max_completion_tokens) || - value.max_completion_tokens < 1 || - value.max_completion_tokens > 128000 - ) { + if (!isIntegerInRange(value.max_completion_tokens, 1, 128000)) { return "Internal output tokens must be between 1 and 128,000."; } - if (value.temperature < 0 || value.temperature > 2) return "Panel temperature must be between 0 and 2."; - if (!Number.isInteger(value.max_tool_calls) || value.max_tool_calls < 1 || value.max_tool_calls > 16) { + if (!isFiniteNumberInRange(value.temperature, 0, 2)) { + return "Panel temperature must be between 0 and 2."; + } + if (!isIntegerInRange(value.max_tool_calls, 1, 16)) { return "Tool calls must be between 1 and 16."; } return null;