Update model correctly when updating from dashboard

This commit is contained in:
IAvecilla 2026-06-11 16:05:54 -03:00 committed by Teknium
parent 1e25358a8f
commit c61815232a
3 changed files with 179 additions and 16 deletions

View file

@ -1051,6 +1051,94 @@ def test_resolve_model_strips_config_model(monkeypatch):
assert server._resolve_model() == "nous/hermes-test"
def _sync_test_session(**extra):
session = {
"agent": types.SimpleNamespace(model="old/model"),
"session_key": "session-key",
}
session.update(extra)
return session
def _patch_config_model(monkeypatch, model, provider=""):
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
cfg_model = {"default": model}
if provider:
cfg_model["provider"] = provider
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": cfg_model})
def test_config_sync_switches_unpinned_session(monkeypatch):
_patch_config_model(monkeypatch, "new/model", provider="nous")
session = _sync_test_session(config_model_seen=("old/model", "nous"))
calls = []
monkeypatch.setattr(
server,
"_apply_model_switch",
lambda sid, sess, raw, **kw: calls.append((sid, raw, kw)),
)
server._sync_agent_model_with_config("sid", session)
assert calls == [
(
"sid",
"new/model --provider nous",
{"confirm_expensive_model": True, "pin_session_override": False},
)
]
assert session["config_model_seen"] == ("new/model", "nous")
def test_config_sync_skips_session_pinned_by_model_command(monkeypatch):
_patch_config_model(monkeypatch, "new/model")
session = _sync_test_session(
config_model_seen=("old/model", ""),
model_override={"model": "pinned/model"},
)
monkeypatch.setattr(
server,
"_apply_model_switch",
lambda *a, **k: pytest.fail("pinned session must not be switched"),
)
server._sync_agent_model_with_config("sid", session)
def test_config_sync_noop_when_config_unchanged(monkeypatch):
_patch_config_model(monkeypatch, "old/model")
session = _sync_test_session(config_model_seen=("old/model", ""))
monkeypatch.setattr(
server,
"_apply_model_switch",
lambda *a, **k: pytest.fail("unchanged config must not switch"),
)
server._sync_agent_model_with_config("sid", session)
def test_config_sync_failure_emits_error_once_per_edit(monkeypatch):
_patch_config_model(monkeypatch, "broken/model")
session = _sync_test_session(config_model_seen=("old/model", ""))
def boom(*a, **k):
raise ValueError("no such model")
monkeypatch.setattr(server, "_apply_model_switch", boom)
emits = []
monkeypatch.setattr(
server, "_emit", lambda ev, sid, payload: emits.append((ev, payload))
)
server._sync_agent_model_with_config("sid", session)
server._sync_agent_model_with_config("sid", session)
assert len(emits) == 1
assert emits[0][0] == "error"
assert "broken/model" in emits[0][1]["message"]
def test_startup_runtime_uses_tui_provider_env(monkeypatch):
monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous")

View file

@ -948,6 +948,9 @@ def _start_agent_build(sid: str, session: dict) -> None:
# Session DB row deferred to first run_conversation() call.
# pending_title applied post-first-message (see cli.exec handler).
current["agent"] = agent
# Baseline for the per-turn config sync; the profile home
# override is still active here.
current["config_model_seen"] = _config_model_target()
try:
worker = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
@ -1414,6 +1417,16 @@ def _resolve_model() -> str:
return "anthropic/claude-sonnet-4"
def _config_model_target() -> tuple[str, str]:
"""(model, provider) currently selected by env/config."""
model = _resolve_model()
cfg_model = _load_cfg().get("model")
provider = ""
if isinstance(cfg_model, dict):
provider = str(cfg_model.get("provider") or "").strip()
return model, provider
def _resolve_startup_runtime() -> tuple[str, str | None]:
model = _resolve_model()
explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip()
@ -1883,6 +1896,7 @@ def _apply_model_switch(
raw_input: str,
*,
confirm_expensive_model: bool = False,
pin_session_override: bool = True,
) -> dict:
from hermes_cli.model_switch import parse_model_flags, switch_model
from hermes_cli.runtime_provider import resolve_runtime_provider
@ -1986,7 +2000,7 @@ def _apply_model_switch(
# contamination bug). agent.switch_model() above already mutated the right
# agent in place; the override dict makes that choice survive a rebuild
# without touching shared process state.
if isinstance(session, dict):
if pin_session_override and isinstance(session, dict):
session["model_override"] = {
"model": result.new_model,
"provider": result.target_provider,
@ -2003,6 +2017,42 @@ def _apply_model_switch(
}
def _sync_agent_model_with_config(sid: str, session: dict) -> None:
"""Adopt a config.yaml model change at turn start, like gateways do per
message. Sessions pinned with /model keep their choice; a failed switch
keeps the current model and never blocks the turn.
"""
agent = session.get("agent")
if agent is None or session.get("model_override"):
return
target = _config_model_target()
if not target[0]:
return
seen = session.get("config_model_seen")
# Record first so a broken config gets one attempt per edit, not per turn.
session["config_model_seen"] = target
if target == seen:
return
if seen is None and target[0] == (getattr(agent, "model", "") or ""):
return
model, provider = target
raw = f"{model} --provider {provider}" if provider else model
try:
_apply_model_switch(
sid,
session,
raw,
confirm_expensive_model=True,
pin_session_override=False,
)
except Exception as e:
_emit(
"error",
sid,
{"message": f"Could not switch to configured model {model}: {e}"},
)
def _compress_session_history(
session: dict,
focus_topic: str | None = None,
@ -3140,6 +3190,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
finally:
_clear_session_context(tokens)
session["agent"] = new_agent
session["config_model_seen"] = _config_model_target()
session["attached_images"] = []
session["edit_snapshots"] = {}
session["image_counter"] = 0
@ -5563,6 +5614,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
# the sudo.request overlay. (secret capture is a module global, so
# re-running is a harmless no-op.)
_wire_callbacks(sid)
_sync_agent_model_with_config(sid, session)
cwd = _session_cwd(session)
_register_session_cwd(session)
cols = session.get("cols", 80)

View file

@ -855,20 +855,29 @@ export default function ModelsPage() {
});
}, []);
const load = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
api.getModelsAnalytics(days),
api.getAuxiliaryModels().catch(() => null),
])
.then(([models, auxData]) => {
setData(models);
setAux(auxData);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
const load = useCallback(
(opts?: { silent?: boolean }) => {
if (!opts?.silent) {
setLoading(true);
setError(null);
}
Promise.all([
api.getModelsAnalytics(days),
api.getAuxiliaryModels().catch(() => null),
])
.then(([models, auxData]) => {
setData(models);
setAux(auxData);
})
.catch((err) => {
if (!opts?.silent) setError(String(err));
})
.finally(() => {
if (!opts?.silent) setLoading(false);
});
},
[days],
);
const onAssigned = useCallback(() => {
// Reload aux state after any assignment change.
@ -903,7 +912,7 @@ export default function ModelsPage() {
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
onClick={() => load()}
disabled={loading}
aria-label={t.common.refresh}
>
@ -922,6 +931,20 @@ export default function ModelsPage() {
load();
}, [load]);
// Model assignments can change outside this page (config editor, chat
// /model --global, CLI) — refetch silently when the page regains focus.
useEffect(() => {
const refetch = () => {
if (document.visibilityState === "visible") load({ silent: true });
};
window.addEventListener("focus", refetch);
document.addEventListener("visibilitychange", refetch);
return () => {
window.removeEventListener("focus", refetch);
document.removeEventListener("visibilitychange", refetch);
};
}, [load]);
return (
<div className="flex min-w-0 max-w-full flex-col gap-6">
<PluginSlot name="models:top" />