fix(ollama_chat): stamp finish_reason tool_calls when tool calls streamed before the done chunk (#39010)

* fix(ollama_chat): stamp finish_reason tool_calls when tool calls streamed before the done chunk

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ollama_chat): trim finish_reason override comment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mubashir1osmani <mubashir.osmani777@gmail.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-02 16:56:35 -07:00 committed by GitHub
parent d3929048f1
commit b0fe71010b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 124 additions and 2 deletions

View file

@ -423,6 +423,7 @@ class OllamaChatConfig(BaseConfig):
class OllamaChatCompletionResponseIterator(BaseModelResponseIterator):
started_reasoning_content: bool = False
finished_reasoning_content: bool = False
seen_tool_calls: bool = False
def _is_function_call_complete(self, function_args: str | dict) -> bool:
if isinstance(function_args, dict):
@ -468,6 +469,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:
self.seen_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:
@ -508,9 +510,10 @@ 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 appeared in any chunk
# Fixes: https://github.com/BerriAI/litellm/issues/18922
if tool_calls is not None:
# Fixes: https://github.com/BerriAI/litellm/issues/34692
if self.seen_tool_calls:
finish_reason = "tool_calls"
choices = [
StreamingChoices(

View file

@ -0,0 +1,79 @@
"""
Regression tests for issue #34692.
ollama_chat streams tool_calls in a mid-stream chunk while its final
(``done: true``) chunk carries only ``done_reason: "stop"``. The provider
iterator must remember the earlier tool_calls and stamp
``finish_reason="tool_calls"`` on the final chunk, so the Anthropic
``/v1/messages`` bridge emits ``stop_reason: "tool_use"``. Before the fix the
bridge emitted ``stop_reason: "end_turn"`` and Anthropic tool-runners
(Claude Code, ``messages.stream``) silently dropped the tool call.
"""
import pytest
from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import (
AnthropicStreamWrapper,
)
from litellm.llms.ollama.chat.transformation import (
OllamaChatCompletionResponseIterator,
)
from litellm.types.utils import ModelResponseStream
_OLLAMA_TOOL_CHUNK = {
"model": "qwen3:8b",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "get_weather", "arguments": {"city": "San Francisco"}}}],
},
"done": False,
}
_OLLAMA_DONE_CHUNK = {
"model": "qwen3:8b",
"message": {"role": "assistant", "content": ""},
"done": True,
"done_reason": "stop",
"prompt_eval_count": 100,
"eval_count": 20,
}
def _ollama_streamed_chunks() -> list[ModelResponseStream]:
iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True)
return [iterator.chunk_parser(_OLLAMA_TOOL_CHUNK), iterator.chunk_parser(_OLLAMA_DONE_CHUNK)]
class _AsyncStream:
def __init__(self, items: list[ModelResponseStream]):
self._it = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._it)
except StopIteration:
raise StopAsyncIteration
def _assert_tool_use_stop_reason(events: list[dict]) -> None:
block_types = [e["content_block"]["type"] for e in events if e.get("type") == "content_block_start"]
assert "tool_use" in block_types, f"no tool_use content block opened: {events}"
message_deltas = [e for e in events if e.get("type") == "message_delta"]
assert message_deltas, f"no message_delta emitted: {events}"
assert message_deltas[-1]["delta"]["stop_reason"] == "tool_use", (
f"expected stop_reason 'tool_use', got: {message_deltas[-1]}"
)
def test_ollama_mid_stream_tool_call_yields_tool_use_stop_reason_sync():
wrapper = AnthropicStreamWrapper(completion_stream=iter(_ollama_streamed_chunks()), model="qwen3:8b")
_assert_tool_use_stop_reason(list(wrapper))
@pytest.mark.asyncio
async def test_ollama_mid_stream_tool_call_yields_tool_use_stop_reason_async():
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(_ollama_streamed_chunks()), model="qwen3:8b")
_assert_tool_use_stop_reason([event async for event in wrapper])

View file

@ -615,6 +615,46 @@ class TestOllamaFinishReasonLength:
result.choices[0].finish_reason == "stop"
), f"Expected 'stop' for natural finish, got '{result.choices[0].finish_reason}'"
def test_finish_reason_tool_calls_streamed_before_done_chunk(self):
"""Streaming: tool_calls arriving mid-stream (not on the done chunk) must
still produce finish_reason='tool_calls' on the final chunk.
Regression test for https://github.com/BerriAI/litellm/issues/34692:
Ollama emits tool_calls in an earlier chunk and the done chunk carries
none, which left finish_reason at 'stop' and made the Anthropic
/v1/messages bridge emit stop_reason 'end_turn' instead of 'tool_use'.
"""
iterator = OllamaChatCompletionResponseIterator(
streaming_response=iter([]),
sync_stream=True,
)
tool_chunk = {
"model": "qwen3:8b",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{"function": {"name": "get_weather", "arguments": {"city": "San Francisco"}}}
],
},
"done": False,
}
done_chunk = {
"model": "qwen3:8b",
"message": {"role": "assistant", "content": ""},
"done": True,
"done_reason": "stop",
}
tool_result = iterator.chunk_parser(tool_chunk)
assert tool_result.choices[0].delta.tool_calls is not None
done_result = iterator.chunk_parser(done_chunk)
assert (
done_result.choices[0].finish_reason == "tool_calls"
), f"Expected 'tool_calls' when tool_calls were streamed earlier, got '{done_result.choices[0].finish_reason}'"
class TestOllamaReasoningContentStreaming:
"""Test that reasoning_content is properly extracted from all thinking chunks."""