Files
hermes-agent/tests/hermes_cli/test_cached_fetch_api_models.py
Austin Pickett e0c3caf3b8 fix(model-picker): serve cached custom-provider catalog on no-probe opens (supersedes #81665, #81556) (#81973)
* fix(model-picker): serve cached custom-provider catalog on no-probe opens

#58183 stopped GUI picker opens from live-probing saved custom
OpenAI-compatible endpoints so a stopped local server could not stall the
picker. It gated the whole discovery block, not just the network call, so
`cached_fetch_api_models()` was skipped too — and with it the catalog an
earlier probe had already written to `provider_models_cache.json`.

A custom endpoint that is not the current provider therefore renders only
the models named in its config entry. A local server with 8 models loaded
shows the 1 model that was saved when the provider was first added, on
every picker open, while an explicit Refresh shows all 8.

Add `cache_only` to `cached_fetch_api_models()`: answer from disk within
the existing stale-serve window, never fetch, never revalidate off-thread,
return None on a miss. Split the three call sites in
`list_authenticated_providers()` into what the user's config permits
(`discover_models`, an explicit `models:` allowlist) and how we may obtain
it, so suppressing the probe now downgrades to a cached read instead of
skipping discovery outright. `discover_models: false` still pins, and a
cache hit no longer writes back to config since the probe that populated
it already did.

The latency win stands: a cold cache is a miss, so picker opens against
offline endpoints still make zero network calls.

* test(model-picker): pin the cached-catalog contract for no-probe opens

Cover both halves of the invariant, since fixing either one alone
reintroduces a bug the other guards against.

`cache_only` on `cached_fetch_api_models()`: a fresh entry and an entry
past its TTL but inside the stale-serve window both serve; an entry beyond
that window, an empty cache, rotated credentials, `force_refresh`, and a
missing base_url are all misses — and none of them fetch or spawn a
background revalidation.

`list_authenticated_providers()` on the GUI path: a non-current endpoint
with a warm cache reports its full catalog across all three provider
shapes (`custom_providers`, `providers:`, bare `provider: custom`) with no
live fetch attempted. A cold cache keeps the configured list and still
makes no network call, which is the #58183 guarantee. `discover_models:
false` keeps pinning, and a cache hit does not write back to config.

* fix: persist discovered custom-provider models in the hermes model flow

The `hermes model` named-custom-provider flow (_model_flow_named_custom)
probes the endpoint and shows the full catalog, but never persists it to the
entry's `models:` list. No-probe surfaces (dashboard, desktop, ACP) call
build_models_payload(..., probe_custom_providers=False) and only render the
configured `models:` list, so a provider added via `hermes model` collapses
to the single `model:` default everywhere except the CLI. OpenAI-compatible
providers added via a probing picker already benefit from
_save_discovered_models_to_config; the CLI flow did not.

Persist the live catalog after a successful probe, mirroring the picker path
in model_switch.py. A failed save is non-fatal.

* fix(model-picker): stop an auto-saved catalog pinning a keyless endpoint

The cached-catalog read added for no-probe picker opens still sat behind
the no-key discovery gate, so it never reached the shape that motivated
it: a keyless local model server.

`bool(api_key) or not has_explicit_models` is a network-cost gate. It
exists so Hermes does not probe an endpoint it cannot authenticate to
when that endpoint already declares its catalog (5f00f36ba, 1039e90b5).
Reading a catalog an earlier probe already paid for costs nothing, so
the gate belongs on the probe, not on discovery as a whole.

Left on the discovery side it re-pins the endpoint it was meant to
spare. A successful probe calls `_save_discovered_models_to_config()`,
which writes a plain list into `models:` — exactly the shape
`_models_config_is_allowlist()` reads back as an explicit user
allowlist. A keyless server therefore froze on the catalog of its first
probe and could never widen again, which is the "lineup changes after
config was written" case. f66319097 already carved the dict shape out of
this trap for the same reason; the list shape is the other door into it.

Move the clause to `_probe_live` at both custom-endpoint sites. Probe
suppression is unchanged — verified byte-identical to main across the
keyed/keyless x declared/undeclared matrix — and `discover_models: false`
remains the documented way to pin a catalog.

* test(model-picker): cover the keyless auto-save pinning trap

Three tests around the gate move, each failing on the code before it:

- a keyless endpoint carrying an auto-saved `models:` list still reads
  its full cached catalog
- the same row, cold cache and probing enabled, still makes zero live
  fetches — the network-cost gate the clause exists for
- an end-to-end round trip: persist a probe result via
  `_save_discovered_models_to_config()`, reload it, and assert the shape
  we wrote does not read back as a user pin

The round-trip test guards the whole chain rather than one branch, so a
future change that makes the saved shape look like an intentional
allowlist fails here even if the gate logic is refactored.

* fix(model-picker): key the custom-endpoint model cache by api_mode

`cached_fetch_api_models()` fingerprints entries with `api_mode`, but no
call site in `list_authenticated_providers()` passed it, so every custom
row resolved to the `api_mode=None` fingerprint. Two rows sharing a
base_url and credential but differing by `api_mode` are deliberately
distinct picker rows — it is part of `group_key` at both sites — yet they
collapsed onto one cache entry.

That was latent while probing was the only way to fill a row: a mismatched
entry was overwritten by the row's own live fetch. Serving that entry
without a probe makes it visible, so an `anthropic_messages` row could
render the catalog an OpenAI-mode row cached against the same URL. The
wire protocols differ (`x-api-key` + `anthropic-version` vs
`Authorization: Bearer`), so those catalogs are not interchangeable.

Persist `api_mode` on the group at both grouping sites — it is already
part of `group_key`, so it is constant across the group — and pass it
into the cache read. Section 3b (bare `provider: custom`) has no
`api_mode` in scope and already reads with the empty-credential
fingerprint, so it is unchanged.

Reported by Copilot review on #81973.

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: Navlem <114683850+Navlem@users.noreply.github.com>
2026-08-08 16:07:03 -04:00

363 lines
18 KiB
Python

"""Cache-contract tests for ``cached_fetch_api_models()``.
Custom OpenAI-compatible endpoints (named ``custom_providers`` rows, bare
``provider: custom``, and per-endpoint-map entries) previously called
``fetch_api_models()`` directly with no disk cache, so the current custom
endpoint's ``/v1/models`` got a live HTTP round-trip on literally every
``/model`` open (#72762). ``cached_fetch_api_models()`` gives custom
endpoints the same ``provider_models_cache.json`` TTL cache first-class
providers already get via ``cached_provider_model_ids()``.
These pin the cache contract directly (hit / stale / rotation / refresh /
fallback), separate from ``test_model_switch_custom_providers.py``'s
higher-level picker-shape tests.
"""
from __future__ import annotations
import time
from unittest.mock import patch
import pytest
class TestCachedFetchApiModels:
def _entry(self, models, age_seconds, fp="fp"):
return {"fp": fp, "at": time.time() - age_seconds, "models": list(models)}
def test_fresh_entry_served_without_live_fetch(self):
import hermes_cli.models as mod
cache = {"custom:https://gw.example.com/v1": self._entry(["m1", "m2"], age_seconds=10)}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache") as save, \
patch.object(mod, "fetch_api_models") as live:
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert out == ["m1", "m2"]
live.assert_not_called()
save.assert_not_called()
def test_cache_key_normalizes_trailing_slash_and_case(self):
"""A saved entry for the lowercased/rstripped URL must be hit even
when the caller passes a differently-cased URL with a trailing
slash — config.yaml entries are not guaranteed to be normalized."""
import hermes_cli.models as mod
cache = {"custom:https://gw.example.com/v1": self._entry(["m1"], age_seconds=10)}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "fetch_api_models") as live:
out = mod.cached_fetch_api_models("sk-key", "HTTPS://GW.example.com/v1/")
assert out == ["m1"]
live.assert_not_called()
def test_expired_entry_triggers_live_fetch_and_is_persisted(self):
import hermes_cli.models as mod
# Beyond the stale-serve window, so the wrapper must block on a
# live fetch (within the window it stale-serves + refreshes off
# thread — covered in TestSalvageFollowups).
too_old = mod._PROVIDER_MODELS_STALE_SERVE_MAX + 60
cache = {"custom:https://gw.example.com/v1": self._entry(["old"], age_seconds=too_old)}
saved = {}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache", side_effect=saved.update), \
patch.object(mod, "fetch_api_models", return_value=["fresh-a", "fresh-b"]) as live:
out = mod.cached_fetch_api_models(
"sk-key", "https://gw.example.com/v1", ttl_seconds=3600
)
assert out == ["fresh-a", "fresh-b"]
live.assert_called_once()
assert saved["custom:https://gw.example.com/v1"]["models"] == ["fresh-a", "fresh-b"]
assert saved["custom:https://gw.example.com/v1"]["fp"] == "fp"
def test_rotated_api_key_busts_cache_even_when_fresh(self):
"""A same-age entry with a DIFFERENT fingerprint (key rotated, or
extra_headers edited) must not be served — it reflects the old
credentials' catalog."""
import hermes_cli.models as mod
cache = {"custom:https://gw.example.com/v1": self._entry(["old-key-models"], 10, fp="old-fp")}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="new-fp"), \
patch.object(mod, "_save_provider_models_cache"), \
patch.object(mod, "fetch_api_models", return_value=["new-key-models"]) as live:
out = mod.cached_fetch_api_models("sk-new-key", "https://gw.example.com/v1")
assert out == ["new-key-models"]
live.assert_called_once()
def test_force_refresh_bypasses_fresh_cache(self):
import hermes_cli.models as mod
cache = {"custom:https://gw.example.com/v1": self._entry(["stale-but-fresh"], age_seconds=5)}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache"), \
patch.object(mod, "fetch_api_models", return_value=["forced-live"]) as live:
out = mod.cached_fetch_api_models(
"sk-key", "https://gw.example.com/v1", force_refresh=True
)
assert out == ["forced-live"]
live.assert_called_once()
def test_live_failure_falls_back_to_stale_same_fingerprint_entry(self):
"""Stale data beats no data when the endpoint is flaky (#72762
proposed-fix: 'same stale-beats-nothing fallback as
cached_provider_model_ids')."""
import hermes_cli.models as mod
cache = {"custom:https://gw.example.com/v1": self._entry(["last-known-good"], age_seconds=99999, fp="fp")}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache") as save, \
patch.object(mod, "fetch_api_models", return_value=None):
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert out == ["last-known-good"]
save.assert_not_called() # nothing new to persist
def test_live_failure_with_no_matching_entry_returns_live_value(self):
import hermes_cli.models as mod
with patch.object(mod, "_load_provider_models_cache", return_value={}), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache") as save, \
patch.object(mod, "fetch_api_models", return_value=None):
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert out is None
save.assert_not_called()
class TestCacheOnly:
"""``cache_only=True`` is the no-network read used by picker opens that
deliberately skip live probing. It must answer from disk or not at all —
never a live fetch, never a background revalidation."""
def _entry(self, models, age_seconds, fp="fp"):
return {"fp": fp, "at": time.time() - age_seconds, "models": list(models)}
def _call(self, cache, *, fp="fp", **kwargs):
import hermes_cli.models as mod
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value=fp), \
patch.object(mod, "_save_provider_models_cache") as save, \
patch.object(mod, "_spawn_swr_refresh") as swr, \
patch.object(mod, "fetch_api_models") as live:
out = mod.cached_fetch_api_models(
"sk-key", "https://gw.example.com/v1", cache_only=True, **kwargs
)
live.assert_not_called()
swr.assert_not_called()
save.assert_not_called()
return out
def test_fresh_entry_is_served(self):
cache = {"custom:https://gw.example.com/v1": self._entry(["m1", "m2"], 10)}
assert self._call(cache) == ["m1", "m2"]
def test_entry_past_ttl_is_still_served_within_the_stale_window(self):
"""The TTL governs when to *revalidate*, and cache_only cannot. Inside
the stale-serve bound the entry is still the best answer available —
collapsing to the config subset an hour in would reintroduce the bug."""
import hermes_cli.models as mod
age = mod._PROVIDER_MODELS_CACHE_TTL + 60
cache = {"custom:https://gw.example.com/v1": self._entry(["m1", "m2"], age)}
assert self._call(cache) == ["m1", "m2"]
def test_entry_beyond_the_stale_window_is_a_miss(self):
import hermes_cli.models as mod
age = mod._PROVIDER_MODELS_STALE_SERVE_MAX + 60
cache = {"custom:https://gw.example.com/v1": self._entry(["ancient"], age)}
assert self._call(cache) is None
def test_empty_cache_is_a_miss(self):
assert self._call({}) is None
def test_rotated_credentials_are_a_miss(self):
cache = {"custom:https://gw.example.com/v1": self._entry(["old"], 10, fp="old-fp")}
assert self._call(cache, fp="new-fp") is None
def test_force_refresh_is_a_miss_rather_than_a_live_fetch(self):
"""cache_only outranks force_refresh: the caller has said no network,
so an un-revalidatable entry is withheld instead of fetched."""
cache = {"custom:https://gw.example.com/v1": self._entry(["m1"], 10)}
assert self._call(cache, force_refresh=True) is None
def test_missing_base_url_is_a_miss_rather_than_a_live_fetch(self):
import hermes_cli.models as mod
with patch.object(mod, "fetch_api_models") as live:
out = mod.cached_fetch_api_models("sk-key", "", cache_only=True)
assert out is None
live.assert_not_called()
def test_empty_live_result_is_not_persisted(self):
"""An empty list from a transient error must never pin an empty
cache entry over real data on the next open."""
import hermes_cli.models as mod
with patch.object(mod, "_load_provider_models_cache", return_value={}), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache") as save, \
patch.object(mod, "fetch_api_models", return_value=[]):
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert out == []
save.assert_not_called()
def test_blank_base_url_skips_cache_entirely(self):
"""No base_url means nothing to key the cache on — call straight
through to fetch_api_models rather than caching under an empty key."""
import hermes_cli.models as mod
with patch.object(mod, "_load_provider_models_cache") as load, \
patch.object(mod, "fetch_api_models", return_value=["x"]) as live:
out = mod.cached_fetch_api_models("sk-key", "")
assert out == ["x"]
live.assert_called_once()
load.assert_not_called()
def test_fingerprint_ignores_timeout_but_reacts_to_headers(self):
"""Sanity check on the real (non-mocked) fingerprint helper: it must
not vary with call-only params like timeout, but must vary with the
actual credential/header inputs."""
import hermes_cli.models as mod
fp_a = mod._custom_endpoint_fingerprint("sk-key", None, {"X-Tenant": "a"})
fp_b = mod._custom_endpoint_fingerprint("sk-key", None, {"X-Tenant": "b"})
fp_a_again = mod._custom_endpoint_fingerprint("sk-key", None, {"X-Tenant": "a"})
assert fp_a != fp_b
assert fp_a == fp_a_again
class TestCachedFetchApiModelsDiskRoundTrip:
"""End-to-end through the real (per-test-isolated) provider_models_cache.json
disk file rather than mocked load/save, so a regression in the on-disk
schema (e.g. a key collision with provider-slug entries) would show up
here even if the mocked unit tests above stayed green."""
def test_second_call_within_ttl_hits_disk_cache_no_live_fetch(self, monkeypatch):
import hermes_cli.models as mod
calls = []
def fake_fetch(api_key, base_url, **kwargs):
calls.append((api_key, base_url))
return ["disk-cached-model"]
monkeypatch.setattr(mod, "fetch_api_models", fake_fetch)
first = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
second = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert first == ["disk-cached-model"]
assert second == ["disk-cached-model"]
assert len(calls) == 1, "second open must be served from disk, not a fresh live fetch"
def test_custom_key_does_not_collide_with_provider_slug_cache(self, monkeypatch):
"""A custom endpoint literally named e.g. 'openrouter' in its
base_url must not read/write the same cache slot as the first-class
'openrouter' provider slug used by cached_provider_model_ids()."""
import hermes_cli.models as mod
monkeypatch.setattr(
mod, "fetch_api_models", lambda *a, **k: ["custom-endpoint-model"]
)
monkeypatch.setattr(
mod, "provider_model_ids", lambda *a, **k: ["openrouter-curated-model"]
)
mod.cached_fetch_api_models("sk-key", "https://openrouter.ai/v1")
mod.cached_provider_model_ids("openrouter")
cache = mod._load_provider_models_cache()
assert cache["custom:https://openrouter.ai/v1"]["models"] == ["custom-endpoint-model"]
assert cache["openrouter"]["models"] == ["openrouter-curated-model"]
class TestSalvageFollowups:
"""Follow-up behaviors added while salvaging PR #80740: SWR stale-serve
parity with cached_provider_model_ids, and corrupt-cache degradation."""
def _entry(self, models, age_seconds, fp="fp"):
return {"fp": fp, "at": time.time() - age_seconds, "models": list(models)}
def test_expired_entry_within_stale_window_is_served_and_refreshed_off_thread(self):
"""TTL-expired (but < stale-serve max) entries must be served
immediately — the picker never blocks on a live round-trip — while a
background refresh is spawned for the next open."""
import hermes_cli.models as mod
cache = {"custom:https://gw.example.com/v1": self._entry(["stale-ok"], age_seconds=7200)}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_spawn_swr_refresh") as spawn, \
patch.object(mod, "fetch_api_models") as live:
out = mod.cached_fetch_api_models(
"sk-key", "https://gw.example.com/v1", ttl_seconds=3600
)
assert out == ["stale-ok"]
live.assert_not_called()
spawn.assert_called_once()
assert spawn.call_args[0][0] == "custom:https://gw.example.com/v1"
def test_entry_beyond_stale_window_blocks_on_live_fetch(self):
import hermes_cli.models as mod
too_old = mod._PROVIDER_MODELS_STALE_SERVE_MAX + 60
cache = {"custom:https://gw.example.com/v1": self._entry(["ancient"], age_seconds=too_old)}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache"), \
patch.object(mod, "_spawn_swr_refresh") as spawn, \
patch.object(mod, "fetch_api_models", return_value=["fresh"]) as live:
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert out == ["fresh"]
live.assert_called_once()
spawn.assert_not_called()
def test_swr_refresh_fn_writes_custom_entry_through_shared_scaffolding(self):
"""The generalized _spawn_swr_refresh(key, refresh_fn) must persist a
custom-endpoint entry via the shared inflight-dedupe machinery."""
import threading as _threading
import hermes_cli.models as mod
done = _threading.Event()
saved = {}
def fake_save(data):
saved.update(data)
done.set()
with patch.object(mod, "_load_provider_models_cache", return_value={}), \
patch.object(mod, "_save_provider_models_cache", side_effect=fake_save):
mod._spawn_swr_refresh(
"custom:https://gw.example.com/v1",
lambda: {"fp": "fp", "at": time.time(), "models": ["refreshed"]},
)
assert done.wait(timeout=5), "background refresh did not complete"
assert saved["custom:https://gw.example.com/v1"]["models"] == ["refreshed"]
assert "custom:https://gw.example.com/v1" not in mod._swr_refresh_inflight
def test_corrupt_at_field_degrades_to_live_fetch_instead_of_raising(self):
"""provider_models_cache.json is user-editable; a corrupted 'at' must
be a cache miss (live fetch), never an exception out of the wrapper."""
import hermes_cli.models as mod
cache = {
"custom:https://gw.example.com/v1": {
"fp": "fp", "at": "yesterday", "models": ["corrupt-row"],
}
}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache"), \
patch.object(mod, "fetch_api_models", return_value=["live-models"]) as live:
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
assert out == ["live-models"]
live.assert_called_once()