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