Files
hermes-agent/tests/tui_gateway/test_profiles_configure_model_guard.py
T
teknium1 11576390fe refactor(model): one persist writer for /model across CLI, gateway, TUI, dashboard; ACP + dashboard validate through switch_model
One `/model --global` produced four config.yaml shapes. CLI wrote
default/provider/base_url/api_mode and cleared the context pin on a route
change; the gateway rewrote the whole `model:` block (whole-file save_config)
and only set api_mode for `custom`; the TUI wrote three keys and never
touched api_mode, so a switch off an Anthropic-wire endpoint left a stale
`api_mode: anthropic_messages` in config; the dashboard main slot had its own
switched-provider logic, wrote `base_url: ""` and always dropped
context_length. ACP `session/set_model` and `POST /api/model/set` accepted
any model string (parse_model_input + detect_provider_for_model) so a model
no catalog knows, or a provider with no credentials, was handed to the
session / persisted and only failed at inference time.

Canonical: `hermes_cli.model_switch.model_selection_config_updates` (the
shape) + `persist_model_selection(result, config_path=None)` (targeted
per-key `atomic_roundtrip_yaml_update` writes, so sibling
`model_slots`/`model_fallback` keys survive; explicit path for the
multiplexed gateway's profile config) + `apply_model_selection` (same shape
applied to an in-memory `model:` dict for callers that save a whole
document). `atomic_roundtrip_yaml_update(value=None)` now REMOVES the key
instead of writing `key: null`, so per-key and whole-document writers land
the same file. Shape = CLI/gateway semantics: default, provider, base_url
(cleared when the target has none), api_mode (cleared when unresolved),
context_length cleared only when `should_clear_context_pin` says the route
identity changed, inline api_key/api cleared for non-custom targets.

Sites -> canonical:
  hermes_cli/cli_model_switch_mixin.py::_persist_global_switch          -> deleted; _commit_model_switch calls persist_model_selection
  hermes_cli/cli_model_switch_mixin.py::_clear_persisted_context_for_model_switch -> deleted (folded into the shape)
  gateway/slash_commands_model.py::_persist_model_switch_to_config       -> to_thread forwarder: persist_model_selection(result, ctx.config_path)
  tui_gateway/model_switch.py::_persist_model_switch                     -> deleted; _apply_model_switch calls persist_model_selection
  hermes_cli/web_server_config.py::_apply_main_model_assignment          -> apply_model_selection(result) (+ explicit custom api_key)
  hermes_cli/web_server_config.py::_validated_main_model_selection       -> NEW: switch_model(--provider) gate; rejection -> HTTP 400
  hermes_cli/web_routers/{models,profiles,config_env}.py main-slot paths -> through _validated_main_model_selection
  acp_adapter/server.py::_resolve_model_selection                        -> deleted; _switch_model calls switch_model (provider:model -> --provider), rejection -> ValueError

Behavior changes: TUI --global now writes/clears model.api_mode and clears a
route-changed context pin; gateway --global no longer rewrites the whole
model block (sibling keys survive) and clears api_mode for every target;
dashboard main slot / profile-create model / custom-endpoint activate now
reject unknown/uncredentialed/unlisted models (HTTP 400) and persist the
resolved base_url/api_mode instead of `base_url: ""`; ACP rejects the same
(ValueError surfaced by the command/protocol handler). Gateway persist runs
on a worker thread against the routed profile's config_path (multiplex-safe).
Cleared keys are removed from config.yaml rather than left as `null`. ACP
still never persists.

Kept `_normalize_main_model_assignment`: switch_model rejects a vendor name
posing as a provider (`moonshotai` -> "Unknown provider"), so the
vendor->aggregator repair is not a duplicate; E2E verified both branches.
No config migration: readers already coalesce `base_url: ""` to absent
(`_config_base_url_for_provider`) and gate api_mode on provider match
(`_provider_supports_explicit_api_mode`), so no stale-shape reader bug.

Tests: tests/hermes_cli/test_model_persist_one_shape.py (four surfaces land
one block; same-route re-pick keeps the pin), tests/acp_adapter/
test_acp_dashboard_model_switch_validation.py (rejection + explicit
provider prefix). Replaces test_acp_set_model_explicit_provider.py and the
two TUI-only persist tests; tests that intercepted the old per-surface seams
(`cli.save_config_value`, `load_config_readonly`, `tui_gateway.server.
_persist_model_switch`) now intercept the canonical seam. Each fix
sabotage-verified red.
2026-09-13 05:21:02 -07:00

119 lines
4.2 KiB
Python

"""profiles.configure honours the model selection guard (#95293 remainder).
The Bots-mode editor writes a profile's default model through
``profiles.configure`` — a surface that historically bypassed the
data-policy / expensive-model selection guard every other model-switch path
enforces (``config.set model`` answers ``confirm_required`` and waits for a
``confirm_expensive_model`` resend). A guarded pick made from the Bots
surface was therefore applied silently, with no confirm flow anywhere.
These tests pin the same handshake contract on ``profiles.configure``:
* a guarded model WITHOUT ``confirm_expensive_model`` answers
``confirm_required`` + ``confirm_message`` and writes NOTHING;
* the confirmed resend (``confirm_expensive_model: true``) writes;
* unguarded models keep writing exactly as before.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
import hermes_cli.model_selection_guards as guards
import tui_gateway.server as srv
GUARDED_MODEL = "muse-spark-1.2-contributor"
GUARD_MESSAGE = "CONTRIBUTOR TIER: this model may train on your data."
@pytest.fixture
def home(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# The profile write now validates through ``switch_model`` (catalog + credentials); these
# tests pin the guard handshake, so echo the pick back as an accepted route.
from hermes_cli.model_switch import ModelSwitchResult
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model",
lambda *, raw_input, explicit_provider, **_kw: ModelSwitchResult(
success=True, new_model=raw_input, target_provider=explicit_provider))
return hermes_home
@pytest.fixture
def contributor_guard(monkeypatch):
"""Fire the selection guard for GUARDED_MODEL only, like the real
data-policy guard fires for ``-contributor`` ids."""
def fake_combined_selection_warning(model_name, **_kwargs):
if model_name == GUARDED_MODEL:
return SimpleNamespace(message=GUARD_MESSAGE, kind="data_policy")
return None
monkeypatch.setattr(guards, "combined_selection_warning", fake_combined_selection_warning)
def _configure(params):
return srv._methods["profiles.configure"]("configure", {"name": "default", **params})["result"]
def _profile_model(home: Path):
cfg_path = home / "config.yaml"
if not cfg_path.is_file():
return None
cfg = yaml.safe_load(cfg_path.read_text()) or {}
model_cfg = cfg.get("model") or {}
return model_cfg.get("default")
def test_guarded_model_answers_confirm_required_and_writes_nothing(home, contributor_guard):
result = _configure({"model": GUARDED_MODEL, "provider": "opencode-go"})
assert result.get("confirm_required") is True
assert GUARD_MESSAGE in (result.get("confirm_message") or "")
# The model section is PENDING confirmation, not failed — it must not
# poison ``ok`` (the Bots editor toasts "Some sections failed" on False).
assert result["applied"].get("model") is not False
assert _profile_model(home) != GUARDED_MODEL
def test_confirmed_resend_writes_the_guarded_model(home, contributor_guard):
result = _configure(
{
"model": GUARDED_MODEL,
"provider": "opencode-go",
"confirm_expensive_model": True,
}
)
assert not result.get("confirm_required")
assert result["applied"].get("model") is True
assert _profile_model(home) == GUARDED_MODEL
def test_unguarded_model_still_writes_without_confirmation(home, contributor_guard):
result = _configure({"model": "hermes-4.5-405b", "provider": "nous"})
assert not result.get("confirm_required")
assert result["applied"].get("model") is True
assert _profile_model(home) == "hermes-4.5-405b"
def test_other_sections_still_apply_while_model_awaits_confirmation(home, contributor_guard):
result = _configure(
{
"model": GUARDED_MODEL,
"provider": "opencode-go",
"soul": "# SOUL\nBe kind.",
}
)
assert result.get("confirm_required") is True
assert result["applied"].get("soul") is True
assert _profile_model(home) != GUARDED_MODEL