feat(desktop): warn when main-model switch leaves auxiliary tasks pinned to another provider (#40286)

Switching the main model never touches auxiliary slot pins (they're
independent, sticky per-task overrides). A user who switches main away
from a now-unpaid provider keeps paying 402s on every background aux call
until they manually reset those pins — silently, with no UI signal.

- /api/model/set scope:'main' now returns stale_aux: slots still pinned
  to a provider different from the new main (additive field).
- Desktop Model Settings shows a switch-time notice after Apply AND a
  persistent banner when any loaded aux slot mismatches the main provider,
  both wired to the existing 'Reset all to main' action.
- Never auto-clears pins — a dedicated cheaper aux model is a legitimate
  config; surface-and-offer instead of nuking.
- Fixes a stale pre-existing assertion in the panel test (main model now
  renders via selectors, not a standalone label).
This commit is contained in:
Teknium
2026-06-05 23:35:36 -07:00
committed by GitHub
parent f8a241e105
commit b91aade176
7 changed files with 217 additions and 3 deletions
+52
View File
@@ -1377,6 +1377,58 @@ class TestWebServerEndpoints:
assert resp.status_code == 200
assert resp.json()["base_url"] == ""
def test_set_model_main_reports_stale_auxiliary_pins(self):
"""Switching the main provider must report auxiliary slots still pinned
to a *different* provider so the UI can warn the user their helper tasks
aren't following the switch (the silent credit-burn path)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["model"] = {"provider": "nous", "default": "hermes-4"}
cfg["auxiliary"] = {
# Pinned to nous — same as the OLD main, becomes stale after switch.
"compression": {"provider": "nous", "model": "anthropic/claude-sonnet-4.6"},
# Auto — follows main, never stale.
"vision": {"provider": "auto", "model": ""},
# Pinned to a third provider — also stale vs the new main.
"curator": {"provider": "deepseek", "model": "deepseek-chat"},
}
save_config(cfg)
resp = self.client.post(
"/api/model/set",
json={"scope": "main", "provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
)
assert resp.status_code == 200
stale = resp.json()["stale_aux"]
stale_tasks = {entry["task"] for entry in stale}
assert stale_tasks == {"compression", "curator"}
# auto slot must never appear.
assert "vision" not in stale_tasks
# Provider/model echoed back for the UI label.
comp = next(e for e in stale if e["task"] == "compression")
assert comp["provider"] == "nous"
assert comp["model"] == "anthropic/claude-sonnet-4.6"
def test_set_model_main_no_stale_when_aux_matches_new_provider(self):
"""Aux slots pinned to the SAME provider as the new main are not stale."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["model"] = {"provider": "nous", "default": "hermes-4"}
cfg["auxiliary"] = {
"compression": {"provider": "openrouter", "model": "google/gemini-2.5-flash"},
"vision": {"provider": "auto", "model": ""},
}
save_config(cfg)
resp = self.client.post(
"/api/model/set",
json={"scope": "main", "provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
)
assert resp.status_code == 200
assert resp.json()["stale_aux"] == []
model_cfg = load_config().get("model")
assert model_cfg["provider"] == "openrouter"
assert model_cfg.get("base_url", "") == ""