From 8dbf05bc7402425a25ebbc04962b781530d99c77 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:14:05 -0700 Subject: [PATCH] fix(responses): return Claude web_fetch calls as web_search_call open_page items --- litellm/llms/anthropic/chat/handler.py | 11 ++- litellm/llms/anthropic/chat/transformation.py | 32 ++++--- litellm/types/responses/main.py | 68 ++++++++++---- .../test_litellm_completion_responses.py | 73 ++++++++++++++ .../test_streaming_iterator_transformation.py | 94 +++++++++++++++++++ 5 files changed, 248 insertions(+), 30 deletions(-) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 9e0331d902c..93c01ec6ae0 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -53,6 +53,7 @@ from litellm.types.llms.openai import ( ChatCompletionToolCallFunctionChunk, ) from litellm.types.responses.main import ( + HOSTED_WEB_TOOL_NAMES, OutputCodeInterpreterCall, build_code_interpreter_log_outputs, build_web_search_call, @@ -918,7 +919,7 @@ class ModelResponseIterator: def _web_search_call_snapshot(self) -> dict[str, object]: return dict(self._web_search_calls) - def _complete_web_search_call(self, result: dict[str, object]) -> None: + def _complete_web_search_call(self, result: dict[str, object], tool_name: str) -> None: tool_use_id: Final = result.get("tool_use_id") if not isinstance(tool_use_id, str) or tool_use_id not in self._web_search_calls: return @@ -926,6 +927,7 @@ class ModelResponseIterator: tool_id=tool_use_id, tool_input=self._server_tool_inputs.get(tool_use_id, {}), result=result, + tool_name=tool_name, ) def _build_code_interpreter_results(self) -> list: @@ -1033,12 +1035,13 @@ class ModelResponseIterator: self._current_server_tool_name = _stream_tool_name tool_input: Final = content_block_start["content_block"].get("input", {}) self._server_tool_inputs[self._current_server_tool_id] = tool_input - if _stream_tool_name == "web_search": + if _stream_tool_name in HOSTED_WEB_TOOL_NAMES: self._web_search_calls[self._current_server_tool_id] = build_web_search_call( self._current_server_tool_id, tool_input, {"content": []}, status="in_progress", + tool_name=_stream_tool_name, ) provider_specific_fields["web_search_calls"] = self._web_search_call_snapshot() # Include caller information if present (for programmatic tool calling) @@ -1075,7 +1078,7 @@ class ModelResponseIterator: # The full content comes in content_block_start, not in deltas # See: https://github.com/BerriAI/litellm/issues/17737 self.web_search_results.append(content_block_start["content_block"]) - self._complete_web_search_call(content_block_start["content_block"]) + self._complete_web_search_call(content_block_start["content_block"], "web_search") provider_specific_fields["web_search_results"] = self.web_search_results provider_specific_fields["web_search_calls"] = self._web_search_call_snapshot() elif content_type == "web_fetch_tool_result": @@ -1083,7 +1086,9 @@ class ModelResponseIterator: # The full content comes in content_block_start, not in deltas # Fixes: https://github.com/BerriAI/litellm/issues/18137 self.web_search_results.append(content_block_start["content_block"]) + self._complete_web_search_call(content_block_start["content_block"], "web_fetch") provider_specific_fields["web_search_results"] = self.web_search_results + provider_specific_fields["web_search_calls"] = self._web_search_call_snapshot() else: self.tool_results.append(content_block_start["content_block"]) provider_specific_fields["tool_results"] = self.tool_results diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 6508edfbe09..2eb9d06f1c5 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -94,6 +94,7 @@ from litellm.types.llms.openai import ( OpenAIWebSearchOptions, ) from litellm.types.responses.main import ( + HOSTED_WEB_TOOL_NAMES, OutputCodeInterpreterCall, build_code_interpreter_log_outputs, build_web_search_call, @@ -251,6 +252,19 @@ def _validated_defer_loading(value: object) -> bool: raise ValueError("defer_loading must be a boolean") from exc +def _hosted_web_tool_use(block: object) -> tuple[str, str, object] | None: + match block: + case { + "type": "server_tool_use", + "name": str() as name, + "id": str() as call_id, + "input": object() as tool_input, + } if name in HOSTED_WEB_TOOL_NAMES: + return call_id, name, tool_input + case _: + return None + + class _AnthropicUsageIteration(TypedDict, total=False): """One entry of the ``usage.iterations`` array on an Anthropic response.""" @@ -2902,26 +2916,22 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) -> list[object]: content: Final = completion_response.get("content") blocks: Final = content if isinstance(content, Sequence) else () - inputs: Final = { - call_id: tool_input - for block in blocks - if isinstance(block, Mapping) - and block.get("type") == "server_tool_use" - and block.get("name") == "web_search" - and isinstance((call_id := block.get("id")), str) - and isinstance((tool_input := block.get("input")), Mapping) + 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)) } return [ build_web_search_call( tool_id=tool_use_id, - tool_input=inputs.get(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 result.get("type") == "web_search_tool_result" and isinstance((tool_use_id := result.get("tool_use_id")), str) - and tool_use_id in inputs + and tool_use_id in server_tool_uses + and result.get("type") == f"{server_tool_uses[tool_use_id][0]}_tool_result" ] def _build_provider_specific_fields( diff --git a/litellm/types/responses/main.py b/litellm/types/responses/main.py index 72dc63a581f..7b1fb0130d8 100644 --- a/litellm/types/responses/main.py +++ b/litellm/types/responses/main.py @@ -3,7 +3,12 @@ from collections.abc import Mapping, Sequence from typing import Final, Literal, Optional, Union from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall -from openai.types.responses.response_function_web_search import ActionSearchSource, ResponseFunctionWebSearch +from openai.types.responses.response_function_web_search import ( + ActionOpenPage, + ActionSearch, + ActionSearchSource, + ResponseFunctionWebSearch, +) from pydantic import ConfigDict, PrivateAttr from typing_extensions import TypedDict @@ -42,14 +47,11 @@ class OutputFunctionToolCall(BaseLiteLLMOpenAIResponseObject): phase: Phase = None -def build_web_search_call( - tool_id: str, - tool_input: object, - result: object, - status: Literal["in_progress", "searching", "completed", "failed"] | None = None, -) -> ResponseFunctionWebSearch: +HOSTED_WEB_TOOL_NAMES: Final = frozenset({"web_search", "web_fetch"}) + + +def _web_search_action(tool_input: object, content: object) -> ActionSearch: query: Final = tool_input.get("query", "") if isinstance(tool_input, Mapping) else "" - content: Final = result.get("content") if isinstance(result, Mapping) else None result_items: Final = content if isinstance(content, Sequence) and not isinstance(content, (str, bytes)) else () sources: Final = [ ActionSearchSource(type="url", url=url) @@ -58,17 +60,51 @@ def build_web_search_call( and item.get("type") == "web_search_result" and isinstance((url := item.get("url")), str) ] - failed: Final = isinstance(content, Mapping) and content.get("type") == "web_search_tool_result_error" + return ActionSearch( + type="search", + query=query if isinstance(query, str) else "", + queries=[query] if isinstance(query, str) and query else [], + sources=sources, + ) + + +def _web_fetch_action(tool_input: object) -> ActionOpenPage: + match tool_input: + case {"url": str() as url}: + return ActionOpenPage(type="open_page", url=url) + case _: + return ActionOpenPage(type="open_page", url=None) + + +def _result_content(result: object) -> object: + match result: + case {"content": object() as content}: + return content + case _: + return None + + +def _is_tool_result_error(content: object, tool_name: str) -> bool: + match content: + case {"type": str() as content_type}: + return content_type == f"{tool_name}_tool_result_error" + case _: + return False + + +def build_web_search_call( + tool_id: str, + tool_input: object, + result: object, + status: Literal["in_progress", "searching", "completed", "failed"] | None = None, + tool_name: str = "web_search", +) -> ResponseFunctionWebSearch: + content: Final = _result_content(result) return ResponseFunctionWebSearch( id=f"ws_{tool_id}", type="web_search_call", - status=status or ("failed" if failed else "completed"), - action={ - "type": "search", - "query": query if isinstance(query, str) else "", - "queries": [query] if isinstance(query, str) and query else [], - "sources": sources, - }, + status=status or ("failed" if _is_tool_result_error(content, tool_name) else "completed"), + action=_web_fetch_action(tool_input) if tool_name == "web_fetch" else _web_search_action(tool_input, content), ) diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 3fd4be6f722..06ffa721cd4 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -4952,6 +4952,79 @@ def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id(): assert convert(openai)["id"] == "call_tokyo" +_FETCHED_URL: Final = "https://example.com/" +_BLOCKED_URL: Final = "https://blocked.invalid/" +_ANTHROPIC_WEB_FETCH_TURN: Final[tuple[dict[str, object], ...]] = ( + {"type": "server_tool_use", "id": "srvtoolu_01Fetch", "name": "web_fetch", "input": {"url": _FETCHED_URL}}, + { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_01Fetch", + "content": { + "type": "web_fetch_result", + "url": _FETCHED_URL, + "content": {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "Example"}}, + }, + }, + {"type": "server_tool_use", "id": "srvtoolu_02Fetch", "name": "web_fetch", "input": {"url": _BLOCKED_URL}}, + { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_02Fetch", + "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"}}, +) + + +def test_anthropic_web_fetch_is_a_hosted_open_page_call_on_responses() -> None: + from unittest.mock import MagicMock + + import httpx + + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + anthropic_response: Final = { + "id": "msg_01Fetch", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5-5", + "content": list(_ANTHROPIC_WEB_FETCH_TURN), + "stop_reason": "tool_use", + "stop_sequence": None, + "usage": {"input_tokens": 100, "output_tokens": 50, "server_tool_use": {"web_fetch_requests": 2}}, + } + raw_response: Final = MagicMock(spec=httpx.Response) + raw_response.json.return_value = anthropic_response + raw_response.status_code = 200 + raw_response.headers = {} + chat_response: Final = AnthropicConfig().transform_parsed_response( + completion_response=anthropic_response, + raw_response=raw_response, + model_response=ModelResponse(), + 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.", + responses_api_request={ + "tools": [ + {"type": "web_fetch_20250910", "name": "web_fetch"}, + {"type": "function", "name": "get_weather", "parameters": {"type": "object"}}, + ] + }, + chat_completion_response=chat_response, + ) + + assert [ + (item.id, item.status, item.action.model_dump()) for item in response.output if item.type == "web_search_call" + ] == [ + ("ws_srvtoolu_01Fetch", "completed", {"type": "open_page", "url": _FETCHED_URL}), + ("ws_srvtoolu_02Fetch", "failed", {"type": "open_page", "url": _BLOCKED_URL}), + ] + 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 = { diff --git a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index d9a0033b179..db7c54c52ca 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -355,6 +355,100 @@ async def test_web_search_stream_preserves_hosted_and_client_calls(sync_mode, to ) +def _anthropic_web_fetch_stream_events() -> tuple[dict[str, object], ...]: + return ( + { + "type": "message_start", + "message": { + "id": "msg_01Fetch", + "type": "message", + "role": "assistant", + "content": [], + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "server_tool_use", "id": "srvtoolu_01Fetch", "name": "web_fetch"}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '{"url": "https://example.com/"}'}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_01Fetch", + "content": {"type": "web_fetch_result", "url": "https://example.com/"}, + }, + }, + {"type": "content_block_stop", "index": 1}, + {"type": "content_block_start", "index": 2, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 2, "delta": {"type": "text_delta", "text": "Fetched."}}, + {"type": "content_block_stop", "index": 2}, + { + "type": "content_block_start", + "index": 3, + "content_block": {"type": "tool_use", "id": "toolu_01Weather", "name": "get_weather", "input": {}}, + }, + { + "type": "content_block_delta", + "index": 3, + "delta": {"type": "input_json_delta", "partial_json": '{"city": "Paris"}'}, + }, + {"type": "content_block_stop", "index": 3}, + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"output_tokens": 20}, + }, + ) + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_streamed_anthropic_web_fetch_is_a_hosted_open_page_call(sync_mode: bool) -> None: + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + parser: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=sync_mode, json_mode=False) + chunks: Final = [parser.chunk_parser(event) for event in _anthropic_web_fetch_stream_events()] + iterator: Final = LiteLLMCompletionStreamingIterator( + model="claude-sonnet-5-5", + litellm_custom_stream_wrapper=_FakeStreamWrapper(chunks), + request_input="Fetch example.com and check the weather in Paris.", + responses_api_request={ + "tools": [ + {"type": "web_fetch_20250910", "name": "web_fetch"}, + {"type": "function", "name": "get_weather", "parameters": {"type": "object"}}, + ] + }, + custom_llm_provider="anthropic", + ) + + events: Final = await _collect_events(iterator, sync_mode) + + completed: Final = next( + event for event in events if getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + ) + assert [ + (item.id, item.status, item.action.model_dump()) + for item in completed.response.output + if item.type == "web_search_call" + ] == [("ws_srvtoolu_01Fetch", "completed", {"type": "open_page", "url": "https://example.com/"})] + assert [item.name for item in completed.response.output if item.type == "function_call"] == ["get_weather"] + assert not any( + event.item.name == "web_fetch" + for event in events + if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED + and event.item.type == "function_call" + ) + + def test_tool_calls_present_only_in_final_response_are_emitted_before_completed(): iterator = LiteLLMCompletionStreamingIterator( model="test-model",