diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index e5afb4b87b6..50816b001f9 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -425,6 +425,7 @@ class OllamaChatConfig(BaseConfig): class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): started_reasoning_content: bool = False finished_reasoning_content: bool = False + saw_tool_calls: bool = False def _is_function_call_complete(self, function_args: str | dict) -> bool: if isinstance(function_args, dict): @@ -470,6 +471,7 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): # process tool calls - if complete function arg - add id to tool call tool_calls = chunk["message"].get("tool_calls") if tool_calls is not None: + self.saw_tool_calls = True for tool_call in tool_calls: function_args = tool_call.get("function").get("arguments") if function_args is not None and len(function_args) > 0: @@ -510,9 +512,13 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): if chunk["done"] is True: finish_reason = chunk.get("done_reason") or "stop" - # Override finish_reason when tool_calls are present + # Override finish_reason when tool_calls are present anywhere in + # the stream. Ollama often sends them in a chunk with done false + # and then a separate done chunk with an empty message, so keying + # off this chunk's tool_calls alone leaves the stream ending in + # "stop". # Fixes: https://github.com/BerriAI/litellm/issues/18922 - if tool_calls is not None: + if self.saw_tool_calls: finish_reason = "tool_calls" choices = [ StreamingChoices( diff --git a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py index 906c51d8064..db3605d973f 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py @@ -597,6 +597,52 @@ class TestOllamaFinishReasonLength: result.choices[0].finish_reason == "length" ), f"Expected 'length' when done_reason='length', got '{result.choices[0].finish_reason}'" + def test_finish_reason_tool_calls_when_split_across_chunks(self): + """Tool calls in an earlier chunk must still end the stream with 'tool_calls'. + + Ollama commonly emits tool_calls in a chunk with done false and then a + separate done chunk whose message is empty and whose done_reason is + 'stop'. Keying the override off the done chunk's own tool_calls left the + stream finishing as 'stop', so spec-strict OpenAI clients dropped the + accumulated tool_calls delta and never ran the tool. + """ + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + tool_call_chunk = { + "model": "glm-4.7-flash", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "list_files", + "arguments": {"path": "."}, + } + } + ], + }, + "done": False, + } + done_chunk = { + "model": "glm-4.7-flash", + "message": {"role": "assistant", "content": ""}, + "done": True, + "done_reason": "stop", + } + + tool_call_result = iterator.chunk_parser(tool_call_chunk) + assert tool_call_result.choices[0].delta.tool_calls is not None + assert tool_call_result.choices[0].finish_reason is None + + done_result = iterator.chunk_parser(done_chunk) + assert ( + done_result.choices[0].finish_reason == "tool_calls" + ), f"Expected 'tool_calls' after a tool call earlier in the stream, got '{done_result.choices[0].finish_reason}'" + def test_finish_reason_stop_streaming(self): """Streaming: done_reason='stop' in final chunk must produce finish_reason='stop'.""" iterator = OllamaChatCompletionResponseIterator(