NousResearch/hermes-agent

Caching local server type detection in model_metadata

Cluster 29971 · agent. Data as of

Nothing open on GitHub. Fix merged in #61368 on 9 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue 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 unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

Most members (two identical titles plus a related 'salvage probe-cache cluster' item) concern caching the result of detect_local_server_type in model_metadata for the process lifetime. One bug report describes repeated calls to unknown endpoints every 5 minutes, which may be related but the titles alone do not confirm the connection.

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": [redacted:secret-assignment]
                   "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

2 PRs closed without merging

From the close comments: closed without comment 1 · merged via another change 1

  • #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
Closed issues · 1
  • Closed#29971[Bug]: Hermes makes bogus calls to unknown endpoints every 5 minutes
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: 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
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
9 Jul 2026

For your agent

The same facts, typed.