From 0664ec51d83a00cf74c75f7988ccecfccbd76d1c Mon Sep 17 00:00:00 2001 From: Chesars Date: Mon, 16 Feb 2026 18:00:42 -0300 Subject: [PATCH] fix(responses): use output_index for parallel tool call streaming indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #21331 — the Responses API streaming bridge hardcoded index=0 for all tool call chunks, making parallel tool calls indistinguishable. Now reads output_index from the Responses API chunk instead. --- .../transformation.py | 9 +- ...responses_transformation_transformation.py | 91 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 753a94295b..b307b2e90d 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -960,9 +960,10 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): provider_specific_fields ) + tool_call_index = parsed_chunk.get("output_index", 0) tool_call_chunk = ChatCompletionToolCallChunk( id=output_item.get("call_id"), - index=0, + index=tool_call_index, type="function", function=function_chunk, ) @@ -983,6 +984,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): elif event_type == "response.function_call_arguments.delta": content_part: Optional[str] = parsed_chunk.get("delta", None) if content_part: + tool_call_index = parsed_chunk.get("output_index", 0) return ModelResponseStream( choices=[ StreamingChoices( @@ -991,7 +993,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): tool_calls=[ ChatCompletionToolCallChunk( id=None, - index=0, + index=tool_call_index, type="function", function=ChatCompletionToolCallFunctionChunk( name=None, arguments=content_part @@ -1033,9 +1035,10 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): provider_specific_fields ) + tool_call_index = parsed_chunk.get("output_index", 0) tool_call_chunk = ChatCompletionToolCallChunk( id=output_item.get("call_id"), - index=0, + index=tool_call_index, type="function", function=function_chunk, ) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index f8a082ee30..243c1f79ca 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1278,3 +1278,94 @@ def test_transform_response_preserves_annotations(): assert result.usage.total_tokens == 30 print("✓ Annotations from Responses API are correctly preserved in Chat Completions format") + + +# ============================================================================= +# Tests for issue #21331: Parallel tool call indices in streaming +# ============================================================================= + + +def test_streaming_parallel_tool_calls_have_distinct_indices(): + """ + Test that parallel tool calls get distinct indices matching output_index + from the Responses API streaming chunks. + + Regression test for issue #21331 where all tool calls were emitted with + index=0, making it impossible to distinguish parallel calls. + """ + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + # Simulate two parallel tool calls with output_index 0 and 1 + chunks = [ + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "id": "fc_001", + "call_id": "call_abc", + "name": "get_weather", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_001", + "delta": '{"city": "SF"}', + }, + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "id": "fc_001", + "call_id": "call_abc", + "name": "get_weather", + "arguments": '{"city": "SF"}', + }, + }, + { + "type": "response.output_item.added", + "output_index": 1, + "item": { + "type": "function_call", + "id": "fc_002", + "call_id": "call_def", + "name": "get_weather", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "output_index": 1, + "item_id": "fc_002", + "delta": '{"city": "NY"}', + }, + { + "type": "response.output_item.done", + "output_index": 1, + "item": { + "type": "function_call", + "id": "fc_002", + "call_id": "call_def", + "name": "get_weather", + "arguments": '{"city": "NY"}', + }, + }, + ] + + for chunk in chunks: + result = OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream( + chunk + ) + expected_index = chunk["output_index"] + for choice in result.choices: + if choice.delta.tool_calls: + for tc in choice.delta.tool_calls: + assert tc.index == expected_index, ( + f"Event {chunk['type']}: expected tool_call.index={expected_index}, " + f"got {tc.index}" + )