fix: follow-up hardening for salvaged #73621 + #35853

- _mark_stale_cache_grace only moves cache_time forward so a completed
  background refresh is never rewound to a 5-minute grace window
- clear _models_dev_refresh_in_flight if Thread.start() raises so a
  one-off thread-exhaustion failure doesn't disable refresh forever
- move empty-registry validation into _fetch_models_dev_from_network
  (was duplicated in the background worker and the foreground fetch)
- pass allow_network through as a plain kwarg in get_provider_info and
  hermes_cli.providers.get_provider instead of the branched call shape
- refresh the stale module docstring (no bundled snapshot exists; the
  resolution order now describes stale-serve + background refresh)
This commit is contained in:
kshitijk4poor 2026-07-29 13:33:49 +05:00 committed by kshitij
parent a479a1599f
commit 11ca7eedf0
3 changed files with 37 additions and 31 deletions

View file

@ -8,11 +8,15 @@ of 4000+ models across 109+ providers. Provides:
(reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff,
open-weights flag, family grouping, deprecation status
Data resolution order (like TypeScript OpenCode):
1. Bundled snapshot (ships with the package offline-first)
2. Disk cache (~/.hermes/models_dev_cache.json)
3. Network fetch (https://models.dev/api.json)
4. Background refresh every 60 minutes
Data resolution order:
1. In-memory cache (fresh, or stale served immediately while a single
background daemon thread refreshes)
2. Disk cache (~/.hermes/models_dev_cache.json any age; stale data is
served rather than blocking callers on the network)
3. Network fetch (https://models.dev/api.json) only when no cache
exists at all; failed refreshes back off for 5 minutes process-wide
Latency-sensitive callers (gateway route-identity checks) pass
``allow_network=False`` and never touch the network.
Other modules should import the dataclasses and query functions from here
rather than parsing the raw JSON themselves.
@ -244,21 +248,29 @@ def _save_disk_cache(data: Dict[str, Any]) -> None:
def _fetch_models_dev_from_network() -> Dict[str, Any]:
"""Fetch the live models.dev registry without touching local caches."""
"""Fetch the live models.dev registry without touching local caches.
Raises on network errors and on an empty/invalid registry payload.
"""
response = requests.get(MODELS_DEV_URL, timeout=15)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data:
return data
return {}
if not isinstance(data, dict) or not data:
raise ValueError("models.dev returned an empty or invalid registry")
return data
def _mark_stale_cache_grace() -> None:
"""Give stale cache data a short in-memory grace before retrying refresh."""
"""Give stale cache data a short in-memory grace before retrying refresh.
Only ever moves the timestamp forward: if a background refresh completed
between the caller's staleness check and this call, the fresh timestamp
is preserved instead of being rewound to a 5-minute grace.
"""
global _models_dev_cache_time
_models_dev_cache_time = (
time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY
)
grace_time = time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY
if grace_time > _models_dev_cache_time:
_models_dev_cache_time = grace_time
def _background_refresh_models_dev() -> None:
@ -267,8 +279,6 @@ def _background_refresh_models_dev() -> None:
global _models_dev_retry_after, _models_dev_refresh_in_flight
try:
data = _fetch_models_dev_from_network()
if not data:
raise ValueError("models.dev returned an empty or invalid registry")
_save_disk_cache(data)
_models_dev_cache = data
_models_dev_cache_time = time.time()
@ -307,7 +317,14 @@ def _start_background_refresh_models_dev() -> None:
name="models-dev-refresh",
daemon=True,
)
thread.start()
try:
thread.start()
except Exception as e:
# Thread/fd exhaustion: clear the flag so refresh isn't disabled
# for the rest of the process lifetime. Callers still get stale data.
with _models_dev_refresh_lock:
_models_dev_refresh_in_flight = False
logger.debug("Failed to start models.dev refresh thread: %s", e)
def fetch_models_dev(
@ -419,9 +436,6 @@ def fetch_models_dev(
try:
data = _fetch_models_dev_from_network()
if not data:
raise ValueError("models.dev returned an empty or invalid registry")
_save_disk_cache(data)
_models_dev_cache = data
_models_dev_cache_time = time.time()
@ -823,11 +837,7 @@ def get_provider_info(
# Resolve Hermes ID → models.dev ID
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
data = (
fetch_models_dev()
if allow_network
else fetch_models_dev(allow_network=False)
)
data = fetch_models_dev(allow_network=allow_network)
raw = data.get(mdev_id)
if not isinstance(raw, dict):
return None

View file

@ -450,11 +450,7 @@ def get_provider(name: str, *, allow_network: bool = True) -> Optional[ProviderD
# Try to get models.dev data
try:
from agent.models_dev import get_provider_info as _mdev_provider
mdev_info = (
_mdev_provider(canonical)
if allow_network
else _mdev_provider(canonical, allow_network=False)
)
mdev_info = _mdev_provider(canonical, allow_network=allow_network)
except Exception:
mdev_info = None

View file

@ -469,11 +469,11 @@ class TestFetchModelsDev:
mock_fetch.assert_called_once_with(allow_network=False)
@patch("agent.models_dev.fetch_models_dev", return_value=SAMPLE_REGISTRY)
def test_provider_info_default_preserves_zero_argument_fetch(self, mock_fetch):
def test_provider_info_default_allows_network(self, mock_fetch):
info = get_provider_info("anthropic")
assert info is not None
mock_fetch.assert_called_once_with()
mock_fetch.assert_called_once_with(allow_network=True)
def test_provider_definition_propagates_network_disabled(self):
from hermes_cli.providers import get_provider