From e12cbb4e1357ac1143fc91d3a77060e384153e91 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 11:54:57 -0700 Subject: [PATCH 1/8] feat(ui): configure web search interception from the Admin UI Web search interception could only be switched on by editing config.yaml and restarting the proxy, so an admin had no way to turn it on, choose which providers it covers, or pick which configured search tool runs the searches without a redeploy. Adds GET/PATCH /get|update/websearch_interception_settings backed by a WebSearchInterceptionSettings model, and an Admin Settings panel that reads and writes them. Config/database precedence comes from the existing settings store, so a key the config file declares is still refused here. The stored settings apply to a running proxy: the DB poll rebuilds the WebSearchInterceptionLogger, removing the old instance before adding the new one, because two instances with different params hash differently in the callback dedup key and the first to short-circuit would win. A proxy that activates interception the existing way, through litellm_settings.callbacks with no stored params, is left untouched. --- litellm/proxy/proxy_server.py | 54 +++ .../proxy_setting_endpoints.py | 108 +++++- .../proxy/proxy_server/test_proxy_config.py | 86 +++++ .../test_proxy_setting_endpoints.py | 71 ++++ .../admin-panel/_components/AdminPanel.tsx | 6 + .../useUpdateWebSearchInterceptionSettings.ts | 23 ++ .../useWebSearchInterceptionSettings.ts | 17 + .../WebSearchInterceptionSettings.test.tsx | 169 ++++++++++ .../WebSearchInterceptionSettings.tsx | 307 ++++++++++++++++++ .../src/components/networking.tsx | 19 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 138 ++++++++ 11 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx 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; From 014f5cbf687b9a967bf385aaae722ad4b8d6f0b9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:07:51 -0700 Subject: [PATCH 2/8] fix(ui): stop the interception panel from disabling a config-driven proxy Self-review found four ways the new settings page could take web search interception down instead of configuring it. A proxy that activates interception through litellm_settings.callbacks stores no enabled flag, so the page reported it as off while it was serving, and saving anything on that page persisted that answer and the next poll removed the running callback. Reads now resolve the flag from the callbacks list, and a stored block without an explicit flag no longer touches the callback list at all. An empty provider list is the page's own default, but the handler reads it as "match no provider" rather than falling back to Bedrock, so enabling the feature without naming a provider switched it on and intercepted nothing. The empty list is now dropped so the handler default applies. The replacement logger is also built before the old one is removed, so a loop ceiling the handler refuses no longer leaves the proxy with none and retrying every poll, and a stored "false" string now reads as off rather than as a truthy string. --- litellm/proxy/proxy_server.py | 31 ++++++++--- .../proxy_setting_endpoints.py | 32 ++++++++++- .../proxy/proxy_server/test_proxy_config.py | 55 ++++++++++++++++++- .../test_proxy_setting_endpoints.py | 48 ++++++++++++++++ 4 files changed, 157 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8212fbe392f..3234f6d0a06 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4787,6 +4787,20 @@ def _swap_in_model_cost_map(new_model_cost_map: dict) -> int: return adopt_model_cost_map(new_model_cost_map) +def _websearch_handler_params(stored: Mapping[str, object]) -> dict[str, object]: + """ + Translate stored web search interception settings into handler kwargs. + + Drops ``enabled``, which gates the callback rather than configuring it, and + drops an empty ``enabled_providers`` so the handler applies its own default + instead of matching no provider at all. + """ + params: Final = {key: value for key, value in stored.items() if key != "enabled"} + if not params.get("enabled_providers"): + params.pop("enabled_providers", None) + return params + + def should_load_db_object(object_type: str | SupportedDBObjectType) -> bool: """ Check if an object type should be loaded from the database based on general_settings.supported_db_objects. @@ -7803,23 +7817,26 @@ class ProxyConfig: 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: + if not isinstance(websearch_config, Mapping) or "enabled" not in websearch_config: return - enabled: Final = bool(websearch_config.get("enabled", True)) + enabled: Final = bool(coerce_bool(websearch_config["enabled"])) 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 + replacement: Final = ( + WebSearchInterceptionLogger.from_config_yaml(_websearch_handler_params(websearch_config)) + if enabled + else None + ) + 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) - ) + if replacement is not None: + litellm.logging_callback_manager.add_litellm_callback(replacement) verbose_proxy_logger.info("Web search interception reinitialized from DB") else: verbose_proxy_logger.info("Web search interception disabled") diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index c0b2eb1daa9..0af13fc9304 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -506,6 +506,36 @@ class WebSearchInterceptionSettingsResponse(SettingsResponse): """Response model for web search interception settings""" +def _with_websearch_enabled_resolved(config: Mapping[str, object]) -> dict[str, object]: + """ + Report interception as on when the config file activates it through litellm_settings.callbacks. + + Such a proxy stores no ``enabled`` flag, and reporting the field's own + default would tell an admin the feature is off while it is serving, then + persist that answer the moment they saved anything on the page. + """ + litellm_settings: Final[Mapping[str, object]] = _as_settings_section(config.get("litellm_settings")) + stored: Final[Mapping[str, object]] = _as_settings_section(litellm_settings.get("websearch_interception_params")) + if "enabled" in stored: + return dict(config) + + callbacks: Final = litellm_settings.get("callbacks") + resolved: Final = { + **stored, + "enabled": isinstance(callbacks, Sequence) + and not isinstance(callbacks, (str, bytes)) + and "websearch_interception" in callbacks, + } + return { + **config, + "litellm_settings": {**litellm_settings, "websearch_interception_params": resolved}, + } + + +def _as_settings_section(value: object) -> Mapping[str, object]: + return cast("Mapping[str, object]", value) if isinstance(value, Mapping) else MappingProxyType({}) + + @router.get( "/get/allowed_ips", tags=["Budget & Spend Tracking"], @@ -1461,7 +1491,7 @@ async def get_websearch_interception_settings( return await _get_settings_with_schema( settings_key="websearch_interception_params", settings_class=WebSearchInterceptionSettings, - config=config, + config=_with_websearch_enabled_resolved(config), ) 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 0d9612d2325..13bcfb3d872 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -4558,12 +4558,25 @@ def test_init_websearch_interception_absent_key_leaves_callbacks_untouched(monke assert litellm.callbacks == [config_registered] -def test_init_websearch_interception_enables_when_enabled_key_missing(monkeypatch): +def test_init_websearch_interception_without_enabled_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={"search_tool_name": "stored-tool"}, + starting_callbacks=[config_registered], + ) + + assert litellm.callbacks == [config_registered] + + +def test_init_websearch_interception_registers_when_explicitly_enabled(monkeypatch): + logger_cls = _websearch_logger_cls() + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": True, "search_tool_name": "stored-tool"}, starting_callbacks=[], ) @@ -4572,6 +4585,46 @@ def test_init_websearch_interception_enables_when_enabled_key_missing(monkeypatc assert registered[0].search_tool_name == "stored-tool" +def test_init_websearch_interception_treats_string_false_as_disabled(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_empty_providers_falls_back_to_handler_default(monkeypatch): + logger_cls = _websearch_logger_cls() + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": True, "enabled_providers": [], "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].enabled_providers == ["bedrock"] + + +def test_init_websearch_interception_keeps_working_callback_when_new_one_cannot_be_built(monkeypatch): + logger_cls = _websearch_logger_cls() + working = logger_cls(search_tool_name="stored-tool", max_agentic_loops=3) + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": True, "search_tool_name": "stored-tool", "max_agentic_loops": 0}, + starting_callbacks=[working], + ) + + assert litellm.callbacks == [working] + + def test_init_websearch_interception_disabled_removes_the_callback(monkeypatch): logger_cls = _websearch_logger_cls() existing = logger_cls(search_tool_name="stored-tool") 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 b1bf9f71379..448f6bd3405 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 @@ -3184,6 +3184,54 @@ class TestWebSearchInterceptionSettingsEndpoints: assert mock_proxy_config["save_call_count"]() == 1 assert mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] == payload + def test_get_reports_enabled_when_the_config_file_activates_the_callback( + self, mock_proxy_config, mock_auth, monkeypatch + ): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + mock_proxy_config["config"]["litellm_settings"]["callbacks"] = ["websearch_interception"] + mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + "enabled_providers": ["bedrock"], + "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"] is True + + def test_get_reports_disabled_when_nothing_activates_the_callback( + self, mock_proxy_config, mock_auth, monkeypatch + ): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + mock_proxy_config["config"]["litellm_settings"].pop("callbacks", None) + mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + "enabled_providers": ["bedrock"], + } + + resp = client.get("/get/websearch_interception_settings") + + assert resp.status_code == 200, resp.text + assert resp.json()["values"]["enabled"] is False + + def test_update_reapplies_settings_to_the_running_proxy(self, mock_proxy_config, monkeypatch): + from unittest.mock import AsyncMock + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + reapply = AsyncMock() + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config.init_websearch_interception_settings_in_db", + reapply, + ) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + try: + resp = client.patch("/update/websearch_interception_settings", json={"enabled": True}) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + reapply.assert_awaited_once() + 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) From 0243d268bcabaf087a770b1494ea7fc37eebae50 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:22:44 -0700 Subject: [PATCH 3/8] fix(ui): report interception as the proxy is actually running it A second review pass found two more ways a write through the generic config endpoint, which validates nothing, could strand the feature. Dropping the enabled flag from a settings block the proxy had already applied stopped the poller from reconciling it ever again, so the callback served the old search tool forever. The poller now yields to litellm_settings.callbacks only while it has applied nothing itself; once it owns the callback it keeps reconciling. A provider list written as a bare string was iterated one character at a time, so interception matched no real provider - the same failure the empty list already had. Anything that is not a non-empty list is now dropped so the handler default applies. The page also derives its toggle from whether the callback is registered rather than from a stored flag, because a block can be live with no flag in it at all, and the toggle is what an admin saves back. --- litellm/proxy/proxy_server.py | 19 +++++++--- .../proxy_setting_endpoints.py | 22 ++++++------ .../proxy/proxy_server/test_proxy_config.py | 35 +++++++++++++++++++ .../test_proxy_setting_endpoints.py | 24 +++++++++---- 4 files changed, 78 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3234f6d0a06..9579807d01c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4792,11 +4792,13 @@ def _websearch_handler_params(stored: Mapping[str, object]) -> dict[str, object] Translate stored web search interception settings into handler kwargs. Drops ``enabled``, which gates the callback rather than configuring it, and - drops an empty ``enabled_providers`` so the handler applies its own default - instead of matching no provider at all. + drops an ``enabled_providers`` that is not a non-empty list so the handler + applies its own default. An empty list otherwise matches no provider at all, + and a bare string is iterated one character at a time. """ params: Final = {key: value for key, value in stored.items() if key != "enabled"} - if not params.get("enabled_providers"): + providers: Final = params.get("enabled_providers") + if not isinstance(providers, list) or not providers: params.pop("enabled_providers", None) return params @@ -7817,10 +7819,17 @@ class ProxyConfig: websearch_config: Final = litellm_settings.get("websearch_interception_params", None) - if not isinstance(websearch_config, Mapping) or "enabled" not in websearch_config: + if not isinstance(websearch_config, Mapping): return - enabled: Final = bool(coerce_bool(websearch_config["enabled"])) + if "enabled" not in websearch_config and self._last_websearch_interception_config is None: + verbose_proxy_logger.debug( + "Web search interception: stored settings carry no 'enabled' flag and none were applied " + "before, so litellm_settings.callbacks keeps ownership of the callback." + ) + return + + enabled: Final = bool(coerce_bool(websearch_config.get("enabled", True))) registered: Final = bool( litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger) ) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 0af13fc9304..36b90d37da8 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -508,23 +508,23 @@ class WebSearchInterceptionSettingsResponse(SettingsResponse): def _with_websearch_enabled_resolved(config: Mapping[str, object]) -> dict[str, object]: """ - Report interception as on when the config file activates it through litellm_settings.callbacks. + Report whether interception is actually running, rather than what a stored flag claims. - Such a proxy stores no ``enabled`` flag, and reporting the field's own - default would tell an admin the feature is off while it is serving, then - persist that answer the moment they saved anything on the page. + A proxy can activate it through litellm_settings.callbacks, which stores no + flag at all, and a write through the generic config endpoint can drop the + flag from a block that is still live. Either way the field's own default + would tell an admin the feature is off while it is serving, and saving the + page would then persist that answer. """ + from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, + ) + litellm_settings: Final[Mapping[str, object]] = _as_settings_section(config.get("litellm_settings")) stored: Final[Mapping[str, object]] = _as_settings_section(litellm_settings.get("websearch_interception_params")) - if "enabled" in stored: - return dict(config) - - callbacks: Final = litellm_settings.get("callbacks") resolved: Final = { **stored, - "enabled": isinstance(callbacks, Sequence) - and not isinstance(callbacks, (str, bytes)) - and "websearch_interception" in callbacks, + "enabled": bool(litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger)), } return { **config, 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 13bcfb3d872..5606ffda02d 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -4549,6 +4549,41 @@ def _run_websearch_init(monkeypatch, stored_params, starting_callbacks): return pc +def _poll_websearch_init(pc, monkeypatch, stored_params): + monkeypatch.setattr( + "litellm.proxy.proxy_server.get_config_param", + AsyncMock(return_value=SimpleNamespace(param_value={"websearch_interception_params": stored_params})), + ) + asyncio.run(pc.init_websearch_interception_settings_in_db(prisma_client=MagicMock())) + + +def test_init_websearch_interception_resyncs_after_a_write_drops_the_enabled_flag(monkeypatch): + logger_cls = _websearch_logger_cls() + pc = ProxyConfig() + monkeypatch.setattr(litellm, "callbacks", []) + + _poll_websearch_init(pc, monkeypatch, {"enabled": True, "search_tool_name": "old-tool"}) + _poll_websearch_init(pc, monkeypatch, {"search_tool_name": "new-tool"}) + + registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] + assert len(registered) == 1 + assert registered[0].search_tool_name == "new-tool" + + +def test_init_websearch_interception_ignores_a_non_list_providers_value(monkeypatch): + logger_cls = _websearch_logger_cls() + + _run_websearch_init( + monkeypatch, + stored_params={"enabled": True, "enabled_providers": "bedrock", "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].enabled_providers == ["bedrock"] + + 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") 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 448f6bd3405..3288966e1e3 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 @@ -3138,7 +3138,13 @@ class TestWebSearchInterceptionSettingsEndpoints: ) def test_get_returns_stored_values_and_field_schema(self, mock_proxy_config, mock_auth, monkeypatch): + import litellm + from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(search_tool_name="running")]) mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { "enabled": True, "enabled_providers": ["bedrock", "vertex_ai"], @@ -3184,11 +3190,16 @@ class TestWebSearchInterceptionSettingsEndpoints: assert mock_proxy_config["save_call_count"]() == 1 assert mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] == payload - def test_get_reports_enabled_when_the_config_file_activates_the_callback( + def test_get_reports_enabled_while_the_callback_is_running_without_a_stored_flag( self, mock_proxy_config, mock_auth, monkeypatch ): + import litellm + from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) - mock_proxy_config["config"]["litellm_settings"]["callbacks"] = ["websearch_interception"] + monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(search_tool_name="from-config")]) mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { "enabled_providers": ["bedrock"], "search_tool_name": "my-perplexity-search", @@ -3199,12 +3210,13 @@ class TestWebSearchInterceptionSettingsEndpoints: assert resp.status_code == 200, resp.text assert resp.json()["values"]["enabled"] is True - def test_get_reports_disabled_when_nothing_activates_the_callback( - self, mock_proxy_config, mock_auth, monkeypatch - ): + def test_get_reports_disabled_when_the_callback_is_not_running(self, mock_proxy_config, mock_auth, monkeypatch): + import litellm + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) - mock_proxy_config["config"]["litellm_settings"].pop("callbacks", None) + monkeypatch.setattr(litellm, "callbacks", []) mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + "enabled": True, "enabled_providers": ["bedrock"], } From d15ceab174790908ccaeb861d6e67f2bcbeadf00 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:30:39 -0700 Subject: [PATCH 4/8] fix(proxy): declare the web search settings auth dependency with Annotated --- litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 36b90d37da8..a86b7732bcf 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -6,6 +6,7 @@ from collections import Counter from collections.abc import Mapping, MutableMapping, Sequence from types import MappingProxyType from typing import ( + Annotated, Final, NamedTuple, Protocol, @@ -1471,7 +1472,7 @@ async def update_mcp_semantic_filter_settings( response_model=WebSearchInterceptionSettingsResponse, ) async def get_websearch_interception_settings( - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], ): """ Get web search interception configuration. @@ -1502,7 +1503,7 @@ async def get_websearch_interception_settings( ) async def update_websearch_interception_settings( settings: WebSearchInterceptionSettings, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], ): """ Update web search interception settings in database. From d5ae810ea9730148f7c696f1a2b73ef46417f169 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 13:00:00 -0700 Subject: [PATCH 5/8] fix: let operators allowlist web search interception settings Peer pods gate the settings poll on general_settings.supported_db_objects, which validates against SupportedDBObjectType. Without a member for this name an operator could not opt in, so a configured allowlist left every pod but the one that served the write on stale settings. Also types the dashboard's settings payload off the generated schema instead of Record. --- litellm/proxy/_types.py | 1 + litellm/proxy/proxy_server.py | 2 +- .../proxy/proxy_server/test_proxy_config.py | 14 ++++++++++++++ .../useUpdateWebSearchInterceptionSettings.ts | 4 ++-- .../useWebSearchInterceptionSettings.ts | 4 ++-- .../src/components/networking.tsx | 17 +++++++++++++---- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 7 files changed, 34 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6322a1212fe..2bf7b1ee803 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -123,6 +123,7 @@ class SupportedDBObjectType(str, enum.Enum): MODEL_COST_MAP = "model_cost_map" TOOLS = "tools" CONFIG_OVERRIDES = "config_overrides" + WEBSEARCH_INTERCEPTION_SETTINGS = "websearch_interception_settings" def __str__(self): return str(self.value) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9579807d01c..3660506dbc8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7714,7 +7714,7 @@ 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"): + if self._should_load_db_object(object_type=SupportedDBObjectType.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"): 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 5606ffda02d..462489f48b0 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -4700,3 +4700,17 @@ def test_init_websearch_interception_honors_enabled_providers(monkeypatch): registered = [cb for cb in litellm.callbacks if isinstance(cb, logger_cls)] assert len(registered) == 1 assert registered[0].enabled_providers == ["bedrock", "vertex_ai"] + + +def test_websearch_interception_settings_can_be_named_in_supported_db_objects(monkeypatch): + from litellm.proxy import proxy_server + from litellm.proxy._types import ConfigGeneralSettings + + allowlist = ConfigGeneralSettings(supported_db_objects=["websearch_interception_settings"]).supported_db_objects + assert allowlist + + monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": allowlist}) + assert proxy_server.should_load_db_object(object_type="websearch_interception_settings") is True + + monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": ["models"]}) + assert proxy_server.should_load_db_object(object_type="websearch_interception_settings") is False diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts index 7de84c52310..ae6454aaba0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useUpdateWebSearchInterceptionSettings.ts @@ -1,4 +1,4 @@ -import { updateWebSearchInterceptionSettings } from "@/components/networking"; +import { updateWebSearchInterceptionSettings, type WebSearchInterceptionSettings } from "@/components/networking"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; @@ -8,7 +8,7 @@ export const useUpdateWebSearchInterceptionSettings = (accessToken: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (settings: Record) => { + mutationFn: async (settings: WebSearchInterceptionSettings) => { if (!accessToken) { throw new Error("Access token is required"); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts index 4c2b549209c..0e6a28ad742 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/webSearchInterceptionSettings/useWebSearchInterceptionSettings.ts @@ -1,4 +1,4 @@ -import { getWebSearchInterceptionSettings } from "@/components/networking"; +import { getWebSearchInterceptionSettings, type WebSearchInterceptionSettingsResponse } from "@/components/networking"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import useAuthorized from "../useAuthorized"; @@ -7,7 +7,7 @@ const webSearchInterceptionSettingsKeys = createQueryKeys("webSearchInterception export const useWebSearchInterceptionSettings = () => { const { accessToken } = useAuthorized(); - return useQuery>({ + return useQuery({ queryKey: webSearchInterceptionSettingsKeys.list({}), queryFn: async () => await getWebSearchInterceptionSettings(accessToken), enabled: !!accessToken, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index b82674f42d1..ab1203cf440 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3667,17 +3667,26 @@ export const updateMCPSemanticFilterSettings = async (accessToken: string, setti } }; -export const getWebSearchInterceptionSettings = async (accessToken: string) => { +export type WebSearchInterceptionSettings = components["schemas"]["WebSearchInterceptionSettings"]; +export type WebSearchInterceptionSettingsResponse = components["schemas"]["WebSearchInterceptionSettingsResponse"]; + +export const getWebSearchInterceptionSettings = async ( + accessToken: string, +): Promise => { try { - const data = await apiClient.get(`/get/websearch_interception_settings`, { accessToken }); - return data; + return await apiClient.get(`/get/websearch_interception_settings`, { + accessToken, + }); } catch (error) { console.error("Failed to get web search interception settings:", error); throw error; } }; -export const updateWebSearchInterceptionSettings = async (accessToken: string, settings: Record) => { +export const updateWebSearchInterceptionSettings = async ( + accessToken: string, + settings: WebSearchInterceptionSettings, +) => { try { return await apiClient.patch(`/update/websearch_interception_settings`, { accessToken, body: settings }); } catch (error) { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 393f7a048ce..7e725da3f46 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -38068,7 +38068,7 @@ export interface components { * Use in general_settings.supported_db_objects to specify which objects to load from DB. * @enum {string} */ - SupportedDBObjectType: "models" | "mcp" | "guardrails" | "policies" | "vector_stores" | "pass_through_endpoints" | "prompts" | "model_cost_map" | "tools" | "config_overrides"; + SupportedDBObjectType: "models" | "mcp" | "guardrails" | "policies" | "vector_stores" | "pass_through_endpoints" | "prompts" | "model_cost_map" | "tools" | "config_overrides" | "websearch_interception_settings"; /** SupportedEndpoint */ SupportedEndpoint: { /** Endpoint */ From 8dab23f6ac7dabe96825c7a614abdcd2a8cf473d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 13:10:43 -0700 Subject: [PATCH 6/8] test: cover the no-database and failed-reinit paths of the web search settings endpoints --- .../test_proxy_setting_endpoints.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 3288966e1e3..102b0657461 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 @@ -3244,6 +3244,32 @@ class TestWebSearchInterceptionSettingsEndpoints: assert resp.status_code == 200, resp.text reapply.assert_awaited_once() + def test_get_reports_no_database_instead_of_empty_settings(self, mock_proxy_config, mock_auth, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + + resp = client.get("/get/websearch_interception_settings") + + assert resp.status_code == 500, resp.text + assert "Database not connected" in resp.json()["detail"]["error"] + + def test_update_still_saves_when_the_live_reinit_fails(self, mock_proxy_config, monkeypatch): + from unittest.mock import AsyncMock + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config.init_websearch_interception_settings_in_db", + AsyncMock(side_effect=RuntimeError("callback blew up")), + ) + self._override_auth(LitellmUserRoles.PROXY_ADMIN) + try: + resp = client.patch("/update/websearch_interception_settings", json={"enabled": True}) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + assert mock_proxy_config["save_call_count"]() == 1 + 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) From e7fd89fc0255ab377d9d6e82398a0f5fbfa60ab0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 14:25:19 -0700 Subject: [PATCH 7/8] fix(ui): narrow the web search settings response instead of asserting its shape --- .../WebSearchInterceptionSettings.test.tsx | 23 +++++++++++++++++++ .../WebSearchInterceptionSettings.tsx | 14 +++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) 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 index f28891a5da5..ae981aa9767 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.test.tsx @@ -127,6 +127,29 @@ describe("WebSearchInterceptionSettings", () => { expect(mockMutate.mock.calls[0][0]).toEqual(ENABLED_PAYLOAD); }); + it("ignores stored values whose types do not match the field", async () => { + vi.mocked(useWebSearchInterceptionSettings).mockReturnValue({ + data: { + ...storedSettings, + values: { + enabled: "yes", + enabled_providers: "bedrock", + search_tool_name: 7, + max_agentic_loops: "3", + }, + }, + isLoading: false, + isError: false, + error: null, + } as any); + + await renderSettings(); + + expect(screen.getByRole("switch")).not.toBeChecked(); + expect(screen.getByLabelText(/max agentic loops/i)).toHaveValue(null); + expect(screen.queryByText("bedrock")).not.toBeInTheDocument(); + }); + 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 } }, diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx index ce2141253ba..3e9e04e720b 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/WebSearchInterceptionSettings/WebSearchInterceptionSettings.tsx @@ -45,7 +45,7 @@ interface WebSearchInterceptionFormValues { max_agentic_loops: number | null; } -const NO_STORED_VALUES: WebSearchInterceptionStoredValues = {}; +const NO_STORED_VALUES: Readonly> = {}; const MAX_AGENTIC_LOOPS_MIN = 1; @@ -69,6 +69,16 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => ( const parseLoops = (raw: string, rawAsNumber: number): number | null => raw === "" || Number.isNaN(rawAsNumber) ? null : rawAsNumber; +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === "string"); + +const toStoredValues = (raw: Readonly>): WebSearchInterceptionStoredValues => ({ + enabled: typeof raw.enabled === "boolean" ? raw.enabled : undefined, + enabled_providers: isStringArray(raw.enabled_providers) ? raw.enabled_providers : undefined, + search_tool_name: typeof raw.search_tool_name === "string" ? raw.search_tool_name : null, + max_agentic_loops: typeof raw.max_agentic_loops === "number" ? raw.max_agentic_loops : null, +}); + const toFormValues = (values: WebSearchInterceptionStoredValues): WebSearchInterceptionFormValues => ({ enabled: values.enabled ?? false, enabled_providers: values.enabled_providers ?? [], @@ -282,7 +292,7 @@ export default function WebSearchInterceptionSettings() { ); } - const values: WebSearchInterceptionStoredValues = data?.values ?? NO_STORED_VALUES; + const values: WebSearchInterceptionStoredValues = toStoredValues(data?.values ?? NO_STORED_VALUES); return (
From 4ea21cb75cf9105a0c0ef8403b21a5dcc2395970 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 15:42:16 -0700 Subject: [PATCH 8/8] fix(ui): answer the interception panel from the stored flag, not the local pod Deriving enabled from whether this process has the callback registered makes a pod that has not polled yet report off while the cluster runs it, and the next save writes that off back for every pod. The stored flag is the cluster's own answer, so prefer it and fall back to local registration only when none is stored, which is the config-activated case that has no flag to read. --- .../proxy_setting_endpoints.py | 19 ++++++++++----- .../test_proxy_setting_endpoints.py | 23 +++++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a86b7732bcf..a972f08b8bf 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -509,13 +509,17 @@ class WebSearchInterceptionSettingsResponse(SettingsResponse): def _with_websearch_enabled_resolved(config: Mapping[str, object]) -> dict[str, object]: """ - Report whether interception is actually running, rather than what a stored flag claims. + Answer with the stored flag when there is one, and only otherwise with what + this process is running. - A proxy can activate it through litellm_settings.callbacks, which stores no - flag at all, and a write through the generic config endpoint can drop the - flag from a block that is still live. Either way the field's own default - would tell an admin the feature is off while it is serving, and saving the - page would then persist that answer. + A stored flag is the cluster's own answer, so it is the same on every pod and + is safe for the page to send back on save. Deriving the answer from this + process instead would report off on a pod that has not polled yet, and the + next save would persist that as a cluster-wide off. Without a stored flag the + only available answer is local: litellm_settings.callbacks activates + interception without storing one, and a write through the generic config + endpoint can drop the flag from a block that is still live. Reporting the + field default there would claim the feature is off while it serves. """ from litellm.integrations.websearch_interception.handler import ( WebSearchInterceptionLogger, @@ -523,6 +527,9 @@ def _with_websearch_enabled_resolved(config: Mapping[str, object]) -> dict[str, litellm_settings: Final[Mapping[str, object]] = _as_settings_section(config.get("litellm_settings")) stored: Final[Mapping[str, object]] = _as_settings_section(litellm_settings.get("websearch_interception_params")) + if "enabled" in stored: + return dict(config) + resolved: Final = { **stored, "enabled": bool(litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger)), 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 102b0657461..9af559e3660 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 @@ -3210,13 +3210,14 @@ class TestWebSearchInterceptionSettingsEndpoints: assert resp.status_code == 200, resp.text assert resp.json()["values"]["enabled"] is True - def test_get_reports_disabled_when_the_callback_is_not_running(self, mock_proxy_config, mock_auth, monkeypatch): + def test_get_reports_disabled_when_nothing_is_stored_and_nothing_is_running( + self, mock_proxy_config, mock_auth, monkeypatch + ): import litellm monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", []) mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { - "enabled": True, "enabled_providers": ["bedrock"], } @@ -3224,6 +3225,7 @@ class TestWebSearchInterceptionSettingsEndpoints: assert resp.status_code == 200, resp.text assert resp.json()["values"]["enabled"] is False + assert resp.json()["values"]["enabled_providers"] == ["bedrock"] def test_update_reapplies_settings_to_the_running_proxy(self, mock_proxy_config, monkeypatch): from unittest.mock import AsyncMock @@ -3244,6 +3246,23 @@ class TestWebSearchInterceptionSettingsEndpoints: assert resp.status_code == 200, resp.text reapply.assert_awaited_once() + def test_get_keeps_the_stored_flag_when_this_pod_has_not_reinitialized( + self, mock_proxy_config, mock_auth, monkeypatch + ): + import litellm + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) + monkeypatch.setattr(litellm, "callbacks", []) + mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + "enabled": True, + "search_tool_name": "cluster-search", + } + + resp = client.get("/get/websearch_interception_settings") + + assert resp.status_code == 200, resp.text + assert resp.json()["values"]["enabled"] is True + def test_get_reports_no_database_instead_of_empty_settings(self, mock_proxy_config, mock_auth, monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)