fix(ollama): forward tool_calls and tool_call_id in transform_request (#26122)

tool_calls on assistant messages were translated to OllamaToolCall format
but never copied into the outgoing OllamaChatCompletionMessage, so Ollama
received {role: assistant, content: ''} with no tool_calls. The model
then had no record of having made a tool call, causing it to re-issue
the identical call on every turn (infinite loop).

Similarly, tool_call_id on role:tool messages was silently dropped.
Ollama uses this field to resolve the tool name from conversation history.

Also add tool_call_id to OllamaChatCompletionMessage TypedDict.

Fixes #26094
This commit is contained in:
Michael Verrilli
2026-04-27 08:58:41 +05:30
committed by Sameer Kankute
parent e68d5f86cf
commit c014bfa683
3 changed files with 103 additions and 2 deletions
+7 -2
View File
@@ -265,8 +265,9 @@ class OllamaChatConfig(BaseConfig):
): # avoid message serialization issues - https://github.com/BerriAI/litellm/issues/5319
m = m.model_dump(exclude_none=True)
tool_calls = m.get("tool_calls")
new_tools: Optional[List[OllamaToolCall]] = None
if tool_calls is not None and isinstance(tool_calls, list):
new_tools: List[OllamaToolCall] = []
new_tools = []
for tool in tool_calls:
typed_tool = ChatCompletionAssistantToolCall(**tool) # type: ignore
if typed_tool["type"] == "function":
@@ -280,7 +281,6 @@ class OllamaChatConfig(BaseConfig):
)
)
new_tools.append(ollama_tool_call)
cast(dict, m)["tool_calls"] = new_tools
reasoning_content, parsed_content = _extract_reasoning_content(
cast(dict, m)
)
@@ -296,6 +296,11 @@ class OllamaChatConfig(BaseConfig):
ollama_message["content"] = content_str
if images is not None:
ollama_message["images"] = images
if new_tools is not None:
ollama_message["tool_calls"] = new_tools
tool_call_id = m.get("tool_call_id")
if tool_call_id is not None:
ollama_message["tool_call_id"] = cast(str, tool_call_id)
new_messages.append(ollama_message)
+1
View File
@@ -37,3 +37,4 @@ class OllamaChatCompletionMessage(TypedDict, total=False):
images: List[str]
tool_calls: List[OllamaToolCall]
tool_name: str
tool_call_id: str
@@ -746,3 +746,98 @@ class TestOllamaReasoningContentStreaming:
result = iterator.chunk_parser(done_chunk)
assert result.choices[0].delta.reasoning_content == "Final thought"
assert result.choices[0].finish_reason == "stop"
class TestOllamaToolCallTransformation:
def test_transform_request_preserves_tool_calls(self):
"""
tool_calls on assistant messages must survive transform_request.
Previously the translated OllamaToolCall list was built but never
copied into the outgoing OllamaChatCompletionMessage, so Ollama
received {role: assistant, content: ''} with no tool_calls and
the model re-issued the same call on every turn.
Regression: https://github.com/BerriAI/litellm/issues/26094
"""
config = OllamaChatConfig()
messages = cast(
list[AllMessageValues],
[
{"role": "user", "content": "What's the weather in SF?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "San Francisco, CA"}',
},
}
],
},
],
)
result = config.transform_request(
model="gemma4:27b",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
assistant_msg = result["messages"][1]
assert "tool_calls" in assistant_msg, "tool_calls must be forwarded to Ollama"
assert len(assistant_msg["tool_calls"]) == 1
tc = assistant_msg["tool_calls"][0]
assert tc["function"]["name"] == "get_weather"
assert tc["function"]["arguments"] == {"location": "San Francisco, CA"}
def test_transform_request_forwards_tool_call_id(self):
"""
tool_call_id on role:tool messages must be forwarded so Ollama can
resolve the tool name from the conversation history.
Regression: https://github.com/BerriAI/litellm/issues/26094
"""
config = OllamaChatConfig()
messages = cast(
list[AllMessageValues],
[
{"role": "user", "content": "What's the weather in SF?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "San Francisco, CA"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "Sunny, 72°F",
},
],
)
result = config.transform_request(
model="gemma4:27b",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
tool_msg = result["messages"][2]
assert tool_msg["role"] == "tool"
assert tool_msg["content"] == "Sunny, 72°F"
assert "tool_call_id" in tool_msg, "tool_call_id must be forwarded to Ollama"
assert tool_msg["tool_call_id"] == "call_abc123"