From b66f3f215af100d897f2f6f766dd0bf21f0ba537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=88=E5=9F=BA=E7=B1=B3?= <140241684+BlueX888@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:27:04 +0800 Subject: [PATCH] fix(azure): key streamed tool calls by wire index (#7487) Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com> --- .../crewai/llms/providers/azure/completion.py | 26 +++--- .../azure/test_azure_streaming_tool_calls.py | 81 +++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 lib/crewai/tests/llms/azure/test_azure_streaming_tool_calls.py diff --git a/lib/crewai/src/crewai/llms/providers/azure/completion.py b/lib/crewai/src/crewai/llms/providers/azure/completion.py index eecc1a059..0f45bfcef 100644 --- a/lib/crewai/src/crewai/llms/providers/azure/completion.py +++ b/lib/crewai/src/crewai/llms/providers/azure/completion.py @@ -998,19 +998,25 @@ class AzureCompletion(BaseLLM): if choice.delta and choice.delta.tool_calls: for idx, tool_call in enumerate(choice.delta.tool_calls): - if idx not in tool_calls: - tool_calls[idx] = { + tool_index = tool_call.get("index") + if tool_index is None: + tool_index = idx + + if tool_index not in tool_calls: + tool_calls[tool_index] = { "id": tool_call.id, "name": "", "arguments": "", } - elif tool_call.id and not tool_calls[idx]["id"]: - tool_calls[idx]["id"] = tool_call.id + elif tool_call.id and not tool_calls[tool_index]["id"]: + tool_calls[tool_index]["id"] = tool_call.id if tool_call.function and tool_call.function.name: - tool_calls[idx]["name"] = tool_call.function.name + tool_calls[tool_index]["name"] = tool_call.function.name if tool_call.function and tool_call.function.arguments: - tool_calls[idx]["arguments"] += tool_call.function.arguments + tool_calls[tool_index]["arguments"] += ( + tool_call.function.arguments + ) self._emit_stream_chunk_event( chunk=tool_call.function.arguments @@ -1019,13 +1025,13 @@ class AzureCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, tool_call={ - "id": tool_calls[idx]["id"], + "id": tool_calls[tool_index]["id"], "function": { - "name": tool_calls[idx]["name"], - "arguments": tool_calls[idx]["arguments"], + "name": tool_calls[tool_index]["name"], + "arguments": tool_calls[tool_index]["arguments"], }, "type": "function", - "index": idx, + "index": tool_index, }, call_type=LLMCallType.TOOL_CALL, response_id=response_id, diff --git a/lib/crewai/tests/llms/azure/test_azure_streaming_tool_calls.py b/lib/crewai/tests/llms/azure/test_azure_streaming_tool_calls.py new file mode 100644 index 000000000..3eca0f6b9 --- /dev/null +++ b/lib/crewai/tests/llms/azure/test_azure_streaming_tool_calls.py @@ -0,0 +1,81 @@ +"""Regression tests for parallel tool calls in Azure streaming completions. + +Azure AI Inference is OpenAI-compatible: each streamed chunk carries a +``tool_call`` whose own ``index`` field identifies the call the delta belongs +to. The streaming accumulator must key on that wire index, not on the position +of the tool_call inside the chunk, otherwise parallel tool calls collapse into +a single corrupted call. +""" + +import json +from unittest.mock import patch + +from azure.ai.inference.models import StreamingChatCompletionsUpdate + +from crewai.llms.providers.azure.completion import AzureCompletion + + +def _tool_call_chunk(index: int, call_id: str, name: str, arguments: str): + """Build one streaming update carrying a single tool-call delta.""" + return StreamingChatCompletionsUpdate( + id="chatcmpl-1", + model="gpt-4o", + created=None, + choices=[ + { + "index": 0, + "delta": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "index": index, + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + ], + }, + "finish_reason": None, + } + ], + ) + + +def test_azure_streaming_parallel_tool_calls_are_keyed_by_wire_index(): + """Both parallel tool calls must reach the executor intact. + + Each chunk carries exactly one tool_call tagged with its own wire ``index`` + (0 and 1). Aggregating by position inside the chunk puts every delta in + slot 0, so the executor receives a single call holding the first call's id, + the last call's name, and the two argument fragments concatenated into + invalid JSON. + """ + chunks = [ + _tool_call_chunk(0, "call_aaa", "get_weather", '{"city":'), + _tool_call_chunk(0, "call_aaa", "get_weather", '"Paris"}'), + _tool_call_chunk(1, "call_bbb", "get_time", '{"tz":'), + _tool_call_chunk(1, "call_bbb", "get_time", '"UTC"}'), + ] + + llm = AzureCompletion( + model="gpt-4o", + api_key="test-key", + endpoint="https://test.openai.azure.com", + stream=True, + ) + + with patch.object(llm._client, "complete") as mock_complete: + mock_complete.return_value = iter(chunks) + + result = llm.call([{"role": "user", "content": "Weather and time in Paris?"}]) + + assert isinstance(result, list) + assert len(result) == 2 + assert [call["id"] for call in result] == ["call_aaa", "call_bbb"] + + weather, clock = result + assert weather["function"]["name"] == "get_weather" + assert json.loads(weather["function"]["arguments"]) == {"city": "Paris"} + assert clock["function"]["name"] == "get_time" + assert json.loads(clock["function"]["arguments"]) == {"tz": "UTC"}