Merge branch 'peek-4798-v2' into gate/4798-v2
This commit is contained in:
commit
b9f1491de7
5 files changed
+443
-17
No files matched your search
+64
-12
@@ -3772,6 +3772,7 @@ _available_models_cache: dict | None = None
|
||||
_available_models_cache_ts: float = 0.0
|
||||
_available_models_cache_source_fingerprint: dict | None = None
|
||||
_AVAILABLE_MODELS_CACHE_TTL: float = 86400.0 # 24 hours
|
||||
_SESSION_VISIT_MODELS_FRESHNESS_SECONDS: float = 300.0
|
||||
_available_models_cache_lock = threading.RLock() # must be RLock: cold path refactoring moved slow work inside this lock, requiring re-entry
|
||||
_cache_build_cv = threading.Condition(_available_models_cache_lock) # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock
|
||||
_cache_build_in_progress = False # True while a cold path is actively building
|
||||
@@ -5268,7 +5269,7 @@ def _read_visible_codex_cache_model_ids() -> list[str]:
|
||||
return ordered
|
||||
|
||||
|
||||
def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = False) -> dict:
|
||||
"""
|
||||
Return available models grouped by provider.
|
||||
|
||||
@@ -5292,6 +5293,10 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
server-initiated wakeup turn (Option Z) takes so a cold catalog can never
|
||||
block the wakeup chat/start on a flaky network. A normal human request
|
||||
leaves this False and keeps the full live-discovery behaviour.
|
||||
|
||||
``force_refresh=True`` is an internal escape hatch for bounded freshness
|
||||
checks that need a real live rebuild while preserving the default cache
|
||||
contract for every existing caller.
|
||||
"""
|
||||
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv
|
||||
# Config mtime check — must come before any config reads.
|
||||
@@ -6718,10 +6723,12 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
# so only one thread rebuilds while others wait.
|
||||
disk_groups = None
|
||||
stale_disk_groups = None
|
||||
if _available_models_cache is None:
|
||||
if _available_models_cache is None and not force_refresh:
|
||||
disk_groups = _load_models_cache_from_disk()
|
||||
if disk_groups is None:
|
||||
stale_disk_groups = _load_stale_models_cache_from_disk()
|
||||
elif force_refresh:
|
||||
stale_disk_groups = _load_stale_models_cache_from_disk()
|
||||
|
||||
with _available_models_cache_lock:
|
||||
# If another thread is already building, wait for its result instead
|
||||
@@ -6746,16 +6753,16 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
|
||||
# Serve from memory cache if fresh
|
||||
now = time.monotonic()
|
||||
cached = _get_fresh_memory_models_cache(now)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not force_refresh:
|
||||
cached = _get_fresh_memory_models_cache(now)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Cold path: disk cache hit — use it (fast, no lock contention)
|
||||
if disk_groups is not None:
|
||||
if disk_groups is not None and not force_refresh:
|
||||
_available_models_cache = disk_groups
|
||||
_available_models_cache_ts = now
|
||||
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
|
||||
_save_models_cache_to_disk(disk_groups)
|
||||
return copy.deepcopy(disk_groups)
|
||||
|
||||
# ── prefer_cache: NEVER run the live provider rebuild ────────────────
|
||||
@@ -6830,9 +6837,12 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
_available_models_cache = result
|
||||
_available_models_cache_ts = time.monotonic()
|
||||
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
|
||||
_cache_build_in_progress = False
|
||||
_cache_build_cv.notify_all()
|
||||
_save_models_cache_to_disk(result)
|
||||
try:
|
||||
_save_models_cache_to_disk(result)
|
||||
finally:
|
||||
with _cache_build_cv:
|
||||
_cache_build_in_progress = False
|
||||
_cache_build_cv.notify_all()
|
||||
return copy.deepcopy(result)
|
||||
|
||||
# ── Bounded rebuild (defense-in-depth) ───────────────────────────────
|
||||
@@ -6873,12 +6883,14 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
_available_models_cache_source_fingerprint = (
|
||||
_models_cache_source_fingerprint()
|
||||
)
|
||||
_cache_build_in_progress = False
|
||||
_cache_build_cv.notify_all()
|
||||
try:
|
||||
_save_models_cache_to_disk(result)
|
||||
except Exception:
|
||||
logger.debug("models cache disk save failed", exc_info=True)
|
||||
finally:
|
||||
with _cache_build_cv:
|
||||
_cache_build_in_progress = False
|
||||
_cache_build_cv.notify_all()
|
||||
|
||||
def _clear_build_in_progress():
|
||||
global _cache_build_in_progress
|
||||
@@ -6973,6 +6985,46 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
return copy.deepcopy(_static_models_catalog_without_live_probes())
|
||||
|
||||
|
||||
def _models_cache_file_age_seconds(cache_path: Path, now: float) -> float | None:
|
||||
try:
|
||||
return max(0.0, now - cache_path.stat().st_mtime)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def get_available_models_for_session_visit() -> dict:
|
||||
"""Return /api/models with a short session-visit freshness horizon."""
|
||||
global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint
|
||||
cache_path = _get_models_cache_path()
|
||||
cache_age = _models_cache_file_age_seconds(cache_path, time.time())
|
||||
disk_cached = None
|
||||
if cache_age is not None and cache_age < _SESSION_VISIT_MODELS_FRESHNESS_SECONDS:
|
||||
now_mono = time.monotonic()
|
||||
with _available_models_cache_lock:
|
||||
cached = _get_fresh_memory_models_cache(now_mono)
|
||||
if cached is not None:
|
||||
return cached
|
||||
disk_cached = _load_models_cache_from_disk()
|
||||
if disk_cached is not None:
|
||||
with _available_models_cache_lock:
|
||||
cached = _get_fresh_memory_models_cache(time.monotonic())
|
||||
if cached is not None:
|
||||
return cached
|
||||
_available_models_cache = copy.deepcopy(disk_cached)
|
||||
_available_models_cache_ts = time.monotonic()
|
||||
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
|
||||
return copy.deepcopy(disk_cached)
|
||||
|
||||
stale_cached = disk_cached or _load_stale_models_cache_from_disk()
|
||||
try:
|
||||
return get_available_models(force_refresh=True)
|
||||
except Exception:
|
||||
logger.debug("session-visit models refresh failed", exc_info=True)
|
||||
if stale_cached is not None:
|
||||
return copy.deepcopy(stale_cached)
|
||||
return get_available_models(prefer_cache=True)
|
||||
|
||||
|
||||
# ── Static file path ─────────────────────────────────────────────────────────
|
||||
_INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
|
||||
|
||||
|
||||
@@ -2331,6 +2331,7 @@ from api.config import (
|
||||
_resolve_cli_toolsets,
|
||||
_INDEX_HTML_PATH,
|
||||
get_available_models,
|
||||
get_available_models_for_session_visit,
|
||||
_provider_is_known_or_configured,
|
||||
IMAGE_EXTS,
|
||||
MD_EXTS,
|
||||
@@ -9216,6 +9217,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
# the detached rebuild worker (and the legacy synchronous rebuild),
|
||||
# which the request-thread wrapper could not reach. See
|
||||
# api.config.get_available_models cold path + profile_scope_for_detached_worker.
|
||||
freshness = parse_qs(parsed.query or "").get("freshness", [""])[0].strip().lower()
|
||||
if freshness == "session_visit":
|
||||
return j(handler, get_available_models_for_session_visit())
|
||||
if freshness:
|
||||
return bad(handler, f"unknown models freshness: {freshness}", status=400)
|
||||
return j(handler, get_available_models())
|
||||
|
||||
if parsed.path == "/api/models/live":
|
||||
|
||||
+9
-2
@@ -1245,9 +1245,16 @@ async function loadSession(sid){
|
||||
}
|
||||
S.session=data.session;
|
||||
// Loading a real existing session abandons any pre-session toolset override
|
||||
// staged on the empty composer — clear it so it can't leak into a later New
|
||||
// Chat started from this session (#4490 follow-up).
|
||||
// staged on the empty composer before any deferred refresh work runs.
|
||||
S._pendingSessionToolsets=null;
|
||||
if(typeof populateModelDropdown==='function'){
|
||||
const modelRefreshSid=sid;
|
||||
const modelRefreshPromise=Promise.resolve().then(()=>{
|
||||
if(_loadingSessionId!==modelRefreshSid) return;
|
||||
return populateModelDropdown({freshness:'session_visit'});
|
||||
}).catch(()=>{});
|
||||
if(typeof window!=='undefined') window._modelDropdownReady=modelRefreshPromise;
|
||||
}
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
S.session._modelResolutionDeferred=true;
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
|
||||
+17
-3
@@ -2008,6 +2008,8 @@ function _persistSessionModelCorrection(model, provider, opts){
|
||||
});
|
||||
return opts&&opts.propagateErrors ? request : request.catch(()=>{});
|
||||
}
|
||||
let _modelDropdownRequestSeq=0;
|
||||
|
||||
function _applySessionModelFallback(sel){
|
||||
if(!sel) return null;
|
||||
const configuredDefault=String(window._defaultModel||'').trim();
|
||||
@@ -2030,10 +2032,16 @@ function _applySessionModelFallback(sel){
|
||||
async function populateModelDropdown(opts={}){
|
||||
const sel=$('modelSelect');
|
||||
if(!sel) return;
|
||||
if(typeof _modelDropdownRequestSeq!=='number') _modelDropdownRequestSeq=0;
|
||||
const requestSeq=++_modelDropdownRequestSeq;
|
||||
try{
|
||||
const _modelsRes=await fetch(new URL('api/models',document.baseURI||location.href).href,{credentials:'include'});
|
||||
const modelsUrl=new URL('api/models',document.baseURI||location.href);
|
||||
if(opts&&opts.freshness) modelsUrl.searchParams.set('freshness',opts.freshness);
|
||||
const _modelsRes=await fetch(modelsUrl.href,{credentials:'include'});
|
||||
if(requestSeq!==_modelDropdownRequestSeq) return;
|
||||
if(_redirectIfUnauth(_modelsRes)) return;
|
||||
const data=await _modelsRes.json();
|
||||
if(requestSeq!==_modelDropdownRequestSeq) return;
|
||||
// Store active provider globally so the send path can warn on mismatch
|
||||
window._activeProvider=data.active_provider||null;
|
||||
// Store default model so newSession() can apply it (#872).
|
||||
@@ -2128,8 +2136,9 @@ async function populateModelDropdown(opts={}){
|
||||
}
|
||||
// Kick off a background live-model fetch for the active provider.
|
||||
// This runs after the static list is already shown (no blocking flicker).
|
||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel, requestSeq);
|
||||
}catch(e){
|
||||
if(requestSeq!==_modelDropdownRequestSeq) return;
|
||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||
console.warn('Failed to load models from server:',e.message);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
@@ -2219,10 +2228,12 @@ function _addLiveModelsToSelect(provider, models, sel){
|
||||
return added;
|
||||
}
|
||||
|
||||
async function _fetchLiveModels(provider, sel){
|
||||
async function _fetchLiveModels(provider, sel, requestSeq=null){
|
||||
if(!provider||!sel) return;
|
||||
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
|
||||
// Already fetched — apply cached models to this select element (#872)
|
||||
if(_liveModelCache[provider]){
|
||||
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
|
||||
const added=_addLiveModelsToSelect(provider,_liveModelCache[provider],sel);
|
||||
if(added>0 && typeof syncModelChip==='function') syncModelChip();
|
||||
return;
|
||||
@@ -2232,10 +2243,13 @@ async function _fetchLiveModels(provider, sel){
|
||||
const url=new URL('api/models/live',document.baseURI||location.href);
|
||||
url.searchParams.set('provider',provider);
|
||||
const _liveRes=await fetch(url.href,{credentials:'include'});
|
||||
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
|
||||
if(_redirectIfUnauth(_liveRes)) return;
|
||||
const data=await _liveRes.json();
|
||||
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
|
||||
if(!data.models||!data.models.length) return;
|
||||
_liveModelCache[provider]=data.models;
|
||||
if(requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq) return;
|
||||
const added=_addLiveModelsToSelect(provider,data.models,sel);
|
||||
if(added>0){
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Regression coverage for #4756 session-visit model catalog freshness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _catalog(label: str) -> dict:
|
||||
return {
|
||||
"active_provider": "openai",
|
||||
"default_model": label,
|
||||
"configured_model_badges": {},
|
||||
"groups": [
|
||||
{
|
||||
"provider": "OpenAI",
|
||||
"provider_id": "openai",
|
||||
"models": [{"id": label, "label": label}],
|
||||
}
|
||||
],
|
||||
"aliases": {},
|
||||
}
|
||||
|
||||
|
||||
def _reset_models_memory_cache(monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
monkeypatch.setattr(cfg, "_available_models_cache", None, raising=False)
|
||||
monkeypatch.setattr(cfg, "_available_models_cache_ts", 0.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_available_models_cache_source_fingerprint", None, raising=False)
|
||||
monkeypatch.setattr(cfg, "_cache_build_in_progress", False, raising=False)
|
||||
|
||||
|
||||
def test_session_visit_fresh_profile_cache_returns_without_live_rebuild(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
disk_catalog = _catalog("cached-model")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
now = time.time()
|
||||
os.utime(cache_path, (now, now))
|
||||
|
||||
monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: disk_catalog)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
|
||||
monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"})
|
||||
|
||||
def _unexpected_live_rebuild(**_kwargs):
|
||||
raise AssertionError("fresh session-visit cache must not run a live rebuild")
|
||||
|
||||
monkeypatch.setattr(cfg, "get_available_models", _unexpected_live_rebuild)
|
||||
|
||||
assert cfg.get_available_models_for_session_visit() == disk_catalog
|
||||
|
||||
|
||||
def test_session_visit_ignores_recently_warmed_memory_when_disk_cache_is_stale(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
stale_catalog = _catalog("stale-model")
|
||||
rebuilt_catalog = _catalog("rebuilt-model")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
old = time.time() - 600.0
|
||||
os.utime(cache_path, (old, old))
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
|
||||
monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"})
|
||||
monkeypatch.setattr(cfg, "_available_models_cache", stale_catalog, raising=False)
|
||||
monkeypatch.setattr(cfg, "_available_models_cache_ts", time.monotonic(), raising=False)
|
||||
monkeypatch.setattr(cfg, "_available_models_cache_source_fingerprint", {"profile": "demo"}, raising=False)
|
||||
|
||||
def _live_rebuild(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return rebuilt_catalog
|
||||
|
||||
monkeypatch.setattr(cfg, "get_available_models", _live_rebuild)
|
||||
|
||||
assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
|
||||
assert calls == [{"force_refresh": True}]
|
||||
|
||||
|
||||
def test_session_visit_stale_profile_cache_revalidates_with_live_rebuild(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
stale_catalog = _catalog("stale-model")
|
||||
rebuilt_catalog = _catalog("rebuilt-model")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
old = time.time() - 600.0
|
||||
os.utime(cache_path, (old, old))
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
|
||||
|
||||
def _live_rebuild(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return rebuilt_catalog
|
||||
|
||||
monkeypatch.setattr(cfg, "get_available_models", _live_rebuild)
|
||||
|
||||
assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
|
||||
assert calls == [{"force_refresh": True}]
|
||||
|
||||
|
||||
def test_session_visit_fresh_disk_hit_does_not_overwrite_newer_memory_cache(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
stale_catalog = _catalog("stale-model")
|
||||
rebuilt_catalog = _catalog("rebuilt-model")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
now = time.time()
|
||||
os.utime(cache_path, (now, now))
|
||||
fingerprint = {"profile": "demo"}
|
||||
|
||||
monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
|
||||
|
||||
def _disk_hit_after_newer_memory_publish():
|
||||
cfg._available_models_cache = rebuilt_catalog
|
||||
cfg._available_models_cache_ts = time.monotonic()
|
||||
cfg._available_models_cache_source_fingerprint = fingerprint
|
||||
return stale_catalog
|
||||
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", _disk_hit_after_newer_memory_publish)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
cfg,
|
||||
"get_available_models",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("fresh disk hit must not rebuild live models")
|
||||
),
|
||||
)
|
||||
|
||||
assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
|
||||
assert cfg._available_models_cache == rebuilt_catalog
|
||||
|
||||
|
||||
def test_force_refresh_keeps_build_flag_set_until_disk_save_finishes(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
rebuilt_catalog = _catalog("rebuilt-model")
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("{}", encoding="utf-8")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
fingerprint = {"profile": "demo"}
|
||||
observed = []
|
||||
|
||||
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
|
||||
monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
|
||||
monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
|
||||
monkeypatch.setattr(cfg, "_invoke_models_rebuild", lambda _builder: rebuilt_catalog)
|
||||
|
||||
def _save_and_observe(_cache):
|
||||
observed.append(cfg._cache_build_in_progress)
|
||||
|
||||
monkeypatch.setattr(cfg, "_save_models_cache_to_disk", _save_and_observe)
|
||||
|
||||
assert cfg.get_available_models(force_refresh=True) == rebuilt_catalog
|
||||
assert observed == [True]
|
||||
assert cfg._cache_build_in_progress is False
|
||||
|
||||
|
||||
def test_default_disk_hit_does_not_restamp_stale_cache_for_session_visit(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
stale_catalog = _catalog("stale-model")
|
||||
rebuilt_catalog = _catalog("rebuilt-model")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
old = time.time() - 600.0
|
||||
os.utime(cache_path, (old, old))
|
||||
refresh_calls = []
|
||||
|
||||
monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
|
||||
monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"})
|
||||
monkeypatch.setattr(cfg, "_cfg_mtime", 0.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: (_ for _ in ()).throw(
|
||||
AssertionError("plain disk hits must not rewrite the models cache file")
|
||||
))
|
||||
|
||||
assert cfg.get_available_models() == stale_catalog
|
||||
|
||||
def _live_rebuild(**kwargs):
|
||||
refresh_calls.append(kwargs)
|
||||
return rebuilt_catalog
|
||||
|
||||
monkeypatch.setattr(cfg, "get_available_models", _live_rebuild)
|
||||
|
||||
assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
|
||||
assert refresh_calls == [{"force_refresh": True}]
|
||||
|
||||
|
||||
def test_session_visit_live_rebuild_failure_falls_back_to_cached_catalog(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
_reset_models_memory_cache(monkeypatch)
|
||||
stale_catalog = _catalog("fallback-model")
|
||||
cache_path = tmp_path / "models_cache.profile.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
old = time.time() - 600.0
|
||||
os.utime(cache_path, (old, old))
|
||||
|
||||
monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
|
||||
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
|
||||
monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
|
||||
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
|
||||
|
||||
def _failing_live_rebuild(**kwargs):
|
||||
assert kwargs == {"force_refresh": True}
|
||||
raise RuntimeError("provider unavailable")
|
||||
|
||||
monkeypatch.setattr(cfg, "get_available_models", _failing_live_rebuild)
|
||||
|
||||
assert cfg.get_available_models_for_session_visit() == stale_catalog
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.sent_headers = []
|
||||
self.body = bytearray()
|
||||
self.wfile = self
|
||||
self.rfile = io.BytesIO(b"")
|
||||
self.headers = {"Content-Length": "0"}
|
||||
self.request = None
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, name, value):
|
||||
self.sent_headers.append((name, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
self.body.extend(data)
|
||||
|
||||
def json_body(self):
|
||||
return json.loads(bytes(self.body).decode("utf-8"))
|
||||
|
||||
|
||||
def test_models_route_session_visit_freshness_uses_bounded_helper(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
expected = _catalog("route-model")
|
||||
calls = []
|
||||
|
||||
def _session_visit_catalog():
|
||||
calls.append("session_visit")
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(routes, "get_available_models_for_session_visit", _session_visit_catalog)
|
||||
|
||||
handler = _FakeHandler()
|
||||
parsed = urlparse("http://example.com/api/models?freshness=session_visit")
|
||||
routes.handle_get(handler, parsed)
|
||||
|
||||
assert handler.status == 200
|
||||
assert handler.json_body()["default_model"] == "route-model"
|
||||
assert calls == ["session_visit"]
|
||||
|
||||
|
||||
def _read_static(name: str) -> str:
|
||||
return (REPO / "static" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function_body(src: str, signature: str) -> str:
|
||||
idx = src.find(signature)
|
||||
if idx == -1:
|
||||
raise AssertionError(f"signature {signature!r} not found")
|
||||
header_end = src.find("){", idx)
|
||||
if header_end == -1:
|
||||
raise AssertionError(f"function body start for {signature!r} not found")
|
||||
open_idx = header_end + 1
|
||||
depth = 0
|
||||
for i in range(open_idx, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[idx : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {signature!r}")
|
||||
|
||||
|
||||
def test_populate_model_dropdown_accepts_session_visit_freshness_and_guards_stale_responses():
|
||||
body = _extract_function_body(_read_static("ui.js"), "async function populateModelDropdown(")
|
||||
live_tail = _extract_function_body(_read_static("ui.js"), "async function _fetchLiveModels(")
|
||||
|
||||
assert "modelsUrl.searchParams.set('freshness',opts.freshness)" in body
|
||||
assert "const requestSeq=++_modelDropdownRequestSeq" in body
|
||||
assert body.count("requestSeq!==_modelDropdownRequestSeq") >= 3
|
||||
assert "_fetchLiveModels(data.active_provider, sel, requestSeq)" in body
|
||||
assert live_tail.count("requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq") >= 4
|
||||
|
||||
|
||||
def test_load_session_schedules_async_session_visit_model_refresh_after_metadata_load():
|
||||
body = _extract_function_body(_read_static("sessions.js"), "async function loadSession(")
|
||||
|
||||
assign_idx = body.index("S.session=data.session")
|
||||
promise_idx = body.index("const modelRefreshPromise=Promise.resolve().then(")
|
||||
ready_idx = body.index("window._modelDropdownReady=modelRefreshPromise")
|
||||
refresh_idx = body.index("populateModelDropdown({freshness:'session_visit'})")
|
||||
stale_guard_idx = body.index("_loadingSessionId!==modelRefreshSid")
|
||||
|
||||
assert assign_idx < promise_idx < refresh_idx < ready_idx
|
||||
assert promise_idx < stale_guard_idx < refresh_idx
|
||||
|
||||
|
||||
def test_boot_model_dropdown_reuses_inflight_session_visit_refresh():
|
||||
body = _extract_function_body(_read_static("boot.js"), "const _startBootModelDropdown=()=>")
|
||||
|
||||
ready_idx = body.index("const ready=window._modelDropdownReady;")
|
||||
reuse_idx = body.index("if(ready&&typeof ready.then==='function') return ready;")
|
||||
hydrate_idx = body.index("const next=_hydrateBootModelDropdown();")
|
||||
|
||||
assert ready_idx < reuse_idx < hydrate_idx
|
||||
Reference in new issue
Block a user