diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index c6f3a7a8280..9a0198df5c7 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -442,6 +442,7 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): started_reasoning_content: bool = False finished_reasoning_content: bool = False seen_tool_calls: bool = False + _next_tool_call_index: int = 0 def _is_function_call_complete(self, function_args: str | dict) -> bool: if isinstance(function_args, dict): @@ -489,6 +490,8 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): if tool_calls is not None: self.seen_tool_calls = True for tool_call in tool_calls: + tool_call["index"] = self._next_tool_call_index + self._next_tool_call_index += 1 function_args = tool_call.get("function").get("arguments") if function_args is not None and len(function_args) > 0: is_function_call_complete = self._is_function_call_complete(function_args) diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 98ca5aee407..8b55cf22868 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -1044,6 +1044,72 @@ class TestOllamaToolCallTransformation: assert tool_msg["tool_call_id"] == "call_abc123" +class TestOllamaStreamingParallelToolCallIndex: + """Regression for https://github.com/BerriAI/litellm/issues/33678. + + Ollama streams each parallel tool call in its own chunk without an OpenAI-style top-level + index. Every delta used to carry index 0, so index-accumulating clients appended the + arguments of every later call onto the first one. + """ + + @staticmethod + def _chunk(*tool_calls: dict) -> dict: + return { + "model": "qwen3:8b", + "message": {"role": "assistant", "content": "", "tool_calls": list(tool_calls)}, + "done": False, + } + + @staticmethod + def _parsed_tool_calls(iterator: OllamaChatCompletionResponseIterator, *chunks: dict) -> list: + return [ + tool_call + for chunk in chunks + for tool_call in (iterator.chunk_parser(chunk).choices[0].delta.tool_calls or []) + ] + + def test_tool_calls_in_separate_chunks_get_distinct_sequential_indices(self): + iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunks = [ + self._chunk({"function": {"name": "bash", "arguments": {"command": command}}}) + for command in ("date", "uname -a", "whoami") + ] + + tool_calls = self._parsed_tool_calls(iterator, *chunks) + + assert [tool_call.index for tool_call in tool_calls] == [0, 1, 2] + assert [json.loads(tool_call.function.arguments) for tool_call in tool_calls] == [ + {"command": "date"}, + {"command": "uname -a"}, + {"command": "whoami"}, + ] + + def test_tool_calls_in_one_chunk_get_distinct_indices(self): + iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunk = self._chunk( + {"function": {"name": "bash", "arguments": {"command": "date"}}}, + {"function": {"name": "bash", "arguments": {"command": "whoami"}}}, + ) + + tool_calls = self._parsed_tool_calls(iterator, chunk) + + assert [tool_call.index for tool_call in tool_calls] == [0, 1] + + def test_index_counter_continues_across_chunks(self): + iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunks = [ + self._chunk( + {"function": {"name": "bash", "arguments": {"command": "date"}}}, + {"function": {"name": "bash", "arguments": {"command": "uname -a"}}}, + ), + self._chunk({"function": {"name": "bash", "arguments": {"command": "whoami"}}}), + ] + + tool_calls = self._parsed_tool_calls(iterator, *chunks) + + assert [tool_call.index for tool_call in tool_calls] == [0, 1, 2] + + class TestOllamaStreamingUsage: @staticmethod def _parse(chunk: dict) -> ModelResponseStream: