fix(responses): keep Claude server web calls without a result as hosted items

A non-streamed Claude turn that calls web_search or web_fetch next to a
client tool can return the server call before its result. Build an
in_progress web_search_call for it, so the Responses bridge no longer
returns it as a client function_call
This commit is contained in:
mateo-berri 2026-10-29 03:33:21 -07:00
parent 8dbf05bc74
commit 8fedc17566
2 changed files with 69 additions and 27 deletions

View file

@ -7,6 +7,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar, Final, NoReturn, cast
import httpx
from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
@ -265,6 +266,16 @@ def _hosted_web_tool_use(block: object) -> tuple[str, str, object] | None:
return None
def _hosted_web_call(call_id: str, tool_name: str, tool_input: object, result: object) -> ResponseFunctionWebSearch:
return build_web_search_call(
tool_id=call_id,
tool_input=tool_input,
result=result,
status=None if result is not None else "in_progress",
tool_name=tool_name,
)
class _AnthropicUsageIteration(TypedDict, total=False):
"""One entry of the ``usage.iterations`` array on an Anthropic response."""
@ -2913,25 +2924,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
self,
web_search_results: Sequence[object],
completion_response: Mapping[str, object],
) -> list[object]:
) -> list[ResponseFunctionWebSearch]:
content: Final = completion_response.get("content")
blocks: Final = content if isinstance(content, Sequence) else ()
server_tool_uses: Final = {
call_id: (tool_name, tool_input)
for call_id, tool_name, tool_input in filter(None, map(_hosted_web_tool_use, blocks))
results_by_call: Final = {
(tool_use_id, result.get("type")): result
for result in web_search_results
if isinstance(result, dict) and isinstance((tool_use_id := result.get("tool_use_id")), str)
}
return [
build_web_search_call(
tool_id=tool_use_id,
tool_input=server_tool_uses[tool_use_id][1],
result=result,
tool_name=server_tool_uses[tool_use_id][0],
)
for result in web_search_results
if isinstance(result, dict)
and isinstance((tool_use_id := result.get("tool_use_id")), str)
and tool_use_id in server_tool_uses
and result.get("type") == f"{server_tool_uses[tool_use_id][0]}_tool_result"
_hosted_web_call(call_id, tool_name, tool_input, results_by_call.get((call_id, f"{tool_name}_tool_result")))
for call_id, tool_name, tool_input in filter(None, map(_hosted_web_tool_use, blocks))
]
def _build_provider_specific_fields(
@ -2955,10 +2958,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if web_search_results is not None:
provider_specific_fields["web_search_results"] = web_search_results
provider_specific_fields["web_search_calls"] = self._build_web_search_calls(
web_search_results,
completion_response,
)
web_search_calls: Final = self._build_web_search_calls(web_search_results or (), completion_response)
if web_search_calls:
provider_specific_fields["web_search_calls"] = web_search_calls
if tool_results is not None:
provider_specific_fields["tool_results"] = tool_results

View file

@ -31,7 +31,7 @@ from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from litellm.types.llms.base import HiddenParams
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams, ResponsesAPIResponse
from litellm.types.responses.main import OutputToolSearchCall, build_web_search_call
from litellm.types.utils import (
ChatCompletionMessageToolCall,
@ -4952,6 +4952,12 @@ def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id():
assert convert(openai)["id"] == "call_tokyo"
_WEATHER_TOOL_USE: Final = {
"type": "tool_use",
"id": "toolu_01Weather",
"name": "get_weather",
"input": {"city": "Paris"},
}
_FETCHED_URL: Final = "https://example.com/"
_BLOCKED_URL: Final = "https://blocked.invalid/"
_ANTHROPIC_WEB_FETCH_TURN: Final[tuple[dict[str, object], ...]] = (
@ -4972,11 +4978,11 @@ _ANTHROPIC_WEB_FETCH_TURN: Final[tuple[dict[str, object], ...]] = (
"content": {"type": "web_fetch_tool_result_error", "error_code": "url_not_allowed"},
},
{"type": "text", "text": "The page is titled Example."},
{"type": "tool_use", "id": "toolu_01Weather", "name": "get_weather", "input": {"city": "Paris"}},
_WEATHER_TOOL_USE,
)
def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None:
def _anthropic_turn_on_responses(content: Sequence[Mapping[str, object]]) -> ResponsesAPIResponse:
from unittest.mock import MagicMock
import httpx
@ -4988,10 +4994,10 @@ def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None:
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5-5",
"content": list(_ANTHROPIC_WEB_FETCH_TURN),
"content": list(content),
"stop_reason": "tool_use",
"stop_sequence": None,
"usage": {"input_tokens": 100, "output_tokens": 50, "server_tool_use": {"web_fetch_requests": 2}},
"usage": {"input_tokens": 100, "output_tokens": 50},
}
raw_response: Final = MagicMock(spec=httpx.Response)
raw_response.json.return_value = anthropic_response
@ -5004,11 +5010,11 @@ def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None:
json_mode=False,
prefix_prompt=None,
)
response: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Fetch two pages and check the weather in Paris.",
return LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Use the web and check the weather in Paris.",
responses_api_request={
"tools": [
{"type": "web_search_20250305", "name": "web_search"},
{"type": "web_fetch_20250910", "name": "web_fetch"},
{"type": "function", "name": "get_weather", "parameters": {"type": "object"}},
]
@ -5016,6 +5022,10 @@ def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None:
chat_completion_response=chat_response,
)
def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None:
response: Final = _anthropic_turn_on_responses(_ANTHROPIC_WEB_FETCH_TURN)
assert [
(item.id, item.status, item.action.model_dump()) for item in response.output if item.type == "web_search_call"
] == [
@ -5025,6 +5035,35 @@ def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None:
assert [item.name for item in response.output if item.type == "function_call"] == ["get_weather"]
@pytest.mark.parametrize(
("server_tool_use", "expected_action"),
[
(
{
"type": "server_tool_use",
"id": "srvtoolu_01Pending",
"name": "web_fetch",
"input": {"url": _FETCHED_URL},
},
{"type": "open_page", "url": _FETCHED_URL},
),
(
{"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": "web_search", "input": {"query": "python"}},
{"type": "search", "query": "python"},
),
],
)
def test_anthropic_server_web_call_without_a_result_is_an_in_progress_hosted_call_on_responses(
server_tool_use: Mapping[str, object], expected_action: Mapping[str, object]
) -> None:
response: Final = _anthropic_turn_on_responses((server_tool_use, _WEATHER_TOOL_USE))
web_calls: Final = [item for item in response.output if item.type == "web_search_call"]
assert [(item.id, item.status) for item in web_calls] == [("ws_srvtoolu_01Pending", "in_progress")]
assert expected_action.items() <= web_calls[0].action.model_dump().items()
assert [item.name for item in response.output if item.type == "function_call"] == ["get_weather"]
class TestHostedWebSearchReplay:
def test_emitted_hosted_search_output_round_trips_with_client_tool_result(self) -> None:
search_result: Final = {