diff --git a/src/llm/providers/ollama.py b/src/llm/providers/ollama.py index 6ee4aa8d4..0787c250e 100644 --- a/src/llm/providers/ollama.py +++ b/src/llm/providers/ollama.py @@ -2,7 +2,11 @@ from __future__ import annotations +import json +import logging import os +import urllib.request +import uuid from typing import Any from llm.core.interface import ( @@ -15,12 +19,36 @@ from llm.core.interface import ( from llm.core.types import ( LLMInput, LLMOutput, + Message, ModelInfo, ProviderType, + Role, ToolCall, ) from llm.providers.reasoning import prefilled_reasoning_from_env, strip_reasoning +logger = logging.getLogger(__name__) + + +def _messages_payload(messages: list[Message]) -> list[dict[str, Any]]: + """Serialize messages, naming the tool each tool result answers. + + Ollama correlates a tool result with its call through ``tool_name``; ids + are optional on its side, so the name is what keeps several results apart. + """ + names: dict[str, str] = {} + payload = [] + for msg in messages: + item = msg.to_dict() + for tc in msg.tool_calls or []: + names[tc.id] = tc.name + if msg.role == Role.TOOL: + tool_name = msg.name or names.get(msg.tool_call_id or "") + if tool_name: + item["tool_name"] = tool_name + payload.append(item) + return payload + class OllamaProvider(LLMProvider): provider_type = ProviderType.OLLAMA @@ -44,7 +72,7 @@ class OllamaProvider(LLMProvider): ModelInfo( name="llama3.2", provider=ProviderType.OLLAMA, - supports_tools=False, + supports_tools=True, supports_vision=False, max_tokens=4096, context_window=128000, @@ -52,7 +80,7 @@ class OllamaProvider(LLMProvider): ModelInfo( name="mistral", provider=ProviderType.OLLAMA, - supports_tools=False, + supports_tools=True, supports_vision=False, max_tokens=4096, context_window=8192, @@ -66,18 +94,48 @@ class OllamaProvider(LLMProvider): context_window=16384, ), ] + self._tool_support: dict[str, bool] = {} + + def model_supports_tools(self, model: str) -> bool: + """Whether ``model`` declares tool support. + + Catalogued models answer from :attr:`ModelInfo.supports_tools`. Any other + model is looked up once through ``/api/show``, whose ``capabilities`` + list is what the installed model itself declares. Only an answered lookup + is cached: a failed one counts as no tool support for that request and + is retried on the next. + """ + for info in self._models: + if info.name == model: + return info.supports_tools + if model in self._tool_support: + return self._tool_support[model] + try: + req = urllib.request.Request( + f"{self.base_url}/api/show", + data=json.dumps({"model": model}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=10) as response: + capabilities = json.loads(response.read().decode("utf-8")).get("capabilities") or [] + except Exception as e: + logger.warning("Could not read capabilities for Ollama model '%s': %s", model, type(e).__name__) + return False + if not isinstance(capabilities, list) or not all(isinstance(item, str) for item in capabilities): + logger.warning("Invalid capabilities for Ollama model '%s'; sending no tools", model) + return False + supports_tools = "tools" in capabilities + self._tool_support = {**self._tool_support, model: supports_tools} + return supports_tools def generate(self, input: LLMInput) -> LLMOutput: - import json - import urllib.request - try: url = f"{self.base_url}/api/chat" model = input.model or self.default_model payload: dict[str, Any] = { "model": model, - "messages": [msg.to_dict() for msg in input.messages], + "messages": _messages_payload(input.messages), "stream": False, } options: dict[str, Any] = {} @@ -87,6 +145,11 @@ class OllamaProvider(LLMProvider): options["num_predict"] = input.max_tokens if options: payload["options"] = options + if input.tools: + if self.model_supports_tools(model): + payload["tools"] = [tool.to_openai_tool() for tool in input.tools] + else: + logger.warning("Ollama model '%s' does not declare tool support; sending no tools", model) data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) @@ -102,7 +165,9 @@ class OllamaProvider(LLMProvider): if result.get("message", {}).get("tool_calls"): tool_calls = [ ToolCall( - id=tc.get("id", ""), + # Native Ollama calls often carry no id; give each one a + # unique id so its result can be matched to it. + id=tc.get("id") or f"call_{uuid.uuid4().hex[:24]}", name=tc.get("function", {}).get("name", ""), arguments=tc.get("function", {}).get("arguments", {}), ) diff --git a/tests/test_provider_tools.py b/tests/test_provider_tools.py index 7155953f7..c95da7c2a 100644 --- a/tests/test_provider_tools.py +++ b/tests/test_provider_tools.py @@ -1,3 +1,4 @@ +import asyncio import json import urllib.request from collections.abc import Callable @@ -15,6 +16,7 @@ from llm.providers.claude import ClaudeProvider from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR from llm.providers.ollama import OllamaProvider from llm.providers.openai import OpenAIProvider +from llm.tools import ReActAgent, ToolExecutor, ToolRegistry def _tool() -> ToolDefinition: @@ -261,6 +263,187 @@ def test_ollama_provider_serializes_generation_options( assert output.stop_reason == "stop" +class _FakeOllama: + """Serves /api/show and replays queued /api/chat replies, recording requests.""" + + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + replies: list[dict[str, Any]], + capabilities: list[str] | None = None, + ) -> None: + self.replies = list(replies) + self.capabilities = capabilities + self.chats: list[dict[str, Any]] = [] + self.shows: list[str] = [] + monkeypatch.setattr(urllib.request, "urlopen", self.urlopen) + + def urlopen(self, request: urllib.request.Request, timeout: float) -> BytesIO: + body = json.loads(request.data) + if request.full_url.endswith("/api/show"): + self.shows.append(body["model"]) + if self.capabilities is None: + raise OSError("connection refused") + return BytesIO(json.dumps({"capabilities": self.capabilities}).encode()) + self.chats.append(body) + return BytesIO(json.dumps(self.replies.pop(0)).encode()) + + +def _ollama_tool_calls(*calls: tuple[str, dict[str, Any]]) -> dict[str, Any]: + return { + "message": { + "content": "", + "tool_calls": [{"function": {"name": name, "arguments": args}} for name, args in calls], + }, + "done_reason": "stop", + } + + +def _ollama_answer(text: str) -> dict[str, Any]: + return {"message": {"content": text}, "done_reason": "stop"} + + +def _lookup_tool() -> ToolDefinition: + return ToolDefinition( + name="lookup", + description="Look up a city", + parameters={"type": "object", "properties": {"city": {"type": "string"}}}, + ) + + +def test_ollama_provider_serializes_tools_and_parses_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None: + server = _FakeOllama( + monkeypatch, [_ollama_tool_calls(("search", {"query": "ollama"}))], capabilities=["completion", "tools"] + ) + provider = OllamaProvider(base_url="http://localhost:11434", default_model="qwen3") + + output = provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()])) + + assert server.shows == ["qwen3"] + assert server.chats[0]["tools"] == [ + { + "type": "function", + "function": { + "name": "search", + "description": "Search", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}}, + "strict": True, + }, + } + ] + assert output.has_tool_calls + assert output.tool_calls[0].name == "search" + assert output.tool_calls[0].arguments == {"query": "ollama"} + assert output.tool_calls[0].id + + +@pytest.mark.parametrize( + ("model", "capabilities", "expect_tools", "expect_shows"), + [ + ("llama3.2", None, True, []), + ("codellama", None, False, []), + ("qwen3", ["completion", "tools"], True, ["qwen3"]), + ("gemma3", ["completion", "vision"], False, ["gemma3"]), + ("unknown", None, False, ["unknown", "unknown"]), + ], +) +def test_ollama_provider_sends_tools_only_to_models_that_declare_them( + monkeypatch: pytest.MonkeyPatch, + model: str, + capabilities: list[str] | None, + expect_tools: bool, + expect_shows: list[str], +) -> None: + server = _FakeOllama(monkeypatch, [_ollama_answer("ok"), _ollama_answer("ok")], capabilities=capabilities) + provider = OllamaProvider(default_model=model) + request = LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]) + + provider.generate(request) + provider.generate(request) + + assert all(("tools" in chat) is expect_tools for chat in server.chats) + # An answered capability lookup is cached per model; a failed one is retried. + assert server.shows == expect_shows + + +def test_ollama_provider_retries_capability_lookup_after_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + server = _FakeOllama(monkeypatch, [_ollama_answer("ok"), _ollama_answer("ok"), _ollama_answer("ok")]) + provider = OllamaProvider(default_model="qwen3") + request = LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]) + + provider.generate(request) + server.capabilities = ["completion", "tools"] + provider.generate(request) + provider.generate(request) + + assert ["tools" in chat for chat in server.chats] == [False, True, True] + assert server.shows == ["qwen3", "qwen3"] + + +def test_ollama_react_agent_correlates_multiple_calls_without_ids(monkeypatch: pytest.MonkeyPatch) -> None: + server = _FakeOllama( + monkeypatch, + [ + _ollama_tool_calls(("lookup", {"city": "Hefei"}), ("search", {"query": "weather"})), + _ollama_answer("Hefei is sunny."), + ], + capabilities=["completion", "tools"], + ) + registry = ToolRegistry() + registry.register(_lookup_tool(), lambda city: f"city={city}") + registry.register(_tool(), lambda query: f"query={query}") + agent = ReActAgent(OllamaProvider(default_model="qwen3"), ToolExecutor(registry)) + + output = asyncio.run( + agent.run( + LLMInput( + messages=[Message(role=Role.USER, content="Weather in Hefei?")], + tools=[_lookup_tool(), _tool()], + ) + ) + ) + + assert output.content == "Hefei is sunny." + follow_up = server.chats[1] + assert "tools" in follow_up + assistant, first, second = follow_up["messages"][1:] + ids = [call["id"] for call in assistant["tool_calls"]] + assert all(ids) and len(set(ids)) == 2 + assert [call["function"]["name"] for call in assistant["tool_calls"]] == ["lookup", "search"] + assert first == {"role": "tool", "content": "city=Hefei", "tool_call_id": ids[0], "tool_name": "lookup"} + assert second == {"role": "tool", "content": "query=weather", "tool_call_id": ids[1], "tool_name": "search"} + + +def test_ollama_react_agent_reports_unknown_tool_under_its_own_name(monkeypatch: pytest.MonkeyPatch) -> None: + server = _FakeOllama( + monkeypatch, + [_ollama_tool_calls(("missing", {}), ("search", {"query": "x"})), _ollama_answer("done")], + capabilities=["completion", "tools"], + ) + registry = ToolRegistry() + registry.register(_tool(), lambda query: f"query={query}") + agent = ReActAgent(OllamaProvider(default_model="qwen3"), ToolExecutor(registry)) + + asyncio.run(agent.run(LLMInput(messages=[Message(role=Role.USER, content="go")], tools=[_tool()]))) + + results = server.chats[1]["messages"][2:] + assert [(r["tool_name"], r["content"]) for r in results] == [ + ("missing", "Error: Tool 'missing' not found"), + ("search", "query=x"), + ] + + +def test_ollama_react_agent_without_tool_support_answers_directly(monkeypatch: pytest.MonkeyPatch) -> None: + server = _FakeOllama(monkeypatch, [_ollama_answer("I cannot call tools.")]) + agent = ReActAgent(OllamaProvider(default_model="codellama"), ToolExecutor()) + + output = asyncio.run(agent.run(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]))) + + assert output.content == "I cannot call tools." + assert "tools" not in server.chats[0] + assert server.shows == [] + + def test_claude_provider_serializes_tools_for_messages_api(): provider = ClaudeProvider(api_key="test") client = _AnthropicClient() @@ -275,3 +458,15 @@ def test_claude_provider_serializes_tools_for_messages_api(): "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}, } ] + + +@pytest.mark.parametrize('capabilities', ['tools', {'tools': True}, ['tools', 1]]) +def test_ollama_malformed_capability_declaration_is_not_authority(monkeypatch, capabilities): + server = _FakeOllama(monkeypatch, [_ollama_answer('ok'), _ollama_answer('ok')], capabilities=capabilities) + provider = OllamaProvider(default_model='custom') + request = LLMInput(messages=[Message(role=Role.USER, content='hi')], tools=[_tool()]) + provider.generate(request) + server.capabilities = ['completion', 'tools'] + provider.generate(request) + assert ['tools' in chat for chat in server.chats] == [False, True] + assert server.shows == ['custom', 'custom']