fix(llm): integrate capability-aware Ollama tools from PR 3275

Preserve contributor head and current main reasoning/errors. Reject malformed capability declarations and retry discovery; actual localhost HTTP ReAct round-trip verified.
This commit is contained in:
haelyra committed 2026-10-11 01:57:34 -04:00
commit 544601912a
2 files changed
+267 -7

No files matched your search

+72 -7
View File
@@ -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", {}),
)
+195
View File
@@ -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']