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:
125
api/config.py
125
api/config.py
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user