diff --git a/libs/code/deepagents_code/model_config.py b/libs/code/deepagents_code/model_config.py index 23c80b45bde..c529328a225 100644 --- a/libs/code/deepagents_code/model_config.py +++ b/libs/code/deepagents_code/model_config.py @@ -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) diff --git a/libs/code/tests/unit_tests/test_model_config.py b/libs/code/tests/unit_tests/test_model_config.py index c0f7b1b8318..367794e733b 100644 --- a/libs/code/tests/unit_tests/test_model_config.py +++ b/libs/code/tests/unit_tests/test_model_config.py @@ -6,7 +6,7 @@ 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 @@ def __exit__(self, *_exc: object) -> None: 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 @@ def test_rejects_unsupported_scheme(self) -> None: 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."""