diff --git a/CHANGELOG.md b/CHANGELOG.md index 7765462b..0f252582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Recover already-journaled visible assistant text and tool cards even when restart repair first syncs a populated Hermes core transcript into an otherwise empty WebUI sidecar. The core-sync branch now merges non-duplicate run-journal output before clearing stale stream state, closing the rare #2427 carve-out where recoverable partial output could be silently skipped. Fixes #2434. - Compact live Thinking cards now reuse the same timeline card across sequential tool calls, preventing repeated Thinking cards from stacking during one multi-tool turn. - Refresh context-window metadata when a session's resolved model changes during load or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Fixes #2442. +- **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) 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"])