"""Regression tests for issue #7540 — Codex models_cache.json churn must not bust a valid /api/models catalog cache and force a live rebuild on session open. Bug shape --------- ``_models_cache_source_fingerprint()`` covers the small local catalogs the catalog depends on, including Codex's ``~/.codex/models_cache.json``, and fingerprinted that file by ``mtime_ns`` + ``size`` (``_models_cache_file_fingerprint``, #2443). Codex rewrites that file on its own refresh timer, and each rewrite bumps both stat fields even when the model payload is byte-identical — only ``fetched_at`` / ``updated_at`` move. So: Codex refresh -> fingerprint differs -> ``_get_fresh_memory_models_cache()`` rejects the warm 24h snapshot -> the session visit (#4756) then finds ``_is_loadable_disk_cache()`` rejecting the disk snapshot for the same reason -> ``get_available_models(force_refresh=True)`` -> serial live provider probes on the session-open path (the multi-second hang in the report). Fix (maintainer recommendation on #7540: semantic fingerprint) -------------------------------------------------------------- ``_codex_models_cache_fingerprint()`` hashes the file's *content* with the refresh-timestamp keys deny-listed, mirroring the auth.json fix (``_auth_store_semantic_fingerprint``, RCA t_16551f61). Metadata churn is invisible to the fingerprint, while anything that actually feeds the Codex model list we merge still changes it. These tests are INVARIANTS, not change-detectors: * churn-immune — a ``fetched_at``-only rewrite keeps the fingerprint identical, keeps a previously-valid disk cache loadable, and keeps a session visit served from memory (no live rebuild). * real-change-invalidates — client_version / etag / models[] / an unknown future field each flip the fingerprint AND reject the previously-valid disk cache, so the fix cannot over-stabilise into serving a stale catalog. """ import copy import json import os import time import pytest import api.config as config _TS_1 = "2026-09-13T12:00:00+00:00" _TS_2 = "2026-09-13T15:45:11+00:00" class _LiveRebuildReached(BaseException): """Sentinel raised if a session visit escalates to a live provider rebuild. Deliberately NOT an ``Exception``: the session-visit path catches broad ``Exception`` around its ``force_refresh`` call and falls back to a stale on-disk snapshot, which would mask the regression. """ def _codex_cache(fetched_at=_TS_1, *, client_version="0.51.0", etag='"codex-etag-1"', models=None, extra_top=None): """Build a realistic ~/.codex/models_cache.json payload.""" if models is None: models = [ {"slug": "gpt-5.3-codex", "visibility": "list", "priority": 1}, {"slug": "gpt-5.1-codex-mini", "visibility": "list", "priority": 2}, ] payload = { "fetched_at": fetched_at, "client_version": client_version, "etag": etag, "models": models, } if extra_top: payload.update(extra_top) return payload def _write_codex(tmp_path, monkeypatch, payload) -> "os.PathLike": """Write the Codex cache into an isolated CODEX_HOME and point config at it.""" codex_home = tmp_path / "codex_home" codex_home.mkdir(parents=True, exist_ok=True) cache_path = codex_home / "models_cache.json" cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") monkeypatch.setenv("CODEX_HOME", str(codex_home)) return cache_path def _rewrite(path, payload): """Rewrite the Codex cache and deterministically bump its mtime. ``os.utime`` is used instead of ``time.sleep`` so the mtime_ns change is guaranteed on every filesystem/timestamp-resolution combination — the test must prove the *old* stat fingerprint churned, not rely on clock luck. """ path.write_text(json.dumps(payload, indent=2), encoding="utf-8") bumped = path.stat().st_mtime + 7.0 os.utime(path, (bumped, bumped)) return path def _catalog_payload(): return { "active_provider": "codex", "default_model": "gpt-5.3-codex", "configured_model_badges": {"gpt-5.3-codex": "Codex"}, "groups": [{"name": "Codex", "models": ["gpt-5.3-codex"]}], } # ── churn-immunity invariant ──────────────────────────────────────────────── def test_codex_fetched_at_only_rewrite_keeps_fingerprint_identical(tmp_path, monkeypatch): """Codex's own refresh moves ``fetched_at`` only — same models, same size.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) fp_before = config._codex_models_cache_fingerprint(p) st_before = p.stat() _rewrite(p, _codex_cache(fetched_at=_TS_2)) fp_after = config._codex_models_cache_fingerprint(p) st_after = p.stat() # Pre-condition: this really is the mtime/size churn from the report — the # old stat-based fingerprint WOULD have changed. assert (st_before.st_mtime_ns, st_before.st_size) != ( st_after.st_mtime_ns, st_after.st_size), "test setup: rewrite must churn the stat fields" # Invariant: the semantic fingerprint does not. assert fp_before == fp_after, ( "A Codex refresh that only moves fetched_at must NOT change the catalog " "fingerprint (#7540) — otherwise the 24h cache is rejected and the next " "session visit pays a live rebuild" ) assert "semantic_sha256" in fp_before def test_unsorted_codex_payload_key_order_keeps_fingerprint_identical(tmp_path, monkeypatch): """Key order is not semantic: a reordered but otherwise identical file must not bust the cache.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache()) fp_before = config._codex_models_cache_fingerprint(p) reordered = { "models": _codex_cache()["models"], "etag": _codex_cache()["etag"], "client_version": _codex_cache()["client_version"], "fetched_at": _codex_cache()["fetched_at"], } _rewrite(p, reordered) assert config._codex_models_cache_fingerprint(p) == fp_before def test_codex_metadata_churn_does_not_reject_valid_disk_models_cache(tmp_path, monkeypatch): """End-to-end: the disk snapshot a warm server wrote must still load after Codex refreshed its own cache.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") config._save_models_cache_to_disk(_catalog_payload()) assert config._load_models_cache_from_disk() is not None _rewrite(p, _codex_cache(fetched_at=_TS_2)) assert config._load_models_cache_from_disk() is not None, ( "Codex metadata churn must NOT reject a valid on-disk models cache " "(#7540) — that rejection is what triggers the live rebuild" ) def test_session_visit_after_codex_refresh_needs_no_live_rebuild(tmp_path, monkeypatch): """The user-visible symptom: open a conversation after Codex refreshed. The warm in-memory snapshot must still be served; a rebuild here is the multi-second session-open hang in the report. """ _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") payload = _catalog_payload() config._save_models_cache_to_disk(payload) # Warm the memory cache the way a normal /api/models call leaves it. monkeypatch.setattr(config, "_available_models_cache", copy.deepcopy(payload)) monkeypatch.setattr(config, "_available_models_cache_ts", time.monotonic()) monkeypatch.setattr( config, "_available_models_cache_source_fingerprint", config._models_cache_source_fingerprint(), ) # Codex's refresh timer fires between two session opens. _rewrite(tmp_path / "codex_home" / "models_cache.json", _codex_cache(fetched_at=_TS_2)) def _boom(**_kwargs): # BaseException, not Exception: get_available_models_for_session_visit # swallows exceptions from its force_refresh path and falls back to the # on-disk stale snapshot, so an AssertionError sentinel would be eaten # and the test would pass while quietly taking the slow path. raise _LiveRebuildReached( "session visit fell through to the live rebuild: Codex fetched_at " "churn invalidated a still-valid catalog cache (#7540)" ) monkeypatch.setattr(config, "get_available_models", _boom) result = config.get_available_models_for_session_visit() assert result is not None assert result["default_model"] == "gpt-5.3-codex" def test_warm_memory_cache_survives_codex_churn(tmp_path, monkeypatch): """Isolates the invalidation point itself (the memory-cache rejection that precedes the disk load and the force_refresh).""" _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") payload = _catalog_payload() config._save_models_cache_to_disk(payload) monkeypatch.setattr(config, "_available_models_cache", copy.deepcopy(payload)) monkeypatch.setattr(config, "_available_models_cache_ts", time.monotonic()) monkeypatch.setattr( config, "_available_models_cache_source_fingerprint", config._models_cache_source_fingerprint(), ) _rewrite(tmp_path / "codex_home" / "models_cache.json", _codex_cache(fetched_at=_TS_2)) assert config._get_fresh_memory_models_cache(time.monotonic()) is not None, ( "The warm in-memory catalog cache must survive a Codex fetched_at-only " "refresh (#7540)" ) # ── real-change-invalidates invariant ─────────────────────────────────────── def test_new_codex_model_changes_fingerprint(tmp_path, monkeypatch): p = _write_codex(tmp_path, monkeypatch, _codex_cache()) fp_before = config._codex_models_cache_fingerprint(p) models = _codex_cache()["models"] + [ {"slug": "gpt-5.4-codex", "visibility": "list", "priority": 3}, ] _rewrite(p, _codex_cache(models=models)) assert config._codex_models_cache_fingerprint(p) != fp_before, ( "A new Codex model changes the merged catalog and MUST bust the cache" ) def test_model_visibility_change_changes_fingerprint(tmp_path, monkeypatch): """``visibility`` gates whether the slug is surfaced — a hide/show flip is a real catalog change at unchanged file size.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache()) fp_before = config._codex_models_cache_fingerprint(p) models = copy.deepcopy(_codex_cache()["models"]) models[0]["visibility"] = "hidden" _rewrite(p, _codex_cache(models=models)) assert config._codex_models_cache_fingerprint(p) != fp_before @pytest.mark.parametrize( "changed", [ {"client_version": "0.52.0"}, {"etag": '"codex-etag-2"'}, {"models": [{"slug": "gpt-5.3-codex", "priority": 9}]}, ], ) def test_codex_catalog_fields_stay_in_fingerprint(tmp_path, monkeypatch, changed): """Everything that actually feeds the Codex model list stays covered.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache()) fp_before = config._codex_models_cache_fingerprint(p) _rewrite(p, _codex_cache(**changed)) assert config._codex_models_cache_fingerprint(p) != fp_before def test_unknown_codex_field_stays_in_fingerprint(tmp_path, monkeypatch): """Deny-list (not allow-list) safety: an unknown field is NOT stripped, so a hypothetical future model-gating field still busts the cache.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache(extra_top={"some_future_model_gate": "v1"})) fp_before = config._codex_models_cache_fingerprint(p) _rewrite(p, _codex_cache(extra_top={"some_future_model_gate": "v2"})) assert config._codex_models_cache_fingerprint(p) != fp_before, ( "An unknown (non-deny-listed) field must remain in the fingerprint — the " "deny-list must fail safe toward over-invalidation" ) def test_real_codex_change_still_rejects_previously_valid_disk_cache(tmp_path, monkeypatch): """Anti-over-stabilisation mirror of the churn test: a disk snapshot that WAS valid must be rejected once a genuine Codex model change lands.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache()) monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") config._save_models_cache_to_disk(_catalog_payload()) assert config._load_models_cache_from_disk() is not None _rewrite(p, _codex_cache(client_version="0.52.0")) assert config._load_models_cache_from_disk() is None, ( "A real Codex catalog change MUST reject the stale disk cache — the fix " "must not over-stabilise into serving wrong data" ) # ── helper unit guards ────────────────────────────────────────────────────── def test_strip_volatile_codex_fields_is_pure_and_recursive(): src = { "fetched_at": "ts", "client_version": "0.51.0", "models": [{"slug": "gpt-5.3-codex", "updated_at": "ts", "priority": 1}], } snapshot = copy.deepcopy(src) out = config._strip_volatile_codex_cache_fields(src) assert src == snapshot # input untouched (pure) assert out == { "client_version": "0.51.0", "models": [{"slug": "gpt-5.3-codex", "priority": 1}], } def test_unparseable_codex_cache_falls_back_to_stat_fingerprint(tmp_path, monkeypatch): """Corrupt / mid-write file: fall back to the old stat fingerprint instead of silently pinning a hash of garbage — behaviour is never less safe than the stat-based version, and a later good file still invalidates.""" p = _write_codex(tmp_path, monkeypatch, _codex_cache()) p.write_text('{"models": [', encoding="utf-8") fp = config._codex_models_cache_fingerprint(p) assert fp.get("semantic") == "unparsed-fallback" assert "mtime_ns" in fp and "size" in fp _rewrite(p, _codex_cache(fetched_at=_TS_2)) assert config._codex_models_cache_fingerprint(p) != fp def test_missing_codex_cache_fingerprint_is_stable_and_marked(tmp_path, monkeypatch): monkeypatch.setenv("CODEX_HOME", str(tmp_path / "absent")) fp = config._codex_models_cache_fingerprint(tmp_path / "absent" / "models_cache.json") assert fp.get("missing") is True assert config._codex_models_cache_fingerprint( tmp_path / "absent" / "models_cache.json") == fp def test_deeply_nested_codex_cache_degrades_to_stat_fallback_without_crashing(tmp_path, monkeypatch): """A strip failure (e.g. RecursionError on a deep tree) must not 500 /api/models. The recursive volatile-field strip runs inside the encode try/except, so if it raises (RecursionError on a pathologically deep JSON tree, or any other error) the fingerprint degrades to the stat-based fallback instead of the exception escaping and turning /api/models into an HTTP 500. (#7540 gate finding.) We force the strip to raise directly so the guard is exercised deterministically regardless of the ambient recursion depth. """ p = _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) def _boom(_obj): raise RecursionError("simulated deep-tree strip overflow") monkeypatch.setattr(config, "_strip_volatile_codex_cache_fields", _boom) # Must not raise; must degrade to the stat-based fingerprint. fp = config._codex_models_cache_fingerprint(p) fp2 = config._codex_models_cache_fingerprint(p) assert isinstance(fp, dict) assert fp.get("semantic") == "encode-fallback" assert "mtime_ns" in fp and "size" in fp assert "semantic_sha256" not in fp # the content hash was NOT produced assert fp2 == fp # stable across repeated calls on the unchanged file