From 08dfa32da76ef82dad30d08502857e959de12fb3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:59:32 -0700 Subject: [PATCH] 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. --- hermes_cli/web_routers/profiles.py | 34 ++++++++++++++---- .../test_web_server_profile_unification.py | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 2ec52a9482..c3bfed728d 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -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 ` 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} diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 8acfda73de..64562cb10b 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -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 ):