From c014bfa6838b69d4a211131aaa3bee5e88cd7772 Mon Sep 17 00:00:00 2001 From: Michael Verrilli Date: Sat, 25 Apr 2026 13:28:17 -0500 Subject: [PATCH] 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 --- litellm/llms/ollama/chat/transformation.py | 9 +- litellm/types/llms/ollama.py | 1 + .../ollama/test_ollama_chat_transformation.py | 95 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index c990cc2e09..48534799c9 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -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) diff --git a/litellm/types/llms/ollama.py b/litellm/types/llms/ollama.py index b863b76c03..ca28120dd9 100644 --- a/litellm/types/llms/ollama.py +++ b/litellm/types/llms/ollama.py @@ -37,3 +37,4 @@ class OllamaChatCompletionMessage(TypedDict, total=False): images: List[str] tool_calls: List[OllamaToolCall] tool_name: str + tool_call_id: str 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 069752e4d2..05b96b8822 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py @@ -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"