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.
This commit is contained in:
Yuneng Jiang 2026-09-19 12:07:51 -07:00
parent e12cbb4e13
commit 014f5cbf68
No known key found for this signature in database
4 changed files with 157 additions and 9 deletions

View file

@ -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")

View file

@ -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),
)

View file

@ -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")

View file

@ -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)