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
+81 -1
View File
@@ -723,6 +723,73 @@ class ModelAssignment(BaseModel):
profile: Optional[str] = None
def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, str]:
"""Normalize a main-slot (provider, model) pair before persisting.
The Models page has two assignment paths and only one of them was safe:
- The "Change" picker sends a real Hermes provider slug fine.
- The per-card "Use as → Main model" menu sends ``entry.provider``
from the analytics rows, falling back to the model's VENDOR prefix
(``modelVendor("anthropic/claude-opus-4.6") == "anthropic"``) when
the session row has no ``billing_provider`` (older sessions, NULL
rows). That wrote ``provider: anthropic`` +
``default: anthropic/claude-opus-4.6`` to config a vendor-prefixed
OpenRouter slug on the NATIVE Anthropic provider. New sessions then
400 against api.anthropic.com ("model: anthropic/claude-opus-4.6 not
found") and the user reads it as "changing models does nothing".
Two repairs, both at this single chokepoint so every caller inherits:
1. Vendor-name Hermes-provider mapping: when the provider string is
not a known Hermes provider/alias (e.g. ``moonshotai``, ``x-ai`` is
known but ``poolside`` isn't) but the model is a vendor-prefixed
aggregator slug, keep the user's CURRENT aggregator if they're on
one, else fall back to openrouter.
2. Model-format normalization for the resolved provider via
``normalize_model_for_provider`` (e.g. ``anthropic/claude-opus-4.6``
on native anthropic ``claude-opus-4-6``).
"""
from hermes_cli.models import _KNOWN_PROVIDER_NAMES, normalize_provider
from hermes_cli.model_normalize import normalize_model_for_provider
prov_in = (provider or "").strip()
model_in = (model or "").strip()
canonical = normalize_provider(prov_in)
if canonical not in _KNOWN_PROVIDER_NAMES and "/" in model_in:
# Vendor prefix posing as a provider (analytics fallback). Resolve
# against the user's current provider when it's an aggregator that
# serves vendor-prefixed slugs; otherwise default to openrouter.
try:
cur_cfg = load_config().get("model", {})
cur_provider = (
str(cur_cfg.get("provider", "") or "").strip().lower()
if isinstance(cur_cfg, dict) else ""
)
except Exception:
cur_provider = ""
from hermes_cli.models import _AGGREGATOR_PROVIDERS
if cur_provider and normalize_provider(cur_provider) in _AGGREGATOR_PROVIDERS:
canonical = normalize_provider(cur_provider)
prov_in = cur_provider
else:
canonical = "openrouter"
prov_in = "openrouter"
# Custom/user-config providers keep the model verbatim — the registry
# normalizer doesn't know their namespaces.
if canonical in _KNOWN_PROVIDER_NAMES and not canonical.startswith("custom"):
try:
normalized_model = normalize_model_for_provider(model_in, canonical)
if normalized_model:
model_in = normalized_model
except Exception:
_log.debug("model normalization failed for %s/%s", prov_in, model_in, exc_info=True)
return prov_in, model_in
def _apply_main_model_assignment(
model_cfg: "Any", provider: str, model: str, base_url: str = ""
) -> dict:
@@ -2892,6 +2959,7 @@ def _apply_model_assignment_sync(
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
provider, model = _normalize_main_model_assignment(provider, model)
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url
)
@@ -7302,6 +7370,14 @@ async def run_backup(body: BackupRequest):
class ImportRequest(BaseModel):
archive: str
# Pass --force to `hermes import`. The spawned action runs with
# stdin=DEVNULL, so the CLI's interactive "Continue? [y/N]" overwrite
# prompt hits EOF and auto-aborts ("Aborted.", exit 1) whenever the
# target already has a config — which it always does when the dashboard
# itself is running from it. The dashboard shows its own confirm modal
# before calling this endpoint, then sends force=True so the restore
# proceeds non-interactively.
force: bool = False
@app.post("/api/ops/import")
@@ -7311,8 +7387,11 @@ async def run_import(body: ImportRequest):
raise HTTPException(status_code=400, detail="archive path is required")
if not os.path.isfile(archive):
raise HTTPException(status_code=404, detail=f"Archive not found: {archive}")
args = ["import", archive]
if body.force:
args.append("--force")
try:
proc = _spawn_hermes_action(["import", archive], "import")
proc = _spawn_hermes_action(args, "import")
except Exception as exc:
_log.exception("Failed to spawn import")
raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}")
@@ -8106,6 +8185,7 @@ def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None:
token = set_hermes_home_override(str(profile_dir))
try:
provider, model = _normalize_main_model_assignment(provider, model)
cfg = load_config()
cfg["model"] = _apply_main_model_assignment(cfg.get("model", {}), provider, model)
save_config(cfg)