"""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, "_source_fingerprint": config._models_cache_source_fingerprint(), # 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", "_source_fingerprint": config._models_cache_source_fingerprint(), **_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()