Merge PR #7632 into stage/0924-k
This commit is contained in:
commit
b26839b996
8 files changed
+408
-27
No files matched your search
+113
-12
@@ -576,6 +576,7 @@ def _refresh_config_cache(config_path: Path | None = None) -> None:
|
||||
# Remember the old mtime so we can tell whether config actually changed
|
||||
# vs. first-ever load (mtime == 0.0, e.g. server start or profile switch).
|
||||
_old_cfg_mtime = _cfg_mtime
|
||||
_old_cfg_path = _cfg_path
|
||||
_cfg_path = config_path
|
||||
_cfg_mtime = 0.0
|
||||
try:
|
||||
@@ -632,10 +633,11 @@ def _refresh_config_cache(config_path: Path | None = None) -> None:
|
||||
_cfg_fingerprint = _fingerprint_config(_cfg_cache)
|
||||
# Bust the models cache so the next request sees fresh config values.
|
||||
# Only delete the disk cache when config has actually changed -- not on
|
||||
# first-ever load (when _old_cfg_mtime == 0.0, i.e. server start or
|
||||
# profile switch) -- preserving the disk cache so the next restart
|
||||
# first-ever load (when _old_cfg_mtime == 0.0, i.e. server start) and not
|
||||
# on a path change (per-client profile switch leaves the old profile's
|
||||
# mtime) -- preserving the disk cache so the next restart
|
||||
# still hits the fast path without a cold run.
|
||||
if _old_cfg_mtime != 0.0:
|
||||
if _old_cfg_mtime != 0.0 and _old_cfg_path == config_path:
|
||||
_delete_models_cache_on_disk()
|
||||
|
||||
|
||||
@@ -7606,7 +7608,7 @@ _MODELS_CACHE_SCHEMA_VERSION = 3
|
||||
_models_cache_path = STATE_DIR / "models_cache.json"
|
||||
|
||||
|
||||
def _get_models_cache_path() -> Path:
|
||||
def _get_models_cache_path(profile: str | None = None) -> Path:
|
||||
"""Return the /api/models disk-cache path for the *active* profile (#3957).
|
||||
|
||||
WebUI profile switching is per-client/cookie scoped (issue #798), but the
|
||||
@@ -7630,12 +7632,13 @@ def _get_models_cache_path() -> Path:
|
||||
The named-profile path is derived from ``_models_cache_path`` (the
|
||||
module-level default), not from ``STATE_DIR`` directly, so the path stays
|
||||
correct if the default is repointed (e.g. tests monkeypatch
|
||||
``_models_cache_path`` to an isolated tmp file).
|
||||
``_models_cache_path`` to an isolated tmp file). Pass *profile* to get
|
||||
another profile's path (profile delete/create).
|
||||
"""
|
||||
try:
|
||||
from api.profiles import get_active_profile_name, _is_root_profile
|
||||
|
||||
name = (get_active_profile_name() or "").strip()
|
||||
name = (profile or get_active_profile_name() or "").strip()
|
||||
if not name or _is_root_profile(name):
|
||||
return _models_cache_path
|
||||
# Defensive filename sanitization: the cookie-derived profile name is
|
||||
@@ -7922,6 +7925,96 @@ def _auth_store_semantic_fingerprint(path: Path) -> dict:
|
||||
return fp
|
||||
|
||||
|
||||
def _active_profile_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home as _gah
|
||||
|
||||
return _gah()
|
||||
except ImportError:
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
def _models_cache_env_fingerprint(path: Path) -> list:
|
||||
"""``[key, HMAC(value)]`` per non-empty ``.env`` entry, parsed like provider detection.
|
||||
|
||||
Values are keyed-hashed with the WebUI signing key so the cache file never holds a secret.
|
||||
"""
|
||||
import hmac
|
||||
from api.auth import _signing_key
|
||||
from api.providers import _load_env_file
|
||||
|
||||
key = _signing_key()
|
||||
return [
|
||||
[k, hmac.new(key, v.encode("utf-8"), hashlib.sha256).hexdigest()]
|
||||
for k, v in sorted(_load_env_file(Path(path).expanduser()).items())
|
||||
if v
|
||||
]
|
||||
|
||||
|
||||
def _declares_model_provider_kind(plugin_dir: Path) -> bool:
|
||||
# Same parse as the agent's providers._declares_model_provider_kind: PyYAML, then a line scan.
|
||||
for filename in ("plugin.yaml", "plugin.yml"):
|
||||
manifest = plugin_dir / filename
|
||||
if not manifest.is_file():
|
||||
continue
|
||||
try:
|
||||
text = manifest.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import yaml as _yaml
|
||||
|
||||
data = _yaml.safe_load(text)
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("kind", "")).strip() == "model-provider"
|
||||
except Exception:
|
||||
pass
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or ":" not in stripped:
|
||||
continue
|
||||
key, _, value = stripped.partition(":")
|
||||
if key.strip() == "kind":
|
||||
return value.strip().strip("\"'") == "model-provider"
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _models_cache_plugin_fingerprint(home: Path) -> list:
|
||||
"""``[dir, file stamps]`` per model-provider plugin, discovered like providers._scan_home_layer."""
|
||||
found = []
|
||||
plugins_root = Path(home).expanduser() / "plugins"
|
||||
for base, flat in ((plugins_root / "model-providers", False), (plugins_root, True)):
|
||||
try:
|
||||
children = sorted(base.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for child in children:
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
if flat and (child.name == "model-providers" or not _declares_model_provider_kind(child)):
|
||||
continue
|
||||
found.append([str(child.relative_to(plugins_root)), _plugin_tree_stamps(child)])
|
||||
return found
|
||||
|
||||
|
||||
def _plugin_tree_stamps(plugin_dir: Path) -> list:
|
||||
# The loader execs __init__.py, which may import siblings or read data files; skip bytecode.
|
||||
stamps = []
|
||||
for root, dirs, files in os.walk(plugin_dir):
|
||||
dirs[:] = sorted(d for d in dirs if d != "__pycache__" and not d.startswith("."))
|
||||
for name in sorted(files):
|
||||
if name.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
stamps.append([os.path.relpath(path, plugin_dir), st.st_mtime_ns, st.st_size])
|
||||
return stamps
|
||||
|
||||
|
||||
def _models_cache_source_fingerprint() -> dict:
|
||||
"""Return the current config/auth/catalog fingerprint for /api/models cache.
|
||||
|
||||
@@ -7933,9 +8026,12 @@ def _models_cache_source_fingerprint() -> dict:
|
||||
mtime/size fingerprint because it is only rewritten on deliberate user
|
||||
edits (which can change anything) and does not churn on a timer.
|
||||
"""
|
||||
home = _active_profile_home()
|
||||
return {
|
||||
"config_yaml": _models_cache_file_fingerprint(_get_config_path()),
|
||||
"auth_json": _auth_store_semantic_fingerprint(_get_auth_store_path()),
|
||||
"env": _models_cache_env_fingerprint(home / ".env"),
|
||||
"plugins": _models_cache_plugin_fingerprint(home),
|
||||
"catalog": _models_cache_catalog_fingerprint(),
|
||||
}
|
||||
|
||||
@@ -8089,10 +8185,10 @@ def _load_stale_models_cache_from_disk() -> dict | None:
|
||||
The main cache loader enforces metadata stamps for a full cold-path cache hit.
|
||||
This helper intentionally does not apply that stricter policy, so we can still
|
||||
recover a useful fallback payload when the strict loader rejected cache because
|
||||
metadata or fingerprint fields are stale. It DOES still enforce the schema
|
||||
version: a cross-schema cache can have an incompatible groups/badge shape, so
|
||||
serving it to the picker could surface a broken catalog — schema mismatch is a
|
||||
hard reject even on the fallback path.
|
||||
the WebUI version stamp is stale. It DOES still enforce the schema version (a
|
||||
cross-schema cache can have an incompatible groups/badge shape) and the source
|
||||
fingerprint: a snapshot built from other config/auth/.env/plugin sources is a
|
||||
wrong catalog, not merely an old one, so it is never served.
|
||||
"""
|
||||
try:
|
||||
import json as _j
|
||||
@@ -8106,6 +8202,8 @@ def _load_stale_models_cache_from_disk() -> dict | None:
|
||||
return None
|
||||
if cache.get("_schema_version") != _MODELS_CACHE_SCHEMA_VERSION:
|
||||
return None
|
||||
if cache.get("_source_fingerprint") != _models_cache_source_fingerprint():
|
||||
return None
|
||||
aliases = cache.get("aliases")
|
||||
if not isinstance(aliases, dict):
|
||||
# The disk cache save path does not persist `aliases`, so a cache
|
||||
@@ -8196,7 +8294,7 @@ def _get_fresh_memory_models_cache(now: float) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def invalidate_models_cache():
|
||||
def invalidate_models_cache(*, delete_disk: bool = True):
|
||||
"""Force the TTL cache for get_available_models() to be cleared.
|
||||
|
||||
Call this after modifying config.cfg in-memory (e.g. in tests) so
|
||||
@@ -8209,6 +8307,8 @@ def invalidate_models_cache():
|
||||
that call invalidate_models_cache() still get back the previous test's
|
||||
result from the disk cache because the disk hit is checked before the memory
|
||||
cache rebuild runs.
|
||||
|
||||
``delete_disk=False`` keeps the fingerprint-guarded disk snapshot (profile switch).
|
||||
"""
|
||||
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts
|
||||
global _available_models_live_rebuild_ts, _available_models_cache_source_fingerprint, _cache_build_cv
|
||||
@@ -8227,7 +8327,8 @@ def invalidate_models_cache():
|
||||
_CREDENTIAL_POOL_CACHE.clear()
|
||||
# Also delete the disk cache so the next cold build starts fresh.
|
||||
# Disk delete is outside the lock — file I/O shouldn't block other readers.
|
||||
_delete_models_cache_on_disk()
|
||||
if delete_disk:
|
||||
_delete_models_cache_on_disk()
|
||||
try:
|
||||
from api.plugin_providers import invalidate_plugin_model_provider_cache
|
||||
|
||||
|
||||
@@ -2708,6 +2708,7 @@ def create_profile_api(name: str, clone_from: str = None,
|
||||
model_provider=model_provider,
|
||||
)
|
||||
|
||||
_drop_profile_models_cache(name)
|
||||
# Invalidate cached root-profile-name lookup; create_profile may have added
|
||||
# a new profile that flips is_default semantics on the agent side (#1612).
|
||||
_SKILLS_STATS_CACHE.clear()
|
||||
@@ -2736,6 +2737,16 @@ def create_profile_api(name: str, clone_from: str = None,
|
||||
}
|
||||
|
||||
|
||||
def _drop_profile_models_cache(name: str) -> None:
|
||||
"""A deleted or new profile must never inherit a same-name models snapshot."""
|
||||
from api.config import _get_models_cache_path
|
||||
|
||||
try:
|
||||
_get_models_cache_path(name).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.debug("Failed to drop models cache for profile %s", name, exc_info=True)
|
||||
|
||||
|
||||
def delete_profile_api(name: str) -> dict:
|
||||
"""Delete a profile. Switches to default first if it's the active one.
|
||||
|
||||
@@ -2769,6 +2780,7 @@ def delete_profile_api(name: str) -> dict:
|
||||
else:
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
_drop_profile_models_cache(name)
|
||||
# Drop cached root-profile-name lookup — list_profiles_api() shape changed.
|
||||
_SKILLS_STATS_CACHE.clear()
|
||||
_invalidate_list_profiles_cache()
|
||||
|
||||
+2
-1
@@ -17155,8 +17155,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
# Invalidate the models cache so the very next /api/models request
|
||||
# rebuilds from the new profile's config.yaml rather than returning
|
||||
# the old profile's cached model list (#1200 — profile-switch model bug).
|
||||
# The per-profile disk snapshot is fingerprint-guarded, so keep it.
|
||||
from api.config import invalidate_models_cache
|
||||
invalidate_models_cache()
|
||||
invalidate_models_cache(delete_disk=False)
|
||||
try:
|
||||
from api.gateway_watcher import restart_watcher_for_profile
|
||||
restart_watcher_for_profile(name)
|
||||
|
||||
Reference in new issue
Block a user