fix(dashboard): profile-create validates the model in the dashboard's home, reports rejections

_write_profile_model validated under the NEW profile's HERMES_HOME. A just-created
profile has no providers:, no .env and no auth, so every non-env provider the
create dialog offered (anthropic, ollama, providers:-keyed custom) came back
'Unknown provider' / 'Could not resolve credentials' and create returned a
silent model_set: false. The picker read THIS dashboard's catalog, so validation
now runs scoped to the process home (validate_in) while the write still lands in
the new profile. PUT /api/profiles/{name}/model keeps validating in the target
profile (it is an existing, credentialed home). A real rejection now surfaces as
model_error in the create response instead of a log line.
This commit is contained in:
teknium1
2026-09-12 23:59:32 -07:00
committed by Teknium
parent 466ccac5ab
commit 08dfa32da7
2 changed files with 62 additions and 7 deletions
+27 -7
View File
@@ -28,6 +28,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from fastapi import APIRouter, HTTPException, Query
from hermes_cli.web_deps import late
from hermes_cli.config import get_process_hermes_home
from hermes_cli.web_server_config import (
_apply_main_model_assignment, _normalize_main_model_assignment, _validated_main_model_selection,
)
@@ -97,14 +98,20 @@ def _profile_setup_command(name: str) -> str:
return "hermes setup" if name == "default" else f"{name} setup"
def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None:
def _write_profile_model(profile_dir: Path, provider: str, model: str, validate_in: Optional[Path] = None) -> None:
"""Write the main model assignment into ``profile_dir``'s config.yaml (HERMES_HOME-scoped)
through the same validated /model shape as ``POST /api/model/set``."""
through the same validated /model shape as ``POST /api/model/set``.
``validate_in`` is the home whose ``providers:``/``.env``/catalog vouch for the pick (default:
``profile_dir`` itself). Profile-create passes the dashboard's own home: the picker that offered
the model read THAT catalog, and a just-created profile has no credentials yet, so validating
in the empty profile rejected every non-env provider (anthropic, ollama, custom)."""
from hermes_cli.config import load_config, save_config
with _hermes_home_scope(profile_dir):
with _hermes_home_scope(validate_in or profile_dir):
provider, model = _normalize_main_model_assignment(provider, model)
result = _validated_main_model_selection(load_config(), provider, model)
with _hermes_home_scope(profile_dir):
cfg = load_config()
result = _validated_main_model_selection(cfg, provider, model)
cfg["model"] = _apply_main_model_assignment(cfg.get("model", {}), result)
save_config(cfg)
@@ -684,9 +691,22 @@ async def create_profile_endpoint(body: ProfileCreate):
# the whole create — the user can fix it from the dashboard or `<profile> setup`.
provider = (body.provider or "").strip()
model = (body.model or "").strip()
# Validate against THIS dashboard's home (the catalog the picker offered), not the empty
# new profile; the write still lands in the new profile. A rejection is reported (not just
# logged) so the dialog can say WHY the model was not set.
model_error = ""
def _set_model() -> bool:
nonlocal model_error
try:
_write_profile_model(path, provider, model, validate_in=get_process_hermes_home())
except HTTPException as exc:
model_error = str(exc.detail)
return False
return True
model_set = bool(provider and model) and _best_effort(
"Setting model for new profile %s failed", body.name,
fn=lambda: (_write_profile_model(path, provider, model), True)[1], default=False)
"Setting model for new profile %s failed", body.name, fn=_set_model, default=False)
mcp_written = _best_effort(
"Writing MCP servers for new profile %s failed", body.name, default=0,
fn=lambda: _write_profile_mcp_servers(path, body.mcp_servers)) if body.mcp_servers else 0
@@ -707,7 +727,7 @@ async def create_profile_endpoint(body: ProfileCreate):
fn=lambda: _spawn_install(ident))}
for ident in ((i or "").strip() for i in body.hub_skills) if ident]
return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set,
return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set, "model_error": model_error,
"mcp_written": mcp_written, "skills_disabled": skills_disabled,
"hub_installs": hub_installs}
@@ -261,6 +261,41 @@ class TestProfileScopedModel:
if isinstance(default_model, dict):
assert default_model.get("default") != "test/model-1"
def test_profile_create_validates_against_the_dashboard_home_and_writes_the_new_profile(
self, client, isolated_profiles, monkeypatch
):
"""The create dialog's picker read THIS dashboard's catalog; the new profile is empty
(no providers:, no .env), so validating there rejected every non-env provider and
create silently returned model_set: false. Validation must see the dashboard home's
config; the write must still land in the new profile only."""
import hermes_cli.profiles as profiles_mod
from hermes_constants import get_hermes_home
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
(isolated_profiles["default"] / "config.yaml").write_text(
"providers:\n mybox:\n base_url: http://box:8000/v1\n key_env: MYBOX_KEY\n", encoding="utf-8")
seen: dict = {}
def _switch(*, raw_input, explicit_provider, user_providers, **_kw):
from hermes_cli.model_switch import ModelSwitchResult
seen["user_providers"] = user_providers
seen["home"] = get_hermes_home()
if explicit_provider not in user_providers:
return ModelSwitchResult(success=False, error_message=f"Unknown provider '{explicit_provider}'.")
return ModelSwitchResult(success=True, new_model=raw_input, target_provider=explicit_provider)
monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch)
resp = client.post("/api/profiles", json={"name": "newbie", "provider": "mybox", "model": "qwen3"})
assert resp.status_code == 200 and resp.json()["model_set"] is True
assert "mybox" in seen["user_providers"] and seen["home"] == isolated_profiles["default"]
new_home = isolated_profiles["default"] / "profiles" / "newbie"
assert _cfg(new_home)["model"]["provider"] == "mybox" and _cfg(new_home)["model"]["default"] == "qwen3"
assert "model" not in _cfg(isolated_profiles["default"])
# A genuine rejection is reported with its reason, not a silent model_set: false.
resp = client.post("/api/profiles", json={"name": "newbie2", "provider": "nobox", "model": "qwen3"})
assert resp.status_code == 200 and resp.json()["model_set"] is False
assert "Unknown provider 'nobox'" in resp.json()["model_error"]
def test_main_assignment_reports_only_target_profile_cron_impact(
self, client, isolated_profiles
):