From df6126edaa046acf018cf1b25a6b60835eba6d31 Mon Sep 17 00:00:00 2001 From: Ted Swinyar <8061019+tedswinyar@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:35:51 -0700 Subject: [PATCH] fix(ollama_chat): give each streamed tool call its own index Ollama streams parallel tool calls one per chunk with no top-level index, and the delta numbers the calls of each chunk from zero, so every call left the proxy as index 0 and OpenAI-style clients appended the arguments of each later call onto the first one. Number the calls with a counter that lives for the whole stream instead. Fixes #33678 --- litellm/llms/ollama/chat/transformation.py | 3 + .../ollama/test_ollama_chat_transformation.py | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index f9e7071db41..37d2345e472 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -429,6 +429,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): @@ -476,6 +477,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 df9f5fd9d14..4cfd4556705 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -948,6 +948,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: