fix(responses): correlate streamed tool call events on normalized item ids

This commit is contained in:
mateo-berri 2026-09-01 12:08:22 -07:00
parent ab2c9aed0f
commit 98ea5eaab4
4 changed files with 75 additions and 15 deletions

View file

@ -1696,9 +1696,9 @@ def convert_function_to_anthropic_tool_invoke(
def _find_server_tool_result(
tool_id: str,
web_search_results: Sequence[Any] | None,
tool_results: Sequence[Any] | None,
) -> dict[str, Any] | None:
web_search_results: Sequence[object] | None,
tool_results: Sequence[object] | None,
) -> dict[str, object] | None:
candidates: Final = (*(web_search_results or ()), *(tool_results or ()))
return next(
(result for result in candidates if isinstance(result, dict) and result.get("tool_use_id") == tool_id),

View file

@ -114,6 +114,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._pending_tool_events: list[BaseLiteLLMOpenAIResponseObject] = []
self._tool_output_index_by_call_id: dict[str, int] = {}
self._tool_args_by_call_id: dict[str, str] = {}
self._tool_item_id_by_call_id: dict[str, str] = {} # mutable-ok: filled per call id as tool call events stream
self._tool_call_id_by_index: dict[int, str] = {}
self._ambiguous_tool_call_indexes: set[int] = set()
self._next_tool_output_index: int = 1 # output_index=0 reserved for the message item
@ -227,6 +228,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
names = self._custom_tool_names
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
self._tool_item_id_by_call_id[call_id] = item_kwargs["id"]
if tool_namespace:
item_kwargs["namespace"] = tool_namespace
event = OutputItemAddedEvent(
@ -248,7 +250,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
delta_event: BaseLiteLLMOpenAIResponseObject = FunctionCallArgumentsDeltaEvent(
type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA,
item_id=call_id,
item_id=self._tool_item_id_by_call_id.get(call_id, call_id),
output_index=output_index,
delta=delta_chunk,
)
@ -300,6 +302,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
names = self._custom_tool_names
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
self._tool_item_id_by_call_id[call_id] = item_kwargs["id"]
if tool_namespace:
item_kwargs["namespace"] = tool_namespace
event = OutputItemAddedEvent(
@ -325,7 +328,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
delta_event = FunctionCallArgumentsDeltaEvent(
type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA,
item_id=call_id,
item_id=self._tool_item_id_by_call_id.get(call_id, call_id),
output_index=output_index,
delta=delta_chunk,
)
@ -335,7 +338,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
done_event = FunctionCallArgumentsDoneEvent(
type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE,
item_id=call_id,
item_id=self._tool_item_id_by_call_id.get(call_id, call_id),
output_index=output_index,
arguments=final_args,
)
@ -345,6 +348,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._sequence_number += 1
names = self._custom_tool_names
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, final_args, "completed", names)
item_kwargs["id"] = self._tool_item_id_by_call_id.setdefault(call_id, item_kwargs["id"])
if tool_namespace:
item_kwargs["namespace"] = tool_namespace
item_done_event = OutputItemDoneEvent(

View file

@ -3409,6 +3409,7 @@ class TestEnsureOutputItemContentPartAdded:
iterator._pending_tool_events = []
iterator._tool_output_index_by_call_id = {}
iterator._tool_args_by_call_id = {}
iterator._tool_item_id_by_call_id = {}
iterator._tool_call_id_by_index = {}
iterator._ambiguous_tool_call_indexes = set()
iterator._next_tool_output_index = 1

View file

@ -132,7 +132,7 @@ def test_tool_call_delta_is_emitted_as_responses_events():
evt2 = iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk)
assert evt2 is not None
assert evt2.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA
assert evt2.item_id == "call_1"
assert evt2.item_id == "fc_call_1"
assert evt2.output_index == 1
# The delta will be a chunk of the arguments, not the full arguments
assert len(evt2.delta) <= 10 # Chunks are max 10 characters
@ -197,7 +197,7 @@ def test_tool_calls_present_only_in_final_response_are_emitted_before_completed(
# The last event should be FUNCTION_CALL_ARGUMENTS_DONE
assert evt.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE
assert evt.item_id == "call_2"
assert evt.item_id == "fc_call_2"
assert evt.output_index == 1
assert evt.arguments == '{"y":2}'
@ -291,7 +291,7 @@ def test_tool_call_arguments_are_chunked_to_match_openai_behavior():
# Verify each delta is at most 10 characters
for evt in delta_events:
assert len(evt.delta) <= 10
assert evt.item_id == "call_test"
assert evt.item_id == "fc_call_test"
assert evt.output_index == 1
assert hasattr(evt, "__dict__") and "sequence_number" in evt.__dict__
@ -405,8 +405,8 @@ def test_parallel_tool_calls_without_ids_use_index_mapping():
arguments_by_call_id.setdefault(evt.item_id, "")
arguments_by_call_id[evt.item_id] += evt.delta
assert arguments_by_call_id["call_a"] == '{"x":1}'
assert arguments_by_call_id["call_b"] == '{"y":2}'
assert arguments_by_call_id["fc_call_a"] == '{"x":1}'
assert arguments_by_call_id["fc_call_b"] == '{"y":2}'
def test_reused_index_with_new_call_id_marks_fallback_ambiguous():
@ -462,10 +462,10 @@ def test_reused_index_with_new_call_id_marks_fallback_ambiguous():
arguments_by_call_id.setdefault(evt.item_id, "")
arguments_by_call_id[evt.item_id] += evt.delta
assert arguments_by_call_id["call_a"] == '{"a":'
assert arguments_by_call_id["call_b"] == '{"b":'
assert arguments_by_call_id["call_a"] != '{"a":1}'
assert arguments_by_call_id["call_b"] != '{"b":1}'
assert arguments_by_call_id["fc_call_a"] == '{"a":'
assert arguments_by_call_id["fc_call_b"] == '{"b":'
assert arguments_by_call_id["fc_call_a"] != '{"a":1}'
assert arguments_by_call_id["fc_call_b"] != '{"b":1}'
@pytest.mark.asyncio
@ -558,3 +558,58 @@ def test_object_tool_call_arguments_stream_as_valid_json():
)
assert json.loads(streamed_arguments) == {"command": "ls", "flags": ["-l"]}
def test_streamed_anthropic_tool_call_events_correlate_on_normalized_item_id():
iterator = LiteLLMCompletionStreamingIterator(
model="test-model",
litellm_custom_stream_wrapper=AsyncMock(),
request_input="Test input",
responses_api_request={},
)
response = ModelResponse(
id="resp-anthropic",
created=123,
model="test-model",
object="chat.completion",
choices=[
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "toolu_01AbCdEf",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city":"Paris"}'},
"index": 0,
}
],
},
}
],
)
iterator.litellm_model_response = response
events = []
while True:
evt = iterator.common_done_event_logic(sync_mode=True)
events.append(evt)
if evt.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
break
added = [e for e in events if e.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED]
deltas = [e for e in events if e.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA]
dones = [e for e in events if e.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE]
item_dones = [e for e in events if e.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE]
assert len(added) == 1 and len(dones) == 1 and len(item_dones) == 1 and deltas
assert added[0].item.id == "fc_toolu_01AbCdEf"
assert added[0].item.call_id == "toolu_01AbCdEf"
assert item_dones[0].item.id == "fc_toolu_01AbCdEf"
assert item_dones[0].item.call_id == "toolu_01AbCdEf"
for evt in deltas + dones:
assert evt.item_id == added[0].item.id