mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
d3929048f1
commit
b0fe71010b
3 changed files with 124 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue