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:
@@ -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", "") == ""
|
||||
|
||||
Reference in New Issue
Block a user