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