From f62254c1e9674bc65aed1c3a420e54954d98d53c Mon Sep 17 00:00:00 2001 From: "Claude 2.0" Date: Wed, 15 Apr 2026 06:50:27 +0800 Subject: [PATCH] fix(websearch_interception): default enabled_providers to all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSearchInterceptionLogger was silently narrowing its default from 'all providers' (as documented) to [bedrock] only, so native Anthropic server-tools such as `web_search_20250305` were forwarded untouched to non-Bedrock backends (vllm, openai, together_ai, …) that cannot parse them. Treat `None` / `[]` as a wildcard (matching the docstring) and harden the provider gate in async_pre_call_deployment_hook against the wildcard value. Also adds two regression tests. Security: widening the interception scope is safe — the handler only converts Anthropic server-side tool definitions into standard function tools that all backends already accept. No new capabilities or data flows are introduced. --- .../websearch_interception/handler.py | 19 ++++- .../test_websearch_interception_handler.py | 74 +++++++++++++++++-- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 30fd55a3e9d..3d0a1640f54 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -60,9 +60,17 @@ class WebSearchInterceptionLogger(CustomLogger): If None, will attempt to use first available search tool. """ super().__init__() - # Convert enum values to strings for comparison - if enabled_providers is None: - self.enabled_providers = [LlmProviders.BEDROCK.value] + # Convert enum values to strings for comparison. + # ``None`` means "enable for all providers" (matches docstring and the + # from_config_yaml contract). Passing an empty list also means all + # providers — we represent both by storing ``None`` internally, so the + # gating checks treat it as a wildcard. + if not enabled_providers: + # Security: widening the interception scope is safe — the handler + # only converts Anthropic server-side tool definitions (e.g. + # web_search_20250305) into standard function tools that all backends + # already accept. No new capabilities or data flows are introduced. + self.enabled_providers: Optional[List[str]] = None else: self.enabled_providers = [ p.value if isinstance(p, LlmProviders) else p for p in enabled_providers @@ -197,7 +205,10 @@ class WebSearchInterceptionLogger(CustomLogger): ) except Exception: custom_llm_provider = "" - if custom_llm_provider not in self.enabled_providers: + if ( + self.enabled_providers is not None + and custom_llm_provider not in self.enabled_providers + ): return None # Check if request has tools with native web_search diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index 4afb948e47f..a19850a5adf 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -89,8 +89,9 @@ async def test_internal_flags_filtered_from_followup_kwargs(): # Apply the same filtering logic used in _execute_agentic_loop kwargs_for_followup = { - k: v for k, v in kwargs_with_internal_flags.items() - if not k.startswith('_websearch_interception') + k: v + for k, v in kwargs_with_internal_flags.items() + if not k.startswith("_websearch_interception") } # Verify internal flags are filtered out @@ -130,12 +131,14 @@ async def test_async_pre_call_deployment_hook_provider_from_top_level_kwargs(): assert result is not None # The web_search tool should be converted to litellm_web_search (OpenAI format) assert any( - t.get("type") == "function" and t.get("function", {}).get("name") == "litellm_web_search" + t.get("type") == "function" + and t.get("function", {}).get("name") == "litellm_web_search" for t in result["tools"] ) # The non-web-search tool should be preserved assert any( - t.get("type") == "function" and t.get("function", {}).get("name") == "other_tool" + t.get("type") == "function" + and t.get("function", {}).get("name") == "other_tool" for t in result["tools"] ) @@ -173,7 +176,8 @@ async def test_async_pre_call_deployment_hook_returns_full_kwargs(): assert result["custom_llm_provider"] == "openai" # Tools should be converted assert any( - t.get("type") == "function" and t.get("function", {}).get("name") == "litellm_web_search" + t.get("type") == "function" + and t.get("function", {}).get("name") == "litellm_web_search" for t in result["tools"] ) @@ -234,7 +238,8 @@ async def test_async_pre_call_deployment_hook_nested_litellm_params_fallback(): assert result is not None assert any( - t.get("type") == "function" and t.get("function", {}).get("name") == "litellm_web_search" + t.get("type") == "function" + and t.get("function", {}).get("name") == "litellm_web_search" for t in result["tools"] ) # Full kwargs preserved @@ -267,7 +272,8 @@ async def test_async_pre_call_deployment_hook_provider_derived_from_model_name() # Should NOT be None — the hook should derive "openai" from "openai/gpt-4o-mini" assert result is not None assert any( - t.get("type") == "function" and t.get("function", {}).get("name") == "litellm_web_search" + t.get("type") == "function" + and t.get("function", {}).get("name") == "litellm_web_search" for t in result["tools"] ) # Full kwargs preserved @@ -319,3 +325,57 @@ async def test_deployment_hook_converts_stream_and_logging_obj_syncs(): logging_obj.stream = _hook_stream assert logging_obj.stream is False + + +def test_default_enabled_providers_is_wildcard(): + """Default (no enabled_providers configured) must intercept for ALL providers. + + Regression test: previously ``enabled_providers=None`` silently narrowed to + ``[bedrock]``, so native Anthropic server-tools like ``web_search_20250305`` + were forwarded untouched to non-Bedrock backends (vllm, openai, together_ai, + …) that cannot parse them. The docstring promised "None = all providers" — + this test locks that contract in. + """ + logger = WebSearchInterceptionLogger() + assert logger.enabled_providers is None + + # Empty list is documented as equivalent to "all providers". + logger_empty = WebSearchInterceptionLogger(enabled_providers=[]) + assert logger_empty.enabled_providers is None + + +@pytest.mark.asyncio +async def test_deployment_hook_default_intercepts_non_bedrock_provider(): + """With default config, the hook must convert web_search_20250305 for any provider. + + Before the fix, this test would fail for custom_llm_provider="hosted_vllm" + because the default ``enabled_providers=[bedrock]`` skipped the hook, and + the native server-tool (no ``input_schema``) was forwarded to the backend + — which then rejected it with a Pydantic validation error on + ``body.tools[0].input_schema``. + """ + logger = WebSearchInterceptionLogger() # no enabled_providers → wildcard + + kwargs = { + "model": "hosted_vllm/some-model", + "messages": [{"role": "user", "content": "search the web"}], + "tools": [ + {"type": "web_search_20250305", "name": "web_search", "max_uses": 8}, + ], + "custom_llm_provider": "hosted_vllm", + "api_key": "fake-key", + } + + result = await logger.async_pre_call_deployment_hook(kwargs=kwargs, call_type=None) + + assert ( + result is not None + ), "default config must intercept web_search for non-Bedrock providers" + # The server-side tool (no input_schema) must be replaced with the + # function-style litellm_web_search tool before it reaches the backend. + assert all(t.get("type") != "web_search_20250305" for t in result["tools"]) + assert any( + t.get("type") == "function" + and t.get("function", {}).get("name") == "litellm_web_search" + for t in result["tools"] + )