Merge PR #7632 into stage/0924-k

This commit is contained in:
nesquena-hermes committed 2026-09-24 16:53:13 +00:00
commit b26839b996
8 files changed
+408 -27

No files matched your search

+113 -12
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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)