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:
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)``.
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user