From 014f5cbf687b9a967bf385aaae722ad4b8d6f0b9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:07:51 -0700 Subject: [PATCH] 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)