fix: invalidate model cache on auth-store drift

This commit is contained in:
Michael Lam
2026-05-05 08:33:44 -07:00
parent 1cde702d47
commit c4ef5b6945
4 changed files with 210 additions and 11 deletions
+70 -11
View File
@@ -1585,6 +1585,7 @@ def set_hermes_default_model(model_id: str) -> dict:
# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache: dict | None = None
_available_models_cache_ts: float = 0.0
_available_models_cache_source_fingerprint: dict | None = None
_AVAILABLE_MODELS_CACHE_TTL: float = 86400.0 # 24 hours
_available_models_cache_lock = threading.RLock() # must be RLock: cold path refactoring moved slow work inside this lock, requiring re-entry
_cache_build_cv = threading.Condition(_available_models_cache_lock) # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock
@@ -1641,12 +1642,48 @@ def _current_webui_version() -> str | None:
# guarantees that even if a future release accidentally reuses the same
# WebUI version string (or a debug build doesn't have a version), a structural
# change still invalidates the cache.
_MODELS_CACHE_SCHEMA_VERSION = 2
_MODELS_CACHE_SCHEMA_VERSION = 3
_models_cache_path = STATE_DIR / "models_cache.json"
def _get_auth_store_path() -> Path:
"""Return the auth.json path for the active Hermes profile."""
try:
from api.profiles import get_active_hermes_home as _gah
return _gah() / "auth.json"
except ImportError:
return HOME / ".hermes" / "auth.json"
def _models_cache_file_fingerprint(path: Path) -> dict:
"""Return non-secret identity metadata for a cache dependency file.
The /api/models response depends on config.yaml (model/provider defaults)
and auth.json (active_provider + credential_pool). The cache only needs
cheap invalidation signals here, not file contents; never include secrets.
"""
fingerprint = {"path": str(Path(path).expanduser())}
try:
st = Path(path).stat()
except OSError:
fingerprint["missing"] = True
return fingerprint
fingerprint["mtime_ns"] = st.st_mtime_ns
fingerprint["size"] = st.st_size
return fingerprint
def _models_cache_source_fingerprint() -> dict:
"""Return the current config/auth-store fingerprint for /api/models cache."""
return {
"config_yaml": _models_cache_file_fingerprint(_get_config_path()),
"auth_json": _models_cache_file_fingerprint(_get_auth_store_path()),
}
def _delete_models_cache_on_disk() -> None:
try:
os.unlink(str(_models_cache_path))
@@ -1717,6 +1754,15 @@ def _is_loadable_disk_cache(cache: object) -> bool:
cached_version, runtime_version,
)
return False
cached_sources = cache.get("_source_fingerprint")
runtime_sources = _models_cache_source_fingerprint()
if cached_sources != runtime_sources:
logger.debug(
"models cache rejected: source_fingerprint=%r vs runtime=%r",
cached_sources,
runtime_sources,
)
return False
return True
@@ -1772,6 +1818,7 @@ def _save_models_cache_to_disk(cache: dict) -> None:
return
payload = {
"_schema_version": _MODELS_CACHE_SCHEMA_VERSION,
"_source_fingerprint": _models_cache_source_fingerprint(),
"active_provider": cache["active_provider"],
"default_model": cache["default_model"],
"configured_model_badges": cache["configured_model_badges"],
@@ -1790,15 +1837,27 @@ def _save_models_cache_to_disk(cache: dict) -> None:
def _get_fresh_memory_models_cache(now: float) -> dict | None:
"""Return a valid fresh in-memory /api/models cache, or clear stale shapes."""
global _available_models_cache, _available_models_cache_ts
global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint
if _available_models_cache is None:
return None
if (now - _available_models_cache_ts) >= _AVAILABLE_MODELS_CACHE_TTL:
return None
current_sources = _models_cache_source_fingerprint()
if _available_models_cache_source_fingerprint != current_sources:
logger.debug(
"models memory cache rejected: source_fingerprint=%r vs runtime=%r",
_available_models_cache_source_fingerprint,
current_sources,
)
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
return None
if _is_valid_models_cache(_available_models_cache):
return copy.deepcopy(_available_models_cache)
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
return None
@@ -1816,10 +1875,11 @@ def invalidate_models_cache():
result from the disk cache because the disk hit is checked before the memory
cache rebuild runs.
"""
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _cache_build_cv
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv
with _available_models_cache_lock:
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
_cache_build_in_progress = False
_cache_build_cv.notify_all()
# Clear the credential pool cache too. The cache key is provider_id
@@ -1856,10 +1916,11 @@ def invalidate_provider_models_cache(provider_id: str):
Args:
provider_id: canonical provider id (e.g. 'openai', 'anthropic', 'custom:my-key')
"""
global _available_models_cache, _available_models_cache_ts, _CREDENTIAL_POOL_CACHE
global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _CREDENTIAL_POOL_CACHE
with _available_models_cache_lock:
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
_provider_models_invalidated_ts[provider_id] = time.time()
# Also evict the credential pool so the next cold path re-loads it.
# Must evict both the original key and its canonical form (load_pool
@@ -1918,7 +1979,7 @@ def get_available_models() -> dict:
'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
}
"""
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _cache_build_cv
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv
# Config mtime check — must come before any config reads.
# (Test #585 verifies _current_mtime appears before active_provider = None)
try:
@@ -2053,12 +2114,7 @@ def get_available_models() -> dict:
# 2. Read auth store (active_provider fallback + credential_pool inspection)
auth_store = {}
try:
from api.profiles import get_active_hermes_home as _gah
auth_store_path = _gah() / "auth.json"
except ImportError:
auth_store_path = HOME / ".hermes" / "auth.json"
auth_store_path = _get_auth_store_path()
if auth_store_path.exists():
try:
import json as _j
@@ -2939,6 +2995,7 @@ def get_available_models() -> dict:
reload_config()
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
disk_groups = None
# Serve from memory cache if fresh
@@ -2951,6 +3008,7 @@ def get_available_models() -> dict:
if disk_groups is not None:
_available_models_cache = disk_groups
_available_models_cache_ts = now
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
_save_models_cache_to_disk(disk_groups)
return copy.deepcopy(disk_groups)
@@ -2968,6 +3026,7 @@ def get_available_models() -> dict:
with _cache_build_cv:
_available_models_cache = result
_available_models_cache_ts = time.monotonic()
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
_cache_build_in_progress = False
_cache_build_cv.notify_all()
_save_models_cache_to_disk(result)
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

@@ -214,6 +214,7 @@ def test_load_skips_version_check_when_runtime_unknown(isolated_cache, monkeypat
# Write a cache that's correct except has no _webui_version
cache = {
"_schema_version": config._MODELS_CACHE_SCHEMA_VERSION,
"_source_fingerprint": config._models_cache_source_fingerprint(),
# no _webui_version
**_shape_cache(),
}
@@ -268,6 +269,7 @@ def test_is_loadable_disk_cache_checks_versions(with_runtime_version):
good = {
"_schema_version": config._MODELS_CACHE_SCHEMA_VERSION,
"_webui_version": "v0.50.293",
"_source_fingerprint": config._models_cache_source_fingerprint(),
**_shape_cache(),
}
assert config._is_loadable_disk_cache(good) is True
@@ -0,0 +1,138 @@
"""Regression tests for #1699: /api/models cache must track external auth/config changes.
The bug: WebUI caches /api/models for 24h in memory and on disk. When a user
runs `hermes setup` in a terminal and the Hermes auth store switches the active
provider outside WebUI, the browser can keep seeing the previous provider's
PRIMARY badge until the cache is manually cleared or expires.
"""
import json
import time
import api.config as config
def _reset_memory_cache() -> None:
with config._available_models_cache_lock:
config._available_models_cache = None
config._available_models_cache_ts = 0.0
if hasattr(config, "_available_models_cache_source_fingerprint"):
config._available_models_cache_source_fingerprint = None
config._cache_build_in_progress = False
config._cache_build_cv.notify_all()
def _valid_models_cache(provider_id: str, model_id: str) -> dict:
return {
"active_provider": provider_id,
"default_model": model_id,
"configured_model_badges": {
model_id: {"role": "primary", "label": "Primary", "provider": provider_id}
},
"groups": [
{
"provider": config._PROVIDER_DISPLAY.get(provider_id, provider_id.title()),
"provider_id": provider_id,
"models": [{"id": model_id, "label": model_id}],
}
],
}
def _write_auth_store(hermes_home, provider_id: str) -> None:
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(
json.dumps({"active_provider": provider_id, "credential_pool": {}}),
encoding="utf-8",
)
def _configure_isolated_sources(tmp_path, monkeypatch, provider_id: str) -> None:
hermes_home = tmp_path / "hermes-home"
state_dir = tmp_path / "state"
cache_path = state_dir / "models_cache.json"
state_dir.mkdir(parents=True, exist_ok=True)
hermes_home.mkdir(parents=True, exist_ok=True)
config_path = hermes_home / "config.yaml"
# Leave model.provider unset so get_available_models() must honor the auth
# store's active_provider fallback, matching CLI setup/auth-store drift.
config_path.write_text("model:\n default: glm-5.1\n", encoding="utf-8")
monkeypatch.setenv("HERMES_CONFIG_PATH", str(config_path))
import api.profiles as profiles
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: hermes_home)
monkeypatch.setattr(config, "_models_cache_path", cache_path)
# Keep the test hermetic: do not let real host credentials/providers leak
# into provider detection while exercising the auth-store active_provider path.
import hermes_cli.auth as hermes_auth
import hermes_cli.models as hermes_models
monkeypatch.setattr(hermes_models, "list_available_providers", lambda: [])
monkeypatch.setattr(
hermes_auth,
"get_auth_status",
lambda provider_id: {"logged_in": False, "key_source": ""},
)
_write_auth_store(hermes_home, provider_id)
config.reload_config()
_reset_memory_cache()
def test_memory_models_cache_invalidates_when_auth_store_active_provider_changes(
tmp_path, monkeypatch
):
_configure_isolated_sources(tmp_path, monkeypatch, "opencode-go")
stale_openrouter = _valid_models_cache("openrouter", "minimax-m2.7")
with config._available_models_cache_lock:
config._available_models_cache = stale_openrouter
config._available_models_cache_ts = time.monotonic()
if hasattr(config, "_available_models_cache_source_fingerprint"):
# Simulate a cache populated before the external CLI auth-store write.
config._available_models_cache_source_fingerprint = {
"auth_json": {"path": "old-auth.json", "mtime_ns": 1, "size": 10},
"config_yaml": {"path": "old-config.yaml", "mtime_ns": 1, "size": 10},
}
result = config.get_available_models()
assert result["active_provider"] == "opencode-go"
assert not any(group.get("provider_id") == "openrouter" for group in result["groups"])
assert any(group.get("provider_id") == "opencode-go" for group in result["groups"])
def test_disk_models_cache_invalidates_when_auth_store_active_provider_changes(
tmp_path, monkeypatch
):
_configure_isolated_sources(tmp_path, monkeypatch, "openrouter")
stale_openrouter = _valid_models_cache("openrouter", "minimax-m2.7")
config._save_models_cache_to_disk(stale_openrouter)
assert config._models_cache_path.exists()
# External terminal `hermes setup` changes auth.json, not WebUI's in-process cache.
hermes_home = config._models_cache_path.parent.parent / "hermes-home"
_write_auth_store(hermes_home, "opencode-go")
_reset_memory_cache()
result = config.get_available_models()
assert result["active_provider"] == "opencode-go"
assert not any(group.get("provider_id") == "openrouter" for group in result["groups"])
assert any(group.get("provider_id") == "opencode-go" for group in result["groups"])
def test_disk_models_cache_still_loads_when_auth_and_config_sources_are_unchanged(
tmp_path, monkeypatch
):
_configure_isolated_sources(tmp_path, monkeypatch, "opencode-go")
fresh_opencode = _valid_models_cache("opencode-go", "glm-5.1")
config._save_models_cache_to_disk(fresh_opencode)
_reset_memory_cache()
result = config.get_available_models()
assert result == fresh_opencode