fix(cache): stamp /api/models disk cache with WebUI version + schema version (#1633)
Closes #1633. STATE_DIR/models_cache.json was persisted across server restarts without any version stamp, so a Docker container update from version A to B read the cache file written by version A — users saw stale picker contents (missing models, phantom provider groups) for up to 24 hours until either the TTL expired, an unrelated provider edit triggered invalidate_models_cache(), or they manually deleted the file. Reporter Deor (Discord) updated to v0.50.292 — which contained fixes for #1538, #1539, and #1568 — did a hard refresh and cleared site data, and still saw byte-for-byte identical picker contents because the server kept reading the v0.50.281 cache file off the host-mounted state volume. Fix: * _save_models_cache_to_disk() stamps payloads with _webui_version (resolved lazily from api.updates.WEBUI_VERSION via sys.modules lookup to avoid the api.config <-> api.updates circular import) and _schema_version = 2. * New _is_loadable_disk_cache() validator checks both stamps in addition to shape. Mismatch on either field rejects the load. * _load_models_cache_from_disk() calls the new validator and strips the disk-only metadata before returning, so the rest of the code sees the same shape it always did. * _is_valid_models_cache() kept loose (shape-only) so in-memory cache writes that never touch disk don't fail validation. Schema version is independent of the WebUI version stamp so future cache-shape changes can invalidate older releases without relying on a tag bump alone. Early-init edge case (api.updates not yet loaded) skips the version check rather than wedging the boot — at worst an unstamped file is written once and rejected on the next call. Updated existing tests/test_model_cache_metadata.py to use subset/ round-trip semantics rather than byte-for-byte equality, since the disk payload now has additional stamps. The four response-shape fields still round-trip verbatim; the load result is unchanged (stamps stripped). 19 new regression tests. 4180 -> 4199 tests pass.
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.294] — 2026-05-04
|
||||
|
||||
### Fixed (1 PR — closes #1633)
|
||||
|
||||
- **`/api/models` disk cache now invalidated on every WebUI version change** (closes #1633) — `STATE_DIR/models_cache.json` was persisted across server restarts without any version stamp. A Docker container update from version A to version B read the cache file written by version A — users saw stale picker contents (missing models, phantom provider groups, e.g. the v0.50.281 4-model Nous Portal + `Opencode_Go` phantom) for up to 24 hours until either the TTL expired, an unrelated provider edit triggered `invalidate_models_cache()`, or they manually deleted the file. Reporter Deor (Discord) updated to v0.50.292 — which contained fixes for #1538, #1539, and #1568 — did a hard refresh and cleared site data, and still saw byte-for-byte identical picker contents because the server kept reading the v0.50.281 cache file off the host-mounted volume. **Fix:** `_save_models_cache_to_disk()` now stamps payloads with `_webui_version` (resolved lazily from `api.updates.WEBUI_VERSION` to avoid a circular import) and `_schema_version = 2`. `_load_models_cache_from_disk()` rejects any cache where either field mismatches the runtime — every release auto-rebuilds from live provider data on the very next `/api/models` call. Legacy unstamped caches (pre-#1633 files) are also rejected, so the first read after upgrading to this release rebuilds cleanly. Schema version is independent of the WebUI version stamp so future cache-shape changes can invalidate older releases without relying on a tag bump alone. The early-init edge case (api.updates not yet loaded) skips the version check rather than wedging the boot — at worst an unstamped file is written once and rejected on the next call.
|
||||
|
||||
### Tests
|
||||
|
||||
4180 → **4199 passing** (+19 regression tests on `tests/test_issue1633_models_cache_version_stamp.py`). 0 regressions. Full suite in ~115s.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- Self-built fix (nesquena-hermes), pending Opus advisor pre-merge pass and independent review APPROVED by nesquena.
|
||||
- End-to-end behavioral test (`test_docker_update_scenario_invalidates_old_cache`) reproduces Deor's exact reported scenario: a cache stamped at `v0.50.281` fails to load when runtime is `v0.50.292`, forcing a fresh rebuild that picks up the picker fixes shipped between releases.
|
||||
- Round-trip + version-mismatch + legacy-unstamped + schema-mismatch + early-init + corrupt-JSON + missing-file + atomic-overwrite + invalidate-cache-tear-down all pinned.
|
||||
- Smoke test: real `api.updates.WEBUI_VERSION` resolved at runtime (`v0.50.293-dirty` against the worktree); cache stamped with that version; round-trip works end-to-end.
|
||||
|
||||
## [v0.50.293] — 2026-05-04
|
||||
|
||||
### Fixed (3 PRs — profile isolation trio + agent version badge + #1597 follow-up)
|
||||
|
||||
131
api/config.py
131
api/config.py
@@ -1506,6 +1506,43 @@ _provider_models_invalidated_ts: dict[str, float] = {} # provider_id -> timesta
|
||||
# HERMES_WEBUI_STATE_DIR / port) has its own file and test runs never
|
||||
# pollute the production server's cache. Also works on macOS and Windows
|
||||
# where /dev/shm does not exist.
|
||||
def _current_webui_version() -> str | None:
|
||||
"""Lazy resolver for the WebUI version, used to stamp the disk cache (#1633).
|
||||
|
||||
`api.updates` imports `api.config` at module-load time, so we cannot
|
||||
`from api.updates import WEBUI_VERSION` at the top of this module without a
|
||||
circular import. Instead we resolve lazily on each cache load/save.
|
||||
|
||||
Returns the runtime version string (e.g. ``v0.50.293``) when api.updates
|
||||
has been imported, or None if it isn't loaded yet (boot-time corner case
|
||||
before the server has finished initializing). A None return is treated as
|
||||
"do not stamp / do not validate" by the cache layer so cache reads/writes
|
||||
that happen during early init still work — the next call after init will
|
||||
stamp normally.
|
||||
"""
|
||||
try:
|
||||
# Read attribute via dotted lookup so we don't add an import-time edge.
|
||||
import sys as _sys
|
||||
mod = _sys.modules.get('api.updates')
|
||||
if mod is None:
|
||||
return None
|
||||
v = getattr(mod, 'WEBUI_VERSION', None)
|
||||
return str(v) if v else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Disk-cache schema version (#1633).
|
||||
#
|
||||
# Bumped any time the disk cache shape changes in a backward-incompatible way
|
||||
# (e.g. new required field, renamed key). Independent of the WebUI version
|
||||
# stamp — _webui_version forces a rebuild on every release; _schema_version
|
||||
# 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_path = STATE_DIR / "models_cache.json"
|
||||
|
||||
|
||||
@@ -1517,7 +1554,15 @@ def _delete_models_cache_on_disk() -> None:
|
||||
|
||||
|
||||
def _is_valid_models_cache(cache: object) -> bool:
|
||||
"""Return True when a disk cache payload has the full /api/models shape."""
|
||||
"""Return True when a cache payload has the full /api/models shape.
|
||||
|
||||
SHAPE-only check: validates structural correctness of an in-memory or
|
||||
on-disk cache. Use _is_loadable_disk_cache() for the strictness needed
|
||||
when reading from disk (it adds version-stamp invalidation per #1633).
|
||||
|
||||
Kept loose so in-memory cache writes (which never touch disk and so don't
|
||||
need version stamping) can use this validator unchanged.
|
||||
"""
|
||||
if not isinstance(cache, dict):
|
||||
return False
|
||||
if not {"active_provider", "default_model", "configured_model_badges", "groups"}.issubset(cache):
|
||||
@@ -1531,8 +1576,42 @@ def _is_valid_models_cache(cache: object) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_loadable_disk_cache(cache: object) -> bool:
|
||||
"""Return True when an on-disk cache is safe to use after a process boot.
|
||||
|
||||
Adds two checks on top of _is_valid_models_cache (#1633):
|
||||
1. ``_schema_version`` matches `_MODELS_CACHE_SCHEMA_VERSION`. A bumped
|
||||
schema version unconditionally invalidates older cache files.
|
||||
2. ``_webui_version`` matches the current runtime version. Forces a
|
||||
rebuild after every release so users see picker-shape fixes
|
||||
immediately, instead of waiting up to 24 hours for the TTL to expire.
|
||||
If the runtime version cannot be resolved (early-init edge case),
|
||||
skip this check rather than wedge the boot.
|
||||
"""
|
||||
if not _is_valid_models_cache(cache):
|
||||
return False
|
||||
if not isinstance(cache, dict): # appease type-narrowing — already guarded above
|
||||
return False
|
||||
if cache.get("_schema_version") != _MODELS_CACHE_SCHEMA_VERSION:
|
||||
return False
|
||||
runtime_version = _current_webui_version()
|
||||
if runtime_version is not None:
|
||||
cached_version = cache.get("_webui_version")
|
||||
if not isinstance(cached_version, str) or cached_version != runtime_version:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_models_cache_from_disk() -> dict | None:
|
||||
"""Load /api/models cache from disk if it exists and has current metadata."""
|
||||
"""Load /api/models cache from disk if it exists and has current metadata.
|
||||
|
||||
Adds the per-release version check from #1633: a cache stamped with a
|
||||
different WebUI version is treated as missing, forcing a fresh rebuild
|
||||
that picks up any picker-shape fixes shipped in the new release. The
|
||||
returned dict is the SHAPE-only cache (without the `_webui_version` /
|
||||
`_schema_version` stamps) so callers don't have to know about the
|
||||
on-disk metadata fields.
|
||||
"""
|
||||
try:
|
||||
import json as _j
|
||||
|
||||
@@ -1540,28 +1619,52 @@ def _load_models_cache_from_disk() -> dict | None:
|
||||
return None
|
||||
with open(_models_cache_path, encoding="utf-8") as f:
|
||||
cache = _j.load(f)
|
||||
return cache if _is_valid_models_cache(cache) else None
|
||||
if not _is_loadable_disk_cache(cache):
|
||||
return None
|
||||
# Strip the disk-only metadata before returning, so the in-memory
|
||||
# cache shape stays exactly what the rest of the code expects.
|
||||
return {
|
||||
"active_provider": cache["active_provider"],
|
||||
"default_model": cache["default_model"],
|
||||
"configured_model_badges": cache["configured_model_badges"],
|
||||
"groups": cache["groups"],
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_models_cache_to_disk(cache: dict) -> None:
|
||||
"""Save cache to disk so it survives server restarts."""
|
||||
"""Save cache to disk so it survives server restarts.
|
||||
|
||||
Stamps the payload with `_webui_version` and `_schema_version` (#1633) so
|
||||
a subsequent process running a different WebUI version, or a future
|
||||
release that bumps the schema, will treat the file as invalid and
|
||||
rebuild from live provider data on its first /api/models call.
|
||||
|
||||
The version stamp is omitted (not the literal None — the field is just
|
||||
skipped) when the runtime version cannot be resolved at the moment of
|
||||
save, which would happen only in a very early boot path before
|
||||
api.updates is loaded. _is_loadable_disk_cache treats a missing field as
|
||||
a mismatch (since runtime_version is non-None on every subsequent call),
|
||||
so this is safe — at worst we write one cache file that gets rejected
|
||||
once on the next boot.
|
||||
"""
|
||||
try:
|
||||
if not _is_valid_models_cache(cache):
|
||||
return
|
||||
payload = {
|
||||
"_schema_version": _MODELS_CACHE_SCHEMA_VERSION,
|
||||
"active_provider": cache["active_provider"],
|
||||
"default_model": cache["default_model"],
|
||||
"configured_model_badges": cache["configured_model_badges"],
|
||||
"groups": cache["groups"],
|
||||
}
|
||||
runtime_version = _current_webui_version()
|
||||
if runtime_version is not None:
|
||||
payload["_webui_version"] = runtime_version
|
||||
tmp = str(_models_cache_path) + f".{os.getpid()}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"active_provider": cache["active_provider"],
|
||||
"default_model": cache["default_model"],
|
||||
"configured_model_badges": cache["configured_model_badges"],
|
||||
"groups": cache["groups"],
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
json.dump(payload, f, indent=2)
|
||||
os.rename(tmp, str(_models_cache_path))
|
||||
except Exception:
|
||||
pass # Non-fatal -- cache will rebuild on next call
|
||||
|
||||
390
tests/test_issue1633_models_cache_version_stamp.py
Normal file
390
tests/test_issue1633_models_cache_version_stamp.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""Tests for #1633: /api/models disk cache must be invalidated on WebUI version change.
|
||||
|
||||
Bug shape: STATE_DIR/models_cache.json was persisted across server restarts
|
||||
without any version stamp. A Docker container update from version A to B
|
||||
read the cache file written by version A — users saw stale picker contents
|
||||
(missing models, phantom provider groups, etc.) for up to 24h until either
|
||||
(a) the TTL expired, (b) a provider edit triggered invalidate_models_cache,
|
||||
or (c) they manually deleted the file.
|
||||
|
||||
Fix: stamp the disk cache with the current WEBUI_VERSION + a schema version,
|
||||
and reject loads where either field mismatches. A new release auto-rebuilds
|
||||
the cache on the very next /api/models call instead of lingering for 24h.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_cache(tmp_path, monkeypatch):
|
||||
"""Redirect the disk cache to a tmp file and reset api.updates between tests."""
|
||||
from api import config
|
||||
|
||||
cache_path = tmp_path / "models_cache.json"
|
||||
monkeypatch.setattr(config, "_models_cache_path", cache_path)
|
||||
yield cache_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_runtime_version():
|
||||
"""Return a setter that forces a particular runtime WEBUI_VERSION."""
|
||||
# api.updates must be loaded for the lazy resolver to find it
|
||||
import api.updates as upd
|
||||
original = upd.WEBUI_VERSION
|
||||
|
||||
def _set(version: str):
|
||||
upd.WEBUI_VERSION = version
|
||||
|
||||
yield _set
|
||||
upd.WEBUI_VERSION = original
|
||||
|
||||
|
||||
def _shape_cache():
|
||||
"""Minimal valid cache shape (no version stamps — those are added on save)."""
|
||||
return {
|
||||
"active_provider": "anthropic",
|
||||
"default_model": "claude-sonnet-4.6",
|
||||
"configured_model_badges": {"foo": "bar"},
|
||||
"groups": [{"name": "Anthropic", "models": ["claude-sonnet-4.6"]}],
|
||||
}
|
||||
|
||||
|
||||
# ── _current_webui_version lazy resolver ──────────────────────────────────
|
||||
|
||||
|
||||
def test_current_webui_version_returns_runtime_version(with_runtime_version):
|
||||
"""When api.updates is loaded, the lazy resolver returns its WEBUI_VERSION."""
|
||||
from api.config import _current_webui_version
|
||||
with_runtime_version("v0.50.999-test")
|
||||
assert _current_webui_version() == "v0.50.999-test"
|
||||
|
||||
|
||||
def test_current_webui_version_returns_none_when_module_missing(monkeypatch):
|
||||
"""Early-init path: if api.updates isn't in sys.modules, return None.
|
||||
|
||||
Required so cache reads/writes during very early server boot don't wedge
|
||||
the startup sequence on AttributeError.
|
||||
"""
|
||||
monkeypatch.delitem(sys.modules, "api.updates", raising=False)
|
||||
from api.config import _current_webui_version
|
||||
assert _current_webui_version() is None
|
||||
|
||||
|
||||
# ── Disk cache version stamping ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_stamps_webui_version_on_disk(isolated_cache, with_runtime_version):
|
||||
"""Saving a cache writes both _webui_version and _schema_version stamps."""
|
||||
from api import config
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
config._save_models_cache_to_disk(_shape_cache())
|
||||
|
||||
on_disk = json.load(open(isolated_cache))
|
||||
assert on_disk["_webui_version"] == "v0.50.293"
|
||||
assert on_disk["_schema_version"] == config._MODELS_CACHE_SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_save_omits_webui_version_when_runtime_unknown(isolated_cache, monkeypatch):
|
||||
"""If api.updates isn't loaded (very early boot), save still works but
|
||||
skips the version stamp. The next load with a known runtime version will
|
||||
treat the file as invalid (fail-safe rebuild on first real call)."""
|
||||
monkeypatch.delitem(sys.modules, "api.updates", raising=False)
|
||||
from api import config
|
||||
|
||||
config._save_models_cache_to_disk(_shape_cache())
|
||||
on_disk = json.load(open(isolated_cache))
|
||||
assert "_webui_version" not in on_disk
|
||||
# Schema version is always written — it doesn't depend on api.updates
|
||||
assert on_disk["_schema_version"] == config._MODELS_CACHE_SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_save_only_writes_known_keys(isolated_cache, with_runtime_version):
|
||||
"""Defensive — extra junk in the cache dict shouldn't leak to disk."""
|
||||
from api import config
|
||||
with_runtime_version("v0.50.999")
|
||||
|
||||
cache = _shape_cache()
|
||||
cache["secret_credentials"] = "definitely should not be on disk"
|
||||
cache["__internal_hint"] = "also nope"
|
||||
config._save_models_cache_to_disk(cache)
|
||||
|
||||
on_disk = json.load(open(isolated_cache))
|
||||
assert "secret_credentials" not in on_disk
|
||||
assert "__internal_hint" not in on_disk
|
||||
|
||||
|
||||
# ── Load: version validation ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_load_round_trip_matching_version(isolated_cache, with_runtime_version):
|
||||
"""Save then load with the same runtime version returns the original shape."""
|
||||
from api import config
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
original = _shape_cache()
|
||||
config._save_models_cache_to_disk(original)
|
||||
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded is not None
|
||||
# Shape preserved
|
||||
assert loaded["active_provider"] == original["active_provider"]
|
||||
assert loaded["default_model"] == original["default_model"]
|
||||
assert loaded["configured_model_badges"] == original["configured_model_badges"]
|
||||
assert loaded["groups"] == original["groups"]
|
||||
# Disk-only metadata stripped before return
|
||||
assert "_webui_version" not in loaded
|
||||
assert "_schema_version" not in loaded
|
||||
|
||||
|
||||
def test_load_rejects_mismatched_webui_version(isolated_cache, with_runtime_version):
|
||||
"""The core #1633 fix: a cache stamped v0.50.281 is invalid at runtime v0.50.293."""
|
||||
from api import config
|
||||
|
||||
# Save under v0.50.281
|
||||
with_runtime_version("v0.50.281")
|
||||
config._save_models_cache_to_disk(_shape_cache())
|
||||
|
||||
# Try to load under v0.50.293
|
||||
with_runtime_version("v0.50.293")
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded is None, (
|
||||
"Cache stamped with a different WebUI version must be rejected so the "
|
||||
"next call rebuilds with the current release's picker shape (#1633)"
|
||||
)
|
||||
|
||||
|
||||
def test_load_rejects_legacy_cache_without_version_stamp(isolated_cache, with_runtime_version):
|
||||
"""Pre-#1633 cache files have no _webui_version field at all. They must
|
||||
be treated as invalid on the very first load post-update so users get
|
||||
a fresh rebuild instead of stale picker contents."""
|
||||
from api import config
|
||||
|
||||
# Hand-write a pre-#1633 cache file (no version fields)
|
||||
legacy = _shape_cache()
|
||||
json.dump(legacy, open(isolated_cache, "w"))
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded is None, (
|
||||
"Legacy (pre-#1633) cache files must be rejected so the first call "
|
||||
"after updating to a release with #1633 rebuilds from live data"
|
||||
)
|
||||
|
||||
|
||||
def test_load_rejects_mismatched_schema_version(isolated_cache, with_runtime_version):
|
||||
"""Schema version mismatch invalidates the cache regardless of WebUI version.
|
||||
Forward-compat for future cache-shape changes."""
|
||||
from api import config
|
||||
|
||||
# Manually write a cache with a stale schema version but matching webui version
|
||||
stale = {
|
||||
"_schema_version": 0, # old
|
||||
"_webui_version": "v0.50.293",
|
||||
**_shape_cache(),
|
||||
}
|
||||
json.dump(stale, open(isolated_cache, "w"))
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded is None, (
|
||||
"Cache with a different schema version must be rejected even when "
|
||||
"WebUI version matches"
|
||||
)
|
||||
|
||||
|
||||
def test_load_skips_version_check_when_runtime_unknown(isolated_cache, monkeypatch):
|
||||
"""Early-init: if api.updates isn't loaded, _current_webui_version returns
|
||||
None. The version check should NOT run (because we have nothing to compare
|
||||
against), but other validity checks still apply.
|
||||
|
||||
This is the fail-safe path that prevents a boot-time wedge if the very
|
||||
first /api/models call fires before api.updates is imported.
|
||||
"""
|
||||
from api import config
|
||||
|
||||
# Write a cache that's correct except has no _webui_version
|
||||
cache = {
|
||||
"_schema_version": config._MODELS_CACHE_SCHEMA_VERSION,
|
||||
# no _webui_version
|
||||
**_shape_cache(),
|
||||
}
|
||||
json.dump(cache, open(isolated_cache, "w"))
|
||||
|
||||
monkeypatch.delitem(sys.modules, "api.updates", raising=False)
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
# Loadable because runtime version was unknown — once api.updates loads,
|
||||
# the next call would re-validate.
|
||||
assert loaded is not None
|
||||
|
||||
|
||||
# ── Validity helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_valid_models_cache_remains_shape_only():
|
||||
"""_is_valid_models_cache must NOT enforce version stamps — keep it loose
|
||||
so in-memory cache validations don't fail on missing _webui_version. The
|
||||
strict version check lives in _is_loadable_disk_cache only."""
|
||||
from api.config import _is_valid_models_cache
|
||||
cache = _shape_cache()
|
||||
# No _webui_version field
|
||||
assert _is_valid_models_cache(cache) is True
|
||||
|
||||
|
||||
def test_is_loadable_disk_cache_checks_versions(with_runtime_version):
|
||||
"""_is_loadable_disk_cache must check both schema + webui_version stamps."""
|
||||
from api import config
|
||||
with_runtime_version("v0.50.293")
|
||||
|
||||
# Missing _webui_version
|
||||
bad1 = {"_schema_version": config._MODELS_CACHE_SCHEMA_VERSION, **_shape_cache()}
|
||||
assert config._is_loadable_disk_cache(bad1) is False
|
||||
|
||||
# Wrong _webui_version
|
||||
bad2 = {
|
||||
"_schema_version": config._MODELS_CACHE_SCHEMA_VERSION,
|
||||
"_webui_version": "v0.50.281",
|
||||
**_shape_cache(),
|
||||
}
|
||||
assert config._is_loadable_disk_cache(bad2) is False
|
||||
|
||||
# Wrong _schema_version
|
||||
bad3 = {
|
||||
"_schema_version": 0,
|
||||
"_webui_version": "v0.50.293",
|
||||
**_shape_cache(),
|
||||
}
|
||||
assert config._is_loadable_disk_cache(bad3) is False
|
||||
|
||||
# Right
|
||||
good = {
|
||||
"_schema_version": config._MODELS_CACHE_SCHEMA_VERSION,
|
||||
"_webui_version": "v0.50.293",
|
||||
**_shape_cache(),
|
||||
}
|
||||
assert config._is_loadable_disk_cache(good) is True
|
||||
|
||||
|
||||
def test_is_loadable_disk_cache_rejects_non_dict():
|
||||
"""Non-dict input is invalid even when version checks are skipped."""
|
||||
from api.config import _is_loadable_disk_cache
|
||||
assert _is_loadable_disk_cache(None) is False
|
||||
assert _is_loadable_disk_cache([]) is False
|
||||
assert _is_loadable_disk_cache("string") is False
|
||||
assert _is_loadable_disk_cache(42) is False
|
||||
|
||||
|
||||
# ── Edge cases ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_load_handles_corrupt_json(isolated_cache, with_runtime_version):
|
||||
"""A corrupt cache file (truncated JSON, non-UTF8 bytes) must return None
|
||||
silently, not raise — the cache layer is best-effort."""
|
||||
from api import config
|
||||
|
||||
with open(isolated_cache, "wb") as f:
|
||||
f.write(b"{not valid json at all")
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded is None
|
||||
|
||||
|
||||
def test_load_handles_missing_file(isolated_cache, with_runtime_version):
|
||||
"""Cache file simply doesn't exist (cold boot) → None, not error."""
|
||||
from api import config
|
||||
# isolated_cache fixture creates the path but not the file
|
||||
assert not isolated_cache.exists()
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded is None
|
||||
|
||||
|
||||
def test_save_overwrite_atomic(isolated_cache, with_runtime_version):
|
||||
"""Saving twice with different versions overwrites cleanly via tmp+rename."""
|
||||
from api import config
|
||||
|
||||
with_runtime_version("v0.50.281")
|
||||
config._save_models_cache_to_disk(_shape_cache())
|
||||
assert json.load(open(isolated_cache))["_webui_version"] == "v0.50.281"
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
config._save_models_cache_to_disk(_shape_cache())
|
||||
assert json.load(open(isolated_cache))["_webui_version"] == "v0.50.293"
|
||||
|
||||
|
||||
def test_save_skips_invalid_shape(isolated_cache, with_runtime_version):
|
||||
"""Pre-#1633 contract: invalid shape never lands on disk. Preserved."""
|
||||
from api import config
|
||||
with_runtime_version("v0.50.293")
|
||||
|
||||
# Missing required keys
|
||||
config._save_models_cache_to_disk({"active_provider": "anthropic"})
|
||||
assert not isolated_cache.exists()
|
||||
|
||||
|
||||
# ── End-to-end: simulate a Docker container update ───────────────────────
|
||||
|
||||
|
||||
def test_docker_update_scenario_invalidates_old_cache(isolated_cache, with_runtime_version):
|
||||
"""Reproduce Deor's exact scenario from the bug report:
|
||||
|
||||
1. Server v0.50.281 builds a cache and writes it to STATE_DIR.
|
||||
2. Container is updated to v0.50.292 (new image, same mounted state volume).
|
||||
3. New server boots and tries to load the cache file.
|
||||
4. Expected: load returns None, forcing a rebuild that picks up the
|
||||
picker fixes shipped between v0.50.281 and v0.50.292.
|
||||
"""
|
||||
from api import config
|
||||
|
||||
# Step 1: v0.50.281 writes cache
|
||||
with_runtime_version("v0.50.281")
|
||||
old_cache = {
|
||||
"active_provider": "nous",
|
||||
"default_model": "anthropic/claude-sonnet-4.6",
|
||||
"configured_model_badges": {"anthropic/claude-sonnet-4.6": "Anthropic"},
|
||||
# The pre-fix Nous group with only 4 models (the v0.50.281 bug)
|
||||
"groups": [{"name": "Nous Portal", "models": ["a", "b", "c", "d"]}],
|
||||
}
|
||||
config._save_models_cache_to_disk(old_cache)
|
||||
on_disk = json.load(open(isolated_cache))
|
||||
assert on_disk["_webui_version"] == "v0.50.281"
|
||||
assert len(on_disk["groups"][0]["models"]) == 4
|
||||
|
||||
# Step 2-3: Container updates to v0.50.292; new server tries to load
|
||||
with_runtime_version("v0.50.292")
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
|
||||
# Step 4: cache rejected → caller will rebuild from live provider data
|
||||
assert loaded is None, (
|
||||
"After a WebUI version bump, the disk cache must be rejected so users "
|
||||
"see picker fixes immediately instead of waiting up to 24h for the TTL "
|
||||
"(#1633: Deor reported v0.50.292 looking identical to v0.50.281 because "
|
||||
"the v0.50.281 cache file was being reused unchanged)"
|
||||
)
|
||||
|
||||
|
||||
# ── invalidate_models_cache still cleans the disk file ───────────────────
|
||||
|
||||
|
||||
def test_invalidate_models_cache_still_deletes_disk_file(isolated_cache, with_runtime_version):
|
||||
"""Pre-existing contract preserved: invalidate_models_cache() drops the
|
||||
in-memory cache AND deletes the disk file. The version stamping must not
|
||||
interfere with this teardown path."""
|
||||
from api import config
|
||||
|
||||
with_runtime_version("v0.50.293")
|
||||
config._save_models_cache_to_disk(_shape_cache())
|
||||
assert isolated_cache.exists()
|
||||
|
||||
config.invalidate_models_cache()
|
||||
assert not isolated_cache.exists()
|
||||
@@ -33,8 +33,22 @@ def test_save_models_cache_to_disk_preserves_response_metadata(tmp_path, monkeyp
|
||||
|
||||
config._save_models_cache_to_disk(payload)
|
||||
|
||||
assert json.loads(cache_path.read_text(encoding="utf-8")) == payload
|
||||
assert config._load_models_cache_from_disk() == payload
|
||||
on_disk = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
# The four response-shape fields round-trip verbatim.
|
||||
for k, v in payload.items():
|
||||
assert on_disk[k] == v, f"Field {k!r} did not round-trip"
|
||||
# Plus the disk-only metadata stamps added by #1633 — present but not part
|
||||
# of the response payload.
|
||||
assert "_schema_version" in on_disk
|
||||
# _webui_version may be absent in early-init paths where api.updates isn't
|
||||
# yet imported; in normal test runs api.updates IS imported, so assert it.
|
||||
import sys
|
||||
if "api.updates" in sys.modules:
|
||||
assert on_disk.get("_webui_version") == sys.modules["api.updates"].WEBUI_VERSION
|
||||
|
||||
# Load returns ONLY the response-shape fields (stamps stripped).
|
||||
loaded = config._load_models_cache_from_disk()
|
||||
assert loaded == payload
|
||||
|
||||
|
||||
def test_load_models_cache_from_disk_rejects_legacy_groups_only_cache(tmp_path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user