mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(ui): promote fusion web access
This commit is contained in:
parent
568b6c910e
commit
40528bb4ed
7 changed files with 109 additions and 26 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue