From 40528bb4ed773f6ff2933103c7746b9dbc3c4852 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 13:19:05 -0700 Subject: [PATCH] feat(ui): promote fusion web access --- cookbook/fusion_models.md | 2 +- .../FusionModels/FusionModelsPanel.test.tsx | 25 ++++++- .../FusionModels/FusionModelsPanel.tsx | 66 ++++++++++++++----- .../FusionModels/fusionModelConfig.test.ts | 11 ++++ .../FusionModels/fusionModelConfig.ts | 8 ++- .../search_tools/SearchToolSelector.test.tsx | 8 ++- .../search_tools/SearchToolSelector.tsx | 15 +++-- 7 files changed, 109 insertions(+), 26 deletions(-) diff --git a/cookbook/fusion_models.md b/cookbook/fusion_models.md index 414be3e1f71..4c9f42e26d8 100644 --- a/cookbook/fusion_models.md +++ b/cookbook/fusion_models.md @@ -39,7 +39,7 @@ model_list: Call `fusion/general` exactly like any other model. `invocation: auto` lets the outer model skip the panel for routine requests. `required` forces deliberation and is useful for evaluations or workloads where every request should receive the same treatment. -`reasoning_effort: none` makes deliberation replace private extended reasoning where a provider supports that parameter. LiteLLM drops it for providers that do not support it. The optional Search Tool supplies search results and bounded page content through LiteLLM's Search API. This first version does not expose a separate URL-fetch tool. +`reasoning_effort: none` makes deliberation replace private extended reasoning where a provider supports that parameter. LiteLLM drops it for providers that do not support it. The optional Search Tool supplies search results and bounded page content through LiteLLM's Search API. `max_tool_calls` limits searches separately for every panel model and the analyst; it defaults to 4 and is capped at 16. This first version does not expose a separate URL-fetch tool. The outer model must support function calling. Panel and analyst models only need function calling when a Search Tool is configured. Granting access to the Fusion model lets the request use its administrator-configured model and search dependencies; the panel query and private research are sent to those deployments under their normal provider data policies. 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 cf77fac459a..71dc926d3c9 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 @@ -32,7 +32,22 @@ vi.mock("@/components/shared/MultiSelect", () => ({ })); vi.mock("@/components/search_tools/SearchToolSelector", () => ({ - default: () =>
, + default: ({ + onChange, + onOptionsLoaded, + }: { + onChange: (tools: string[]) => void; + onOptionsLoaded?: (tools: string[]) => void; + }) => ( +
+ + +
+ ), })); vi.mock("@/components/common_components/team_dropdown", () => ({ @@ -97,6 +112,7 @@ describe("FusionModelsPanel", () => { await user.click(screen.getByRole("button", { name: "Choose panel models" })); await user.click(screen.getByLabelText("Outer model")); await user.click(screen.getByRole("option", { name: "outer" })); + await user.click(screen.getByRole("button", { name: "Load search tools" })); await user.click(screen.getByRole("button", { name: "Create Fusion Model" })); await waitFor(() => expect(modelCreateCall).toHaveBeenCalledTimes(1)); @@ -113,6 +129,7 @@ describe("FusionModelsPanel", () => { max_completion_tokens: 16000, temperature: 0, reasoning_effort: "none", + search_tool_name: "web-search", max_tool_calls: 4, }, }, @@ -135,6 +152,11 @@ describe("FusionModelsPanel", () => { await user.click(screen.getByRole("option", { name: /Always deliberate/ })); expect(behaviorSelector).toHaveTextContent("Always deliberate"); + + expect(screen.getByRole("switch", { name: "Web access" })).toBeChecked(); + expect(screen.getByTestId("search-tool-selector")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Advanced settings" })); + expect(screen.getByLabelText("Maximum searches per internal model")).toHaveValue(4); }); it("requires and sends a team for team-admin creation", async () => { @@ -145,6 +167,7 @@ describe("FusionModelsPanel", () => { await user.click(screen.getByRole("button", { name: "Choose panel models" })); await user.click(screen.getByLabelText("Outer model")); await user.click(screen.getByRole("option", { name: "outer" })); + await user.click(screen.getByRole("button", { name: "Load search tools" })); await user.click(screen.getByRole("button", { name: "Create Fusion Model" })); expect(await screen.findByText("Select a team to continue.")).toBeInTheDocument(); expect(modelCreateCall).not.toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.tsx index be71f58acf3..fa118852da8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/FusionModels/FusionModelsPanel.tsx @@ -1,7 +1,8 @@ "use client"; import { Edit2, Plus, Trash2 } from "lucide-react"; -import { type FormEvent, useMemo, useState } from "react"; +import Link from "next/link"; +import { type FormEvent, useCallback, useMemo, useRef, useState } from "react"; import { AutoRouterDeployment, @@ -19,6 +20,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { toast } from "@/lib/toast"; import { canModifyModel, type ModelWriteScope } from "@/utils/modelPermissions"; @@ -80,6 +82,8 @@ function FusionModelDialog({ const [temperature, setTemperature] = useState(initialConfig.temperature); const [reasoningEffort, setReasoningEffort] = useState(initialConfig.reasoning_effort); const [searchToolName, setSearchToolName] = useState(initialConfig.search_tool_name); + const [webAccessEnabled, setWebAccessEnabled] = useState(editing ? Boolean(initialConfig.search_tool_name) : true); + const searchDefaultsApplied = useRef(editing); const [maxToolCalls, setMaxToolCalls] = useState(initialConfig.max_tool_calls); const [preset, setPreset] = useState(initialConfig.invocation === "required" ? "always" : "auto"); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -96,6 +100,13 @@ function FusionModelDialog({ setPreset(nextPreset); }; + const handleSearchOptionsLoaded = useCallback((options: string[]) => { + if (searchDefaultsApplied.current) return; + searchDefaultsApplied.current = true; + setWebAccessEnabled(options.length > 0); + setSearchToolName((current) => current || options[0] || ""); + }, []); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const value: FusionFormValue = { @@ -112,6 +123,7 @@ function FusionModelDialog({ reasoning_effort: reasoningEffort, search_tool_name: searchToolName, max_tool_calls: maxToolCalls, + web_access_enabled: webAccessEnabled, }; const validationError = fusionConfigError(value, requiresTeamScope); if (validationError) { @@ -258,9 +270,42 @@ function FusionModelDialog({

+
+
+
+ +

+ Let every panel model and the analyst independently search for current evidence. +

+
+ +
+ {webAccessEnabled && ( + setSearchToolName(tools.at(-1) ?? "")} + onOptionsLoaded={handleSearchOptionsLoaded} + placeholder="Select a Search Tool" + /> + )} +

+ Search runs privately and never exposes client action tools to the panel.{" "} + + Manage Search Tools + +

+
+
-
- - setSearchToolName(tools.at(-1) ?? "")} - placeholder="Select one Search Tool (optional)" - /> -

- When selected, panel and analyst models can search through this server-side LiteLLM Search Tool. -

-
- {searchToolName && ( + {webAccessEnabled && (
- + setMaxToolCalls(Number(event.target.value))} /> +

+ Applied separately to every panel model and the analyst. Default 4; maximum 16. +

)} 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 5073df03ed9..68af9c7d4a1 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 @@ -22,6 +22,7 @@ const validValue = (overrides: Partial = {}): FusionFormValue = reasoning_effort: "none", search_tool_name: "", max_tool_calls: 4, + web_access_enabled: false, ...overrides, }); @@ -63,6 +64,16 @@ describe("Fusion model configuration", () => { ).toMatch(/at most eight/); }); + it("requires a Search Tool when web access is enabled", () => { + expect(fusionConfigError(validValue({ web_access_enabled: true }), false)).toBe( + "Select a Search Tool or turn Web access off.", + ); + expect( + fusionModelPayload(validValue({ web_access_enabled: true, search_tool_name: "web-search" }), false).litellm_params + .fusion_router_config, + ).toMatchObject({ search_tool_name: "web-search", max_tool_calls: 4 }); + }); + 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 44d7037aba7..bcb579fa2c5 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 @@ -19,6 +19,7 @@ export interface FusionRouterConfigValue { export interface FusionFormValue extends FusionRouterConfigValue { model_name: string; team_id: string; + web_access_enabled: boolean; } export const DEFAULT_FUSION_CONFIG: FusionRouterConfigValue = { @@ -45,6 +46,9 @@ 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; + export const parseFusionConfig = (value: unknown): FusionRouterConfigValue => { const config = asRecord(value); const panelModels = Array.isArray(config.panel_models) @@ -76,6 +80,8 @@ export const fusionConfigError = (value: FusionFormValue, requiresTeamScope: boo if (!value.outer_model) 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) { return "Panel timeout must be between 1 and 600 seconds."; } @@ -110,7 +116,7 @@ export const fusionModelPayload = (value: FusionFormValue, requiresTeamScope: bo max_completion_tokens: value.max_completion_tokens, temperature: value.temperature, reasoning_effort: value.reasoning_effort, - ...(value.search_tool_name ? { search_tool_name: value.search_tool_name } : {}), + ...(value.web_access_enabled && value.search_tool_name ? { search_tool_name: value.search_tool_name } : {}), max_tool_calls: value.max_tool_calls, }, }, diff --git a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx index b8895718df0..fdef9ed6ca4 100644 --- a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.test.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import { fetchSearchTools } from "../networking"; import SearchToolSelector from "./SearchToolSelector"; @@ -24,12 +24,16 @@ describe("SearchToolSelector", () => { it("should load and display available search tools", async () => { const user = userEvent.setup(); - renderWithProviders(); + const onOptionsLoaded = vi.fn(); + renderWithProviders( + , + ); await user.click(screen.getByRole("combobox")); expect(await screen.findByRole("option", { name: "search-one" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "search-two" })).toBeInTheDocument(); + await waitFor(() => expect(onOptionsLoaded).toHaveBeenCalledWith(["search-one", "search-two"])); }); it("should clear all selected search tools", async () => { diff --git a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx index 8b4e9b297fa..d929ca027f3 100644 --- a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx +++ b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx @@ -17,6 +17,7 @@ import { fetchSearchTools } from "../networking"; export interface SearchToolSelectorProps { onChange: (selected: string[]) => void; + onOptionsLoaded?: (options: string[]) => void; value?: string[]; className?: string; accessToken: string; @@ -26,6 +27,7 @@ export interface SearchToolSelectorProps { const SearchToolSelector: React.FC = ({ onChange, + onOptionsLoaded, value, className, accessToken, @@ -47,19 +49,20 @@ const SearchToolSelector: React.FC = ({ : Array.isArray(data?.data) ? data.data : []; - setOptions( - tools - .map((tool: { search_tool_name?: string }) => tool?.search_tool_name) - .filter((name: unknown): name is string => typeof name === "string" && name.length > 0), - ); + const loadedOptions = tools + .map((tool: { search_tool_name?: string }) => tool?.search_tool_name) + .filter((name: unknown): name is string => typeof name === "string" && name.length > 0); + setOptions(loadedOptions); + onOptionsLoaded?.(loadedOptions); } catch (e) { console.error("Failed to load search tools:", e); + onOptionsLoaded?.([]); } finally { setLoading(false); } }; load(); - }, [accessToken]); + }, [accessToken, onOptionsLoaded]); return (