litellm/tests/unit/integrations/websearch_interception/test_websearch_responses.py
Priyansh Nandwana 67ae3fb527
fix(websearch)!: keep the provider prefix on the agentic follow-up model (#38885)
* fix(websearch): keep the provider prefix on the agentic follow-up model

The agentic follow-up rebuilt its model string with "a slash means it is already
qualified". Handlers are handed the provider-stripped model, and for a provider
that routes through a sub-path the remainder still holds a slash, so
bedrock/mantle/anthropic.claude-sonnet-5 arrived as mantle/anthropic.claude-sonnet-5
and stayed that way. The follow-up acompletion could not resolve a provider from it
and raised "LLM Provider NOT provided".

Prefix unless the model is already qualified with that same provider, in one shared
helper used by both the handler and the websearch patch builder, which carried
copies of the same heuristic. This also fixes openrouter, whose stripped model
keeps a slash for the same reason, and the old startswith test that read openai as
a prefix of openai_like.

Fixes #38829

* test(websearch): cover the agentic follow-up call site

The helper had unit tests but the call site did not, so the two changed lines in
_execute_chat_completion_agentic_plan were uncovered. Drive the real follow-up with
mock_response instead of patching litellm, which keeps the test on the behaviour:
against the old code the sub-path case raises "LLM Provider NOT provided", and the
ordinary-model case passes either way as a guard.

* refactor(websearch): trim the comments on the provider-qualify helper

* refactor(websearch): cut the helper and test docstrings to one line

* fix(websearch): leave a hook-qualified follow-up model on its own provider

A hook that patches in its own model owns that choice, so re-qualifying it sent a
cross-provider follow-up to the original request's provider instead. Only a bare
patched model and the request's provider-stripped model take the prefix now, and
the sibling agentic-loop path resolves the model the same way.

* fix(websearch): fall back to the request model when a patch carries no model

An empty patched model dispatched a provider-only name instead of falling back, and a
provider-stripped sub-path copied into the patch lost its prefix. A patched model now
keeps its own provider only when its leading segment names one.

* fix(websearch): keep the provider prefix on the responses follow-up model too

The responses patch builder still used the old "a slash means already qualified" test, so
a provider-stripped sub-path model reached the follow-up unqualified on that surface. It
now resolves the model the same way the chat completion builder does.

* fix(websearch): read custom_llm_provider without a member call on untyped kwargs

* fix(websearch): type the chat patch builder kwargs instead of working around them

* test(websearch): run the real search path and flatten the qualify helper tests

* fix(websearch): route a hook's model on a JSON-configured provider and keep the request provider on an echoed model

* fix(websearch): leave a follow-up model alone when its prefix already routes to the request provider

* fix(websearch): send the agentic follow-up to the model the caller requested

completion() and responses() record the caller's model before routing strips
it, and the chat and Responses follow-up builders prefer that model. This keeps
bedrock/converse_like/, bedrock/claude_platform/ and openrouter/openrouter/*
deployments on their own route for the follow-up.

* test(websearch): cover the recorded caller model in Headroom's retrieval follow-up

* test(websearch): cover the recorded caller model in Compresr's retrieval follow-up

Also merges the repeated provider-prefix check in core_helpers into one helper
and drops a test docstring

* test(websearch): drive the real search path through a mocked Perplexity HTTP route

* refactor(websearch): read the provider from kwargs inside _caller_model

* test(http-handler): type the agentic follow-up provider tests

* refactor(websearch): record agentic loop params through a Logging method

Moving the recording out of completion() keeps it under basedpyright's
code-flow complexity limit after the merge with main.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-29 04:47:18 -07:00

429 lines
15 KiB
Python

"""
Integration tests for WebSearch interception with the Responses API.
Tests that the websearch_interception callback intercepts litellm_web_search
tool calls returned by /v1/responses, executes the search server-side, and
builds a Responses-format follow-up request.
"""
import datetime
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)
from litellm.types.integrations.custom_logger import (
RESPONSES_AGENTIC_SURFACE,
)
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import CallTypes, LlmProviders
def _responses_output_with_web_search(call_id: str = "fc_1", query: str = "latest ai news"):
return SimpleNamespace(
output=[
SimpleNamespace(
type="function_call",
name="litellm_web_search",
call_id=call_id,
arguments='{"query": "%s"}' % query,
)
]
)
@pytest.mark.asyncio
async def test_responses_hook_detects_function_call():
"""async_should_run_responses_agentic_loop detects a litellm_web_search function_call."""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
should_run, tools_dict = await logger.async_should_run_responses_agentic_loop(
response=_responses_output_with_web_search(),
model="gpt-4o",
messages=[{"role": "user", "content": "What's the latest AI news?"}],
tools=[{"type": "function", "name": "litellm_web_search"}],
stream=False,
custom_llm_provider="openai",
kwargs={},
)
assert should_run is True
assert tools_dict["response_format"] == "responses"
assert len(tools_dict["tool_calls"]) == 1
assert tools_dict["tool_calls"][0]["name"] == "litellm_web_search"
assert tools_dict["tool_calls"][0]["call_id"] == "fc_1"
assert tools_dict["tool_calls"][0]["input"] == {"query": "latest ai news"}
@pytest.mark.asyncio
async def test_responses_hook_not_triggered_without_tool():
"""No web search tool in the request -> hook must not run."""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
should_run, tools_dict = await logger.async_should_run_responses_agentic_loop(
response=_responses_output_with_web_search(),
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
tools=[{"type": "function", "name": "get_weather"}],
stream=False,
custom_llm_provider="openai",
kwargs={},
)
assert should_run is False
assert tools_dict == {}
@pytest.mark.asyncio
async def test_responses_hook_not_triggered_for_disabled_provider():
"""Provider not in enabled_providers -> hook must not run."""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.BEDROCK])
should_run, tools_dict = await logger.async_should_run_responses_agentic_loop(
response=_responses_output_with_web_search(),
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
tools=[{"type": "function", "name": "litellm_web_search"}],
stream=False,
custom_llm_provider="openai",
kwargs={},
)
assert should_run is False
assert tools_dict == {}
@pytest.mark.asyncio
async def test_responses_hook_ignores_non_websearch_function_call():
"""A function_call for a different tool must not be intercepted."""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
response = SimpleNamespace(
output=[SimpleNamespace(type="function_call", name="get_weather", call_id="c1", arguments="{}")]
)
should_run, tools_dict = await logger.async_should_run_responses_agentic_loop(
response=response,
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
tools=[{"type": "function", "name": "litellm_web_search"}],
stream=False,
custom_llm_provider="openai",
kwargs={},
)
assert should_run is False
assert tools_dict == {}
@pytest.mark.asyncio
async def test_responses_hook_ignores_bare_web_search_function_call():
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
response = SimpleNamespace(
output=[SimpleNamespace(type="function_call", name="web_search", call_id="c1", arguments="{}")]
)
should_run, tools_dict = await logger.async_should_run_responses_agentic_loop(
response=response,
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
tools=[{"type": "function", "name": "litellm_web_search"}],
stream=False,
custom_llm_provider="openai",
kwargs={},
)
assert should_run is False
assert tools_dict == {}
@pytest.mark.asyncio
async def test_surface_marker_routes_should_run_to_responses_branch():
"""async_should_run_agentic_loop must dispatch to the responses branch when the
surface marker says responses.
Without the marker the default anthropic branch runs and never detects the
Responses-format function_call, so interception silently no-ops on /v1/responses.
"""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
should_run, tools_dict = await logger.async_should_run_agentic_loop(
response=_responses_output_with_web_search(),
model="gpt-4o",
messages=[{"role": "user", "content": "What's the latest AI news?"}],
tools=[{"type": "function", "name": "litellm_web_search"}],
stream=False,
custom_llm_provider="openai",
kwargs={"_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE},
)
assert should_run is True
assert tools_dict["response_format"] == "responses"
@pytest.mark.asyncio
async def test_default_branch_does_not_detect_responses_output():
"""Regression guard: the default (anthropic) branch must not detect a
Responses-format function_call, proving the responses branch is required.
"""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
should_run, tools_dict = await logger.async_should_run_agentic_loop(
response=_responses_output_with_web_search(),
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
tools=[{"type": "function", "name": "litellm_web_search"}],
stream=False,
custom_llm_provider="openai",
kwargs={},
)
assert should_run is False
@pytest.mark.asyncio
async def test_build_responses_plan_produces_responses_input():
"""async_build_responses_agentic_loop_plan builds a Responses-format follow-up:
the user input followed by function_call + function_call_output items, with
the web search tool preserved and tool_choice stripped.
"""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
tools_dict = {
"tool_calls": [
{
"id": "fc_1",
"call_id": "fc_1",
"type": "function_call",
"name": "litellm_web_search",
"arguments": '{"query": "latest ai news"}',
"input": {"query": "latest ai news"},
}
],
"tool_type": "websearch",
"provider": "openai",
"response_format": "responses",
}
with patch.object(
logger,
"_execute_search",
new=AsyncMock(return_value=("OpenAI shipped a new model", None)),
):
plan = await logger.async_build_responses_agentic_loop_plan(
tools=tools_dict,
model="gpt-4o",
messages=[{"role": "user", "content": "What's the latest AI news?"}],
response=_responses_output_with_web_search(),
optional_params={
"tools": [{"type": "function", "name": "litellm_web_search"}],
"tool_choice": {"type": "function", "name": "litellm_web_search"},
},
logging_obj=MagicMock(),
stream=False,
kwargs={
"custom_llm_provider": "openai",
"_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE,
},
)
assert plan.run_agentic_loop is True
patch_obj = plan.request_patch
assert patch_obj is not None
input_items = patch_obj.messages
assert input_items is not None
assert input_items[0] == {"role": "user", "content": "What's the latest AI news?"}
assert input_items[1] == {
"type": "function_call",
"call_id": "fc_1",
"name": "litellm_web_search",
"arguments": '{"query": "latest ai news"}',
}
assert input_items[2] == {
"type": "function_call_output",
"call_id": "fc_1",
"output": "OpenAI shipped a new model",
}
assert patch_obj.tools == [{"type": "function", "name": "litellm_web_search"}]
assert "tool_choice" not in patch_obj.optional_params
assert "_agentic_loop_api_surface" not in patch_obj.kwargs
assert patch_obj.model == "openai/gpt-4o"
_WEB_SEARCH_FUNCTION_CALL: Final = {
"tool_calls": [
{
"id": "fc_1",
"call_id": "fc_1",
"type": "function_call",
"name": "litellm_web_search",
"arguments": '{"query": "latest ai news"}',
"input": {"query": "latest ai news"},
}
],
"tool_type": "websearch",
"response_format": "responses",
}
async def _responses_followup_model(model: str, custom_llm_provider: str, logging_obj: Logging | None) -> str | None:
logger: Final = WebSearchInterceptionLogger()
plan: Final = await logger.async_build_responses_agentic_loop_plan(
tools={**_WEB_SEARCH_FUNCTION_CALL, "provider": custom_llm_provider},
model=model,
messages=[{"role": "user", "content": "What's the latest AI news?"}],
response=_responses_output_with_web_search(),
optional_params={"tools": [{"type": "function", "name": "litellm_web_search"}]},
logging_obj=logging_obj,
stream=False,
kwargs={
"custom_llm_provider": custom_llm_provider,
"_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE,
},
)
assert plan.request_patch is not None
return plan.request_patch.model
@pytest.mark.asyncio
@pytest.mark.usefixtures("perplexity_search")
async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model() -> None:
"""#38829 on the Responses surface: a provider-stripped model whose remainder still holds a
slash was left unqualified, so the follow-up could not resolve a provider."""
followup_model: Final = await _responses_followup_model("mantle/anthropic.claude-sonnet-5", "bedrock", None)
assert followup_model == "bedrock/mantle/anthropic.claude-sonnet-5"
@pytest.mark.asyncio
@pytest.mark.usefixtures("perplexity_search")
async def test_responses_followup_reuses_the_model_the_caller_requested() -> None:
"""An OpenRouter model whose own id starts with "openrouter/" reaches the handler as
"openrouter/free", which re-qualifying alone cannot tell apart from a request for model "free"."""
requested_model: Final = "openrouter/openrouter/free"
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.responses.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-responses-followup",
function_id="fn-responses-followup",
)
litellm.responses(
model=requested_model,
input="What's the latest AI news?",
mock_response="searching",
litellm_logging_obj=logging_obj,
)
followup_model: Final = await _responses_followup_model("openrouter/free", "openrouter", logging_obj)
assert followup_model == requested_model
@pytest.mark.asyncio
@pytest.mark.usefixtures("perplexity_search")
async def test_responses_followup_keeps_the_caller_model_across_the_chat_completions_bridge() -> None:
"""A Responses request bridged to chat completions re-enters completion() with the stripped model."""
requested_model: Final = "openrouter/openrouter/free"
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.responses.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-bridged-followup",
function_id="fn-bridged-followup",
)
litellm.responses(
model=requested_model,
input="What's the latest AI news?",
mock_response="searching",
litellm_logging_obj=logging_obj,
)
litellm.completion(
model="openrouter/free",
custom_llm_provider="openrouter",
messages=[{"role": "user", "content": "What's the latest AI news?"}],
mock_response="searching",
litellm_logging_obj=logging_obj,
_skip_responses_api_bridge=True,
)
followup_model: Final = await _responses_followup_model("openrouter/free", "openrouter", logging_obj)
assert followup_model == requested_model
@pytest.mark.asyncio
async def test_deployment_hook_converts_native_responses_web_search_tool():
"""async_pre_call_deployment_hook converts a native Responses web_search tool
into the flat litellm_web_search function tool (Responses shape, not the
nested Chat Completions {"function": {...}} wrapper).
"""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
result = await logger.async_pre_call_deployment_hook(
kwargs={
"model": "gpt-4o",
"custom_llm_provider": "openai",
"tools": [{"type": "web_search"}],
},
call_type=CallTypes.aresponses,
)
assert result is not None
converted_tools = result["tools"]
assert len(converted_tools) == 1
tool = converted_tools[0]
assert tool["type"] == "function"
assert tool["name"] == "litellm_web_search"
assert "function" not in tool
assert tool["parameters"]["required"] == ["query"]
@pytest.mark.asyncio
async def test_deployment_hook_responses_returns_none_without_web_search():
"""No web search tool in a responses request -> deployment hook makes no change."""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
result = await logger.async_pre_call_deployment_hook(
kwargs={
"model": "gpt-4o",
"custom_llm_provider": "openai",
"tools": [{"type": "function", "name": "get_weather"}],
},
call_type=CallTypes.aresponses,
)
assert result is None
@pytest.mark.asyncio
async def test_deployment_hook_responses_converts_stream_to_non_stream():
"""Streaming responses requests are converted to non-streaming so the agentic
loop can run, and flagged for re-wrapping afterwards.
"""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
result = await logger.async_pre_call_deployment_hook(
kwargs={
"model": "gpt-4o",
"custom_llm_provider": "openai",
"tools": [{"type": "web_search_preview"}],
"stream": True,
},
call_type=CallTypes.aresponses,
)
assert result is not None
assert result["stream"] is False
assert result["_websearch_interception_converted_stream"] is True