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

0open issues
0open PRs
11items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #61368 perf(model_metadata): salvage probe-cache cluster

    merged to main

    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

    Show 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)

    merged to main

    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
  • Closed#13091[Bug]: /v1/props should be GET /props
  • Closed#29971[Bug]: Hermes makes bogus calls to unknown endpoints every 5 minutes
  • Closed#61421[Bug]: Hermes probes non‑OpenAI endpoints (/api/tags, /v1/props, /version) on api.openai.com, causing noisy 404s
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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#61368, #115838
Closed issues
3
Attempts
6 PRs closed without merging
Fix link checked
19 Sep 2026

For your agent

The same facts, typed.