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)