Files
hermes-agent/tests/hermes_cli/test_model_persist_one_shape.py

177 lines
8.5 KiB
Python

"""One config.yaml shape for a persisted model selection, whichever surface wrote it.
``/model --global`` reaches config.yaml from four places (CLI mixin, gateway slash command, TUI
gateway, dashboard main slot). Each used to hand-roll its own write; the TUI never touched
``api_mode`` (stale wire protocol after a switch) and the dashboard wrote ``base_url: ""``. All
four now go through ``hermes_cli.model_switch.persist_model_selection`` /
``apply_model_selection``, so the same ``ModelSwitchResult`` must land as the same ``model.*``
keys on disk — including the api_mode clear and the route-changed context_length clear.
"""
from __future__ import annotations
import asyncio
import pytest
import yaml
from hermes_cli.model_switch import ModelSwitchResult
_SEED = (
"model:\n"
" default: local-model\n"
" provider: custom\n"
" base_url: http://localhost:1234/v1\n"
" api_mode: anthropic_messages\n"
" api_key: sk-stale\n"
" context_length: 32000\n"
" model_slots:\n"
" fast: gpt-5-mini\n"
"agent:\n"
" system_prompt: keepme\n"
)
_RESULT = ModelSwitchResult(
success=True, new_model="claude-haiku", target_provider="anthropic", provider_changed=True,
api_key="sk-new", base_url="", api_mode="", is_global=True,
)
@pytest.fixture
def seeded_home(tmp_path, monkeypatch):
import cli
(tmp_path / "config.yaml").write_text(_SEED, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(cli, "_hermes_home", tmp_path)
return tmp_path
def _model_block(home) -> dict:
return yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8"))["model"]
def _via_cli(home):
from hermes_cli import cli_model_switch_mixin as mixin
stub = type("Stub", (), {
"agent": None, "model": "local-model", "_pending_one_turn_model_restore": None,
"_stage_and_swap_model": lambda self, r, o: True})()
mixin._commit_model_switch(stub, _RESULT, persist_global=True)
def _via_gateway(home):
from gateway.slash_commands_model import _persist_model_switch_to_config
asyncio.run(_persist_model_switch_to_config(_RESULT, home / "config.yaml"))
def _via_tui(home):
from tui_gateway import server
session = {"agent": None}
server._apply_model_switch(
"sid", session, "claude-haiku --provider anthropic --global", confirm_expensive_model=True)
def _via_dashboard(home):
from hermes_cli.web_server_config import _apply_model_assignment_sync
_apply_model_assignment_sync("main", "anthropic", "claude-haiku", "", "")
@pytest.mark.parametrize("surface", [_via_cli, _via_gateway, _via_tui, _via_dashboard],
ids=["cli", "gateway", "tui", "dashboard"])
def test_every_persist_surface_writes_the_same_model_block(seeded_home, monkeypatch, surface):
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kw: _RESULT)
monkeypatch.setattr("cli.HermesCLI._persist_model_switch_to_session", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.cli_model_switch_mixin._print_switch_summary", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_selection_guards.combined_selection_warning",
lambda *a, **k: None, raising=False)
monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False)
surface(seeded_home)
block = _model_block(seeded_home)
assert (block["default"], block["provider"]) == ("claude-haiku", "anthropic")
# The target route has no endpoint/wire override: the OLD custom ones must not linger.
assert not block.get("base_url") and not block.get("api_mode")
# Route identity changed → the pinned window belongs to the old route. Cleared keys are
# REMOVED, not left as ``key: null`` litter.
assert "context_length" not in block and "base_url" not in block and "api_mode" not in block
# Non-custom providers never read an inline key; the stale secret is gone.
assert "api_key" not in block
# Sibling keys under ``model:`` survive (targeted writes, no block rewrite).
assert block["model_slots"] == {"fast": "gpt-5-mini"}
assert yaml.safe_load((seeded_home / "config.yaml").read_text())["agent"]["system_prompt"] == "keepme"
def test_same_route_repick_keeps_the_context_pin(seeded_home):
"""A model re-pick on the SAME route keeps ``context_length`` (only the owner change drops it)."""
from hermes_cli.model_switch import persist_model_selection
same_route = ModelSwitchResult(
success=True, new_model="local-model", target_provider="custom",
base_url="http://localhost:1234/v1", api_mode="anthropic_messages", is_global=True)
persist_model_selection(same_route)
block = _model_block(seeded_home)
assert block["context_length"] == 32000
assert block["api_key"] == "sk-stale" # custom targets keep their inline key
def test_custom_to_other_custom_endpoint_drops_the_inline_key(seeded_home):
"""``custom`` -> ``custom:other-box``: endpoint A's inline ``api_key`` must not become endpoint B's
credential (the pointer-not-secret rule, #88990). Same provider *string* but a different
``base_url`` drops it too — the key belongs to one endpoint, not to the word "custom"."""
from hermes_cli.model_switch import persist_model_selection
other_provider = ModelSwitchResult(
success=True, new_model="other-model", target_provider="custom:other-box",
base_url="http://other-box:8000/v1", api_mode="chat_completions", is_global=True)
persist_model_selection(other_provider)
assert "api_key" not in _model_block(seeded_home)
(seeded_home / "config.yaml").write_text(_SEED, encoding="utf-8")
same_provider_other_host = ModelSwitchResult(
success=True, new_model="local-model", target_provider="custom",
base_url="http://10.0.0.9:1234/v1", api_mode="anthropic_messages", is_global=True)
persist_model_selection(same_provider_other_host)
assert "api_key" not in _model_block(seeded_home)
def test_provider_switch_drops_the_key_env_pointer_but_a_same_route_repick_keeps_it(seeded_home):
"""``model.key_env`` is a credential POINTER (custom-endpoint activation writes it with no inline
key). Surviving a provider switch it routes the new provider's requests to the old endpoint's
env var, so it clears like ``api_key``; a same-route re-pick keeps it like ``api_key``."""
from hermes_cli.model_switch import persist_model_selection
seed = _SEED.replace(" api_key: sk-stale\n", " key_env: CUSTOM_BOX_API_KEY\n")
(seeded_home / "config.yaml").write_text(seed, encoding="utf-8")
persist_model_selection(_RESULT)
assert "key_env" not in _model_block(seeded_home)
(seeded_home / "config.yaml").write_text(seed, encoding="utf-8")
same_route = ModelSwitchResult(
success=True, new_model="local-model", target_provider="custom",
base_url="http://localhost:1234/v1", api_mode="anthropic_messages", is_global=True)
persist_model_selection(same_route)
assert _model_block(seeded_home)["key_env"] == "CUSTOM_BOX_API_KEY"
# Registry providers get the pointer too (Desktop stores e.g. HERMES_CUSTOM_LMSTUDIO_API_KEY
# as model.key_env with provider lmstudio, #106336): a same-route model re-pick keeps it.
(seeded_home / "config.yaml").write_text(
seed.replace("provider: custom\n", "provider: lmstudio\n")
.replace("CUSTOM_BOX_API_KEY", "HERMES_CUSTOM_LMSTUDIO_API_KEY"), encoding="utf-8")
persist_model_selection(ModelSwitchResult(
success=True, new_model="qwen3-8b", target_provider="lmstudio",
base_url="http://localhost:1234/v1", api_mode="openai_chat", is_global=True))
block = _model_block(seeded_home)
assert block["default"] == "qwen3-8b"
assert block["key_env"] == "HERMES_CUSTOM_LMSTUDIO_API_KEY"
def test_gateway_persists_to_the_profile_config_it_was_given(tmp_path, monkeypatch):
"""Multiplexed gateway: the write lands in the routed profile's config.yaml, never the
process-level HERMES_HOME."""
from gateway.slash_commands_model import _persist_model_switch_to_config
process_home, profile_home = tmp_path / "default", tmp_path / "profiles" / "named"
for home in (process_home, profile_home):
home.mkdir(parents=True)
(home / "config.yaml").write_text("model:\n default: old\n provider: openai-codex\n")
monkeypatch.setenv("HERMES_HOME", str(process_home))
asyncio.run(_persist_model_switch_to_config(_RESULT, profile_home / "config.yaml"))
assert _model_block(profile_home)["default"] == "claude-haiku"
assert _model_block(process_home)["default"] == "old"