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.
This commit is contained in:
@@ -170,7 +170,8 @@ def _commit_model_switch(
|
||||
cli._pending_one_turn_model_restore = snapshot
|
||||
_print_switch_summary(cli, result, old_model, one_turn=one_turn, strict_context=not picker)
|
||||
if persist_global:
|
||||
_persist_global_switch(cli, result)
|
||||
from hermes_cli.model_switch import persist_model_selection
|
||||
persist_model_selection(result)
|
||||
_cprint(" Saved to config.yaml (--global)" if picker else " Saved to config.yaml")
|
||||
elif one_turn:
|
||||
_cprint(" (next turn only — restores after one response)")
|
||||
@@ -182,24 +183,6 @@ def _commit_model_switch(
|
||||
HermesCLI._persist_model_switch_to_session(cli, result)
|
||||
|
||||
|
||||
def _persist_global_switch(cli, result) -> None:
|
||||
"""Write the switched route to config.yaml (--global). base_url/api_mode are freshly resolved
|
||||
for the target provider, so sync them every time (None clears a value the new provider doesn't
|
||||
need) — otherwise the OLD provider's endpoint/wire-protocol lingers in config.yaml."""
|
||||
from cli import HermesCLI, save_config_value
|
||||
HermesCLI._clear_persisted_context_for_model_switch(cli, result)
|
||||
save_config_value("model.default", result.new_model)
|
||||
save_config_value("model.provider", result.target_provider)
|
||||
# base_url/api_mode were previously never persisted here, so a global switch left the OLD provider's
|
||||
# endpoint/wire-protocol in config.yaml. result.base_url/api_mode are always freshly resolved for the
|
||||
# target provider (see model_switch.py), so sync them every time; None clears a value the new provider
|
||||
# doesn't need (#25106).
|
||||
# See _apply_model_switch_result above for why base_url/api_mode must be synced on every global switch
|
||||
# (#25106).
|
||||
save_config_value("model.base_url", result.base_url or None)
|
||||
save_config_value("model.api_mode", result.api_mode or None)
|
||||
|
||||
|
||||
def _show_model_picker(cli, ctx, force_refresh: bool) -> None:
|
||||
"""``/model`` with no args: open the picker, or print usage when nothing is authed."""
|
||||
from cli import _cprint
|
||||
@@ -545,24 +528,6 @@ class CLIModelSwitchMixin:
|
||||
scroll_offset = selected - visible + 1
|
||||
return max(0, min(scroll_offset, n - visible)), visible
|
||||
|
||||
def _clear_persisted_context_for_model_switch(self, result) -> None:
|
||||
"""Drop a global context pin when its configured owner changes."""
|
||||
from cli import save_config_value
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
from hermes_cli.route_identity import should_clear_context_pin
|
||||
config = load_config_readonly()
|
||||
model_cfg = config.get("model", {}) if isinstance(config, dict) else {}
|
||||
if not isinstance(model_cfg, dict) or "context_length" not in model_cfg:
|
||||
return
|
||||
if should_clear_context_pin(
|
||||
model_cfg.get("default") or model_cfg.get("model"), result.new_model,
|
||||
model_cfg.get("base_url"), result.base_url,
|
||||
model_cfg.get("provider"), result.target_provider):
|
||||
save_config_value("model.context_length", None)
|
||||
except Exception:
|
||||
save_config_value("model.context_length", None)
|
||||
|
||||
def _stage_and_swap_model(self, result, old_model) -> bool:
|
||||
"""Stage ``result`` onto the CLI fields, then swap the live agent in place.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user