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