From 3f69357104c67e15cb13a2c45fc609d51eae7f81 Mon Sep 17 00:00:00 2001 From: Meryem Sakin Date: Thu, 10 Sep 2026 21:55:41 +0300 Subject: [PATCH] fix(ollama): preserve compatibility when routing native tools --- litellm/llms/ollama/chat/transformation.py | 5 +- litellm/main.py | 9 +- .../test_ollama_completion_transformation.py | 89 ++++++++++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 181894646e3..55d6603f8ab 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -24,6 +24,7 @@ from litellm.types.llms.ollama import ( from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionAssistantToolCall, + ChatCompletionToolParam, ChatCompletionUsageBlock, ) from litellm.types.utils import ModelResponse, ModelResponseStream @@ -184,7 +185,9 @@ class OllamaChatConfig(BaseConfig): optional_params["tools"] = value if param == "functions": - optional_params["tools"] = value + optional_params["tools"] = tuple( + ChatCompletionToolParam(type="function", function=function) for function in 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 diff --git a/litellm/main.py b/litellm/main.py index d69b737ce04..9b81cec1c7e 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5307,8 +5307,15 @@ def completion( GenericLiteLLMParams(**_supplemental_provider_params) if _supplemental_provider_params else None ), ) - if custom_llm_provider == "ollama" and (tools is not None or functions is not None): + if custom_llm_provider == "ollama" and (tools or functions): custom_llm_provider = "ollama_chat" # rebind-ok: /api/generate has no native tool calling + if api_base is not None: + api_base = api_base.rstrip("/").removesuffix( + "/api/generate" + ) # rebind-ok: preserve generate URLs for native tool requests + elif custom_llm_provider == "ollama": + tools = None # rebind-ok: empty tools must not change plain completion behavior + functions = None # rebind-ok: empty functions must not change plain completion behavior ## RESPONSES API BRIDGE LOGIC ## - check early and normalize model name responses_api_model_info, model = responses_api_bridge_check( diff --git a/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py b/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py index 151bd66e98f..9942f44dcca 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py @@ -1,11 +1,12 @@ import json -from litellm._uuid import uuid +from typing import Final from unittest.mock import MagicMock, patch import httpx import pytest import litellm +from litellm._uuid import uuid from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.ollama.completion.transformation import ( OllamaConfig, @@ -476,7 +477,7 @@ class TestOllamaTextCompletionResponseIterator: # Updated to handle ModelResponseStream return type assert isinstance(result, ModelResponseStream) assert result.choices and result.choices[0].delta is not None - assert result.choices[0].delta.content == None + assert result.choices[0].delta.content is None assert getattr(result.choices[0].delta, "reasoning_content", None) == "" def test_chunk_parser_done_chunk(self): @@ -558,7 +559,17 @@ GRAPH_STATS_TOOLS = [ ] -def test_ollama_tool_result_turn_is_sent_to_native_chat_api(): +@pytest.mark.parametrize( + "api_base", + [ + "http://ollama.example:11434", + "http://ollama.example:11434/", + "http://ollama.example:11434/api/generate", + "http://ollama.example:11434/api/generate/", + "http://ollama.example:11434/api/chat", + ], +) +def test_ollama_tool_result_turn_is_sent_to_native_chat_api(api_base: str): """https://github.com/BerriAI/litellm/issues/40575""" requests = [] @@ -590,7 +601,7 @@ def test_ollama_tool_result_turn_is_sent_to_native_chat_api(): {"role": "tool", "tool_call_id": "call_1", "name": "graph_stats", "content": '{"nodes": 190921}'}, ], tools=GRAPH_STATS_TOOLS, - api_base="http://ollama.example:11434", + api_base=api_base, client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handle))), ) @@ -646,3 +657,73 @@ def test_ollama_streamed_tool_call_is_returned_as_tool_call(): assert [tool_call.function.name for tool_call in tool_calls] == ["graph_stats"] assert "".join(chunk.choices[0].delta.content or "" for chunk in streamed) == "" assert streamed[-1].choices[0].finish_reason == "tool_calls" + + +@pytest.mark.parametrize("empty_parameter", ["none", "tools", "functions"]) +def test_ollama_empty_tools_preserve_generate_request(empty_parameter: str) -> None: + def handle(request: httpx.Request) -> httpx.Response: + body: Final = json.loads(request.content) + assert request.url.path == "/api/generate" + assert "format" not in body + assert "tools" not in body + return httpx.Response(200, json={"response": "Hello", "done": True}) + + response: Final = litellm.completion( + model="ollama/qwen3.8:27b", + messages=[{"role": "user", "content": "Hello"}], + tools=[] if empty_parameter == "tools" else None, + functions=[] if empty_parameter == "functions" else None, + api_base="http://ollama.example:11434/api/generate", + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handle))), + ) + assert response.choices[0].message.content == "Hello" + + +def test_ollama_native_tool_support_error_is_preserved() -> None: + def handle(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/chat" + return httpx.Response(400, json={"error": "model does not support tools"}) + + with pytest.raises(litellm.BadRequestError, match="does not support tools"): + litellm.completion( + model="ollama/qwen3.8:27b", + messages=[{"role": "user", "content": "Hello"}], + tools=GRAPH_STATS_TOOLS, + api_base="http://ollama.example:11434/api/generate", + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handle))), + num_retries=0, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("legacy_functions", [False, True]) +async def test_ollama_async_native_tools(legacy_functions: bool) -> None: + def handle(request: httpx.Request) -> httpx.Response: + body: Final = json.loads(request.content) + assert request.url.path == "/prefix/api/chat" + assert body["tools"] == GRAPH_STATS_TOOLS + return httpx.Response( + 200, + json={ + "model": "qwen3.8:27b", + "message": {"role": "assistant", "content": "Hello"}, + "done": True, + "done_reason": "stop", + "prompt_eval_count": 1, + "eval_count": 1, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: + handler: Final = AsyncHTTPHandler() + await handler.client.aclose() + handler.client = client + response: Final = await litellm.acompletion( + model="ollama/qwen3.8:27b", + messages=[{"role": "user", "content": "Hello"}], + tools=None if legacy_functions else GRAPH_STATS_TOOLS, + functions=[GRAPH_STATS_TOOLS[0]["function"]] if legacy_functions else None, + api_base="http://ollama.example:11434/prefix/api/generate/", + client=handler, + ) + assert response.choices[0].message.content == "Hello"