fix(responses): preserve hosted web search calls

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Tin Chi Lo 2026-09-10 18:04:15 -07:00
parent 7419a536ad
commit 09183b3346
11 changed files with 602 additions and 35 deletions

View file

@ -44,6 +44,7 @@ from litellm.types.llms.openai import (
from litellm.types.responses.main import (
OutputCodeInterpreterCall,
build_code_interpreter_log_outputs,
build_web_search_call,
)
from litellm.types.utils import (
Delta,
@ -649,6 +650,7 @@ class ModelResponseIterator:
# Accumulate web_search_tool_result blocks for multi-turn reconstruction
# See: https://github.com/BerriAI/litellm/issues/17737
self.web_search_results: list[dict[str, object]] = []
self._web_search_calls: dict[str, object] = {} # mutable-ok: provider call state by id
# Accumulate compaction blocks for multi-turn reconstruction
self.compaction_blocks: list[dict[str, object]] = []
@ -821,6 +823,19 @@ class ModelResponseIterator:
return content_block_start
def _web_search_call_snapshot(self) -> dict[str, object]:
return dict(self._web_search_calls) # mutable-ok: stream payload snapshot
def _complete_web_search_call(self, result: dict[str, object]) -> 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
self._web_search_calls[tool_use_id] = build_web_search_call(
tool_id=tool_use_id,
tool_input=self._server_tool_inputs.get(tool_use_id, {}), # mutable-ok: empty provider input
result=result,
)
def _build_code_interpreter_results(self) -> list:
"""Convert accumulated tool_results to OutputCodeInterpreterCall objects.
@ -922,6 +937,14 @@ class ModelResponseIterator:
self._current_server_tool_id = content_block_start["content_block"]["id"]
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":
self._web_search_calls[self._current_server_tool_id] = build_web_search_call(
self._current_server_tool_id,
tool_input,
{"content": []}, # mutable-ok: no provider result yet
status="in_progress",
)
provider_specific_fields["web_search_calls"] = self._web_search_call_snapshot()
# Include caller information if present (for programmatic tool calling)
if "caller" in content_block_start["content_block"]:
caller_data: Final = content_block_start["content_block"]["caller"]
@ -956,7 +979,9 @@ 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"])
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":
# Capture web_fetch_tool_result for multi-turn reconstruction
# The full content comes in content_block_start, not in deltas

View file

@ -70,6 +70,7 @@ from litellm.types.llms.openai import (
from litellm.types.responses.main import (
OutputCodeInterpreterCall,
build_code_interpreter_log_outputs,
build_web_search_call,
)
from litellm.types.utils import (
CacheCreationTokenDetails,
@ -2464,6 +2465,35 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
)
return code_interpreter_results
def _build_web_search_calls(
self,
web_search_results: Sequence[object],
completion_response: Mapping[str, object],
) -> list[object]:
content: Final = completion_response.get("content")
blocks: Final = content if isinstance(content, Sequence) else ()
inputs: Final = { # mutable-ok: indexes provider server inputs
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)
}
return [ # mutable-ok: provider-neutral response items
build_web_search_call(
tool_id=tool_use_id,
tool_input=inputs.get(tool_use_id, {}), # mutable-ok: empty provider input
result=result,
)
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
]
def _build_provider_specific_fields(
self,
completion_response: dict,
@ -2485,6 +2515,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,
)
if tool_results is not None:
provider_specific_fields["tool_results"] = tool_results

View file

@ -40,6 +40,9 @@ from litellm.types.llms.openai import (
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
ResponsesAPIStreamingResponse,
WebSearchCallCompletedEvent,
WebSearchCallInProgressEvent,
WebSearchCallSearchingEvent,
)
from litellm.types.utils import Delta as ChatCompletionDelta
from litellm.types.utils import (
@ -135,6 +138,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
self.responses_api_request.get("tools")
)
self._web_search_calls: dict[str, object] = {} # mutable-ok: latest call by provider id
self._queued_web_search_call_ids: set[str] = set() # mutable-ok: emitted call ids
def _get_or_assign_tool_output_index(self, call_id: str) -> int:
existing: Final = self._tool_output_index_by_call_id.get(call_id)
@ -172,6 +177,43 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
return delta.content or delta.function_call or delta.tool_calls or chunk.choices[0].finish_reason is not None
def _reserve_web_search_indexes(self, provider_fields: object) -> None:
if not isinstance(provider_fields, dict):
return
calls: Final = provider_fields.get("web_search_calls")
items: Final = calls.values() if isinstance(calls, dict) else calls if isinstance(calls, list) else ()
for item in items:
try:
call_id = item.id.removeprefix("ws_")
status = item.status
except AttributeError:
call_id = str(item.get("id", "")).removeprefix("ws_") if isinstance(item, dict) else ""
status = item.get("status") if isinstance(item, dict) else None
if call_id:
output_index = self._get_or_assign_tool_output_index(call_id)
self._web_search_calls[call_id] = item
if status == "in_progress":
self._pending_tool_events = [ # mutable-ok: replaces speculative function events
event
for event in self._pending_tool_events
if getattr(event, "output_index", None) != output_index
]
def _tool_call_id(self, tool_call: object) -> str:
index: Final = self._normalize_tool_call_index(tool_call)
call_id_raw: Final = tool_call.get("id") if isinstance(tool_call, dict) else getattr(tool_call, "id", None)
if call_id_raw:
call_id: Final = str(call_id_raw)
if index is not None:
existing: Final = self._tool_call_id_by_index.get(index)
if existing is not None and existing != call_id:
self._ambiguous_tool_call_indexes.add(index)
self._tool_call_id_by_index[index] = call_id
return call_id
if index is None or index in self._ambiguous_tool_call_indexes:
return ""
return self._tool_call_id_by_index.get(index, "")
def _queue_tool_call_delta_events(self, tool_calls: object) -> None:
"""
Convert chat-completions streaming `tool_calls` deltas into Responses API streaming events.
@ -187,28 +229,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
return
for tc in tool_calls:
tc_index = self._normalize_tool_call_index(tc)
call_id_raw = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
call_id = ""
if call_id_raw:
call_id = str(call_id_raw)
if tc_index is not None:
existing_call_id = self._tool_call_id_by_index.get(tc_index)
if existing_call_id is not None and existing_call_id != call_id:
# Reusing the same index for multiple call_ids is ambiguous for id-less deltas.
# Guard against silent misrouting by disabling index fallback for this index.
self._ambiguous_tool_call_indexes.add(tc_index)
self._tool_call_id_by_index[tc_index] = call_id
elif tc_index is not None:
if tc_index in self._ambiguous_tool_call_indexes:
continue
mapped_call_id = self._tool_call_id_by_index.get(tc_index)
if mapped_call_id:
call_id = mapped_call_id
call_id = self._tool_call_id(tc)
if not call_id:
continue
if call_id in self._web_search_calls:
continue
fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None)
fn_name = ""
@ -220,7 +245,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
fn_name = str(getattr(fn, "name", "") or "")
fn_args_delta = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
output_index = self._get_or_assign_tool_output_index(call_id)
if call_id not in self._tool_args_by_call_id:
@ -292,9 +316,15 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
fn_name = str(getattr(fn, "name", "") or "")
fn_args = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
web_search_call = self._web_search_calls.get(call_id)
if web_search_call is not None:
if call_id not in self._queued_web_search_call_ids:
self._queue_web_search_events(call_id, web_search_call)
self._queued_web_search_call_ids.add(call_id)
continue
# Track if this is a new tool call that wasn't streamed
is_new_tool_call = call_id not in self._tool_args_by_call_id
is_new_tool_call = call_id not in self._tool_item_id_by_call_id
# If we never sent output_item.added for this call_id, emit it now.
if is_new_tool_call:
@ -359,6 +389,49 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
)
self._pending_tool_events.append(item_done_event)
def _queue_web_search_events(self, call_id: str, web_search_call: object) -> None:
from openai.types.responses import ResponseFunctionWebSearch
item: Final = (
web_search_call
if isinstance(web_search_call, ResponseFunctionWebSearch)
else ResponseFunctionWebSearch.model_validate(web_search_call)
)
output_index: Final = self._get_or_assign_tool_output_index(call_id)
self._sequence_number += 1
added: Final = OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=output_index,
item=BaseLiteLLMOpenAIResponseObject(
**{ # mutable-ok: BaseLiteLLM object accepts dynamic item fields
"id": item.id,
"type": item.type,
"status": "in_progress",
"action": None,
}
),
)
added.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(added)
for event_type, event_class in (
(ResponsesAPIStreamEvents.WEB_SEARCH_CALL_IN_PROGRESS, WebSearchCallInProgressEvent),
(ResponsesAPIStreamEvents.WEB_SEARCH_CALL_SEARCHING, WebSearchCallSearchingEvent),
(ResponsesAPIStreamEvents.WEB_SEARCH_CALL_COMPLETED, WebSearchCallCompletedEvent),
):
self._sequence_number += 1
event = event_class(type=event_type, output_index=output_index, item_id=item.id)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(event)
self._sequence_number += 1
self._pending_tool_events.append(
OutputItemDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
output_index=output_index,
sequence_number=self._sequence_number,
item=BaseLiteLLMOpenAIResponseObject(**item.model_dump()),
)
)
def _adopt_response_id_from_chunk(self, chunk: ModelResponseStream) -> None:
if self._cached_response_id is not None:
return
@ -915,8 +988,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
chunk = await self.litellm_custom_stream_wrapper.__anext__()
if chunk is not None:
chunk = cast(ModelResponseStream, chunk)
self._ensure_output_item_for_chunk(chunk)
# Accumulate provider_specific_fields from chunk and delta
for src in (
getattr(chunk, "provider_specific_fields", None),
getattr(
@ -927,6 +998,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
):
if src and isinstance(src, dict):
self._merge_provider_specific_fields(src)
self._reserve_web_search_indexes(src)
self._ensure_output_item_for_chunk(chunk)
# Proceed to transformation
self.collected_chat_completion_chunks.append(
self._snapshot_chunk_for_stream_chunk_builder(chunk)
@ -1021,8 +1094,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
raise StopIteration
else:
chunk = self.litellm_custom_stream_wrapper.__next__()
self._ensure_output_item_for_chunk(chunk)
# Accumulate provider_specific_fields from chunk and delta
for src in (
getattr(chunk, "provider_specific_fields", None),
getattr(
@ -1033,6 +1104,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
):
if src and isinstance(src, dict):
self._merge_provider_specific_fields(src)
self._reserve_web_search_indexes(src)
self._ensure_output_item_for_chunk(chunk)
# Always snapshot before returning any pending events so that
# finish_reason (e.g. content_filter) is captured even when
# _ensure_output_item_for_chunk queues events on the same chunk.
@ -1168,7 +1241,12 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"message",
self._cached_item_id,
)
return _output_items_with_id(message_aligned, "reasoning", self._cached_reasoning_item_id)
reasoning_aligned: Final = _output_items_with_id(
message_aligned,
"reasoning",
self._cached_reasoning_item_id,
)
return reasoning_aligned
def _emit_response_completed_event(self, litellm_model_response: ModelResponse) -> ResponseCompletedEvent | None:
if litellm_model_response:

View file

@ -25,7 +25,7 @@ from openai.types.chat.chat_completion_named_tool_choice_param import (
from openai.types.chat.chat_completion_named_tool_choice_param import (
Function as NamedToolChoiceFunction,
)
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses import ResponseFunctionToolCall, ResponseFunctionWebSearch
from openai.types.responses.response_create_params import ResponseInputParam
from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomParam
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
@ -1438,7 +1438,6 @@ class LiteLLMCompletionResponsesConfig:
return input_item.get("type") in [
"function_call_output",
"custom_tool_call_output",
"web_search_call",
"computer_call_output",
"tool_result", # Anthropic/MCP format
]
@ -2041,7 +2040,7 @@ class LiteLLMCompletionResponsesConfig:
def transform_chat_completion_tools_to_responses_tools(
chat_completion_response: ModelResponse,
responses_api_request: ResponsesAPIOptionalRequestParams | None = None,
) -> list[ResponseFunctionToolCall | CustomToolCallOutputItem]:
) -> list[ResponseFunctionToolCall | ResponseFunctionWebSearch | CustomToolCallOutputItem]:
"""
Transform a Chat Completion tools into a Responses API tools.
@ -2064,7 +2063,12 @@ class LiteLLMCompletionResponsesConfig:
custom_tool_names: Final = extract_custom_tool_names(request_tools)
namespace_tool_names: Final = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools)
responses_tools: Final[list[ResponseFunctionToolCall | CustomToolCallOutputItem]] = []
web_search_calls: Final = LiteLLMCompletionResponsesConfig._web_search_calls_by_call_id(
chat_completion_response
)
responses_tools: Final[
list[ResponseFunctionToolCall | ResponseFunctionWebSearch | CustomToolCallOutputItem]
] = [] # mutable-ok: preserves provider tool-call order
for tool in all_chat_completion_tools:
if tool.type == "function":
function_definition = tool.function
@ -2072,8 +2076,10 @@ class LiteLLMCompletionResponsesConfig:
tool_id = tool.id or ""
tool_arguments = serialize_tool_call_arguments(function_definition.get("arguments"))
# Check if this is a custom tool
if is_custom_tool_call(tool_name, custom_tool_names):
web_search_call = web_search_calls.get(tool_id)
if web_search_call is not None:
responses_tools.append(web_search_call)
elif is_custom_tool_call(tool_name, custom_tool_names):
# Build custom_tool_call output item
input_str = unwrap_custom_tool_arguments(tool_arguments)
custom_item = CustomToolCallOutputItem(
@ -2128,6 +2134,35 @@ class LiteLLMCompletionResponsesConfig:
responses_tools.append(output_tool_call)
return responses_tools
@staticmethod
def _web_search_calls_by_call_id(
chat_completion_response: ModelResponse,
) -> Mapping[str, ResponseFunctionWebSearch]:
calls: Final[dict[str, ResponseFunctionWebSearch]] = {} # mutable-ok: indexes provider-built calls
for choice in chat_completion_response.choices:
provider_fields = getattr(choice.message, "provider_specific_fields", None)
if not isinstance(provider_fields, Mapping):
continue
web_search_calls = provider_fields.get("web_search_calls")
items = (
web_search_calls.values()
if isinstance(web_search_calls, Mapping)
else web_search_calls
if isinstance(web_search_calls, Sequence)
else ()
)
for item in items:
try:
call = (
item
if isinstance(item, ResponseFunctionWebSearch)
else ResponseFunctionWebSearch.model_validate(item)
)
except (TypeError, ValueError):
continue
calls[call.id.removeprefix("ws_")] = call
return MappingProxyType(calls)
@staticmethod
def _map_chat_completion_finish_reason_to_responses_status(
finish_reason: str | None,
@ -2326,6 +2361,7 @@ class LiteLLMCompletionResponsesConfig:
| OutputFunctionToolCall
| OutputImageGenerationCall
| ResponseFunctionToolCall
| ResponseFunctionWebSearch
| CustomToolCallOutputItem
]:
responses_output: list[
@ -2334,6 +2370,7 @@ class LiteLLMCompletionResponsesConfig:
| OutputFunctionToolCall
| OutputImageGenerationCall
| ResponseFunctionToolCall
| ResponseFunctionWebSearch
| CustomToolCallOutputItem
] = []

View file

@ -61,6 +61,7 @@ from openai.types.responses.response_create_params import (
ToolParam,
)
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch
from pydantic import (
BaseModel,
ConfigDict,
@ -1358,6 +1359,7 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
| OutputFunctionToolCall
| OutputImageGenerationCall
| ResponseFunctionToolCall
| ResponseFunctionWebSearch
| CustomToolCallOutputItem
]
)

View file

@ -1,6 +1,8 @@
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 pydantic import PrivateAttr
from typing_extensions import Any, TypedDict
@ -39,6 +41,36 @@ 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:
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 = [ # mutable-ok: official SDK expects a source list
ActionSearchSource(type="url", url=url)
for item in result_items
if isinstance(item, Mapping)
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 ResponseFunctionWebSearch(
id=f"ws_{tool_id}",
type="web_search_call",
status=status or ("failed" if failed else "completed"),
action={ # mutable-ok: official SDK expects an action mapping
"type": "search",
"query": query if isinstance(query, str) else "",
"queries": [query] if isinstance(query, str) and query else [], # mutable-ok: SDK list field
"sources": sources,
},
)
class OutputImageGenerationCall(BaseLiteLLMOpenAIResponseObject):
"""An image generation call output"""

View file

@ -1382,6 +1382,52 @@ def test_current_content_block_type_tracking():
assert iterator.current_content_block_type is None
def test_web_search_calls_are_cumulative_through_incomplete_search():
iterator = ModelResponseIterator(None, sync_stream=True)
first_start = iterator.chunk_parser(
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "server_tool_use",
"id": "srvtoolu_A",
"name": "web_search",
"input": {"query": "a"},
},
}
)
first_result = iterator.chunk_parser(
{
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_A",
"content": [],
},
}
)
second_start = iterator.chunk_parser(
{
"type": "content_block_start",
"index": 2,
"content_block": {
"type": "server_tool_use",
"id": "srvtoolu_B",
"name": "web_search",
"input": {"query": "b"},
},
}
)
assert list(first_start.choices[0].delta.provider_specific_fields["web_search_calls"]) == ["srvtoolu_A"]
assert first_result.choices[0].delta.provider_specific_fields["web_search_calls"]["srvtoolu_A"].status == "completed"
calls = second_start.choices[0].delta.provider_specific_fields["web_search_calls"]
assert list(calls) == ["srvtoolu_A", "srvtoolu_B"]
assert calls["srvtoolu_A"].status == "completed"
assert calls["srvtoolu_B"].status == "in_progress"
def test_web_search_tool_result_captured_in_provider_specific_fields():
"""
Test that web_search_tool_result content is captured in provider_specific_fields.

View file

@ -8,6 +8,7 @@ from litellm.responses.litellm_completion_transformation.transformation import (
TOOL_CALLS_CACHE,
LiteLLMCompletionResponsesConfig,
)
from litellm.types.responses.main import build_web_search_call
from litellm.types.utils import (
ChatCompletionMessageToolCall,
Choices,
@ -3513,6 +3514,8 @@ class TestEnsureOutputItemContentPartAdded:
iterator._custom_tool_names = set()
iterator.responses_api_request = {}
iterator._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(None)
iterator._web_search_calls = {}
iterator._queued_web_search_call_ids = set()
return iterator
def _make_text_chunk(self):
@ -4058,6 +4061,105 @@ class TestBridgedOutputItemIdPrefixes:
chat_completion_response=chat_completion_response,
)
@pytest.mark.parametrize(
"tool_type,result_kind,expected_sources",
[
(
"web_search",
"valid",
{"srvtoolu_01Search": ["https://example.com/one"], "srvtoolu_02Search": ["https://example.com/two"]},
),
(
"web_search_preview",
"valid",
{"srvtoolu_01Search": ["https://example.com/one"], "srvtoolu_02Search": ["https://example.com/two"]},
),
("function", "valid", {}),
("web_search", "unpaired", {"srvtoolu_01Search": ["https://example.com/one"]}),
("web_search", "web_fetch", {"srvtoolu_02Search": ["https://example.com/two"]}),
("web_search", "error", {"srvtoolu_01Search": [], "srvtoolu_02Search": ["https://example.com/two"]}),
],
)
def test_anthropic_web_search_output_mapping(self, tool_type, result_kind, expected_sources):
call_ids: Final = ("srvtoolu_01Search", "srvtoolu_02Search")
valid_results: Final = (
{
"type": "web_search_tool_result",
"tool_use_id": call_ids[0],
"content": [{"type": "web_search_result", "url": "https://example.com/one"}],
},
{
"type": "web_search_tool_result",
"tool_use_id": call_ids[1],
"content": [{"type": "web_search_result", "url": "https://example.com/two"}],
},
)
first_result: Final = (
{**valid_results[0], "type": "web_fetch_tool_result"}
if result_kind == "web_fetch"
else {**valid_results[0], "content": {"type": "web_search_tool_result_error", "error_code": "unavailable"}}
if result_kind == "error"
else valid_results[0]
)
results: Final = (first_result,) if result_kind == "unpaired" else (first_result, valid_results[1])
message: Final = Message(
role="assistant",
content="answer",
tool_calls=[
ChatCompletionMessageToolCall(
id=call_id,
type="function",
function=Function(name="web_search", arguments=json.dumps({"query": query})),
)
for call_id, query in zip(call_ids, ("one", "two"), strict=True)
]
+ [
ChatCompletionMessageToolCall(
id="toolu_regular",
type="function",
function=Function(name="get_weather", arguments='{"city":"Paris"}'),
)
],
provider_specific_fields={
"web_search_results": results,
"web_search_calls": [
build_web_search_call(
tool_id=result["tool_use_id"],
tool_input={"query": "one" if result["tool_use_id"].endswith("01Search") else "two"},
result=result,
)
for result in results
if tool_type != "function" and result["type"] == "web_search_tool_result"
],
},
)
request_tools: Final = (
[{"type": "function", "name": "web_search", "parameters": {"type": "object"}}]
if tool_type == "function"
else [{"type": tool_type}]
)
response: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="search",
responses_api_request={"tools": request_tools},
chat_completion_response=_bridged_chat_completion_response(
choices=[Choices(index=0, finish_reason="stop", message=message)]
),
)
search_items: Final = {
item.id.removeprefix("ws_"): item for item in response.output if item.type == "web_search_call"
}
function_ids: Final = {item.call_id for item in response.output if item.type == "function_call"}
assert set(search_items) == set(expected_sources)
assert function_ids == set(call_ids).difference(expected_sources) | {"toolu_regular"}
assert [item.content[0].text for item in response.output if item.type == "message"] == ["answer"]
for call_id, item in search_items.items():
assert item.status == ("failed" if result_kind == "error" and call_id.endswith("01Search") else "completed")
assert item.action.type == "search"
assert item.action.query == ("one" if call_id.endswith("01Search") else "two")
assert item.action.queries == [item.action.query]
assert [source.url for source in item.action.sources] == expected_sources[call_id]
def test_message_item_id_uses_msg_prefix(self):
response = self._transform(_bridged_chat_completion_response())

View file

@ -21,6 +21,7 @@ from litellm.responses.litellm_completion_transformation.streaming_iterator impo
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.openai import ResponsesAPIStreamEvents
from litellm.types.responses.main import build_web_search_call
from litellm.types.utils import (
Delta,
ModelResponse,
@ -140,6 +141,214 @@ def test_tool_call_delta_is_emitted_as_responses_events():
assert len(evt2.delta) <= 10 # Chunks are max 10 characters
@pytest.mark.asyncio
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.parametrize(
"tool_type,result_kind,expected_sources",
[
(
"web_search",
"valid",
{"srvtoolu_01Search": ["https://example.com/one"], "srvtoolu_02Search": ["https://example.com/two"]},
),
(
"web_search_preview",
"valid",
{"srvtoolu_01Search": ["https://example.com/one"], "srvtoolu_02Search": ["https://example.com/two"]},
),
("function", "valid", {}),
("web_search", "unpaired", {"srvtoolu_01Search": ["https://example.com/one"]}),
("web_search", "web_fetch", {"srvtoolu_02Search": ["https://example.com/two"]}),
("web_search", "error", {"srvtoolu_01Search": [], "srvtoolu_02Search": ["https://example.com/two"]}),
],
)
async def test_web_search_stream_preserves_hosted_and_client_calls(sync_mode, tool_type, result_kind, expected_sources):
call_ids: Final = ("srvtoolu_01Search", "srvtoolu_02Search")
valid_results: Final = (
{
"type": "web_search_tool_result",
"tool_use_id": call_ids[0],
"content": [{"type": "web_search_result", "url": "https://example.com/one"}],
},
{
"type": "web_search_tool_result",
"tool_use_id": call_ids[1],
"content": [{"type": "web_search_result", "url": "https://example.com/two"}],
},
)
first_result: Final = (
{**valid_results[0], "type": "web_fetch_tool_result"}
if result_kind == "web_fetch"
else {**valid_results[0], "content": {"type": "web_search_tool_result_error", "error_code": "unavailable"}}
if result_kind == "error"
else valid_results[0]
)
results: Final = [first_result] if result_kind == "unpaired" else [first_result, valid_results[1]]
deltas: Final = (
Delta(
role="assistant",
content=None,
tool_calls=[
{"index": 0, "id": call_ids[0], "type": "function", "function": {"name": "web_search", "arguments": ""}}
],
provider_specific_fields={
"web_search_calls": [
build_web_search_call(
call_ids[0],
{},
{"content": []},
status="in_progress",
)
]
if tool_type != "function" and result_kind != "web_fetch"
else [],
},
),
Delta(
content=None,
tool_calls=[
{
"index": 1,
"id": "toolu_regular",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city":"Paris"}'},
}
],
),
Delta(content=None, tool_calls=[{"index": 0, "function": {"arguments": '{"query":'}}]),
Delta(content=None, tool_calls=[{"index": 0, "function": {"arguments": '"one"}'}}]),
Delta(
content=None,
provider_specific_fields={
"web_search_results": [first_result],
"web_search_calls": [
build_web_search_call(call_ids[0], {"query": "one"}, first_result)
]
if tool_type != "function" and first_result["type"] == "web_search_tool_result"
else [],
},
),
Delta(
content=None,
provider_specific_fields={
"web_search_results": results,
"web_search_calls": [
build_web_search_call(
result["tool_use_id"],
{"query": "one" if result["tool_use_id"].endswith("01Search") else "two"},
result,
)
for result in results
if tool_type != "function" and result["type"] == "web_search_tool_result"
],
},
),
Delta(
content="answer",
tool_calls=[
{
"index": 2,
"id": call_ids[1],
"type": "function",
"function": {"name": "web_search", "arguments": '{"query":"two"}'},
}
],
),
)
chunks: Final = tuple(
ModelResponseStream(
id=CHAT_COMPLETION_ID,
created=1748575031,
model="claude-fable-5-1",
object="chat.completion.chunk",
choices=[
StreamingChoices(index=0, delta=delta, finish_reason="stop" if index == len(deltas) - 1 else None)
],
)
for index, delta in enumerate(deltas)
)
request_tools: Final = (
[{"type": "function", "name": "web_search", "parameters": {"type": "object"}}]
if tool_type == "function"
else [{"type": tool_type}]
)
iterator: Final = LiteLLMCompletionStreamingIterator(
model="claude-fable-5-1",
litellm_custom_stream_wrapper=_FakeStreamWrapper(chunks),
request_input="search",
responses_api_request={"tools": request_tools},
custom_llm_provider="anthropic",
)
events: Final = (
[event.model_dump(exclude_none=True) for event in iterator]
if sync_mode
else [event.model_dump(exclude_none=True) async for event in iterator]
)
completed: Final = events[-1]
search_items: Final = {
item["id"].removeprefix("ws_"): item
for item in completed["response"]["output"]
if item["type"] == "web_search_call"
}
function_items: Final = {
item["call_id"]: item for item in completed["response"]["output"] if item["type"] == "function_call"
}
function_events: Final = [event for event in events if "function_call_arguments" in event["type"]]
expected_functions: Final = set(call_ids).difference(expected_sources) | {"toolu_regular"}
search_indexes: Final = {
event["output_index"] for event in events if event["type"] == "response.web_search_call.completed"
}
completed_indexes: Final = {item["id"]: index for index, item in enumerate(completed["response"]["output"])}
assert completed["type"] == "response.completed"
assert [item["content"][0]["text"] for item in completed["response"]["output"] if item["type"] == "message"] == [
"answer"
]
assert set(search_items) == set(expected_sources)
assert set(function_items) == expected_functions
assert {event["item_id"] for event in function_events} == {item["id"] for item in function_items.values()}
assert len(search_indexes) == len(expected_sources)
for call_id, item in search_items.items():
search_events = [
event for event in events if event.get("item_id", event.get("item", {}).get("id")) == item["id"]
]
assert [event["type"] for event in search_events] == [
"response.output_item.added",
"response.web_search_call.in_progress",
"response.web_search_call.searching",
"response.web_search_call.completed",
"response.output_item.done",
]
assert {event["output_index"] for event in search_events} == {completed_indexes[item["id"]]}
assert search_events[0]["item"]["status"] == "in_progress"
assert search_events[-1]["item"] == item
assert item["status"] == (
"failed" if result_kind == "error" and call_id.endswith("01Search") else "completed"
)
assert item["action"]["type"] == "search"
assert item["action"]["query"] == ("one" if call_id.endswith("01Search") else "two")
assert item["action"]["queries"] == [item["action"]["query"]]
assert [source["url"] for source in item["action"]["sources"]] == expected_sources[call_id]
for call_id, item in function_items.items():
argument_deltas = [
event["delta"]
for event in function_events
if event["item_id"] == item["id"] and event["type"].endswith(".delta")
]
assert json.loads("".join(argument_deltas)) == json.loads(item["arguments"])
assert json.loads(item["arguments"]) == (
{"city": "Paris"}
if call_id == "toolu_regular"
else {"query": "one" if call_id.endswith("01Search") else "two"}
)
assert any(
event["type"] == "response.output_item.done"
and event.get("item") == item
and event["output_index"] == completed_indexes[item["id"]]
for event in events
)
def test_tool_calls_present_only_in_final_response_are_emitted_before_completed():
iterator = LiteLLMCompletionStreamingIterator(
model="test-model",

View file

@ -374,6 +374,8 @@ class TestTransformationCustomTools:
"srvtoolu_01ServerCall",
"toolu_01CustomCall",
]
assert result[1].type == "function_call"
assert result[1].name == "web_search"
def test_transform_mixed_tool_calls(self):
"""Test transformation with both custom and regular tool calls."""

View file

@ -35064,7 +35064,7 @@ export interface components {
classification_prompt?: string | null;
/**
* Classifier Context Budget Chars
* @description Maximum characters of prior-turn text quoted to the LLM classifier, across the whole context window, per classification call. Turns are taken newest first and quoted whole while they fit, so a conversation small enough to quote entirely is never cut; once the budget runs out the older turns are dropped whole and only the turn straddling the boundary is truncated, into whatever space is left. The current ask and the caller's system prompt sit outside this budget and are always sent in full, as does the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and suppresses the block; set classifier_context_window_size to 0 to turn context off deliberately. Only applies when classifier_type is 'llm'.
* @description Maximum characters of prior-turn text quoted to the LLM classifier, across the whole context window, per classification call. Turns are taken newest first and quoted whole while they fit, so a conversation small enough to quote entirely is never cut; once the budget runs out the older turns are dropped whole and only the turn straddling the boundary is truncated, into whatever space is left. The current ask and, except for Claude Code requests, the extracted system-role text sit outside this budget and are sent in full, as does the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and suppresses the block; set classifier_context_window_size to 0 to turn context off deliberately. Only applies when classifier_type is 'llm'.
* @default 8000
*/
classifier_context_budget_chars: number;
@ -35081,7 +35081,7 @@ export interface components {
classifier_context_per_turn_chars?: number | null;
/**
* Classifier Context Window Size
* @description Number of prior user turns (tool output and harness reminders excluded) to include as context in the LLM classifier prompt, so a follow-up like 'now do the same for the streaming path' is classified against what it refers to. Counts turns of both roles when classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier model, which may be a different deployment or provider than the routed completion model; that call already carries the current user ask and the caller's system prompt in full. Set to 0 to send neither prior turns nor any conversation context beyond the current ask. Only applies when classifier_type is 'llm'.
* @description Number of prior user turns (tool output and harness reminders excluded) to include as context in the LLM classifier prompt, so a follow-up like 'now do the same for the streaming path' is classified against what it refers to. Counts turns of both roles when classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier model, which may be a different deployment or provider than the routed completion model; that call carries the current user ask and, except for Claude Code requests, the extracted system-role text in full. Claude Code system text is omitted to avoid classifying harness instructions; the routed completion still receives it. Set to 0 to send neither prior turns nor any conversation context beyond the current ask. Only applies when classifier_type is 'llm'.
* @default 3
*/
classifier_context_window_size: number;