fix(dashboard): normalize model assignments + confirm-modal for backup import (#44237)

Two beta-reported dashboard bugs:

1. Models page: 'Use as -> Main model' on an analytics card sends
   entry.provider, which falls back to the model's VENDOR prefix
   (modelVendor('anthropic/claude-opus-4.6') == 'anthropic') when the
   session row has no billing_provider. That persisted
   provider: anthropic + default: anthropic/claude-opus-4.6 — a
   vendor-prefixed OpenRouter slug on the NATIVE Anthropic provider.
   New sessions then 400 against api.anthropic.com and the user reads
   it as 'changing models does nothing'. Unknown vendors (moonshotai,
   poolside, ...) were worse: a provider that can never resolve
   credentials.

   Fix: _normalize_main_model_assignment() at the single write
   chokepoint — maps non-provider vendor names back to the user's
   current aggregator (else openrouter), and runs the model through
   normalize_model_for_provider() so the persisted name matches the
   target provider's API format. Wired into both /api/model/set and
   the profile-scoped _write_profile_model.

2. System page: 'Restore from backup' spawns hermes import with
   stdin=DEVNULL, so the CLI's interactive 'Continue? [y/N]' overwrite
   prompt hits EOF and auto-aborts whenever a config already exists
   (always, when the dashboard is running). Fix: ConfirmDialog in the
   dashboard owns the consent, then the endpoint passes --force so the
   restore runs non-interactively.

Validated live: dashboard on a temp HERMES_HOME, repro'd both failure
modes pre-fix (vendor-slug write verified via config.yaml + tui
session.create; import 'Aborted.' in action-import.log), then verified
post-fix (normalized writes, modal -> --force -> restored marker file).
This commit is contained in:
Teknium
2026-06-11 05:07:58 -07:00
committed by GitHub
parent 4717989c10
commit 9c16ca8790
4 changed files with 209 additions and 4 deletions
+107
View File
@@ -1104,6 +1104,113 @@ class TestWebServerEndpoints:
assert confirmed.status_code == 200
assert confirmed.json()["ok"] is True
def test_model_set_normalizes_vendor_slug_for_native_provider(self, monkeypatch):
"""'Use as → Main' with an OpenRouter slug + native provider must not
persist the vendor-prefixed slug verbatim (it 400s against the native
API and reads as "changing models does nothing")."""
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: None,
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "anthropic",
"model": "anthropic/claude-opus-4.6",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["provider"] == "anthropic"
# Vendor prefix stripped + dots→hyphens for the native Anthropic API.
assert data["model"] == "claude-opus-4-6"
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["model"]["provider"] == "anthropic"
assert cfg["model"]["default"] == "claude-opus-4-6"
def test_model_set_maps_unknown_vendor_to_aggregator(self, monkeypatch):
"""A bare vendor name from analytics rows (no billing_provider) is not
a Hermes provider — keep the user's aggregator instead of writing a
provider that can never resolve credentials."""
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: None,
)
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["model"] = {"provider": "openrouter", "default": "openai/gpt-5.5"}
save_config(cfg)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "moonshotai", # vendor prefix, not a provider
"model": "moonshotai/kimi-k2.6",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["provider"] == "openrouter"
assert data["model"] == "moonshotai/kimi-k2.6"
def test_model_set_keeps_aggregator_slug_unchanged(self, monkeypatch):
"""The happy path (picker → openrouter + vendor/model) is untouched."""
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: None,
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.6",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["provider"] == "openrouter"
assert data["model"] == "anthropic/claude-sonnet-4.6"
def test_ops_import_passes_force_flag(self, tmp_path, monkeypatch):
"""force=True must append --force so the spawned non-interactive
`hermes import` doesn't auto-abort at the overwrite prompt."""
import hermes_cli.web_server as ws
archive = tmp_path / "backup.zip"
import zipfile
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("config.yaml", "model: {}\n")
captured = {}
def fake_spawn(subcommand, name):
captured["args"] = subcommand
captured["name"] = name
from types import SimpleNamespace as NS
return NS(pid=12345)
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
resp = self.client.post(
"/api/ops/import", json={"archive": str(archive), "force": True},
)
assert resp.status_code == 200
assert captured["args"] == ["import", str(archive), "--force"]
resp = self.client.post(
"/api/ops/import", json={"archive": str(archive)},
)
assert resp.status_code == 200
assert captured["args"] == ["import", str(archive)]
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""