From cdbb785037177cc8e91464c3c358bd99b01f892d Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 22:23:24 -0700 Subject: [PATCH] fix: invalidate model cache on catalog changes --- CHANGELOG.md | 4 ++ api/config.py | 37 ++++++++++++++++++- ...ssue1699_model_cache_source_fingerprint.py | 36 ++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..56cfd53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2445** by @Michaelyklam (fixes #2443) — `/api/models` now fingerprints model-catalog inputs as part of its persisted cache metadata, so server-side catalog additions and Codex local catalog changes invalidate `models_cache.json` immediately instead of waiting for the 24-hour TTL or manual cache deletion. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/config.py b/api/config.py index ea890e63..302f898a 100644 --- a/api/config.py +++ b/api/config.py @@ -11,6 +11,7 @@ Discovery order for all paths: import collections import copy +import hashlib import json import logging import os @@ -2205,11 +2206,45 @@ def _models_cache_file_fingerprint(path: Path) -> dict: return fingerprint +def _models_cache_catalog_fingerprint() -> dict: + """Return non-secret model-catalog identity metadata for cache invalidation. + + The /api/models payload is not only a function of user config/auth files. + It also depends on the provider/model catalog baked into this module and on + small local catalogs such as Codex's models_cache.json. Keep this cheap and + deterministic so a server restart after catalog changes does not keep + serving an otherwise-valid persisted models_cache.json until the 24h TTL + expires (#2443). + """ + catalog_payload = { + "provider_models": _PROVIDER_MODELS, + "provider_display": _PROVIDER_DISPLAY, + } + try: + encoded = json.dumps( + catalog_payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=str, + ).encode("utf-8") + provider_catalog_sha = hashlib.sha256(encoded).hexdigest() + except Exception: + provider_catalog_sha = "unavailable" + + codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser() + return { + "provider_catalog_sha256": provider_catalog_sha, + "codex_models_cache": _models_cache_file_fingerprint(codex_home / "models_cache.json"), + } + + def _models_cache_source_fingerprint() -> dict: - """Return the current config/auth-store fingerprint for /api/models cache.""" + """Return the current config/auth/catalog 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()), + "catalog": _models_cache_catalog_fingerprint(), } diff --git a/tests/test_issue1699_model_cache_source_fingerprint.py b/tests/test_issue1699_model_cache_source_fingerprint.py index 30500eb5..0a06d3b8 100644 --- a/tests/test_issue1699_model_cache_source_fingerprint.py +++ b/tests/test_issue1699_model_cache_source_fingerprint.py @@ -142,3 +142,39 @@ def test_disk_models_cache_still_loads_when_auth_and_config_sources_are_unchange result = config.get_available_models() assert result == fresh_opencode + + +def test_memory_models_cache_invalidates_when_static_catalog_changes(tmp_path, monkeypatch): + _configure_isolated_sources(tmp_path, monkeypatch, "opencode-go") + stale_opencode = _valid_models_cache("opencode-go", "glm-5.1") + with config._available_models_cache_lock: + config._available_models_cache = stale_opencode + config._available_models_cache_ts = time.monotonic() + config._available_models_cache_source_fingerprint = config._models_cache_source_fingerprint() + + updated_models = list(config._PROVIDER_MODELS["opencode-go"]) + updated_models.append({"id": "new-catalog-model", "label": "New Catalog Model"}) + monkeypatch.setitem(config._PROVIDER_MODELS, "opencode-go", updated_models) + + result = config.get_available_models() + + opencode_group = next(g for g in result["groups"] if g.get("provider_id") == "opencode-go") + assert any(m.get("id") == "new-catalog-model" for m in opencode_group["models"]) + + +def test_disk_models_cache_invalidates_when_static_catalog_changes(tmp_path, monkeypatch): + _configure_isolated_sources(tmp_path, monkeypatch, "opencode-go") + stale_opencode = _valid_models_cache("opencode-go", "glm-5.1") + config._save_models_cache_to_disk(stale_opencode) + assert config._models_cache_path.exists() + + updated_models = list(config._PROVIDER_MODELS["opencode-go"]) + updated_models.append({"id": "new-disk-catalog-model", "label": "New Disk Catalog Model"}) + monkeypatch.setitem(config._PROVIDER_MODELS, "opencode-go", updated_models) + _reset_memory_cache() + + result = config.get_available_models() + + assert result != stale_opencode + opencode_group = next(g for g in result["groups"] if g.get("provider_id") == "opencode-go") + assert any(m.get("id") == "new-disk-catalog-model" for m in opencode_group["models"])