feat(ui): promote fusion web access

This commit is contained in:
moe-berri 2026-09-03 13:19:05 -07:00
parent 568b6c910e
commit 40528bb4ed
7 changed files with 109 additions and 26 deletions

View file

@ -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.

View file

@ -32,7 +32,22 @@ vi.mock("@/components/shared/MultiSelect", () => ({
}));
vi.mock("@/components/search_tools/SearchToolSelector", () => ({
default: () => <div data-testid="search-tool-selector" />,
default: ({
onChange,
onOptionsLoaded,
}: {
onChange: (tools: string[]) => void;
onOptionsLoaded?: (tools: string[]) => void;
}) => (
<div data-testid="search-tool-selector">
<button type="button" onClick={() => onOptionsLoaded?.(["web-search"])}>
Load search tools
</button>
<button type="button" onClick={() => onChange(["web-search"])}>
Select web search
</button>
</div>
),
}));
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();

View file

@ -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<FusionPreset>(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({
</p>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="fusion-web-access">Web access</Label>
<p className="text-xs text-muted-foreground">
Let every panel model and the analyst independently search for current evidence.
</p>
</div>
<Switch
id="fusion-web-access"
aria-label="Web access"
checked={webAccessEnabled}
onCheckedChange={setWebAccessEnabled}
/>
</div>
{webAccessEnabled && (
<SearchToolSelector
accessToken={accessToken}
value={searchToolName ? [searchToolName] : []}
onChange={(tools) => setSearchToolName(tools.at(-1) ?? "")}
onOptionsLoaded={handleSearchOptionsLoaded}
placeholder="Select a Search Tool"
/>
)}
<p className="text-xs text-muted-foreground">
Search runs privately and never exposes client action tools to the panel.{" "}
<Link href="/search-tools" className="font-medium text-primary hover:underline">
Manage Search Tools
</Link>
</p>
</div>
<div className="rounded-md border">
<button
type="button"
aria-label="Advanced settings"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
onClick={() => setAdvancedOpen((current) => !current)}
>
@ -336,21 +381,9 @@ function FusionModelDialog({
Unsupported reasoning parameters are dropped for that provider.
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Web research</Label>
<SearchToolSelector
accessToken={accessToken}
value={searchToolName ? [searchToolName] : []}
onChange={(tools) => setSearchToolName(tools.at(-1) ?? "")}
placeholder="Select one Search Tool (optional)"
/>
<p className="text-xs text-muted-foreground">
When selected, panel and analyst models can search through this server-side LiteLLM Search Tool.
</p>
</div>
{searchToolName && (
{webAccessEnabled && (
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="fusion-tool-calls">Tool calls per internal model</Label>
<Label htmlFor="fusion-tool-calls">Maximum searches per internal model</Label>
<Input
id="fusion-tool-calls"
type="number"
@ -359,6 +392,9 @@ function FusionModelDialog({
value={maxToolCalls}
onChange={(event) => setMaxToolCalls(Number(event.target.value))}
/>
<p className="text-xs text-muted-foreground">
Applied separately to every panel model and the analyst. Default 4; maximum 16.
</p>
</div>
)}
</div>

View file

@ -22,6 +22,7 @@ const validValue = (overrides: Partial<FusionFormValue> = {}): 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.");

View file

@ -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<FusionReasoningEffort>(["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,
},
},

View file

@ -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(<SearchToolSelector accessToken="token" onChange={vi.fn()} />);
const onOptionsLoaded = vi.fn();
renderWithProviders(
<SearchToolSelector accessToken="token" onChange={vi.fn()} onOptionsLoaded={onOptionsLoaded} />,
);
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 () => {

View file

@ -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<SearchToolSelectorProps> = ({
onChange,
onOptionsLoaded,
value,
className,
accessToken,
@ -47,19 +49,20 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
: 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 (
<Combobox