mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): preflight Ollama host reachability before discovery probe (#4702)
Deep Agents Code no longer logs a misleading "discovery failed" error when Ollama is not running; it now detects the daemon's absence and skips discovery quietly. --- When Ollama is not installed/running, model discovery probes `http://localhost:11434/api/tags`, and the refused connection surfaces as a misleading `Ollama model discovery failed ... Connection refused` debug line. This adds a lightweight TCP presence preflight (`_ollama_host_reachable`) that runs before the HTTP probe: if nothing is listening, discovery is skipped quietly with an `Ollama daemon not detected` debug line instead. The check is best-effort — any failure (refused, DNS error, timeout, blocked sockets under `pytest-socket`) is treated as absent, and an unparseable host defers to the existing HTTP probe. Made by [Open SWE](https://openswe.vercel.app/agents/c3696bf2-ea9f-2932-0d07-4ce636b8f83e) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -1435,6 +1435,63 @@ def _get_ollama_installed_models(endpoint: str | None) -> list[str]:
|
||||
return list(models)
|
||||
|
||||
|
||||
def _ollama_host_reachable(
|
||||
base: str, *, timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS
|
||||
) -> bool:
|
||||
"""Return whether a TCP listener appears to accept connections at `base`.
|
||||
|
||||
A lightweight presence preflight so Ollama discovery can skip the HTTP
|
||||
probe entirely when no daemon is running (e.g. Ollama is not installed).
|
||||
The check opens and immediately closes a TCP connection to the endpoint's
|
||||
host and port. Any failure -- connection refused, DNS error, timeout, or
|
||||
sockets blocked under `pytest-socket` -- is treated as "not reachable" so
|
||||
discovery falls back gracefully; an unexpected (non-`OSError`) failure is
|
||||
additionally logged at warning so a real bug isn't misreported as absence.
|
||||
|
||||
Args:
|
||||
base: Base URL of the Ollama daemon, e.g. `http://localhost:11434`.
|
||||
timeout: Socket connection timeout in seconds.
|
||||
|
||||
Returns:
|
||||
`True` when a connection is established (a daemon appears present) or
|
||||
when the target cannot be determined (deferring to the HTTP probe);
|
||||
`False` when the connection cannot be made.
|
||||
"""
|
||||
import socket
|
||||
|
||||
parsed = urlparse(base)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
# Can't determine a target host; let the HTTP probe make the decision.
|
||||
return True
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
# Malformed port (out of range / non-numeric); defer to the HTTP probe.
|
||||
return True
|
||||
if port is None:
|
||||
port = 443 if parsed.scheme == "https" else 80
|
||||
# Mirror the discovery probe below: expected transport failures (refused,
|
||||
# DNS, timeout) just mean the daemon is absent and stay silent; anything
|
||||
# else is surfaced at warning so a real bug isn't misreported as "not
|
||||
# detected". `pytest-socket`'s `SocketBlockedError` inherits from
|
||||
# `Exception` (not `OSError`), so the broad branch catches it. The socket
|
||||
# is its own context manager, so `with` closes the probe connection.
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
except Exception as exc: # noqa: BLE001 # see comment above
|
||||
logger.warning(
|
||||
"Ollama presence preflight raised unexpected %s for %s: %s",
|
||||
type(exc).__name__,
|
||||
base,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_ollama_installed_models(
|
||||
endpoint: str | None,
|
||||
*,
|
||||
@@ -1472,6 +1529,17 @@ def _fetch_ollama_installed_models(
|
||||
base,
|
||||
)
|
||||
return []
|
||||
|
||||
# Presence preflight (local endpoints only -- remote hosts may be reachable
|
||||
# only through a proxy that the HTTP probe honors but a raw socket does
|
||||
# not). A dead/absent daemon (the common case when Ollama is not installed)
|
||||
# refuses the connection; detecting that here lets us skip the HTTP probe
|
||||
# and log a quiet "not detected" line instead of a misleading
|
||||
# "discovery failed ... Connection refused" debug line.
|
||||
if _is_local_endpoint(base) and not _ollama_host_reachable(base, timeout=timeout):
|
||||
logger.debug("Ollama daemon not detected at %s; skipping discovery", base)
|
||||
return []
|
||||
|
||||
url = f"{base}/api/tags"
|
||||
|
||||
headers = _ollama_discovery_headers(base, content_type=False)
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Iterator
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, cast
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -2764,6 +2764,82 @@ class _BytesContext:
|
||||
class TestFetchOllamaInstalledModels:
|
||||
"""Tests for the `_fetch_ollama_installed_models` HTTP probe."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _assume_host_reachable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Bypass the TCP presence preflight so HTTP parsing paths are exercised."""
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config._ollama_host_reachable",
|
||||
lambda *_args, **_kwargs: True,
|
||||
)
|
||||
|
||||
def test_skips_http_probe_when_host_unreachable(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An unreachable daemon short-circuits before any HTTP request."""
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config._ollama_host_reachable",
|
||||
lambda *_args, **_kwargs: False,
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen") as fake:
|
||||
assert (
|
||||
model_config._fetch_ollama_installed_models("http://localhost:11434")
|
||||
== []
|
||||
)
|
||||
|
||||
fake.assert_not_called()
|
||||
|
||||
def test_hosted_endpoint_skips_tcp_preflight(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Hosted endpoints proceed through the proxy-aware HTTP probe."""
|
||||
import json
|
||||
|
||||
reachable = MagicMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config._ollama_host_reachable", reachable
|
||||
)
|
||||
|
||||
with patch(
|
||||
"urllib.request.urlopen",
|
||||
return_value=_BytesContext(json.dumps({"models": []}).encode("utf-8")),
|
||||
) as urlopen:
|
||||
assert (
|
||||
model_config._fetch_ollama_installed_models(
|
||||
"https://ollama.example.com"
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
reachable.assert_not_called()
|
||||
urlopen.assert_called_once()
|
||||
|
||||
def test_forwards_normalized_base_and_timeout_to_preflight(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The rstrip'd base and the caller's timeout reach the preflight."""
|
||||
import json
|
||||
|
||||
reachable = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config._ollama_host_reachable", reachable
|
||||
)
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_OLLAMA_API_KEY", raising=False)
|
||||
|
||||
with patch(
|
||||
"urllib.request.urlopen",
|
||||
return_value=_BytesContext(json.dumps({"models": []}).encode("utf-8")),
|
||||
):
|
||||
assert (
|
||||
model_config._fetch_ollama_installed_models(
|
||||
"http://localhost:11434/", timeout=0.5
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
reachable.assert_called_once_with("http://localhost:11434", timeout=0.5)
|
||||
|
||||
def test_returns_sorted_names_from_payload(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -2936,6 +3012,146 @@ class TestFetchOllamaInstalledModels:
|
||||
fake.assert_not_called()
|
||||
|
||||
|
||||
class TestOllamaHostReachable:
|
||||
"""Tests for the `_ollama_host_reachable` TCP presence preflight."""
|
||||
|
||||
def test_true_when_connection_succeeds(self) -> None:
|
||||
"""A successful TCP connection reports the daemon as present.
|
||||
|
||||
Also pins that the default timeout reaches `socket.create_connection`:
|
||||
the preflight exists to fail *fast*, so a dropped timeout would let an
|
||||
absent host stall on the OS connect timeout -- the hang this removes.
|
||||
"""
|
||||
captured: list[tuple[tuple[str, int], float]] = []
|
||||
|
||||
def fake_create_connection(
|
||||
address: tuple[str, int], *, timeout: float
|
||||
) -> MagicMock:
|
||||
captured.append((address, timeout))
|
||||
return MagicMock()
|
||||
|
||||
with patch("socket.create_connection", side_effect=fake_create_connection):
|
||||
assert model_config._ollama_host_reachable("http://localhost:11434") is True
|
||||
|
||||
assert captured == [
|
||||
(("localhost", 11434), model_config.OLLAMA_DISCOVERY_TIMEOUT_SECONDS)
|
||||
]
|
||||
|
||||
def test_forwards_explicit_timeout(self) -> None:
|
||||
"""A caller-supplied timeout is forwarded to the socket connect."""
|
||||
captured: list[float] = []
|
||||
|
||||
def fake_create_connection(
|
||||
address: tuple[str, int], # noqa: ARG001
|
||||
*,
|
||||
timeout: float,
|
||||
) -> MagicMock:
|
||||
captured.append(timeout)
|
||||
return MagicMock()
|
||||
|
||||
with patch("socket.create_connection", side_effect=fake_create_connection):
|
||||
assert (
|
||||
model_config._ollama_host_reachable(
|
||||
"http://localhost:11434", timeout=0.25
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert captured == [0.25]
|
||||
|
||||
def test_false_and_silent_when_connection_refused(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Connection refused (an `OSError`) reports absent without warning."""
|
||||
refused = ConnectionRefusedError(61, "Connection refused")
|
||||
|
||||
def boom(*_args: object, **_kwargs: object) -> None:
|
||||
raise refused
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"),
|
||||
patch("socket.create_connection", side_effect=boom),
|
||||
):
|
||||
assert (
|
||||
model_config._ollama_host_reachable("http://localhost:11434") is False
|
||||
)
|
||||
|
||||
assert caplog.records == []
|
||||
|
||||
def test_false_and_warns_when_error_unexpected(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A non-`OSError` failure reports absent and surfaces a warning.
|
||||
|
||||
Covers `pytest-socket`'s `SocketBlockedError`, which inherits from
|
||||
`Exception` (not `OSError`); an unexpected error here is a possible
|
||||
real bug, so it is logged rather than silently swallowed.
|
||||
"""
|
||||
blocked = RuntimeError("sockets disabled")
|
||||
|
||||
def boom(*_args: object, **_kwargs: object) -> None:
|
||||
raise blocked
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"),
|
||||
patch("socket.create_connection", side_effect=boom),
|
||||
):
|
||||
assert (
|
||||
model_config._ollama_host_reachable("http://localhost:11434") is False
|
||||
)
|
||||
|
||||
assert any("unexpected RuntimeError" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_defers_to_probe_when_host_unparseable(self) -> None:
|
||||
"""A URL without a host defers to the HTTP probe instead of blocking it."""
|
||||
with patch("socket.create_connection") as fake:
|
||||
assert model_config._ollama_host_reachable("http://") is True
|
||||
|
||||
fake.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint",
|
||||
["http://localhost:notaport", "http://localhost:99999"],
|
||||
)
|
||||
def test_defers_to_probe_when_port_invalid(self, endpoint: str) -> None:
|
||||
"""An invalid port defers to the best-effort HTTP probe."""
|
||||
with patch("socket.create_connection") as fake:
|
||||
assert model_config._ollama_host_reachable(endpoint) is True
|
||||
|
||||
fake.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("endpoint", "expected"),
|
||||
[
|
||||
("https://ollama.example.com", ("ollama.example.com", 443)),
|
||||
("http://ollama.internal", ("ollama.internal", 80)),
|
||||
],
|
||||
)
|
||||
def test_defaults_scheme_port_when_absent(
|
||||
self, endpoint: str, expected: tuple[str, int]
|
||||
) -> None:
|
||||
"""A schemed URL without an explicit port falls back to the scheme default.
|
||||
|
||||
The preflight and the HTTP probe must agree on the target, so a portless
|
||||
`http` host resolves to 80 and `https` to 443 -- matching what urllib
|
||||
would connect to.
|
||||
"""
|
||||
captured: list[tuple[str, int]] = []
|
||||
|
||||
def fake_create_connection(
|
||||
address: tuple[str, int],
|
||||
*,
|
||||
timeout: float, # noqa: ARG001
|
||||
) -> MagicMock:
|
||||
captured.append(address)
|
||||
return MagicMock()
|
||||
|
||||
with patch("socket.create_connection", side_effect=fake_create_connection):
|
||||
assert model_config._ollama_host_reachable(endpoint) is True
|
||||
|
||||
assert captured == [expected]
|
||||
|
||||
|
||||
class TestFetchOllamaInstalledModelProfiles:
|
||||
"""Tests for Ollama `/api/show` profile discovery."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user