diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 181894646e3..b68198b62fd 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -468,7 +468,7 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): # process tool calls - if complete function arg - add id to tool call tool_calls: Final = chunk["message"].get("tool_calls") - if tool_calls is not None: + if tool_calls: self.seen_tool_calls = True for tool_call in tool_calls: function_args = tool_call.get("function").get("arguments") 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 25f9645faa0..a858b58ae95 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py @@ -1,7 +1,7 @@ import inspect import os import sys -from typing import cast +from typing import Final, cast import pytest from pydantic import BaseModel @@ -478,6 +478,105 @@ class TestOllamaToolCalling: assert result.choices[0].finish_reason == "stop" assert result.choices[0].message.tool_calls is None + def test_tool_calls_streaming(self): + """Streaming: native Ollama tool_calls must surface with string arguments, and the + terminal chunk must report finish_reason='tool_calls'. + + Ollama /api/chat streams the tool call in a non-final chunk (done=False) and then + sends a SEPARATE terminal chunk (done=True, done_reason='stop') that carries no + tool_calls. finish_reason must still be upgraded to 'tool_calls' on that terminal + chunk so clients execute the tool. Regression guard for + https://github.com/BerriAI/litellm/issues/24091 on the streaming path; the + non-streaming path is covered by test_finish_reason_tool_calls_non_streaming. + """ + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + tool_call_chunk = { + "model": "qwen3:4b", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_weather", + "arguments": {"location": "Beijing", "date": "tomorrow"}, + } + } + ], + }, + "done": False, + } + + tool_result = iterator.chunk_parser(tool_call_chunk) + + assert tool_result.choices[0].finish_reason is None + + tool_calls = tool_result.choices[0].delta.tool_calls + assert tool_calls is not None + assert len(tool_calls) == 1 + + tool_call = tool_calls[0] + assert tool_call.index == 0 + assert tool_call.id is not None and tool_call.id != "" + assert tool_call.type == "function" + assert tool_call.function.name == "get_weather" + assert isinstance(tool_call.function.arguments, str) + assert json.loads(tool_call.function.arguments) == { + "location": "Beijing", + "date": "tomorrow", + } + + done_chunk = { + "model": "qwen3:4b", + "message": {"role": "assistant", "content": ""}, + "done": True, + "done_reason": "stop", + "prompt_eval_count": 100, + "eval_count": 50, + } + + done_result = iterator.chunk_parser(done_chunk) + + assert done_result.choices[0].finish_reason == "tool_calls" + + @pytest.mark.parametrize("done_reason", ["stop", "length"]) + @pytest.mark.parametrize("empty_calls_in_terminal_chunk", [False, True]) + def test_empty_tool_calls_preserve_finish_reason( + self, done_reason: str, empty_calls_in_terminal_chunk: bool + ) -> None: + iterator: Final = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + content_result: Final = iterator.chunk_parser( + { + "model": "qwen3:4b", + "message": {"role": "assistant", "content": "Hello", "tool_calls": []}, + "done": False, + } + ) + done_result: Final = iterator.chunk_parser( + { + "model": "qwen3:4b", + "message": { + "role": "assistant", + "content": "", + **({"tool_calls": []} if empty_calls_in_terminal_chunk else {}), + }, + "done": True, + "done_reason": done_reason, + } + ) + + assert content_result.choices[0].finish_reason is None + assert not content_result.choices[0].delta.tool_calls + assert done_result.choices[0].finish_reason == done_reason + assert not done_result.choices[0].delta.tool_calls + class TestOllamaFinishReasonLength: """Tests for done_reason 'length' → finish_reason 'length' mapping.