fix: fingerprint .env key names and provider plugins; drop a profile's models cache on delete/create

A retained per-profile snapshot is now rejected after a switch when the target
profile's .env credential set or model-provider plugins changed, and a
recreated same-name profile no longer inherits the deleted profile's catalog.
This commit is contained in:
carlotestor committed 2026-09-23 15:31:44 +00:00
1 parent 78bb366560
commit 7f2e812280
5 files changed
+334 -32

No files matched your search

@@ -0,0 +1,177 @@
"""A retained per-profile models disk snapshot must be rejected after a switch
when a catalog source changed in the target profile: its ``.env`` provider
credentials, its installed model-provider plugins, or the profile itself
(delete + recreate with the same name).
Each test saves a real snapshot for profile ``demo`` through
``_save_models_cache_to_disk()``, changes one source while ``default`` is
active, performs the per-client switch exactly as ``/api/profile/switch`` does
(``invalidate_models_cache(delete_disk=False)``), and asserts the first
``get_available_models()`` for ``demo`` serves the fresh rebuild.
"""
from __future__ import annotations
import os
import shutil
import sys
from types import SimpleNamespace
from pathlib import Path
import pytest
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, "supports_fast_tier": False}],
}
],
"aliases": {},
}
@pytest.fixture
def two_profiles(tmp_path: Path, monkeypatch):
import api.config as cfg
import api.profiles as profiles
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False)
default_home = tmp_path / ".hermes"
default_home.mkdir()
(default_home / "config.yaml").write_text("model:\n default: default-model\n", encoding="utf-8")
demo_home = default_home / "profiles" / "demo"
demo_home.mkdir(parents=True)
(demo_home / "config.yaml").write_text("model:\n default: demo-model\n", encoding="utf-8")
os.utime(demo_home / "config.yaml", (1_000_000, 1_000_000))
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home)
monkeypatch.setattr(profiles, "_active_profile", "default")
monkeypatch.setattr(profiles._tls, "profile", None, raising=False)
monkeypatch.setattr(cfg, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
monkeypatch.setattr(cfg, "_invoke_models_rebuild", lambda _builder: _catalog("fresh-model"))
for attr in ("_cfg_mtime", "_cfg_path", "_cfg_fingerprint"):
monkeypatch.setattr(cfg, attr, getattr(cfg, attr), raising=False)
saved_cfg = dict(cfg._cfg_cache)
cfg.invalidate_models_cache(delete_disk=False)
cfg.reload_config()
yield SimpleNamespace(cfg=cfg, profiles=profiles, demo_home=demo_home, cache=tmp_path / "models_cache.demo.json")
profiles.clear_request_profile()
cfg.invalidate_models_cache(delete_disk=False)
cfg._cfg_cache.clear()
cfg._cfg_cache.update(saved_cfg)
def _save_demo_snapshot(env) -> None:
env.profiles.set_request_profile("demo")
try:
env.cfg._save_models_cache_to_disk(_catalog("snapshot-model"))
finally:
env.profiles.clear_request_profile()
assert env.cache.exists()
def _switch_to_demo_and_fetch(env) -> dict:
env.profiles.switch_profile("demo", process_wide=False)
env.cfg.invalidate_models_cache(delete_disk=False)
env.profiles.set_request_profile("demo")
try:
return env.cfg.get_available_models()
finally:
env.profiles.clear_request_profile()
def _write_plugin(root: Path, name: str, version: str, *, flat: bool) -> Path:
base = root / "plugins" if flat else root / "plugins" / "model-providers"
d = base / name
d.mkdir(parents=True, exist_ok=True)
(d / "plugin.yaml").write_text(
f"name: {name}\nkind: model-provider\nversion: {version}\n", encoding="utf-8"
)
return d
def test_unchanged_sources_keep_serving_the_retained_snapshot(two_profiles):
"""Control: with no source change the switch reuses the snapshot (the speed-up)."""
_save_demo_snapshot(two_profiles)
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "snapshot-model"
def test_switch_after_adding_env_key_rejects_snapshot(two_profiles):
_save_demo_snapshot(two_profiles)
(two_profiles.demo_home / ".env").write_text("DEEPSEEK_API_KEY=sk-new\n", encoding="utf-8")
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "fresh-model"
def test_switch_after_removing_env_key_rejects_snapshot(two_profiles):
env_file = two_profiles.demo_home / ".env"
env_file.write_text("DEEPSEEK_API_KEY=sk-old\nOTHER=1\n", encoding="utf-8")
_save_demo_snapshot(two_profiles)
env_file.write_text("OTHER=1\n", encoding="utf-8")
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "fresh-model"
def test_env_value_rotation_keeps_snapshot_and_fingerprint_has_no_secret(two_profiles):
env_file = two_profiles.demo_home / ".env"
env_file.write_text("DEEPSEEK_API_KEY=sk-secret-one\n", encoding="utf-8")
_save_demo_snapshot(two_profiles)
assert "sk-secret-one" not in two_profiles.cache.read_text(encoding="utf-8")
env_file.write_text("DEEPSEEK_API_KEY=sk-secret-two\n", encoding="utf-8")
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "snapshot-model"
@pytest.mark.parametrize("flat", [False, True], ids=["model-providers-dir", "flat-install-dir"])
def test_switch_after_installing_plugin_rejects_snapshot(two_profiles, flat):
_save_demo_snapshot(two_profiles)
_write_plugin(two_profiles.demo_home, "acme", "1.0.0", flat=flat)
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "fresh-model"
def test_switch_after_removing_plugin_rejects_snapshot(two_profiles):
plugin = _write_plugin(two_profiles.demo_home, "acme", "1.0.0", flat=False)
_save_demo_snapshot(two_profiles)
shutil.rmtree(plugin)
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "fresh-model"
def test_switch_after_plugin_version_change_rejects_snapshot(two_profiles):
_write_plugin(two_profiles.demo_home, "acme", "1.0.0", flat=True)
_save_demo_snapshot(two_profiles)
_write_plugin(two_profiles.demo_home, "acme", "2.0.0", flat=True)
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "fresh-model"
def test_delete_profile_removes_its_models_cache(two_profiles, monkeypatch):
import api.profiles as profiles
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setitem(sys.modules, "hermes_cli.profiles", None)
_save_demo_snapshot(two_profiles)
profiles.delete_profile_api("demo")
assert not two_profiles.demo_home.exists()
assert not two_profiles.cache.exists()
def test_recreate_same_name_profile_does_not_resurrect_old_catalog(two_profiles, monkeypatch):
import api.profiles as profiles
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setitem(sys.modules, "hermes_cli.profiles", None)
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [])
_save_demo_snapshot(two_profiles)
stale = two_profiles.cache.read_text(encoding="utf-8")
profiles.delete_profile_api("demo")
# A snapshot left behind by an older build or another process.
two_profiles.cache.write_text(stale, encoding="utf-8")
profiles.create_profile_api("demo")
assert not two_profiles.cache.exists()
(two_profiles.demo_home / "config.yaml").write_text("model:\n default: demo-model\n", encoding="utf-8")
os.utime(two_profiles.demo_home / "config.yaml", (1_000_000, 1_000_000))
assert _switch_to_demo_and_fetch(two_profiles)["default_model"] == "fresh-model"
@@ -5,9 +5,9 @@
deleted the on-disk ``models_cache.<profile>.json`` — a test-isolation
behaviour — which forced a full cold rebuild (live provider ``fetch_models``
HTTPS calls, ~490k deepcopy calls) on every switch even though no source had
changed. The disk cache is already keyed per profile and rejected on read when
``_models_cache_source_fingerprint()`` differs, so deleting it buys no
correctness on the switch path and only costs seconds of latency.
changed. The disk cache is keyed per profile and rejected on read when
``_models_cache_source_fingerprint()`` differs; the source changes that must
still force a rebuild are covered in test_profile_switch_models_cache_source_axes.py.
The switch route now uses ``invalidate_models_cache(delete_disk=False)``.
"""