mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(responses): return Claude web_fetch calls as web_search_call open_page items
This commit is contained in:
parent
ef9851af3d
commit
8dbf05bc74
5 changed files with 248 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue