From 5c5abc1f23e93f20c6a3e617d7121560c211fbbb Mon Sep 17 00:00:00 2001 From: rlaope <105429536+rlaope@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:08:56 +0900 Subject: [PATCH 1/2] fix(agent): stop re-probing endpoints that blackhole TCP connects (#71281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A routable-but-dead endpoint (corp LAN while off-VPN, sleeping Tailscale box) drops SYNs without a RST or ICMP error, so every probe in this module waits out its full timeout. Startup runs a waterfall of them across four functions and resolves the context length more than once, stacking into a ~60s hang before the banner renders — repeated on every launch, since only successful probes are persisted to context_length_cache.yaml. Record the first observed connect timeout per host:port and short-circuit the other probe paths on it for a short TTL. Keying on the exception the real HTTP layer raises (rather than a reachability probe of our own) adds no network I/O, nothing new for tests to mock, and can only fire after a timeout has already been paid — so it cannot suppress a probe that would have succeeded. Connection-refused and read timeouts deliberately do not trigger it: refused answers instantly (the "local server not started yet" path), and a read timeout means the connection was accepted. 59.65s -> 3.85s to first banner, measured under a pty. Co-Authored-By: Claude Opus 5 (1M context) --- agent/model_metadata.py | 157 ++++++++++++++++++++++-- tests/agent/test_endpoint_blackhole.py | 161 +++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 tests/agent/test_endpoint_blackhole.py diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 296fe0aedcab3..d657aa2176e5e 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -123,6 +123,99 @@ def _strip_provider_prefix(model: str) -> str: _ENDPOINT_PROBE_TTL_SECONDS = 3600.0 _endpoint_probe_path_cache: Dict[str, tuple] = {} +# A configured endpoint that is routable-but-dead — e.g. a corp LAN address +# while off-VPN — blackholes TCP: the SYN draws no SYN-ACK, no RST and no ICMP +# error, so a probe waits out its full timeout instead of failing fast. Startup +# runs a whole waterfall of such probes across several functions here, and the +# stalls stack into a minute-long hang before the banner renders. +# +# Once ANY probe has actually observed a connect timeout for an endpoint, the +# others have nothing to gain by repeating it. Recording that observation and +# short-circuiting on it performs no network I/O of its own — it adds no probe +# for callers or tests to mock, and it can only ever fire after a real timeout +# has already been paid, so it cannot suppress a probe that would have worked. +_ENDPOINT_BLACKHOLE_TTL_SECONDS = float( + os.environ.get("HERMES_ENDPOINT_BLACKHOLE_TTL", "30.0") +) +# Values are monotonic timestamps of the last observed connect timeout. +_endpoint_blackhole_cache: Dict[str, float] = {} + + +def _endpoint_host_key(base_url: str) -> Optional[str]: + """Return a ``host:port`` key for ``base_url``, or None if it has no host. + + Keyed on host:port rather than the full URL so every probe path for one + server — ``/v1``-suffixed or not, LM Studio root or API root — shares a + single entry. + """ + normalized = _normalize_base_url(base_url) + if not normalized: + return None + url = normalized if "://" in normalized else f"http://{normalized}" + try: + parsed = urlparse(url) + host = parsed.hostname + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except Exception: + return None + return f"{host}:{port}" if host else None + + +def _note_endpoint_blackholed(base_url: str) -> None: + """Record that a probe to ``base_url`` timed out during TCP connect.""" + key = _endpoint_host_key(base_url) + if key is None: + return + _endpoint_blackhole_cache[key] = time.monotonic() + logger.debug( + "Endpoint %s timed out connecting — skipping further probes for %.0fs", + key, _ENDPOINT_BLACKHOLE_TTL_SECONDS, + ) + + +def _endpoint_blackholed(base_url: str) -> bool: + """True if a recent probe to ``base_url`` timed out during TCP connect. + + Pure cache lookup; never touches the network. The entry expires after + _ENDPOINT_BLACKHOLE_TTL_SECONDS — long enough to collapse one startup's + burst of probes, short enough that bringing the VPN up mid-session is + picked up without a restart. ``HERMES_ENDPOINT_BLACKHOLE_TTL=0`` disables + the short-circuit entirely. + """ + if _ENDPOINT_BLACKHOLE_TTL_SECONDS <= 0: + return False + key = _endpoint_host_key(base_url) + if key is None: + return False + seen = _endpoint_blackhole_cache.get(key) + if seen is None: + return False + if (time.monotonic() - seen) >= _ENDPOINT_BLACKHOLE_TTL_SECONDS: + del _endpoint_blackhole_cache[key] + return False + return True + + +def _is_connect_timeout(exc: BaseException) -> bool: + """True for connect-phase timeouts raised by httpx or requests. + + Read timeouts are deliberately excluded: those mean the server accepted + the connection, which is the opposite of the blackhole this guards. + """ + try: + import httpx + if isinstance(exc, httpx.ConnectTimeout): + return True + except Exception: + pass + try: + from requests.exceptions import ConnectTimeout + if isinstance(exc, ConnectTimeout): + return True + except Exception: + pass + return False + def _get_model_metadata_cache_path() -> Path: """Return path to the OpenRouter model metadata disk cache.""" @@ -752,8 +845,25 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS: return cached[0] + # The host already blackholed a connect: skip the waterfall below, each leg + # of which would otherwise burn its full 2s timeout. Deliberately NOT + # written to _endpoint_probe_path_cache — that entry lives for an hour, + # which would pin the endpoint to "undetected" long after it comes back. + if _endpoint_blackholed(server_url): + return None + headers = _auth_headers(api_key) + def _probe_failed(exc: Exception) -> None: + """Swallow a probe error — or abort the waterfall if we were blackholed. + + Re-raising propagates out of the ``with`` block to the outer handler, + so the remaining legs are skipped instead of each stalling in turn. + """ + if _is_connect_timeout(exc): + _note_endpoint_blackholed(server_url) + raise exc + result: Optional[str] = None try: with httpx.Client(timeout=2.0, headers=headers) as client: @@ -762,8 +872,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: result = "lm-studio" - except Exception: - pass + except Exception as exc: + _probe_failed(exc) if result is None: # Ollama exposes /api/tags and responds with {"models": [...]} # LM Studio returns {"error": "Unexpected endpoint"} with status 200 @@ -777,8 +887,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: result = "ollama" except Exception: pass - except Exception: - pass + except Exception as exc: + _probe_failed(exc) if result is None: # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) try: @@ -787,8 +897,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: r = client.get(f"{server_url}/props") # fallback for older builds if r.status_code == 200 and "default_generation_settings" in r.text: result = "llamacpp" - except Exception: - pass + except Exception as exc: + _probe_failed(exc) if result is None: # vLLM: /version try: @@ -797,8 +907,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: data = r.json() if "version" in data: result = "vllm" - except Exception: - pass + except Exception as exc: + _probe_failed(exc) except Exception: pass @@ -989,6 +1099,12 @@ def fetch_endpoint_model_metadata( if cached is not None and (time.time() - cached_at) < _ENDPOINT_MODEL_CACHE_TTL: return cached + # Blackholed endpoint: every candidate below would spend its full 5s + # connect budget. Returned empty rather than cached, so the endpoint is + # retried as soon as the blackhole entry expires. + if _endpoint_blackholed(normalized): + return {} + candidates = [normalized] if normalized.endswith("/v1"): alternate = normalized[:-3].rstrip("/") @@ -1051,8 +1167,15 @@ def fetch_endpoint_model_metadata( return cache except Exception as exc: last_error = exc + if _is_connect_timeout(exc): + _note_endpoint_blackholed(normalized) for candidate in candidates: + # A connect timeout on one candidate condemns the host, not the path: + # the remaining candidates differ only by URL suffix, so trying them + # would repeat the same stall. + if _endpoint_blackholed(normalized): + break url = candidate.rstrip("/") + "/models" try: response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) @@ -1105,6 +1228,8 @@ def fetch_endpoint_model_metadata( return cache except Exception as exc: last_error = exc + if _is_connect_timeout(exc): + _note_endpoint_blackholed(normalized) if last_error: logger.debug("Failed to fetch model metadata from %s/models: %s", normalized, last_error) @@ -1654,6 +1779,9 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "" if server_url.endswith("/v1"): server_url = server_url[:-3] + if _endpoint_blackholed(server_url): + return None + headers = _auth_headers(api_key) try: @@ -1685,8 +1813,9 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "" return ctx except ValueError: pass - except Exception: - pass + except Exception as exc: + if _is_connect_timeout(exc): + _note_endpoint_blackholed(server_url) return None @@ -1774,6 +1903,9 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str server_url = server_url[:-3] lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url)) + if _endpoint_blackholed(server_url): + return None + headers = _auth_headers(api_key) try: @@ -1849,8 +1981,9 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str ctx = m.get("max_model_len") or m.get("context_length") or m.get("max_tokens") if ctx and isinstance(ctx, (int, float)): return int(ctx) - except Exception: - pass + except Exception as exc: + if _is_connect_timeout(exc): + _note_endpoint_blackholed(server_url) return None diff --git a/tests/agent/test_endpoint_blackhole.py b/tests/agent/test_endpoint_blackhole.py new file mode 100644 index 0000000000000..a43cd85112459 --- /dev/null +++ b/tests/agent/test_endpoint_blackhole.py @@ -0,0 +1,161 @@ +"""Tests for short-circuiting probes to endpoints that blackhole TCP connects. + +A routable-but-dead endpoint (e.g. a corp LAN address while off-VPN) drops SYNs +without a RST or ICMP error, so each probe waits out its full timeout. Once one +probe has observed that, the rest must not repeat it. + +Covers: +- _endpoint_blackholed / _note_endpoint_blackholed host:port keying and TTL +- detect_local_server_type aborting its waterfall on the first connect timeout +- non-timeout failures (refused, no route) leaving the waterfall untouched +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +@pytest.fixture(autouse=True) +def _clear_caches(): + """Module-level caches must not leak between tests.""" + from agent import model_metadata + model_metadata._endpoint_blackhole_cache.clear() + model_metadata._endpoint_probe_path_cache.clear() + yield + model_metadata._endpoint_blackhole_cache.clear() + model_metadata._endpoint_probe_path_cache.clear() + + +def _client_mock(side_effect): + client = MagicMock() + client.__enter__ = lambda s: client + client.__exit__ = MagicMock(return_value=False) + client.get.side_effect = side_effect + return client + + +class TestBlackholeCache: + def test_unseen_endpoint_is_not_blackholed(self): + from agent.model_metadata import _endpoint_blackholed + + assert _endpoint_blackholed("http://10.0.0.9:30080/v1") is False + + def test_note_then_detected(self): + from agent.model_metadata import _endpoint_blackholed, _note_endpoint_blackholed + + _note_endpoint_blackholed("http://10.0.0.9:30080/v1") + assert _endpoint_blackholed("http://10.0.0.9:30080/v1") is True + + def test_keyed_on_host_port_not_path(self): + """Every probe path for one server shares a single entry.""" + from agent.model_metadata import _endpoint_blackholed, _note_endpoint_blackholed + + _note_endpoint_blackholed("http://10.0.0.9:30080") + assert _endpoint_blackholed("http://10.0.0.9:30080/v1") is True + assert _endpoint_blackholed("http://10.0.0.9:30080/api/v1") is True + + def test_different_port_is_independent(self): + from agent.model_metadata import _endpoint_blackholed, _note_endpoint_blackholed + + _note_endpoint_blackholed("http://10.0.0.9:30080/v1") + assert _endpoint_blackholed("http://10.0.0.9:11434/v1") is False + + def test_entry_expires_after_ttl(self): + """A recovered endpoint (VPN back up) is probed again without a restart.""" + from agent import model_metadata + from agent.model_metadata import _endpoint_blackholed, _note_endpoint_blackholed + + _note_endpoint_blackholed("http://10.0.0.9:30080/v1") + stale = ( + model_metadata._endpoint_blackhole_cache["10.0.0.9:30080"] + - model_metadata._ENDPOINT_BLACKHOLE_TTL_SECONDS + - 1 + ) + model_metadata._endpoint_blackhole_cache["10.0.0.9:30080"] = stale + assert _endpoint_blackholed("http://10.0.0.9:30080/v1") is False + + def test_ttl_zero_disables_short_circuit(self): + from agent import model_metadata + from agent.model_metadata import _endpoint_blackholed, _note_endpoint_blackholed + + _note_endpoint_blackholed("http://10.0.0.9:30080/v1") + with patch.object(model_metadata, "_ENDPOINT_BLACKHOLE_TTL_SECONDS", 0.0): + assert _endpoint_blackholed("http://10.0.0.9:30080/v1") is False + + +class TestDetectLocalServerTypeBlackhole: + URL = "http://10.0.0.9:30080/v1" + + def test_connect_timeout_aborts_waterfall_after_one_probe(self): + """Four sequential 2s probes against a dead host must collapse to one.""" + from agent.model_metadata import _endpoint_blackholed, detect_local_server_type + + client = _client_mock(httpx.ConnectTimeout("timed out")) + with patch("httpx.Client", return_value=client): + assert detect_local_server_type(self.URL) is None + + assert client.get.call_count == 1 + assert _endpoint_blackholed(self.URL) is True + + def test_second_call_makes_no_request_at_all(self): + from agent.model_metadata import detect_local_server_type + + client = _client_mock(httpx.ConnectTimeout("timed out")) + with patch("httpx.Client", return_value=client): + detect_local_server_type(self.URL) + first_count = client.get.call_count + assert detect_local_server_type(self.URL) is None + + assert client.get.call_count == first_count + + def test_refused_does_not_blackhole_and_runs_full_waterfall(self): + """Refused answers instantly, so skipping buys nothing and must not fire. + + This is the common "local server not started yet" path. + """ + from agent.model_metadata import _endpoint_blackholed, detect_local_server_type + + client = _client_mock(httpx.ConnectError("connection refused")) + with patch("httpx.Client", return_value=client): + assert detect_local_server_type(self.URL) is None + + assert client.get.call_count > 1 + assert _endpoint_blackholed(self.URL) is False + + def test_read_timeout_does_not_blackhole(self): + """A read timeout means the connection was accepted — not a blackhole.""" + from agent.model_metadata import _endpoint_blackholed, detect_local_server_type + + client = _client_mock(httpx.ReadTimeout("slow")) + with patch("httpx.Client", return_value=client): + detect_local_server_type(self.URL) + + assert _endpoint_blackholed(self.URL) is False + + +class TestIsConnectTimeout: + def test_httpx_connect_timeout(self): + from agent.model_metadata import _is_connect_timeout + + assert _is_connect_timeout(httpx.ConnectTimeout("x")) is True + + def test_requests_connect_timeout(self): + from requests.exceptions import ConnectTimeout + + from agent.model_metadata import _is_connect_timeout + + assert _is_connect_timeout(ConnectTimeout("x")) is True + + def test_unrelated_errors_are_not_connect_timeouts(self): + from agent.model_metadata import _is_connect_timeout + + assert _is_connect_timeout(httpx.ReadTimeout("x")) is False + assert _is_connect_timeout(httpx.ConnectError("x")) is False + assert _is_connect_timeout(ValueError("x")) is False From 3fc03dd48a709e4c3594358cb6ff11329df6a15a Mon Sep 17 00:00:00 2001 From: rlaope <105429536+rlaope@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:52:42 +0900 Subject: [PATCH 2/2] fix(agent): drop HERMES_ENDPOINT_BLACKHOLE_TTL env override, cover all guarded probe paths Review follow-up: - Replace the HERMES_ENDPOINT_BLACKHOLE_TTL env var with a module constant. Repository policy reserves .env for secrets; a hardcoded 30s matches the existing _LOCAL_CTX_PROBE_TTL_SECONDS / _ENDPOINT_PROBE_TTL_SECONDS precedent in this module rather than adding a speculative config knob. - Add mocked-ConnectTimeout tests for the guarded paths the first cut left uncovered: fetch_endpoint_model_metadata's requests candidate loop, _query_ollama_api_show_uncached, and _query_local_context_length_uncached (guard honoured, blackhole recorded, read-timeout/refused excluded). --- agent/model_metadata.py | 7 +- tests/agent/test_endpoint_blackhole.py | 130 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index d657aa2176e5e..3133f280009f2 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -134,9 +134,7 @@ def _strip_provider_prefix(model: str) -> str: # short-circuiting on it performs no network I/O of its own — it adds no probe # for callers or tests to mock, and it can only ever fire after a real timeout # has already been paid, so it cannot suppress a probe that would have worked. -_ENDPOINT_BLACKHOLE_TTL_SECONDS = float( - os.environ.get("HERMES_ENDPOINT_BLACKHOLE_TTL", "30.0") -) +_ENDPOINT_BLACKHOLE_TTL_SECONDS = 30.0 # Values are monotonic timestamps of the last observed connect timeout. _endpoint_blackhole_cache: Dict[str, float] = {} @@ -179,8 +177,7 @@ def _endpoint_blackholed(base_url: str) -> bool: Pure cache lookup; never touches the network. The entry expires after _ENDPOINT_BLACKHOLE_TTL_SECONDS — long enough to collapse one startup's burst of probes, short enough that bringing the VPN up mid-session is - picked up without a restart. ``HERMES_ENDPOINT_BLACKHOLE_TTL=0`` disables - the short-circuit entirely. + picked up without a restart. """ if _ENDPOINT_BLACKHOLE_TTL_SECONDS <= 0: return False diff --git a/tests/agent/test_endpoint_blackhole.py b/tests/agent/test_endpoint_blackhole.py index a43cd85112459..b3bdc93a08a54 100644 --- a/tests/agent/test_endpoint_blackhole.py +++ b/tests/agent/test_endpoint_blackhole.py @@ -7,6 +7,9 @@ Covers: - _endpoint_blackholed / _note_endpoint_blackholed host:port keying and TTL - detect_local_server_type aborting its waterfall on the first connect timeout +- fetch_endpoint_model_metadata skipping its candidate loop once blackholed +- _query_ollama_api_show_uncached / _query_local_context_length_uncached + honouring and recording the blackhole - non-timeout failures (refused, no route) leaving the waterfall untouched """ @@ -18,6 +21,7 @@ import httpx import pytest +import requests sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -28,9 +32,15 @@ def _clear_caches(): from agent import model_metadata model_metadata._endpoint_blackhole_cache.clear() model_metadata._endpoint_probe_path_cache.clear() + model_metadata._endpoint_model_metadata_cache.clear() + model_metadata._endpoint_model_metadata_cache_time.clear() + model_metadata._LOCAL_CTX_PROBE_CACHE.clear() yield model_metadata._endpoint_blackhole_cache.clear() model_metadata._endpoint_probe_path_cache.clear() + model_metadata._endpoint_model_metadata_cache.clear() + model_metadata._endpoint_model_metadata_cache_time.clear() + model_metadata._LOCAL_CTX_PROBE_CACHE.clear() def _client_mock(side_effect): @@ -38,6 +48,7 @@ def _client_mock(side_effect): client.__enter__ = lambda s: client client.__exit__ = MagicMock(return_value=False) client.get.side_effect = side_effect + client.post.side_effect = side_effect return client @@ -140,6 +151,125 @@ def test_read_timeout_does_not_blackhole(self): assert _endpoint_blackholed(self.URL) is False +class TestFetchEndpointModelMetadataBlackhole: + URL = "http://10.0.0.9:30080/v1" + + def test_connect_timeout_skips_remaining_candidates(self): + """A timeout condemns the host, not the URL suffix — one stall, not two.""" + from agent.model_metadata import _endpoint_blackholed, fetch_endpoint_model_metadata + + with patch("agent.model_metadata.detect_local_server_type", return_value=None), \ + patch( + "agent.model_metadata.requests.get", + side_effect=requests.exceptions.ConnectTimeout("timed out"), + ) as get: + assert fetch_endpoint_model_metadata(self.URL) == {} + + assert get.call_count == 1 + assert _endpoint_blackholed(self.URL) is True + + def test_refused_tries_every_candidate_and_does_not_blackhole(self): + from agent.model_metadata import _endpoint_blackholed, fetch_endpoint_model_metadata + + with patch("agent.model_metadata.detect_local_server_type", return_value=None), \ + patch( + "agent.model_metadata.requests.get", + side_effect=requests.exceptions.ConnectionError("refused"), + ) as get: + assert fetch_endpoint_model_metadata(self.URL) == {} + + assert get.call_count == 2 # /v1-suffixed and bare candidates + assert _endpoint_blackholed(self.URL) is False + + def test_blackholed_endpoint_issues_no_request(self): + """force_refresh bypasses the metadata cache, so only the guard can stop it.""" + from agent.model_metadata import _note_endpoint_blackholed, fetch_endpoint_model_metadata + + _note_endpoint_blackholed(self.URL) + with patch("agent.model_metadata.detect_local_server_type", return_value=None), \ + patch("agent.model_metadata.requests.get") as get: + assert fetch_endpoint_model_metadata(self.URL, force_refresh=True) == {} + + get.assert_not_called() + + +class TestQueryOllamaApiShowBlackhole: + URL = "http://10.0.0.9:30080/v1" + + def test_connect_timeout_records_blackhole(self): + from agent.model_metadata import _endpoint_blackholed, _query_ollama_api_show_uncached + + client = _client_mock(httpx.ConnectTimeout("timed out")) + with patch("httpx.Client", return_value=client): + assert _query_ollama_api_show_uncached("some-model", self.URL) is None + + assert client.post.call_count == 1 + assert _endpoint_blackholed(self.URL) is True + + def test_blackholed_endpoint_issues_no_request(self): + from agent.model_metadata import _note_endpoint_blackholed, _query_ollama_api_show_uncached + + _note_endpoint_blackholed(self.URL) + with patch("httpx.Client") as client_cls: + assert _query_ollama_api_show_uncached("some-model", self.URL) is None + + client_cls.assert_not_called() + + def test_read_timeout_does_not_blackhole(self): + from agent.model_metadata import _endpoint_blackholed, _query_ollama_api_show_uncached + + client = _client_mock(httpx.ReadTimeout("slow")) + with patch("httpx.Client", return_value=client): + assert _query_ollama_api_show_uncached("some-model", self.URL) is None + + assert _endpoint_blackholed(self.URL) is False + + +class TestQueryLocalContextLengthBlackhole: + URL = "http://10.0.0.9:30080/v1" + + def test_connect_timeout_records_blackhole(self): + from agent.model_metadata import ( + _endpoint_blackholed, + _query_local_context_length_uncached, + ) + + client = _client_mock(httpx.ConnectTimeout("timed out")) + with patch("agent.model_metadata.detect_local_server_type", return_value=None), \ + patch("httpx.Client", return_value=client): + assert _query_local_context_length_uncached("some-model", self.URL) is None + + assert _endpoint_blackholed(self.URL) is True + + def test_blackholed_endpoint_skips_detection_and_requests(self): + """The guard sits before detect_local_server_type — nothing runs at all.""" + from agent.model_metadata import ( + _note_endpoint_blackholed, + _query_local_context_length_uncached, + ) + + _note_endpoint_blackholed(self.URL) + with patch("agent.model_metadata.detect_local_server_type") as detect, \ + patch("httpx.Client") as client_cls: + assert _query_local_context_length_uncached("some-model", self.URL) is None + + detect.assert_not_called() + client_cls.assert_not_called() + + def test_read_timeout_does_not_blackhole(self): + from agent.model_metadata import ( + _endpoint_blackholed, + _query_local_context_length_uncached, + ) + + client = _client_mock(httpx.ReadTimeout("slow")) + with patch("agent.model_metadata.detect_local_server_type", return_value=None), \ + patch("httpx.Client", return_value=client): + assert _query_local_context_length_uncached("some-model", self.URL) is None + + assert _endpoint_blackholed(self.URL) is False + + class TestIsConnectTimeout: def test_httpx_connect_timeout(self): from agent.model_metadata import _is_connect_timeout