mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fix(responses): correlate streamed tool call events on normalized item ids
This commit is contained in:
parent
ab2c9aed0f
commit
98ea5eaab4
4 changed files with 75 additions and 15 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue