mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
8dbf05bc74
commit
8fedc17566
2 changed files with 69 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue