fix(config): use Hermes config.yaml as single source of default model (#773)

Removes split-brain where WebUI Settings persisted default_model separately from Hermes runtime config.yaml. New POST /api/default-model endpoint writes to config.yaml. Existing saved values migrated on first load.

Fixes #761

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-20 15:12:01 -07:00
committed by GitHub
parent f35ac3a727
commit 63f9b719bb
8 changed files with 192 additions and 41 deletions

View File

@@ -209,6 +209,35 @@ def reload_config() -> None:
logger.debug("Failed to load yaml config from %s", config_path)
def _load_yaml_config_file(config_path: Path) -> dict:
try:
import yaml as _yaml
except ImportError:
return {}
if not config_path.exists():
return {}
try:
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
return loaded if isinstance(loaded, dict) else {}
except Exception:
logger.debug("Failed to parse yaml config from %s", config_path)
return {}
def _save_yaml_config_file(config_path: Path, config_data: dict) -> None:
try:
import yaml as _yaml
except ImportError as exc:
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
_yaml.safe_dump(config_data, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
# Initial load
reload_config()
cfg = _cfg_cache # alias for backward compat with existing references
@@ -710,6 +739,69 @@ def resolve_model_provider(model_id: str) -> tuple:
return model_id, config_provider, config_base_url
def get_effective_default_model(config_data: dict | None = None) -> str:
"""Resolve the effective Hermes default model from config, then env overrides."""
active_cfg = config_data if config_data is not None else cfg
default_model = DEFAULT_MODEL
model_cfg = active_cfg.get("model", {})
if isinstance(model_cfg, str):
default_model = model_cfg.strip()
elif isinstance(model_cfg, dict):
cfg_default = str(model_cfg.get("default") or "").strip()
if cfg_default:
default_model = cfg_default
env_model = (
os.getenv("HERMES_MODEL") or os.getenv("OPENAI_MODEL") or os.getenv("LLM_MODEL")
)
if env_model:
default_model = env_model.strip()
return default_model
def set_hermes_default_model(model_id: str) -> dict:
"""Persist the Hermes default model in config.yaml and reload runtime config."""
selected_model = str(model_id or "").strip()
if not selected_model:
raise ValueError("model is required")
config_path = _get_config_path()
# Hold _cfg_lock only around the read-modify-write of the YAML file.
# reload_config() acquires _cfg_lock internally (it's not reentrant) so
# it must be called AFTER releasing the lock to avoid deadlock.
with _cfg_lock:
config_data = _load_yaml_config_file(config_path)
model_cfg = config_data.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
previous_provider = str(model_cfg.get("provider") or "").strip()
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(
selected_model
)
persisted_model = str(resolved_model or selected_model).strip()
persisted_provider = str(resolved_provider or previous_provider or "").strip()
model_cfg["default"] = persisted_model
if persisted_provider:
model_cfg["provider"] = persisted_provider
if resolved_base_url:
model_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/")
elif persisted_provider != previous_provider:
if persisted_provider == "openai":
model_cfg["base_url"] = "https://api.openai.com/v1"
elif not persisted_provider.startswith("custom:"):
model_cfg.pop("base_url", None)
config_data["model"] = model_cfg
_save_yaml_config_file(config_path, config_data)
# Reload outside the lock — reload_config() acquires _cfg_lock itself.
reload_config()
return get_available_models()
def get_available_models() -> dict:
"""
Return available models grouped by provider.
@@ -736,7 +828,7 @@ def get_available_models() -> dict:
if _current_mtime != _cfg_mtime:
reload_config()
active_provider = None
default_model = DEFAULT_MODEL
default_model = get_effective_default_model(cfg)
groups = []
# 1. Read config.yaml model section
@@ -744,7 +836,7 @@ def get_available_models() -> dict:
model_cfg = cfg.get("model", {})
cfg_base_url = ""
if isinstance(model_cfg, str):
default_model = model_cfg
pass # default_model already set by get_effective_default_model
elif isinstance(model_cfg, dict):
active_provider = model_cfg.get("provider")
cfg_default = model_cfg.get("default", "")
@@ -752,14 +844,7 @@ def get_available_models() -> dict:
if cfg_default:
default_model = cfg_default
# 2. Also check env vars for model override
env_model = (
os.getenv("HERMES_MODEL") or os.getenv("OPENAI_MODEL") or os.getenv("LLM_MODEL")
)
if env_model:
default_model = env_model.strip()
# 3. Try to read auth store for active provider (if hermes is installed)
# 2. Try to read auth store for active provider (if hermes is installed)
if not active_provider:
try:
from api.profiles import get_active_hermes_home as _gah
@@ -1239,7 +1324,6 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
# ── Settings persistence ─────────────────────────────────────────────────────
_SETTINGS_DEFAULTS = {
"default_model": DEFAULT_MODEL,
"default_workspace": str(DEFAULT_WORKSPACE),
"onboarding_completed": False,
"send_key": "enter", # 'enter' or 'ctrl+enter'
@@ -1259,7 +1343,7 @@ _SETTINGS_DEFAULTS = {
"sidebar_density": "compact", # compact | detailed
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
}
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "default_model"}
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
_SETTINGS_SKIN_VALUES = {
"default",
@@ -1346,10 +1430,14 @@ def load_settings() -> dict:
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
)
settings["default_model"] = get_effective_default_model()
return settings
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {"password_hash"}
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
"password_hash",
"default_model",
}
_SETTINGS_ENUM_VALUES = {
"send_key": {"enter", "ctrl+enter"},
"sidebar_density": {"compact", "detailed"},
@@ -1420,16 +1508,16 @@ def save_settings(settings: dict) -> dict:
current["default_workspace"] = str(
resolve_default_workspace(current.get("default_workspace"))
)
persisted = {k: v for k, v in current.items() if k != "default_model"}
SETTINGS_FILE.write_text(
json.dumps(current, ensure_ascii=False, indent=2),
json.dumps(persisted, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Update runtime defaults so new sessions use them immediately
global DEFAULT_MODEL, DEFAULT_WORKSPACE
if "default_model" in current:
DEFAULT_MODEL = current["default_model"]
global DEFAULT_WORKSPACE
if "default_workspace" in current:
DEFAULT_WORKSPACE = resolve_default_workspace(current["default_workspace"])
current["default_model"] = get_effective_default_model()
return current
@@ -1444,12 +1532,11 @@ try:
except OSError:
_settings_file_exists = False
if _settings_file_exists:
if _startup_settings.get("default_model"):
DEFAULT_MODEL = _startup_settings["default_model"]
if not os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
DEFAULT_WORKSPACE = resolve_default_workspace(
_startup_settings.get("default_workspace")
)
_startup_settings.pop("default_model", None) # always drop stale value; model comes from config.yaml
if _startup_settings.get("default_workspace") != str(DEFAULT_WORKSPACE):
_startup_settings["default_workspace"] = str(DEFAULT_WORKSPACE)
try:

View File

@@ -11,7 +11,8 @@ from pathlib import Path
import api.config as _cfg
from api.config import (
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
get_effective_default_model,
)
from api.workspace import get_last_workspace
@@ -134,13 +135,18 @@ def get_session(sid):
raise KeyError(sid)
def new_session(workspace=None, model=None):
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
# Use the live config-derived default so Hermes config changes apply without restart.
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
effective_model = model or get_effective_default_model()
s = Session(
workspace=workspace or get_last_workspace(),
model=effective_model,
profile=_profile,
)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)

View File

@@ -47,6 +47,7 @@ from api.config import (
CHAT_LOCK,
load_settings,
save_settings,
set_hermes_default_model,
)
from api.helpers import (
require,
@@ -881,6 +882,14 @@ def handle_post(handler, parsed) -> bool:
s = new_session(workspace=workspace, model=body.get("model"))
return j(handler, {"session": s.compact() | {"messages": s.messages}})
if parsed.path == "/api/default-model":
try:
return j(handler, set_hermes_default_model(body.get("model")))
except ValueError as e:
return bad(handler, str(e))
except RuntimeError as e:
return bad(handler, str(e), 500)
if parsed.path == "/api/sessions/cleanup":
return _handle_sessions_cleanup(handler, body, zero_only=False)