Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 13 additions & 37 deletions litellm/llms/ollama/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,46 +190,14 @@ def map_openai_params(
else:
optional_params["think"] = value in {"low", "medium", "high"}
### FUNCTION CALLING LOGIC ###
# Ollama 0.4+ supports native tool calling - pass tools directly
# and let Ollama handle model capability detection
# Fixes: https://github.com/BerriAI/litellm/issues/18922
if param == "tools":
## CHECK IF MODEL SUPPORTS TOOL CALLING ##
try:
model_info = litellm.get_model_info(
model=model, custom_llm_provider="ollama"
)
if model_info.get("supports_function_calling") is True:
optional_params["tools"] = value
else:
raise Exception
except Exception:
optional_params["format"] = "json"
litellm.add_function_to_prompt = (
True # so that main.py adds the function call to the prompt
)
optional_params["functions_unsupported_model"] = value

if len(optional_params["functions_unsupported_model"]) == 1:
optional_params["function_name"] = optional_params[
"functions_unsupported_model"
][0]["function"]["name"]
optional_params["tools"] = value

if param == "functions":
## CHECK IF MODEL SUPPORTS TOOL CALLING ##
try:
model_info = litellm.get_model_info(
model=model, custom_llm_provider="ollama"
)
if model_info.get("supports_function_calling") is True:
optional_params["tools"] = value
else:
raise Exception
except Exception:
optional_params["format"] = "json"
litellm.add_function_to_prompt = (
True # so that main.py adds the function call to the prompt
)
optional_params["functions_unsupported_model"] = (
non_default_params.get("functions")
)
optional_params["tools"] = value
non_default_params.pop("tool_choice", None) # causes ollama requests to hang
non_default_params.pop("functions", None) # causes ollama requests to hang
return optional_params
Expand Down Expand Up @@ -431,6 +399,10 @@ def transform_response(

_message = litellm.Message(**response_json_message)
model_response.choices[0].message = _message # type: ignore
# Set finish_reason to "tool_calls" when tool_calls are present
# Fixes: https://github.com/BerriAI/litellm/issues/18922
if _message.tool_calls:
model_response.choices[0].finish_reason = "tool_calls"
model_response.created = int(time.time())
model_response.model = "ollama_chat/" + model
prompt_tokens = response_json.get("prompt_eval_count", litellm.token_counter(messages=messages)) # type: ignore
Expand Down Expand Up @@ -563,6 +535,10 @@ def chunk_parser(self, chunk: dict) -> ModelResponseStream:

if chunk["done"] is True:
finish_reason = chunk.get("done_reason", "stop")
# Override finish_reason when tool_calls are present
# Fixes: https://github.com/BerriAI/litellm/issues/18922
if tool_calls is not None:
finish_reason = "tool_calls"
choices = [
StreamingChoices(
delta=delta,
Expand Down
150 changes: 150 additions & 0 deletions tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,153 @@ def test_transform_request_no_images_no_images_key(self):
# and the code checks "if images is not None", an empty list will still be set
assert "images" in result["messages"][0]
assert result["messages"][0]["images"] == []


class TestOllamaToolCalling:
"""Tests for Ollama tool calling fixes.

Issue: https://github.com/BerriAI/litellm/issues/18922
"""

def test_tools_passed_directly_without_capability_check(self):
"""Test that tools are passed directly to Ollama without model capability checks.

Previously, the code called litellm.get_model_info() which could fail
when Ollama runs on a remote server, causing a broken fallback.
Now tools are passed directly - Ollama 0.4+ handles capability detection.
"""
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
]

optional_params = get_optional_params(
model="ollama_chat/qwen3:14b",
tools=tools,
custom_llm_provider="ollama_chat",
)

# Tools should be passed through directly
assert "tools" in optional_params
assert optional_params["tools"] == tools
# Should NOT trigger the broken fallback
assert "functions_unsupported_model" not in optional_params
assert "format" not in optional_params or optional_params.get("format") != "json"

def test_finish_reason_tool_calls_non_streaming(self):
"""Test that finish_reason is set to 'tool_calls' when tool_calls present.

Previously, finish_reason was hardcoded to 'stop' even when tool_calls
were in the response, causing clients to ignore the tool calls.
"""
import json
from unittest.mock import MagicMock

import litellm
from litellm.types.utils import Choices, Message, ModelResponse

config = OllamaChatConfig()

# Simulated Ollama response with tool_calls
ollama_response = {
"model": "qwen3:14b",
"created_at": "2025-01-11T00:00:00.000000Z",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "get_weather",
"arguments": {"location": "Tokyo"},
}
}
],
},
"done": True,
"prompt_eval_count": 100,
"eval_count": 50,
}

mock_response = MagicMock()
mock_response.json.return_value = ollama_response
mock_response.text = json.dumps(ollama_response)

mock_logging = MagicMock()

model_response = ModelResponse()
model_response.choices = [Choices(message=Message(content=""), index=0)]

result = config.transform_response(
model="qwen3:14b",
raw_response=mock_response,
model_response=model_response,
logging_obj=mock_logging,
request_data={},
messages=[{"role": "user", "content": "Weather?"}],
optional_params={},
litellm_params={},
encoding=None,
api_key=None,
json_mode=False,
)

# finish_reason should be "tool_calls", not "stop"
assert result.choices[0].finish_reason == "tool_calls"
assert result.choices[0].message.tool_calls is not None

def test_finish_reason_stop_when_no_tool_calls(self):
"""Test that finish_reason remains 'stop' when no tool_calls present."""
import json
from unittest.mock import MagicMock

import litellm
from litellm.types.utils import Choices, Message, ModelResponse

config = OllamaChatConfig()

# Simulated Ollama response without tool_calls
ollama_response = {
"model": "qwen3:14b",
"created_at": "2025-01-11T00:00:00.000000Z",
"message": {
"role": "assistant",
"content": "Hello! How can I help you?",
},
"done": True,
"prompt_eval_count": 100,
"eval_count": 50,
}

mock_response = MagicMock()
mock_response.json.return_value = ollama_response
mock_response.text = json.dumps(ollama_response)

mock_logging = MagicMock()

model_response = ModelResponse()
model_response.choices = [Choices(message=Message(content=""), index=0)]

result = config.transform_response(
model="qwen3:14b",
raw_response=mock_response,
model_response=model_response,
logging_obj=mock_logging,
request_data={},
messages=[{"role": "user", "content": "Hello"}],
optional_params={},
litellm_params={},
encoding=None,
api_key=None,
json_mode=False,
)

# finish_reason should be "stop" (default behavior)
assert result.choices[0].finish_reason == "stop"
assert result.choices[0].message.tool_calls is None
3 changes: 3 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading