diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 3d06b975342..e6402e8c1bd 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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), diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index b2edf2bf9ed..db1c3acbefb 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -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( diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index fa373759cdd..b2b8eb5da80 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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 diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index 59bd80791e3..4a03913f55a 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -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