Noisy probes to wrong endpoints, caching fixes
NousResearch/hermes-agent · cluster 13091 · agent
Data as of
Nothing open on GitHub. Fix merged in #61368. Check it covers your case.
The short answer
- Known?
- Yes. 11 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #61368. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #115838. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A bug where Hermes probes non-standard endpoints (like /api/tags, /v1/props, /version) against public providers like OpenAI, causing 404 noise. Related work addresses caching probe results, correcting the llama.cpp props endpoint path, and skipping local-server detection for known public hosts.
Open issues0
Open PRs0
Fixes on record
-
#61368 perf(model_metadata): salvage probe-cache cluster
files:
agent/model_metadata.py,model_tools.py,scripts/release.py,tests/agent/test_model_metadata_local_ctx.py,tests/agent/test_probe_cache_followups.pyShow the diff
-
agent/model_metadata.py@@ -111,6 +111,15 @@ def _strip_provider_prefix(model: str) -> str: _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} _ENDPOINT_MODEL_CACHE_TTL = 300 +# Bounded-lifetime cache: after the first successful probe we remember the +# server type so subsequent refreshes skip the full waterfall (no more 404 +# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm). +# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the +# same port (stop Ollama, start LM Studio) is eventually re-detected instead +# of being pinned to the stale type for the whole process lifetime. +# Values are (server_type, monotonic_timestamp). +_ENDPOINT_PROBE_TTL_SECONDS = 3600.0 +_endpoint_probe_path_cache: Dict[str, tuple] = {} def _get_model_metadata_cache_path() -> Path: @@ -632,66 +641,109 @@ def is_local_endpoint(base_url: str) -> bool: return False +def _localhost_to_ipv4(url: str) -> str: + """Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL. + + On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1`` + first and pays a ~2s IPv6 connect timeout before falling back to IPv4 + when the local server only listens on IPv4 (LM Studio, Ollama defaults). + Probing the IPv4 loopback directly skips that penalty. + + Only the URL's own host component is rewritten (anchored at the scheme), + so a non-localhost URL whose path or query merely embeds the substring + ``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``) + passes through untouched. + """ + if not url: + return url + return re.sub( + r"^(https?://)localhost(?=[:/]|$)", + r"\g<1>127.0.0.1", + url, + count=1, + ) + + def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: """Detect which local server is running at base_url by probing known endpoints. Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None. + + The result is cached for the lifetime of the process so that repeated + calls (e.g. every 5-minute metadata refresh) never re-run the waterfall + and never spray 404s at endpoints the server does not expose. """ import httpx normalized = _normalize_base_url(base_url) + + # Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack. + # Applied to ``normalized`` before deriving server/LM Studio URLs AND + # before the cache lookup, so localhost and 127.0.0.1 share a cache entry. + normalized = _localhost_to_ipv4(normalized) + server_url = normalized if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _lmstudio_server_root(normalized) + + cached = _endpoint_probe_path_cache.get(server_url) + if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS: + return cached[0] headers = _auth_headers(api_key) + result: Optional[str] = None try: with httpx.Client(timeout=2.0, headers=headers) as client: # LM Studio exposes /api/v1/models — check first (most specific) try: r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: - return "lm-studio" + result = "lm-studio" except Exception: pass - # Ollama exposes /api/tags and responds with {"models": [...]} - # LM Studio returns {"error": "Unexpected endpoint"} with status 200 - # on this path, so we must verify the response contains "models". - try: - r = client.get(f"{server_url}/api/tags") - if r.status_code == 200: - try: + if result is None: + # Ollama exposes /api/tags and responds with {"models": [...]} + # LM Studio returns {"error": "Unexpected endpoint"} with status 200 + # on this path, so we must verify the response contains "models". + try: + r = client.get(f"{server_url}/api/tags") + if r.status_code == 200: + try: + data = r.json() + if "models" in data: + result = "ollama" + except Exception: + pass + except Exception: + pass + if result is None: + # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) + try: + r = client.get(f"{server_url}/v1/props") + if r.status_code != 200: + 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 + if result is None: + # vLLM: /version + try: + r = client.get(f"{server_url}/version") + if r.status_code == 200: data = r.json() - if "models" in data: - return "ollama" - except Exception: - pass - except Exception: - pass - # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) - try: - r = client.get(f"{server_url}/v1/props") - if r.status_code != 200: - r = client.get(f"{server_url}/props") # fallback for older builds - if r.status_code == 200 and "default_generation_settings" in r.text: - return "llamacpp" - except Exception: - pass - # vLLM: /version - try: - r = client.get(f"{server_url}/version") - if r.status_code == 200: - data = r.json() - if "version" in data: - return "vllm" - except Exception: - pass + if "version" in data: + result = "vllm" + except Exception: + pass except Exception: pass - return None + if result is not None: + _endpoint_probe_path_cache[server_url] = (result, time.monotonic()) + return result def _iter_nested_dicts(value: Any): @@ -795,7 +847,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any return _model_metadata_cache try: - response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify()) + # Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per + # retry stage through proxies that 403 CONNECT, ballooning to minutes + # (#46620). 5s connect / 10s read fails fast on unreachable hosts. + response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() data = response.json() @@ -873,7 +928,7 @@ def fetch_endpoint_model_metadata( response = requests.get( server_url.rstrip("/") + "/api/v1/models", headers=headers, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) response.raise_for_status() @@ -921,7 +976,7 @@ def fetch_endpoint_model_metadata( for candidate in candidates: url = candidate.rstrip("/") + "/models" try: - response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() payload = response.json() cache: Dict[str, Dict[str, Any]] = {} @@ -1022,13 +1077,23 @@ def _load_context_cache() -> Dict[str, int]: return {} +def _context_cache_key(model: str, base_url: str) -> str: + """Canonical ``model@base_url`` key for the persistent context cache. + + Trailing slashes are stripped so ``http://host/v1`` and + ``http://host/v1/`` share one entry instead of creating duplicates + that can go stale independently. + """ + return f"{model}@{(base_url or '').rstrip('/')}" + + def save_context_length(model: str, base_url: str, length: int) -> None: """Persist a discovered context length for a model+provider combo. Cache key is ``model@base_url`` so the same model name served from different providers can have different limits. """ - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() if cache.get(key) == length: return # already stored @@ -1045,18 +1110,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None: def get_cached_context_length(model: str, base_url: str) -> Optional[int]: """Look up a previously discovered context length for model+provider.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - return cache.get(key) + hit = cache.get(key) + if hit is not None: + return hit + # Legacy rows written before key normalization may carry a trailing + # slash — honor them rather than re-probing. Checked regardless of the + # caller's slash form: the row's shape and the caller's shape can differ + # in either direction (old slashed row + new normalized config, or the + # reverse), so probe the literal form and the slashed canonical form. + for legacy_key in (f"{model}@{base_url}", f"{key}/"): + if legacy_key != key: + hit = cache.get(legacy_key) + if hit is not None: + return hit + return None def _invalidate_cached_context_length(model: str, base_url: str) -> None: """Drop a stale cache entry so it gets re-resolved on the next lookup.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - if key not in cache: + # Invalidation must also drop the in-memory TTL probe entries for this + # pair — otherwise the next resolution inside the TTL window reuses the + # very value we just declared stale and re-persists it. + bare = _strip_provider_prefix(model) + stripped = (base_url or "").rstrip("/") + _LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None) + _LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None) + # Clear every key shape for this pair: canonical, the caller's literal + # form, and the slashed legacy form — same set get_cached_context_length + # consults, so a lookup can never resurrect a row invalidation missed. + stale_keys = {key, f"{model}@{base_url}", f"{key}/"} + if not any(k in cache for k in stale_keys): return - del cache[key] + for k in stale_keys: + cache.pop(k, None) path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) @@ -1343,7 +1433,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option import httpx bare_model = _strip_provider_prefix(model) - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1404,7 +1494,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") - except Exception: return None - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1443,6 +1533,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti hosting behind a reverse proxy, etc. For non-Ollama servers the POST returns 404/405 quickly; the function handles errors gracefully. + Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL, + positive-only — see ``_query_local_context_length``) so back-to-back + resolutions during one startup issue a single POST instead of one per + call site. Failures are never memoized: a server that isn't up yet must + be re-probed once it comes up. + For hosted servers the GGUF ``model_info.*.context_length`` is the authoritative source: the user can't set their own ``num_ctx``, and the OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length`` @@ -1454,9 +1550,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti The order is flipped vs ``query_ollama_num_ctx()`` because local users control ``num_ctx`` themselves; hosted users can't. """ + import time as _time + + # Namespaced cache key: shares the TTL store with + # _query_local_context_length but never collides with its (model, url) + # keys — the two probes can return different values for the same pair. + cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/")) + now = _time.monotonic() + cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key) + if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS: + return cached[0] + + result = _query_ollama_api_show_uncached(model, base_url, api_key=[redacted:secret-assignment] + if result: # positive-only — never memoize a failed probe + _LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now) + return result + + +def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]: + """Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``.""" import httpx - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1575,10 +1690,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str model = _strip_provider_prefix(model) # Strip /v1 suffix to get the server root - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url)) headers = _auth_headers(api_key) @@ -1688,7 +1803,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "x-api-key": api_key, "anthropic-version": "2023-06-01", } - resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) if resp.status_code != 200: return None data = resp.json() @@ -1755,7 +1870,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: resp = requests.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) if resp.status_code != 200: -
model_tools.py@@ -583,7 +583,13 @@ def _resolve_active_context_length() -> int: if not model_id: return 0 from agent.model_metadata import get_model_context_length - return int(get_model_context_length(model_id) or 0) + # Honor explicit `model.context_length` in config.yaml — short-circuits + # the OpenRouter /models probe at get_model_context_length step 0, so + # non-OpenRouter providers don't pay the ~2-3s OpenRouter fetch at every + # CLI startup. See issue #46620. + raw_ctx = model_cfg.get("context_length") + config_ctx = raw_ctx if isinstance(raw_ctx, int) and raw_ctx > 0 else None + return int(get_model_context_length(model_id, config_context_length=config_ctx) or 0) except Exception as e: logger.debug("Could not resolve active context length: %s", e) return 0 -
scripts/release.py@@ -1933,6 +1933,8 @@ "[redacted:email]": "mahdiwafy", # PR #60347 salvage (session messages pagination) "[redacted:email]": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip) "[redacted:email]": "dexhunter", # PR #60339 salvage (skills snapshot manifest speedup) + "[redacted:email]": "uzaylisak", # PR #29988 salvage (detect_local_server_type process-lifetime cache) + "[redacted:email]": "8294", # PR #50572 salvage (honor config context_length on banner) } -
tests/agent/test_model_metadata_local_ctx.py@@ -465,7 +465,7 @@ def test_lmstudio_native_api_base_url_is_not_doubled(self): result = _query_local_context_length("publisher/model-a", "http://localhost:1234/api/v1") assert result == 32768 - assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" + assert client_mock.get.call_args_list[0].args[0] == "http://127.0.0.1:1234/api/v1/models" class TestDetectLocalServerTypeAuth: @@ -504,7 +504,67 @@ def test_native_api_base_url_is_not_doubled(self): result = detect_local_server_type("http://localhost:1234/api/v1") assert result == "lm-studio" - assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" + assert client_mock.get.call_args_list[0].args[0] == "http://127.0.0.1:1234/api/v1/models" + + +class TestDetectLocalServerTypeLocalhostIPv4: + """detect_local_server_type should resolve localhost to 127.0.0.1.""" + + def test_localhost_resolved_to_ipv4(self): + """Probes should use 127.0.0.1, not localhost, to avoid IPv6 timeout.""" + from agent.model_metadata import detect_local_server_type + + resp = MagicMock() + resp.status_code = 200 + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://localhost:8317/v1") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "localhost" not in url, f"Probe URL still uses localhost: {url}" + assert "127.0.0.1" in url + + def test_non_localhost_urls_unchanged(self): + """Non-localhost URLs should not be modified.""" + from agent.model_metadata import detect_local_server_type + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.status_code = 404 + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://192.168.1.100:8080") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "192.168.1.100" in url + + def test_127_0_0_1_urls_unchanged(self): + """URLs already using 127.0.0.1 should pass through unchanged.""" + from agent.model_metadata import detect_local_server_type + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.status_code = 404 + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://127.0.0.1:8317") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "127.0.0.1" in url class TestFetchEndpointModelMetadataLmStudio: -
tests/agent/test_probe_cache_followups.py@@ -0,0 +1,313 @@ +"""Tests for probe-cache follow-ups on the #29988/#37595/#50572 salvage. + +Covers: +- _query_ollama_api_show TTL caching (positive-only, namespaced key) +- persistent context-cache key normalization (trailing-slash dedup) +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + [redacted:email](autouse=True) +def _clear_probe_cache(): + """Module-level caches must not leak between tests.""" + from agent import model_metadata + model_metadata._LOCAL_CTX_PROBE_CACHE.clear() + model_metadata._endpoint_probe_path_cache.clear() + yield + model_metadata._LOCAL_CTX_PROBE_CACHE.clear() + model_metadata._endpoint_probe_path_cache.clear() + + +def _mock_show_response(ctx=131072): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "model_info": {"llama.context_length": ctx}, + "parameters": "", + } + return resp + + +def _client_mock(resp): + client = MagicMock() + client.__enter__ = lambda s: client + client.__exit__ = MagicMock(return_value=False) + client.post.return_value = resp + return client + + +class TestOllamaApiShowCaching: + def test_positive_result_cached_within_ttl(self): + from agent.model_metadata import _query_ollama_api_show + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + first = _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + second = _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + + assert first == second == 131072 + assert client.post.call_count == 1 # second call served from cache + + def test_failure_never_memoized(self): + """A down server must be re-probed on the next call (startup race).""" + from agent.model_metadata import _query_ollama_api_show + + bad = MagicMock() + bad.status_code = 404 + client = _client_mock(bad) + with patch("httpx.Client", return_value=client): + assert _query_ollama_api_show("llama3", "http://127.0.0.1:11434") is None + assert _query_ollama_api_show("llama3", "http://127.0.0.1:11434") is None + + assert client.post.call_count == 2 # None was NOT cached + + def test_ttl_expiry_reprobes(self): + """After the 30s TTL lapses, the next call must hit the network again.""" + from agent import model_metadata + from agent.model_metadata import _query_ollama_api_show + import time as _time + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + # Age the entry past the TTL. + ((key, (val, _ts)),) = list(model_metadata._LOCAL_CTX_PROBE_CACHE.items()) + model_metadata._LOCAL_CTX_PROBE_CACHE[key] = ( + val, _time.monotonic() - model_metadata._LOCAL_CTX_PROBE_TTL_SECONDS - 1, + ) + _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + + assert client.post.call_count == 2 # expired entry re-probed + + def test_cache_key_does_not_collide_with_local_ctx_probe(self): + """The ollama_show namespace must not read _query_local_context_length rows.""" + from agent import model_metadata + from agent.model_metadata import _query_ollama_api_show + import time as _time + + # Seed a same-(model,url) entry under the sibling probe's key shape. + model_metadata._LOCAL_CTX_PROBE_CACHE[("llama3", "http://127.0.0.1:11434")] = ( + 999, _time.monotonic(), + ) + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + result = _query_ollama_api_show("llama3", "http://127.0.0.1:11434") + + assert result == 131072 # probed for real, not the sibling's 999 + assert client.post.call_count == 1 + + +class TestDetectLocalServerTypeCache: + """#29988: detect_local_server_type memoized with a bounded TTL.""" + + def _get_client(self, server_type="ollama"): + ollama_resp = MagicMock() + ollama_resp.status_code = 200 + ollama_resp.json.return_value = {"models": []} + miss = MagicMock() + miss.status_code = 404 + + client = MagicMock() + client.__enter__ = lambda s: client + client.__exit__ = MagicMock(return_value=False) + + def _get(url, *a, **k): + if url.endswith("/api/tags"): + return ollama_resp + return miss + + client.get.side_effect = _get + return client + + def test_second_call_served_from_cache(self): + from agent.model_metadata import detect_local_server_type + + client = self._get_client() + with patch("httpx.Client", return_value=client): + first = detect_local_server_type("http://127.0.0.1:11434") + calls_after_first = client.get.call_count + second = detect_local_server_type("http://127.0.0.1:11434") + + assert first == second == "ollama" + assert client.get.call_count == calls_after_first # no new HTTP traffic + + def test_ttl_expiry_allows_server_swap_redetection(self): + """Stopping Ollama and starting LM Studio on the same port must be + re-detected once the TTL lapses — the cache is bounded, not + process-lifetime.""" + from agent import model_metadata + from agent.model_metadata import detect_local_server_type + import time as _time + + client = self._get_client() + with patch("httpx.Client", return_value=client): + assert detect_local_server_type("http://127.0.0.1:11434") == "ollama" + + # Age the entry past the TTL, then swap the backend behind the URL. + ((key, (val, _ts)),) = list(model_metadata._endpoint_probe_path_cache.items()) + model_metadata._endpoint_probe_path_cache[key] = ( + val, _time.monotonic() - model_metadata._ENDPOINT_PROBE_TTL_SECONDS - 1, + ) + + lmstudio_resp = MagicMock() + lmstudio_resp.status_code = 200 + lmstudio_resp.json.return_value = {"data": []} + swap_client = MagicMock() + swap_client.__enter__ = lambda s: swap_client + swap_client.__exit__ = MagicMock(return_value=False) + + def _get(url, *a, **k): + if url.endswith("/api/v1/models"): + return lmstudio_resp + miss = MagicMock(); miss.status_code = 404 + return miss + + swap_client.get.side_effect = _get + with patch("httpx.Client", return_value=swap_client): + assert detect_local_server_type("http://127.0.0.1:11434") == "lm-studio" + + +class TestLocalhostIPv4SiblingSites: + """#37595 widened: every probe helper rewrites localhost→127.0.0.1, + not just detect_local_server_type.""" + + def test_helper_rewrites_all_forms(self): + from agent.model_metadata import _localhost_to_ipv4 + + assert _localhost_to_ipv4("http://localhost:1234/v1") == "http://127.0.0.1:1234/v1" + assert _localhost_to_ipv4("http://localhost/v1") == "http://127.0.0.1/v1" + assert _localhost_to_ipv4("http://localhost") == "http://127.0.0.1" + # Non-localhost passes through untouched. + assert _localhost_to_ipv4("http://192.168.1.10:8080") == "http://192.168.1.10:8080" + assert _localhost_to_ipv4("https://api.openai.com/v1") == "https://api.openai.com/v1" + assert _localhost_to_ipv4("") == "" + + def test_rewrite_is_host_only_not_substring(self): + """A URL that merely EMBEDS 'http://localhost' in its path/query must + not be corrupted — only the URL's own host is rewritten.""" + from agent.model_metadata import _localhost_to_ipv4 + + proxied = "https://proxy.example.com/route?upstream=http://localhost:11434" + assert _localhost_to_ipv4(proxied) == proxied + # Host must be a full label: localhost.example.com is NOT localhost. + assert _localhost_to_ipv4("http://localhost.example.com/v1") == ( + "http://localhost.example.com/v1" + ) + + def test_ollama_api_show_probes_ipv4(self): + from agent.model_metadata import _query_ollama_api_show + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + _query_ollama_api_show("llama3", "http://localhost:11434") + + assert client.post.call_args[0][0].startswith("http://127.0.0.1:11434") + + def test_query_ollama_num_ctx_probes_ipv4(self): + from agent.model_metadata import query_ollama_num_ctx + + client = _client_mock(_mock_show_response(131072)) + with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \ + patch("httpx.Client", return_value=client): + query_ollama_num_ctx("llama3", "http://localhost:11434") + + assert client.post.call_args[0][0].startswith("http://127.0.0.1:11434") + + +class TestContextCacheKeyNormalization: + def test_trailing_slash_variants_share_one_entry(self, tmp_path, monkeypatch): + from agent import model_metadata + + monkeypatch.setattr( + model_metadata, "_get_context_cache_path", + lambda: tmp_path / "context_lengths.yaml", + ) + + model_metadata.save_context_length("m1", "http://host/v1/", 200_000) + # Both slash variants resolve to the same row. + assert model_metadata.get_cached_context_length("m1", "http://host/v1") == 200_000 + assert model_metadata.get_cached_context_length("m1", "http://host/v1/") == 200_000 + + cache = model_metadata._load_context_cache() + assert list(cache.keys()) == ["m1@http://host/v1"] + + def test_legacy_unnormalized_row_still_honored(self, tmp_path, monkeypatch): + """Rows written pre-normalization (trailing slash in key) must not force a re-probe.""" + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": {"m1@http://host/v1/": 128_000}})) + + assert model_metadata.get_cached_context_length("m1", "http://host/v1/") == 128_000 + + def test_legacy_slashed_row_found_with_normalized_caller(self, tmp_path, monkeypatch): + """Reverse migration direction: old row has the slash, current runtime + passes the normalized no-slash URL — must still hit, not re-probe.""" + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": {"m1@http://host/v1/": 128_000}})) + + assert model_metadata.get_cached_context_length("m1", "http://host/v1") == 128_000 + + def test_invalidate_clears_both_key_shapes(self, tmp_path, monkeypatch): + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": { + "m1@http://host/v1": 128_000, + "m1@http://host/v1/": 64_000, + }})) + + model_metadata._invalidate_cached_context_length("m1", "http://host/v1/") + cache = model_metadata._load_context_cache() + assert "m1@http://host/v1" not in cache + assert "m1@http://host/v1/" not in cache + + def test_invalidate_with_normalized_caller_clears_legacy_row(self, tmp_path, monkeypatch): + """Reverse direction: invalidating with the no-slash URL must also + drop a legacy slashed row, or the next lookup resurrects stale data.""" + import yaml + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + path.write_text(yaml.dump({"context_lengths": {"m1@http://host/v1/": 64_000}})) + + model_metadata._invalidate_cached_context_length("m1", "http://host/v1") + assert model_metadata.get_cached_context_length("m1", "http://host/v1") is None + assert model_metadata.get_cached_context_length("m1", "http://host/v1/") is None + + def test_invalidate_also_drops_in_memory_probe_entries(self, tmp_path, monkeypatch): + """Disk invalidation must clear the in-memory TTL rows too, or the + next resolution inside the TTL window re-persists the stale value.""" + import time as _time + from agent import model_metadata + + path = tmp_path / "context_lengths.yaml" + monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) + + now = _time.monotonic() + model_metadata._LOCAL_CTX_PROBE_CACHE[("m1", "http://host/v1")] = (999, now) + model_metadata._LOCAL_CTX_PROBE_CACHE[("ollama_show", "m1", "http://host/v1")] = (999, now) + + model_metadata._invalidate_cached_context_length("m1", "http://host/v1") + + assert ("m1", "http://host/v1") not in model_metadata._LOCAL_CTX_PROBE_CACHE + assert ("ollama_show", "m1", "http://host/v1") not in model_metadata._LOCAL_CTX_PROBE_CACHE
-
-
#115838 fix(agent): hosted provider hosts no longer receive local-server discovery probes, ending 404 noise on api.openai.com (#61421, salvage #61428)
files:
agent/model_metadata.py,tests/agent/test_model_metadata_local_ctx.py
6 PRs closed without merging
From the close comments: merged via another change 2 · closed without comment 1 · duplicate 1 · stale or abandoned 1 · other 1
-
#13317 fix(model_metadata): use /props endpoint at server root for llama.cpp · duplicate →
#13539,#13091 -
#13539 fix(agent): prefer /props over /v1/props for llama.cpp server detection · other →
29d0541 - #29986 fix(model_metadata): cache detect_local_server_type result for process lifetime · closed without comment
-
#29988 fix(model_metadata): cache detect_local_server_type result for process lifetime · merged via another change →
f556edc,a49c489,#61368 -
#61428 fix(agent): short-circuit local server detection for known public provider hosts · merged via another change →
35f69a9,#115838,#61421,#61428
Show 1 more
- #61692 fix(agent): skip local server probes on known public endpoints · stale or abandoned
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab