fix(websearch_interception): default enabled_providers to all providers

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.
This commit is contained in:
Claude 2.0 2026-04-15 06:50:27 +08:00
parent 43efe76f17
commit f62254c1e9
2 changed files with 82 additions and 11 deletions

View file

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

View file

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