diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3c7d06268ad..8212fbe392f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4888,6 +4888,7 @@ class ProxyConfig: def __init__(self) -> None: self.config: Mapping[str, object] = MappingProxyType({}) self._last_semantic_filter_config: dict[str, object] | None = None + self._last_websearch_interception_config: dict[str, object] | None = None self._last_hashicorp_vault_config: dict[str, object] | None = None self._last_cyberark_config: dict[str, object] | None = None # mutable-ok: change-detection cache self._cyberark_boot_env: dict[str, str | None] | None = None # mutable-ok: deployment env snapshot, set once @@ -7697,6 +7698,9 @@ class ProxyConfig: if self._should_load_db_object(object_type="semantic_filter_settings"): await self._init_semantic_filter_settings_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="websearch_interception_settings"): + await self.init_websearch_interception_settings_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="config_overrides"): await self._init_hashicorp_vault_config_override(prisma_client=prisma_client) await self._init_cyberark_config_override(prisma_client=prisma_client) @@ -7775,6 +7779,56 @@ class ProxyConfig: except Exception as e: verbose_proxy_logger.exception("Error initializing semantic filter settings from DB: %s", e) + async def init_websearch_interception_settings_in_db(self, prisma_client: PrismaClient): + """ + Initialize web search interception settings from database. + Called periodically (approximately every 10 seconds) by background task to hot-reload settings across all pods. + """ + import json + + import litellm + from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, + ) + + try: + config_record: Final = await get_config_param(prisma_client, "litellm_settings") + + if config_record is None or config_record.param_value is None: + return + + litellm_settings = config_record.param_value + if isinstance(litellm_settings, str): + litellm_settings = json.loads(litellm_settings) + + websearch_config: Final = litellm_settings.get("websearch_interception_params", None) + + # Absent means nobody stored params, so a callbacks-list proxy keeps its callback. + if websearch_config is None: + return + + enabled: Final = bool(websearch_config.get("enabled", True)) + registered: Final = bool( + litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger) + ) + if self._last_websearch_interception_config == websearch_config and registered == enabled: + return + + litellm.logging_callback_manager.remove_callbacks_by_type(litellm.callbacks, WebSearchInterceptionLogger) + + if enabled: + litellm.logging_callback_manager.add_litellm_callback( + WebSearchInterceptionLogger.from_config_yaml(websearch_config) + ) + verbose_proxy_logger.info("Web search interception reinitialized from DB") + else: + verbose_proxy_logger.info("Web search interception disabled") + + self._last_websearch_interception_config = dict(websearch_config) + + except Exception as e: + verbose_proxy_logger.exception("Error initializing web search interception settings from DB: %s", e) + async def _init_sso_settings_in_db(self, prisma_client: PrismaClient): """ Initialize SSO settings from database into the router on startup. diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index b2baef126e9..c0b2eb1daa9 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -477,6 +477,35 @@ class MCPToolSearchSettingsResponse(SettingsResponse): """Response model for native MCP tool search settings""" +class WebSearchInterceptionSettings(BaseModel): + """Configuration for server-side web search interception""" + + enabled: bool = Field( + default=False, + description="Serve web search tool calls from a configured search tool instead of passing them upstream", + ) + + enabled_providers: list[str] = Field( + default_factory=list, + description="LLM providers to intercept for (e.g. 'bedrock', 'vertex_ai'). Empty intercepts Bedrock only.", + ) + + search_tool_name: str | None = Field( + default=None, + description="Name of the configured search tool to run searches through. Empty uses the first one available.", + ) + + max_agentic_loops: int | None = Field( + default=None, + ge=1, + description="How many follow-up model calls one intercepted request may chain. Empty applies the default of 3.", + ) + + +class WebSearchInterceptionSettingsResponse(SettingsResponse): + """Response model for web search interception settings""" + + @router.get( "/get/allowed_ips", tags=["Budget & Spend Tracking"], @@ -875,7 +904,13 @@ async def update_default_team_member_budget(teams: list[NewUserRequestTeam], use async def _update_litellm_setting( - settings: DefaultInternalUserParams | DefaultTeamSSOParams | MCPSemanticFilterSettings | MCPToolSearchSettings, + settings: ( + DefaultInternalUserParams + | DefaultTeamSSOParams + | MCPSemanticFilterSettings + | MCPToolSearchSettings + | WebSearchInterceptionSettings + ), settings_key: str, success_message: str, user_api_key_dict: UserAPIKeyAuth, @@ -1399,6 +1434,77 @@ async def update_mcp_semantic_filter_settings( return result +@router.get( + "/get/websearch_interception_settings", + tags=["Settings"], + dependencies=[Depends(user_api_key_auth)], + response_model=WebSearchInterceptionSettingsResponse, +) +async def get_websearch_interception_settings( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get web search interception configuration. + + Returns the current settings plus their schema, for the Admin UI to render. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": "Database not connected. Please connect a database."}, + ) + + config: Final = await proxy_config.get_config() + + return await _get_settings_with_schema( + settings_key="websearch_interception_params", + settings_class=WebSearchInterceptionSettings, + config=config, + ) + + +@router.patch( + "/update/websearch_interception_settings", + tags=["Settings"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_websearch_interception_settings( + settings: WebSearchInterceptionSettings, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update web search interception settings in database. + + Settings will be picked up by all pods within approximately 10 seconds via background polling. + """ + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only proxy admins can update web search interception settings.", + ) + + result: Final = await _update_litellm_setting( + settings=settings, + settings_key="websearch_interception_params", + success_message=( + "Web search interception settings updated successfully. " + "Changes will be applied across all pods within 10 seconds." + ), + user_api_key_dict=user_api_key_dict, + ) + try: + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if prisma_client is not None: + await proxy_config.init_websearch_interception_settings_in_db(prisma_client=prisma_client) + except Exception as e: + verbose_proxy_logger.warning("Failed to reinitialize web search interception settings immediately: %s", e) + + return result + + @router.get( "/get/mcp_tool_search_settings", tags=["Settings"], # mutable-ok: FastAPI's route decorator only accepts a list diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 76e4214c35a..0d9612d2325 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -4526,3 +4526,89 @@ async def test_add_deployment_syncs_ui_settings_even_when_the_model_reconcile_fa await config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=MagicMock()) assert general_settings["allow_agents_for_team_admins"] is True + + +def _websearch_logger_cls(): + from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, + ) + + return WebSearchInterceptionLogger + + +def _run_websearch_init(monkeypatch, stored_params, starting_callbacks): + pc = ProxyConfig() + monkeypatch.setattr(litellm, "callbacks", list(starting_callbacks)) + monkeypatch.setattr( + "litellm.proxy.proxy_server.get_config_param", + AsyncMock(return_value=SimpleNamespace(param_value={"websearch_interception_params": stored_params})) + if stored_params is not None + else AsyncMock(return_value=SimpleNamespace(param_value={})), + ) + asyncio.run(pc.init_websearch_interception_settings_in_db(prisma_client=MagicMock())) + return pc + + +def test_init_websearch_interception_absent_key_leaves_callbacks_untouched(monkeypatch): + logger_cls = _websearch_logger_cls() + config_registered = logger_cls(search_tool_name="from-config-yaml") + + _run_websearch_init(monkeypatch, stored_params=None, starting_callbacks=[config_registered]) + + assert litellm.callbacks == [config_registered] + + +def test_init_websearch_interception_enables_when_enabled_key_missing(monkeypatch): + logger_cls = _websearch_logger_cls() + + _run_websearch_init( + monkeypatch, + stored_params={"search_tool_name": "stored-tool"}, + starting_callbacks=[], + ) + + registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] + assert len(registered) == 1 + assert registered[0].search_tool_name == "stored-tool" + + +def test_init_websearch_interception_disabled_removes_the_callback(monkeypatch): + logger_cls = _websearch_logger_cls() + existing = logger_cls(search_tool_name="stored-tool") + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": False, "search_tool_name": "stored-tool"}, + starting_callbacks=[existing], + ) + + assert [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] == [] + + +def test_init_websearch_interception_replaces_stale_instance_on_param_change(monkeypatch): + logger_cls = _websearch_logger_cls() + stale = logger_cls(search_tool_name="old-tool", max_agentic_loops=2) + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": True, "search_tool_name": "new-tool", "max_agentic_loops": 7}, + starting_callbacks=[stale], + ) + + registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] + assert len(registered) == 1 + assert (registered[0].search_tool_name, registered[0].max_agentic_loops) == ("new-tool", 7) + + +def test_init_websearch_interception_honors_enabled_providers(monkeypatch): + logger_cls = _websearch_logger_cls() + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": True, "enabled_providers": ["bedrock", "vertex_ai"]}, + starting_callbacks=[], + ) + + registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] + assert len(registered) == 1 + assert registered[0].enabled_providers == ["bedrock", "vertex_ai"] diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 58201bd14ce..b1bf9f71379 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3127,6 +3127,77 @@ class TestMcpToolSearchSettingsEndpoints: assert mock_proxy_config["save_call_count"]() == 0 +class TestWebSearchInterceptionSettingsEndpoints: + @staticmethod + def _override_auth(role: LitellmUserRoles): + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="u", api_key="hashed", user_role=role + ) + + def test_get_returns_stored_values_and_field_schema(self, mock_proxy_config, mock_auth, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + "enabled": True, + "enabled_providers": ["bedrock", "vertex_ai"], + "search_tool_name": "my-perplexity-search", + } + + resp = client.get("/get/websearch_interception_settings") + + assert resp.status_code == 200, resp.text + assert resp.json()["values"] == { + "enabled": True, + "enabled_providers": ["bedrock", "vertex_ai"], + "search_tool_name": "my-perplexity-search", + "max_agentic_loops": None, + } + assert resp.json()["field_schema"]["properties"]["enabled_providers"]["type"] == "array" + + def test_update_requires_proxy_admin(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + self._override_auth(LitellmUserRoles.INTERNAL_USER) + try: + resp = client.patch("/update/websearch_interception_settings", json={"enabled": True}) + finally: + app.dependency_overrides.clear() + assert resp.status_code == 403 + assert "proxy admin" in resp.json()["detail"].lower() + + def test_update_persists_settings(self, mock_proxy_config, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + payload = { + "enabled": True, + "enabled_providers": ["bedrock"], + "search_tool_name": "my-perplexity-search", + "max_agentic_loops": 5, + } + try: + resp = client.patch("/update/websearch_interception_settings", json=payload) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + assert mock_proxy_config["save_call_count"]() == 1 + assert mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] == payload + + def test_update_rejects_zero_max_agentic_loops(self, mock_proxy_config, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + try: + resp = client.patch( + "/update/websearch_interception_settings", + json={"enabled": True, "max_agentic_loops": 0}, + ) + finally: + app.dependency_overrides.clear() + assert resp.status_code == 422 + assert mock_proxy_config["save_call_count"]() == 0 + + def test_upload_logo_requires_proxy_admin(monkeypatch): """Any authenticated key could previously write a file to the server's disk here.""" from litellm.proxy._types import UserAPIKeyAuth diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 1c8425251fd..386cbebd38d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -22,6 +22,7 @@ import UserBannerSettings from "@/components/Settings/AdminSettings/UserBannerSe import CyberArk from "@/components/Settings/AdminSettings/CyberArk/CyberArk"; import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault"; import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings"; +import WebSearchInterceptionSettings from "@/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings"; import SSOModals from "@/components/SSOModals"; import { emptySSOSettingsFormValues, @@ -408,6 +409,11 @@ const AdminPanel: React.FC = ({ proxySettings }) => { label: "Plugins", children: , }, + { + key: "web-search-interception", + label: "Web Search Interception", + children: , + }, ]; return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts new file mode 100644 index 00000000000..7de84c52310 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts @@ -0,0 +1,23 @@ +import { updateWebSearchInterceptionSettings } from "@/components/networking"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const webSearchInterceptionSettingsKeys = createQueryKeys("webSearchInterceptionSettings"); + +export const useUpdateWebSearchInterceptionSettings = (accessToken: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settings: Record) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return updateWebSearchInterceptionSettings(accessToken, settings); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: webSearchInterceptionSettingsKeys.all, + }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts new file mode 100644 index 00000000000..4c2b549209c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts @@ -0,0 +1,17 @@ +import { getWebSearchInterceptionSettings } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import useAuthorized from "../useAuthorized"; + +const webSearchInterceptionSettingsKeys = createQueryKeys("webSearchInterceptionSettings"); + +export const useWebSearchInterceptionSettings = () => { + const { accessToken } = useAuthorized(); + return useQuery>({ + queryKey: webSearchInterceptionSettingsKeys.list({}), + queryFn: async () => await getWebSearchInterceptionSettings(accessToken), + enabled: !!accessToken, + staleTime: 60 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +}; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.test.tsx new file mode 100644 index 00000000000..f28891a5da5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.test.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WebSearchInterceptionSettings from "./WebSearchInterceptionSettings"; +import { useWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings"; +import { useUpdateWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +vi.mock("@/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings", () => ({ + useWebSearchInterceptionSettings: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings", () => ({ + useUpdateWebSearchInterceptionSettings: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + fetchSearchTools: vi.fn().mockResolvedValue({ + search_tools: [{ search_tool_name: "my-perplexity-search" }, { search_tool_name: "backup-search" }], + }), +})); + +const mockMutate = vi.fn(); + +const ENABLED_PAYLOAD = { + enabled: true, + enabled_providers: ["bedrock"], + search_tool_name: "my-perplexity-search", + max_agentic_loops: null, +}; + +const storedSettings = { + field_schema: { + properties: { + enabled: { description: "Serve web search tool calls from a configured search tool" }, + }, + }, + values: { + enabled: false, + enabled_providers: ["bedrock"], + search_tool_name: "my-perplexity-search", + max_agentic_loops: null, + }, +}; + +async function renderSettings() { + const result = render(); + await act(async () => {}); + return result; +} + +describe("WebSearchInterceptionSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAuthorized).mockReturnValue({ accessToken: "test-token" } as any); + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: storedSettings, + isLoading: false, + isError: false, + error: null, + } as any); + vi.mocked(useUpdateWebSearchInterceptionSettings).mockReturnValue({ + mutate: mockMutate, + isPending: false, + error: null, + } as any); + }); + + it("renders the settings section", async () => { + await renderSettings(); + expect(screen.getByText("Web Search Interception")).toBeInTheDocument(); + }); + + it("shows a login prompt when there is no access token", () => { + vi.mocked(useAuthorized).mockReturnValue({ accessToken: null } as any); + render(); + expect(screen.getByText(/please log in/i)).toBeInTheDocument(); + }); + + it("hides the settings while loading", async () => { + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + } as any); + await renderSettings(); + expect(screen.queryByText("Enable Web Search Interception")).not.toBeInTheDocument(); + }); + + it("surfaces a load failure", async () => { + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("boom"), + } as any); + await renderSettings(); + expect(screen.getByText("Could not load web search interception settings")).toBeInTheDocument(); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("keeps save disabled until something changes", async () => { + const user = userEvent.setup(); + await renderSettings(); + + const save = screen.getByRole("button", { name: /save settings/i }); + expect(save).toBeDisabled(); + + await user.click(save); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("submits the stored values with the toggled enabled flag", async () => { + const user = userEvent.setup(); + await renderSettings(); + + await user.click(screen.getByRole("switch")); + await user.click(screen.getByRole("button", { name: /save settings/i })); + + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate.mock.calls[0][0]).toEqual(ENABLED_PAYLOAD); + }); + + it("reseeds the form when the stored settings change underneath it", async () => { + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: { ...storedSettings, values: { ...storedSettings.values, max_agentic_loops: 3 } }, + isLoading: false, + isError: false, + error: null, + } as any); + const { rerender } = await renderSettings(); + expect(screen.getByRole("spinbutton")).toHaveValue(3); + + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: { ...storedSettings, values: { ...storedSettings.values, max_agentic_loops: 9 } }, + isLoading: false, + isError: false, + error: null, + } as any); + await act(async () => { + rerender(); + }); + + expect(screen.getByRole("spinbutton")).toHaveValue(9); + }); + + it("sends null rather than a number when the loop cap is cleared", async () => { + const user = userEvent.setup(); + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: { ...storedSettings, values: { ...storedSettings.values, max_agentic_loops: 5 } }, + isLoading: false, + isError: false, + error: null, + } as any); + await renderSettings(); + + await user.clear(screen.getByRole("spinbutton")); + await user.click(screen.getByRole("button", { name: /save settings/i })); + + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate.mock.calls[0][0].max_agentic_loops).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx new file mode 100644 index 00000000000..ce2141253ba --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings"; +import { useUpdateWebSearchInterceptionSettings } from "@/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { toast } from "@/lib/toast"; +import { Skeleton } from "@/components/ui/skeleton"; +import { CircleHelp, Info, Save } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { FieldGroup } from "@/components/ui/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { Providers, provider_map } from "@/components/provider_info_helpers"; +import { fetchSearchTools } from "@/components/networking"; + +interface WebSearchInterceptionStoredValues { + enabled?: boolean; + enabled_providers?: string[]; + search_tool_name?: string | null; + max_agentic_loops?: number | null; +} + +interface WebSearchInterceptionFieldSchema { + properties?: { + enabled?: { description?: string }; + enabled_providers?: { description?: string }; + search_tool_name?: { description?: string }; + max_agentic_loops?: { description?: string }; + }; +} + +interface WebSearchInterceptionFormValues { + enabled: boolean; + enabled_providers: string[]; + search_tool_name: string | null; + max_agentic_loops: number | null; +} + +const NO_STORED_VALUES: WebSearchInterceptionStoredValues = {}; + +const MAX_AGENTIC_LOOPS_MIN = 1; + +const PROVIDER_OPTIONS = Object.entries(provider_map) + .map(([enumKey, providerValue]) => ({ + label: Providers[enumKey as keyof typeof Providers] ?? providerValue, + value: providerValue, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + +const parseLoops = (raw: string, rawAsNumber: number): number | null => + raw === "" || Number.isNaN(rawAsNumber) ? null : rawAsNumber; + +const toFormValues = (values: WebSearchInterceptionStoredValues): WebSearchInterceptionFormValues => ({ + enabled: values.enabled ?? false, + enabled_providers: values.enabled_providers ?? [], + search_tool_name: values.search_tool_name ?? null, + max_agentic_loops: values.max_agentic_loops ?? null, +}); + +const readSearchToolNames = (response: unknown): string[] => { + const payload = response as { search_tools?: unknown; data?: unknown } | null; + const tools = Array.isArray(payload?.search_tools) ? payload.search_tools : payload?.data; + if (!Array.isArray(tools)) { + return []; + } + return tools + .map((tool: { search_tool_name?: string }) => tool?.search_tool_name) + .filter((name: unknown): name is string => typeof name === "string" && name.length > 0); +}; + +const useSearchToolNames = (accessToken: string) => { + const [searchTools, setSearchTools] = useState([]); + const [loadingSearchTools, setLoadingSearchTools] = useState(true); + + useEffect(() => { + const loadSearchTools = async () => { + if (!accessToken) return; + try { + setSearchTools(readSearchToolNames(await fetchSearchTools(accessToken))); + } catch (loadError) { + console.error("Error fetching search tools:", loadError); + } finally { + setLoadingSearchTools(false); + } + }; + + loadSearchTools(); + }, [accessToken]); + + return { searchTools, loadingSearchTools }; +}; + +interface WebSearchInterceptionFormProps { + accessToken: string; + initial: WebSearchInterceptionFormValues; + schema: WebSearchInterceptionFieldSchema | undefined; +} + +function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchInterceptionFormProps) { + const { + mutate: updateSettings, + isPending: isUpdating, + error: updateError, + } = useUpdateWebSearchInterceptionSettings(accessToken); + const { searchTools, loadingSearchTools } = useSearchToolNames(accessToken); + const form = useForm({ defaultValues: initial }); + const isDirty = form.formState.isDirty; + + const handleSave = (formValues: WebSearchInterceptionFormValues) => { + updateSettings(formValues, { + onSuccess: () => { + form.reset(formValues); + toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds."); + }, + onError: (saveError) => { + toast.fromError(saveError); + }, + }); + }; + + return ( + <> + {updateError && ( + + Could not update settings + {updateError instanceof Error && {updateError.message}} + + )} + + +
event.preventDefault()} noValidate> + + + + + {({ value, onChange, onBlur, id }) => ( + + )} + + + + {({ value, onChange, id }) => ( + + )} + + + + {({ value, onChange, id }) => ( + ({ label: name, value: name }))} + value={value} + onValueChange={onChange} + placeholder="Select a search tool (defaults to the first available)" + disabled={isUpdating || loadingSearchTools} + /> + )} + + + + {({ value, onChange, onBlur, id, ref }) => ( + onChange(parseLoops(event.target.value, event.target.valueAsNumber))} + onBlur={onBlur} + disabled={isUpdating} + /> + )} + + + + + +
+ +
+
+
+ + ); +} + +export default function WebSearchInterceptionSettings() { + const { accessToken } = useAuthorized(); + const { data, isLoading, isError, error } = useWebSearchInterceptionSettings(); + + if (!accessToken) { + return ( +
+ Please log in to configure web search interception settings. +
+ ); + } + + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + if (isError) { + return ( + + Could not load web search interception settings + {error instanceof Error && {error.message}} + + ); + } + + const values: WebSearchInterceptionStoredValues = data?.values ?? NO_STORED_VALUES; + + return ( +
+ + + Web Search Interception + + Serve web search tool calls from a configured search tool instead of passing them upstream, so models without + native web search can still answer with fresh results. Click 'Save Settings' to apply changes across + all pods (takes effect within 10 seconds). + + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 80b4a72649d..b82674f42d1 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3667,6 +3667,25 @@ export const updateMCPSemanticFilterSettings = async (accessToken: string, setti } }; +export const getWebSearchInterceptionSettings = async (accessToken: string) => { + try { + const data = await apiClient.get(`/get/websearch_interception_settings`, { accessToken }); + return data; + } catch (error) { + console.error("Failed to get web search interception settings:", error); + throw error; + } +}; + +export const updateWebSearchInterceptionSettings = async (accessToken: string, settings: Record) => { + try { + return await apiClient.patch(`/update/websearch_interception_settings`, { accessToken, body: settings }); + } catch (error) { + console.error("Failed to update web search interception settings:", error); + throw error; + } +}; + export const testMCPSemanticFilter = async (accessToken: string, model: string, query: string) => { /** * Test MCP semantic filter by making a responses API call diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4fe8bff3da8..051e78975b5 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -5430,6 +5430,28 @@ export interface paths { patch?: never; trace?: never; }; + "/get/websearch_interception_settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Websearch Interception Settings + * @description Get web search interception configuration. + * + * Returns the current settings plus their schema, for the Admin UI to render. + */ + get: operations["get_websearch_interception_settings_get_websearch_interception_settings_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/get_favicon": { parameters: { query?: never; @@ -16820,6 +16842,28 @@ export interface paths { patch: operations["update_user_banner_update_user_banner_patch"]; trace?: never; }; + "/update/websearch_interception_settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update Websearch Interception Settings + * @description Update web search interception settings in database. + * + * Settings will be picked up by all pods within approximately 10 seconds via background polling. + */ + patch: operations["update_websearch_interception_settings_update_websearch_interception_settings_patch"]; + trace?: never; + }; "/upload/logo": { parameters: { query?: never; @@ -41154,6 +41198,47 @@ export interface components { /** Vector Store Name */ vector_store_name?: string | null; }; + /** + * WebSearchInterceptionSettings + * @description Configuration for server-side web search interception + */ + WebSearchInterceptionSettings: { + /** + * Enabled + * @description Serve web search tool calls from a configured search tool instead of passing them upstream + * @default false + */ + enabled: boolean; + /** + * Enabled Providers + * @description LLM providers to intercept for (e.g. 'bedrock', 'vertex_ai'). Empty intercepts Bedrock only. + */ + enabled_providers?: string[]; + /** + * Max Agentic Loops + * @description How many follow-up model calls one intercepted request may chain. Empty applies the default of 3. + */ + max_agentic_loops?: number | null; + /** + * Search Tool Name + * @description Name of the configured search tool to run searches through. Empty uses the first one available. + */ + search_tool_name?: string | null; + }; + /** + * WebSearchInterceptionSettingsResponse + * @description Response model for web search interception settings + */ + WebSearchInterceptionSettingsResponse: { + /** Field Schema */ + field_schema: { + [key: string]: unknown; + }; + /** Values */ + values: { + [key: string]: unknown; + }; + }; /** WorkerRegistryEntry */ WorkerRegistryEntry: { /** Name */ @@ -49622,6 +49707,26 @@ export interface operations { }; }; }; + get_websearch_interception_settings_get_websearch_interception_settings_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebSearchInterceptionSettingsResponse"]; + }; + }; + }; + }; get_favicon_get_favicon_get: { parameters: { query?: never; @@ -62749,6 +62854,39 @@ export interface operations { }; }; }; + update_websearch_interception_settings_update_websearch_interception_settings_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebSearchInterceptionSettings"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; upload_logo_upload_logo_post: { parameters: { query?: never;