fix(llm): retry Ollama capability lookups that failed

Only an answered /api/show lookup is cached now, and the cache is replaced rather than mutated. A failed lookup sends no tools for that request and is retried on the next one, so a transient outage no longer disables tools for the provider's lifetime. Also annotate the test helpers and tests this PR adds.

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
This commit is contained in:
FenjuFu
2026-10-03 01:33:53 +08:00
parent 558c06c3f8
commit 7556360412
2 changed files with 57 additions and 30 deletions
+19 -16
View File
@@ -92,26 +92,29 @@ class OllamaProvider(LLMProvider):
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. A failed lookup counts
as no tool support.
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 not in self._tool_support:
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 []
self._tool_support[model] = "tools" in capabilities
except Exception as e:
logger.warning("Could not read capabilities for Ollama model '%s': %s", model, type(e).__name__)
self._tool_support[model] = False
return self._tool_support[model]
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
supports_tools = "tools" in capabilities
self._tool_support = {**self._tool_support, model: supports_tools}
return supports_tools
def generate(self, input: LLMInput) -> LLMOutput:
try:
+38 -14
View File
@@ -3,6 +3,7 @@ import json
import urllib.request
from io import BytesIO
from types import SimpleNamespace
from typing import Any
import pytest
@@ -173,14 +174,19 @@ def test_ollama_provider_serializes_generation_options(
class _FakeOllama:
"""Serves /api/show and replays queued /api/chat replies, recording requests."""
def __init__(self, monkeypatch, replies, capabilities=None):
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 = []
self.shows = []
self.chats: list[dict[str, Any]] = []
self.shows: list[str] = []
monkeypatch.setattr(urllib.request, "urlopen", self.urlopen)
def urlopen(self, request, timeout):
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"])
@@ -191,7 +197,7 @@ class _FakeOllama:
return BytesIO(json.dumps(self.replies.pop(0)).encode())
def _ollama_tool_calls(*calls):
def _ollama_tool_calls(*calls: tuple[str, dict[str, Any]]) -> dict[str, Any]:
return {
"message": {
"content": "",
@@ -201,7 +207,7 @@ def _ollama_tool_calls(*calls):
}
def _ollama_answer(text):
def _ollama_answer(text: str) -> dict[str, Any]:
return {"message": {"content": text}, "done_reason": "stop"}
@@ -213,7 +219,7 @@ def _lookup_tool() -> ToolDefinition:
)
def test_ollama_provider_serializes_tools_and_parses_tool_calls(monkeypatch):
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"]
)
@@ -246,12 +252,16 @@ def test_ollama_provider_serializes_tools_and_parses_tool_calls(monkeypatch):
("codellama", None, False, []),
("qwen3", ["completion", "tools"], True, ["qwen3"]),
("gemma3", ["completion", "vision"], False, ["gemma3"]),
("unknown", None, False, ["unknown"]),
("unknown", None, False, ["unknown", "unknown"]),
],
)
def test_ollama_provider_sends_tools_only_to_models_that_declare_them(
monkeypatch, model, capabilities, expect_tools, expect_shows
):
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()])
@@ -260,11 +270,25 @@ def test_ollama_provider_sends_tools_only_to_models_that_declare_them(
provider.generate(request)
assert all(("tools" in chat) is expect_tools for chat in server.chats)
# The capability lookup is cached per model.
# An answered capability lookup is cached per model; a failed one is retried.
assert server.shows == expect_shows
def test_ollama_react_agent_correlates_multiple_calls_without_ids(monkeypatch):
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,
[
@@ -298,7 +322,7 @@ def test_ollama_react_agent_correlates_multiple_calls_without_ids(monkeypatch):
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):
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")],
@@ -317,7 +341,7 @@ def test_ollama_react_agent_reports_unknown_tool_under_its_own_name(monkeypatch)
]
def test_ollama_react_agent_without_tool_support_answers_directly(monkeypatch):
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())