* 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.f66319097already 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>
363 lines
18 KiB
Python
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()
|