# NousResearch/hermes-agent · Noisy probes to wrong endpoints, caching fixes

## Open now

- 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.

## TL;DR

- 11 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #61368. Verify it addresses your case.
- **Recommended:** read PR #61368 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-115838`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #13091: \[Bug\]: /v1/props should be GET /props
- #29971: \[Bug\]: Hermes makes bogus calls to unknown endpoints every 5 minutes
- #61421: \[Bug\]: Hermes probes non‑OpenAI endpoints (/api/tags, /v1/props, /version) on api.openai.com, causing noisy 404s

## Prior & competing PRs

- **#61368: perf(model_metadata): salvage probe-cache cluster**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.py`
  - fix hunks:
  - `agent/model_metadata.py`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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()
     
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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]] = {}
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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]
     
    ```

    ```diff
    @@ -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]
     
    ```

    ```diff
    @@ -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``
    ```

    ```diff
    @@ -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]
     
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/model_metadata.py`, `tests/agent/test_model_metadata_local_ctx.py`
- **#13317: fix(model_metadata): use /props endpoint at server root for llama.cpp**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#13539`, `#13091`
- **#13539: fix(agent): prefer /props over /v1/props for llama.cpp server detection**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `29d0541ac`
- **#29986: fix(model_metadata): cache detect_local_server_type result for process lifetime**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#29988: fix(model_metadata): cache detect_local_server_type result for process lifetime**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `f556edc10`, `a49c48927`, `#61368`
- **#61428: fix(agent): short-circuit local server detection for known public provider hosts**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `35f69a910124`, `#115838`, `#61421`, `#61428`
- **#61692: fix(agent): skip local server probes on known public endpoints**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`

## Fix status

- Merged to main in PR #61368 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-19`.
- Subsystem: `agent`
