diff --git a/hermes_cli/models.py b/hermes_cli/models.py index a6a86c3c92..33e6547e08 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from hermes_cli import __version__ as _HERMES_VERSION from hermes_cli.urllib_security import open_credentialed_url, url_origin -from utils import base_url_host_matches +from utils import atomic_json_write, base_url_host_matches logger = logging.getLogger(__name__) @@ -1600,6 +1600,124 @@ _openrouter_reasoning_caps_cache: dict[str, Optional[dict[str, Any]]] | None = N _openrouter_reasoning_caps_failed_at: float | None = None +# ── Disk mirror ──────────────────────────────────────────────────────── +# +# The in-process caches are always cold in a short-lived process, and every +# consumer is on a hot path that must never block on HTTP — so without a disk +# copy, `hermes -p`, a cron job, or a freshly booted gateway answers +# "capability unknown" for its whole first turn and falls back to the +# conservative wire shape. Persisting the parsed catalog makes every run after +# the first correct from its first turn. +# +# One file holds every catalog, keyed by the URL it came from: OpenRouter and +# the Nous Portal list different models, and a staging Portal must not answer +# for production. +_REASONING_CAPS_DISK_TTL_SECONDS = 24 * 3600 + + +def _reasoning_caps_disk_path() -> Path: + from hermes_constants import get_hermes_home + return get_hermes_home() / "cache" / "reasoning_caps.json" + + +def _read_reasoning_caps_disk() -> dict[str, Any]: + try: + with _reasoning_caps_disk_path().open(encoding="utf-8") as fh: + data = json.load(fh) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _load_reasoning_caps_disk( + url: str, +) -> tuple[Optional[dict[str, Optional[dict[str, Any]]]], float]: + """Return ``(caps, age_seconds)`` for *url*, or ``(None, 0.0)``.""" + entry = _read_reasoning_caps_disk().get(url) + if not isinstance(entry, dict): + return None, 0.0 + caps = entry.get("caps") + if not isinstance(caps, dict) or not caps: + return None, 0.0 + try: + age = max(0.0, time.time() - float(entry.get("ts") or 0)) + except (TypeError, ValueError): + age = float(_REASONING_CAPS_DISK_TTL_SECONDS) + return {str(mid): model_caps for mid, model_caps in caps.items()}, age + + +def _save_reasoning_caps_disk( + url: str, caps: dict[str, Optional[dict[str, Any]]] +) -> None: + """Merge *url*'s catalog into the shared disk mirror, atomically.""" + try: + data = _read_reasoning_caps_disk() + data[url] = {"ts": time.time(), "caps": caps} + path = _reasoning_caps_disk_path() + path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(path, data, indent=0, separators=(",", ":")) + except Exception as exc: + logger.debug("Failed to save reasoning-caps disk cache: %s", exc) + + +def _warm_reasoning_caps_async(refresh) -> None: + """Run *refresh* in a background thread. Fire-and-forget. + + Called from hot paths that found the cache cold or the disk copy stale, so + the next call — or, via the disk mirror, the next process — benefits + without this turn ever blocking on HTTP. Callers own the once-per-process + guard; the fetch keeps its own failure TTL. Skipped under pytest, where a + mid-suite background fetch would make cache state, and therefore test + behavior, timing-dependent. + """ + if os.environ.get("PYTEST_CURRENT_TEST"): + return + threading.Thread( + target=refresh, name="reasoning-caps-warm", daemon=True + ).start() + + +def _hydrate_reasoning_caps_from_disk(url: str, refresh): + """The disk copy of *url*'s catalog, queueing *refresh* when it's stale. + + A copy past its TTL is still returned — a stale verdict beats no verdict, + and reasoning capabilities change rarely — with a background refresh so + the next run is current. + """ + caps, age = _load_reasoning_caps_disk(url) + if caps is None: + return None + if age >= _REASONING_CAPS_DISK_TTL_SECONDS: + _warm_reasoning_caps_async(refresh) + return caps + + +def _seed_reasoning_caps( + url: str, items: Any +) -> Optional[dict[str, Optional[dict[str, Any]]]]: + """Parse a ``/v1/models`` ``data`` array and mirror it for *url*. + + Takes the payload rather than fetching it, so the picker and pricing + fetches — which pull the same document a capability fetch would — leave the + mirror warm at no network cost. None when the array has no usable entries, + which callers remember as a failure rather than caching as empty. + """ + if not isinstance(items, list): + return None + caps_by_id: dict[str, Optional[dict[str, Any]]] = {} + for item in items: + if not isinstance(item, dict): + continue + mid = str(item.get("id") or "").strip() + if not mid: + continue + caps_by_id[mid] = parse_openrouter_reasoning_capabilities(item) + if not caps_by_id: + return None + _save_reasoning_caps_disk(url, caps_by_id) + return caps_by_id + + def _fetch_reasoning_caps_catalog( url: str, timeout: float ) -> Optional[dict[str, Optional[dict[str, Any]]]]: @@ -1619,39 +1737,32 @@ def _fetch_reasoning_caps_catalog( payload = json.loads(resp.read().decode()) except Exception: return None - items = payload.get("data") - if not isinstance(items, list): - return None - caps_by_id: dict[str, Optional[dict[str, Any]]] = {} - for item in items: - if not isinstance(item, dict): - continue - mid = str(item.get("id") or "").strip() - if not mid: - continue - caps_by_id[mid] = parse_openrouter_reasoning_capabilities(item) - return caps_by_id or None + return _seed_reasoning_caps(url, payload.get("data")) -def _fetch_openrouter_reasoning_caps(timeout: float = 6.0) -> Optional[dict[str, Optional[dict[str, Any]]]]: +_OPENROUTER_CATALOG_URL = "https://openrouter.ai/api/v1/models" + + +def _fetch_openrouter_reasoning_caps( + timeout: float = 6.0, *, force: bool = False +) -> Optional[dict[str, Optional[dict[str, Any]]]]: """Fetch + cache per-model reasoning capabilities from the live catalog. Returns None (without poisoning the cache) when the catalog is unreachable so callers can retry later and fall back in the meantime. Failed fetches are remembered for 60 seconds so hot per-turn callers - don't pay an HTTP round-trip on every call while offline. + don't pay an HTTP round-trip on every call while offline. *force* refetches + past a cache populated from the disk mirror. """ global _openrouter_reasoning_caps_cache, _openrouter_reasoning_caps_failed_at - if _openrouter_reasoning_caps_cache is not None: + if _openrouter_reasoning_caps_cache is not None and not force: return _openrouter_reasoning_caps_cache if ( _openrouter_reasoning_caps_failed_at is not None and (time.monotonic() - _openrouter_reasoning_caps_failed_at) < 60 ): return None - caps_by_id = _fetch_reasoning_caps_catalog( - "https://openrouter.ai/api/v1/models", timeout - ) + caps_by_id = _fetch_reasoning_caps_catalog(_OPENROUTER_CATALOG_URL, timeout) if caps_by_id is None: _openrouter_reasoning_caps_failed_at = time.monotonic() return None @@ -1659,6 +1770,10 @@ def _fetch_openrouter_reasoning_caps(timeout: float = 6.0) -> Optional[dict[str, return caps_by_id +def _refresh_openrouter_reasoning_caps() -> None: + _fetch_openrouter_reasoning_caps(force=True) + + def openrouter_model_reasoning_capabilities( model_id: Optional[str], *, @@ -1679,14 +1794,15 @@ def openrouter_model_reasoning_capabilities( By default this is a CACHE-ONLY lookup — safe on per-request hot paths (never blocks on HTTP). The cache is populated for free whenever - ``fetch_openrouter_models()`` runs (model picker, setup), by the - non-blocking ``warm_openrouter_reasoning_caps_async()`` warmer, or by - passing ``allow_fetch=True`` from non-latency-sensitive callers. + ``fetch_openrouter_models()`` runs (model picker, setup), from the disk + mirror a previous run left behind, by the non-blocking + ``warm_openrouter_reasoning_caps_async()`` warmer, or by passing + ``allow_fetch=True`` from non-latency-sensitive callers. """ model = str(model_id or "").strip() if not model: return None - caps_by_id = _openrouter_reasoning_caps_cache + caps_by_id = _openrouter_caps_cached() if caps_by_id is None and allow_fetch: caps_by_id = _fetch_openrouter_reasoning_caps(timeout=timeout) if caps_by_id is None: @@ -1694,29 +1810,28 @@ def openrouter_model_reasoning_capabilities( return caps_by_id.get(model) +_openrouter_caps_disk_checked = False _openrouter_caps_warm_started = False -def warm_openrouter_reasoning_caps_async() -> None: - """Warm the reasoning-capability cache in a background thread. +def _openrouter_caps_cached() -> Optional[dict[str, Optional[dict[str, Any]]]]: + """Cache-only OpenRouter caps: memory, else the disk mirror. Never HTTP.""" + global _openrouter_reasoning_caps_cache, _openrouter_caps_disk_checked + if _openrouter_reasoning_caps_cache is None and not _openrouter_caps_disk_checked: + _openrouter_caps_disk_checked = True + _openrouter_reasoning_caps_cache = _hydrate_reasoning_caps_from_disk( + _OPENROUTER_CATALOG_URL, _refresh_openrouter_reasoning_caps + ) + return _openrouter_reasoning_caps_cache - Fire-and-forget: called from hot paths that found the cache cold so the - NEXT call benefits, without ever blocking a turn on HTTP. One warm - attempt per process (the fetch has its own 60s failure TTL). Skipped - under pytest — a mid-suite background fetch would make cache state, and - therefore test behavior, timing-dependent. - """ + +def warm_openrouter_reasoning_caps_async() -> None: + """Warm the OpenRouter reasoning-capability cache in the background.""" global _openrouter_caps_warm_started - if _openrouter_caps_warm_started or _openrouter_reasoning_caps_cache is not None: - return - if os.environ.get("PYTEST_CURRENT_TEST"): + if _openrouter_caps_warm_started or _openrouter_caps_cached() is not None: return _openrouter_caps_warm_started = True - threading.Thread( - target=_fetch_openrouter_reasoning_caps, - name="openrouter-reasoning-caps-warm", - daemon=True, - ).start() + _warm_reasoning_caps_async(_refresh_openrouter_reasoning_caps) # Nous Portal serves OpenRouter's catalog schema, so the same parser and @@ -1724,22 +1839,32 @@ def warm_openrouter_reasoning_caps_async() -> None: # list different models (and different capabilities for shared ids). _nous_reasoning_caps_cache: dict[str, Optional[dict[str, Any]]] | None = None _nous_reasoning_caps_failed_at: float | None = None -_nous_caps_warm_started = False -def _fetch_nous_reasoning_caps(timeout: float = 6.0) -> Optional[dict[str, Optional[dict[str, Any]]]]: +def nous_catalog_url() -> str: + """The Portal ``/v1/models`` URL for the endpoint we actually talk to. + + Resolved through the documented ladder rather than pinned to production — + ``NOUS_INFERENCE_BASE_URL`` → resolved credential base → prod — so a + staging profile reads staging's capabilities. Reading prod's would answer + the reasoning-mandatory question for the wrong deployment. + """ + return f"{_resolve_nous_pricing_credentials()[1]}/v1/models" + + +def _fetch_nous_reasoning_caps( + timeout: float = 6.0, *, force: bool = False +) -> Optional[dict[str, Optional[dict[str, Any]]]]: """Nous Portal counterpart of :func:`_fetch_openrouter_reasoning_caps`.""" global _nous_reasoning_caps_cache, _nous_reasoning_caps_failed_at - if _nous_reasoning_caps_cache is not None: + if _nous_reasoning_caps_cache is not None and not force: return _nous_reasoning_caps_cache if ( _nous_reasoning_caps_failed_at is not None and (time.monotonic() - _nous_reasoning_caps_failed_at) < 60 ): return None - caps_by_id = _fetch_reasoning_caps_catalog( - f"{_DEFAULT_NOUS_INFERENCE_BASE}/v1/models", timeout - ) + caps_by_id = _fetch_reasoning_caps_catalog(nous_catalog_url(), timeout) if caps_by_id is None: _nous_reasoning_caps_failed_at = time.monotonic() return None @@ -1747,6 +1872,10 @@ def _fetch_nous_reasoning_caps(timeout: float = 6.0) -> Optional[dict[str, Optio return caps_by_id +def _refresh_nous_reasoning_caps() -> None: + _fetch_nous_reasoning_caps(force=True) + + def nous_model_reasoning_capabilities( model_id: Optional[str], *, @@ -1762,7 +1891,7 @@ def nous_model_reasoning_capabilities( model = str(model_id or "").strip() if not model: return None - caps_by_id = _nous_reasoning_caps_cache + caps_by_id = _nous_caps_cached() if caps_by_id is None and allow_fetch: caps_by_id = _fetch_nous_reasoning_caps(timeout=timeout) if caps_by_id is None: @@ -1770,19 +1899,33 @@ def nous_model_reasoning_capabilities( return caps_by_id.get(model) +_nous_caps_disk_checked = False +_nous_caps_warm_started = False + + +def _nous_caps_cached() -> Optional[dict[str, Optional[dict[str, Any]]]]: + """Cache-only Portal caps: memory, else the disk mirror. Never HTTP. + + Guarded to one attempt per process because naming the catalog means + resolving Portal credentials, which can itself reach the network to + refresh a token — far too expensive for a caller that runs every turn. + """ + global _nous_reasoning_caps_cache, _nous_caps_disk_checked + if _nous_reasoning_caps_cache is None and not _nous_caps_disk_checked: + _nous_caps_disk_checked = True + _nous_reasoning_caps_cache = _hydrate_reasoning_caps_from_disk( + nous_catalog_url(), _refresh_nous_reasoning_caps + ) + return _nous_reasoning_caps_cache + + def warm_nous_reasoning_caps_async() -> None: """Nous Portal counterpart of :func:`warm_openrouter_reasoning_caps_async`.""" global _nous_caps_warm_started - if _nous_caps_warm_started or _nous_reasoning_caps_cache is not None: - return - if os.environ.get("PYTEST_CURRENT_TEST"): + if _nous_caps_warm_started or _nous_caps_cached() is not None: return _nous_caps_warm_started = True - threading.Thread( - target=_fetch_nous_reasoning_caps, - name="nous-reasoning-caps-warm", - daemon=True, - ).start() + _warm_reasoning_caps_async(_refresh_nous_reasoning_caps) # Canonical low→high ordering used for nearest-level clamping. Kept as an @@ -1835,7 +1978,7 @@ def fetch_openrouter_models( try: req = urllib.request.Request( - "https://openrouter.ai/api/v1/models", + _OPENROUTER_CATALOG_URL, headers={"Accept": "application/json"}, ) with _urlopen_model_catalog_request(req, timeout=timeout) as resp: @@ -1856,16 +1999,14 @@ def fetch_openrouter_models( continue live_by_id[mid] = item - # Free warm-up for the reasoning-capability cache: this is the same - # payload _fetch_openrouter_reasoning_caps would fetch, so parse it once - # here and hot-path callers (openrouter_model_reasoning_capabilities) - # never need their own HTTP round-trip. + # Free warm-up for the reasoning-capability cache: this is the same payload + # _fetch_openrouter_reasoning_caps would fetch, so parse it once here and + # hot-path callers (openrouter_model_reasoning_capabilities) never need + # their own HTTP round-trip. global _openrouter_reasoning_caps_cache - if _openrouter_reasoning_caps_cache is None and live_by_id: - _openrouter_reasoning_caps_cache = { - mid: parse_openrouter_reasoning_capabilities(item) - for mid, item in live_by_id.items() - } + seeded = _seed_reasoning_caps(_OPENROUTER_CATALOG_URL, live_items) + if _openrouter_reasoning_caps_cache is None and seeded is not None: + _openrouter_reasoning_caps_cache = seeded curated: list[tuple[str, str]] = [] silent_default = get_preferred_silent_default_model("openrouter") @@ -2208,6 +2349,11 @@ def fetch_models_with_pricing( except Exception: return _cache_catalog(cache_key, {}) + # Same document the reasoning-capability fetch would pull, and every + # picker/pricing surface goes through here — mirror it so a later hot-path + # lookup (and the next process) has an answer without its own round-trip. + _seed_reasoning_caps(url, payload.get("data")) + result: dict[str, dict[str, Any]] = {} for item in payload.get("data", []): mid = item.get("id") @@ -2342,6 +2488,10 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: pass base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/") + # Credential bases arrive with or without the ``/v1`` suffix. Callers + # append their own path, so hand back the bare origin. + if base_url.endswith("/v1"): + base_url = base_url[:-3] return (api_key, base_url) @@ -2365,14 +2515,9 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: - # Nous base_url typically looks like https://inference-api.nousresearch.com/v1 - # We need the part before /v1 for our fetch function - stripped = base_url.rstrip("/") - if stripped.endswith("/v1"): - stripped = stripped[:-3] return fetch_models_with_pricing( api_key=api_key, - base_url=stripped, + base_url=base_url, force_refresh=force_refresh, # Sale chrome (pricing.original) is Nous Portal-only. include_sale_original=True, diff --git a/tests/hermes_cli/test_nous_reasoning_metadata.py b/tests/hermes_cli/test_nous_reasoning_metadata.py index b4247efa81..6c52f2cb61 100644 --- a/tests/hermes_cli/test_nous_reasoning_metadata.py +++ b/tests/hermes_cli/test_nous_reasoning_metadata.py @@ -36,10 +36,14 @@ _CATALOG = ( @pytest.fixture def cold_cache(monkeypatch): + """A freshly started process that has never mirrored a catalog to disk.""" import hermes_cli.models as models_mod monkeypatch.setattr(models_mod, "_nous_reasoning_caps_cache", None) monkeypatch.setattr(models_mod, "_nous_reasoning_caps_failed_at", None) + monkeypatch.setattr(models_mod, "_nous_caps_disk_checked", False) + monkeypatch.setattr(models_mod, "_nous_caps_warm_started", False) + models_mod._reasoning_caps_disk_path().unlink(missing_ok=True) return models_mod @@ -76,7 +80,24 @@ class TestNousModelReasoningCapabilities: # urllib title-cases header names. assert seen[0].get_header("User-agent") - assert "nousresearch.com" in seen[0].full_url + + def test_catalog_read_follows_the_configured_endpoint( + self, cold_cache, monkeypatch + ): + """Capabilities come from the deployment we actually talk to. + + Pinned to production, a staging profile would take its + reasoning-mandatory verdicts from a different deployment's catalog. + """ + from hermes_cli.models import nous_catalog_url + + monkeypatch.setenv( + "NOUS_INFERENCE_BASE_URL", "https://staging.nousresearch.com/v1" + ) + assert nous_catalog_url() == "https://staging.nousresearch.com/v1/models" + + monkeypatch.delenv("NOUS_INFERENCE_BASE_URL") + assert nous_catalog_url().endswith("/v1/models") def test_unlisted_and_empty_models_return_none(self, cold_cache, monkeypatch): from hermes_cli.models import nous_model_reasoning_capabilities diff --git a/tests/hermes_cli/test_reasoning_caps_disk_cache.py b/tests/hermes_cli/test_reasoning_caps_disk_cache.py new file mode 100644 index 0000000000..3b6dcd1759 --- /dev/null +++ b/tests/hermes_cli/test_reasoning_caps_disk_cache.py @@ -0,0 +1,234 @@ +"""The reasoning-capability disk mirror. + +Every consumer of these capabilities sits on a per-request hot path that must +never block on HTTP, so a process whose in-memory cache is cold answers +"unknown" — and on that answer the Nous profile drops a "thinking off" disable +rather than risk a 400. A short-lived process (``hermes -p``, a cron job, a +freshly booted gateway) is ALWAYS cold, so without a disk copy that fallback is +the only behavior those runs ever get and the user keeps paying for reasoning +they turned off. + +These tests pin the mirror that makes every run after the first correct from +its first turn. +""" + +import json + +import pytest + +import hermes_cli.models as models_mod + + +_CATALOG = json.dumps({ + "data": [ + { + "id": "deepseek/deepseek-v4-pro", + "supported_parameters": ["reasoning", "tools"], + "reasoning": {"mandatory": False}, + }, + { + "id": "arcee-ai/trinity-large-thinking", + "supported_parameters": ["reasoning"], + "reasoning": {"mandatory": True}, + }, + ] +}).encode() + + +def _response(body: bytes): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return body + + return _Resp() + + +@pytest.fixture +def cold_process(monkeypatch): + """Put the module in the state a freshly started process would be in.""" + + def _reset(): + for name in ( + "_nous_reasoning_caps_cache", + "_nous_reasoning_caps_failed_at", + "_openrouter_reasoning_caps_cache", + "_openrouter_reasoning_caps_failed_at", + ): + monkeypatch.setattr(models_mod, name, None) + for name in ( + "_nous_caps_disk_checked", + "_nous_caps_warm_started", + "_openrouter_caps_disk_checked", + "_openrouter_caps_warm_started", + ): + monkeypatch.setattr(models_mod, name, False) + + _reset() + return _reset + + +@pytest.fixture +def offline(): + """Fail the test if anything reaches for the network.""" + + def _boom(req, *, timeout): + raise AssertionError(f"must not fetch: {req.full_url}") + + return _boom + + +def test_fetched_catalog_answers_a_later_process_offline( + cold_process, offline, monkeypatch +): + """The whole point: run once online, and the next run starts out correct. + + Without the mirror this second lookup is the reported bug — an unknown + verdict, and a silently-ignored "thinking off" for the whole turn. + """ + monkeypatch.setattr( + models_mod, "_urlopen_model_catalog_request", + lambda req, *, timeout: _response(_CATALOG), + ) + assert models_mod.nous_model_reasoning_capabilities( + "deepseek/deepseek-v4-pro", allow_fetch=True + ) is not None + + cold_process() + monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", offline) + + caps = models_mod.nous_model_reasoning_capabilities("deepseek/deepseek-v4-pro") + assert caps["mandatory"] is False + mandatory = models_mod.nous_model_reasoning_capabilities( + "arcee-ai/trinity-large-thinking" + ) + assert mandatory["mandatory"] is True + + +def test_mirror_is_keyed_by_catalog_url(cold_process, offline, monkeypatch): + """One catalog's verdicts must never answer for another's. + + The Portal and OpenRouter list different models, and a staging Portal + answering for production would decide the reasoning-mandatory question for + the wrong deployment. + """ + monkeypatch.setattr( + models_mod, "_urlopen_model_catalog_request", + lambda req, *, timeout: _response(_CATALOG), + ) + models_mod.nous_model_reasoning_capabilities( + "deepseek/deepseek-v4-pro", allow_fetch=True + ) + + cold_process() + monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", offline) + + assert models_mod.openrouter_model_reasoning_capabilities( + "deepseek/deepseek-v4-pro" + ) is None + + +def test_staging_portal_does_not_read_productions_mirror( + cold_process, offline, monkeypatch +): + monkeypatch.setattr( + models_mod, "_urlopen_model_catalog_request", + lambda req, *, timeout: _response(_CATALOG), + ) + models_mod.nous_model_reasoning_capabilities( + "deepseek/deepseek-v4-pro", allow_fetch=True + ) + + cold_process() + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", "https://staging.nousresearch.com") + monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", offline) + + assert models_mod.nous_model_reasoning_capabilities( + "deepseek/deepseek-v4-pro" + ) is None + + +def test_stale_copy_is_still_served(cold_process, offline, monkeypatch): + """A stale verdict beats no verdict — capabilities change rarely. + + Refusing to read an aged mirror would put every long-idle install back on + the cold-start fallback it exists to prevent. + """ + url = models_mod.nous_catalog_url() + models_mod._save_reasoning_caps_disk( + url, {"deepseek/deepseek-v4-pro": {"supports_reasoning": True, "mandatory": False}} + ) + raw = json.loads(models_mod._reasoning_caps_disk_path().read_text()) + raw[url]["ts"] = 0 # epoch — far past any TTL + models_mod._reasoning_caps_disk_path().write_text(json.dumps(raw)) + + cold_process() + monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", offline) + + caps = models_mod.nous_model_reasoning_capabilities("deepseek/deepseek-v4-pro") + assert caps["mandatory"] is False + + +def test_unreadable_mirror_degrades_to_unknown(cold_process, offline, monkeypatch): + """A corrupt file answers "unknown", never raises into the request path.""" + path = models_mod._reasoning_caps_disk_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{ this is not json") + + monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", offline) + assert models_mod.nous_model_reasoning_capabilities("deepseek/deepseek-v4-pro") is None + + +def test_pricing_fetch_seeds_the_mirror(cold_process, offline, monkeypatch): + """The picker's pricing call already holds the catalog — mirror it for free. + + Every surface that renders prices goes through here, so the common case + never pays a second round-trip to learn the same thing. + """ + monkeypatch.setattr(models_mod, "_pricing_cache", {}) + monkeypatch.setattr(models_mod, "_pricing_cache_retry_after", {}) + monkeypatch.setattr( + models_mod, "_urlopen_model_catalog_request", + lambda req, *, timeout: _response(_CATALOG), + ) + models_mod.fetch_models_with_pricing( + base_url="https://inference-api.nousresearch.com" + ) + + cold_process() + monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", offline) + + caps = models_mod.nous_model_reasoning_capabilities("deepseek/deepseek-v4-pro") + assert caps is not None + + +def test_a_missing_mirror_is_looked_for_once_per_process(cold_process, monkeypatch): + """Coming up empty must not re-cost the lookup on every later turn. + + This path runs once per request, and naming the Portal catalog resolves + Portal credentials — which can itself reach the network to refresh a + token. Both halves have to be paid at most once. + """ + counts = {"url": 0, "read": 0} + real_url = models_mod.nous_catalog_url + real_read = models_mod._read_reasoning_caps_disk + + def _counting_url(): + counts["url"] += 1 + return real_url() + + def _counting_read(): + counts["read"] += 1 + return real_read() + + monkeypatch.setattr(models_mod, "nous_catalog_url", _counting_url) + monkeypatch.setattr(models_mod, "_read_reasoning_caps_disk", _counting_read) + for _ in range(5): + models_mod.nous_model_reasoning_capabilities("deepseek/deepseek-v4-pro") + + assert counts == {"url": 1, "read": 1} diff --git a/tests/hermes_cli/test_sale_pricing.py b/tests/hermes_cli/test_sale_pricing.py index fa54797b85..0fa2720fb2 100644 --- a/tests/hermes_cli/test_sale_pricing.py +++ b/tests/hermes_cli/test_sale_pricing.py @@ -88,7 +88,26 @@ def test_resolve_nous_pricing_credentials_honors_inference_env_override(monkeypa ) api_key, base_url = models_mod._resolve_nous_pricing_credentials() assert api_key == "" - assert base_url == "https://stg-inference-api.nousresearch.com/v1" + # The bare origin, whichever form the override was written in: callers + # append their own path (``/v1/models``), so a suffix here would double up. + assert base_url == "https://stg-inference-api.nousresearch.com" + + +def test_resolve_nous_pricing_credentials_normalizes_either_suffix(monkeypatch): + """``/v1`` on the override is optional and must not change the result.""" + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_runtime_credentials", lambda: None + ) + for override in ( + "https://stg-inference-api.nousresearch.com", + "https://stg-inference-api.nousresearch.com/", + "https://stg-inference-api.nousresearch.com/v1", + "https://stg-inference-api.nousresearch.com/v1/", + ): + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", override) + assert models_mod._resolve_nous_pricing_credentials()[1] == ( + "https://stg-inference-api.nousresearch.com" + ) def test_a_failed_catalog_fetch_is_not_cached_forever(monkeypatch):