merge: upgrade to upstream v0.50.95 + keep custom additions
Upstream v0.50.95 features merged (Russian localization, slash commands, mic toggle fix, gateway sync fix, KaTeX/Prism.js, etc.) Custom additions preserved: - Tier-2 agent switching commands in commands.js - MC panel in index.html + MC CSS - _resolve_cli_toolsets() in config.py - Custom routes.py, server.py, boot.js, i18n.js, messages.js, workspace.js Files with conflict resolution (took upstream, custom code in other files): - CHANGELOG.md, config.py, commands.js, index.html, panels.js, style.css, ui.js
This commit is contained in:
398
api/config.py
398
api/config.py
@@ -165,7 +165,6 @@ else:
|
||||
# ── Config file (reloadable -- supports profile switching) ──────────────────
|
||||
_cfg_cache = {}
|
||||
_cfg_lock = threading.Lock()
|
||||
_cfg_mtime: float = 0.0 # last known mtime of config.yaml; 0 = never loaded
|
||||
|
||||
|
||||
def _get_config_path() -> Path:
|
||||
@@ -190,7 +189,6 @@ def get_config() -> dict:
|
||||
|
||||
def reload_config() -> None:
|
||||
"""Reload config.yaml from the active profile's directory."""
|
||||
global _cfg_mtime
|
||||
with _cfg_lock:
|
||||
_cfg_cache.clear()
|
||||
config_path = _get_config_path()
|
||||
@@ -198,13 +196,9 @@ def reload_config() -> None:
|
||||
import yaml as _yaml
|
||||
|
||||
if config_path.exists():
|
||||
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
loaded = _yaml.safe_load(config_path.read_text())
|
||||
if isinstance(loaded, dict):
|
||||
_cfg_cache.update(loaded)
|
||||
try:
|
||||
_cfg_mtime = Path(config_path).stat().st_mtime
|
||||
except OSError:
|
||||
_cfg_mtime = 0.0
|
||||
except Exception:
|
||||
logger.debug("Failed to load yaml config from %s", config_path)
|
||||
|
||||
@@ -282,7 +276,7 @@ def _discover_default_workspace() -> Path:
|
||||
|
||||
|
||||
DEFAULT_WORKSPACE = _discover_default_workspace()
|
||||
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "") # Empty = use provider default; avoids showing unavailable OpenAI model to non-OpenAI users (#646)
|
||||
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "openai/gpt-5.4-mini")
|
||||
|
||||
|
||||
# ── Startup diagnostics ───────────────────────────────────────────────────────
|
||||
@@ -386,10 +380,6 @@ MIME_MAP = {
|
||||
".bmp": "image/bmp",
|
||||
".pdf": "application/pdf",
|
||||
".json": "application/json",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
|
||||
@@ -426,35 +416,31 @@ CLI_TOOLSETS = _resolve_cli_toolsets()
|
||||
# ── Model / provider discovery ───────────────────────────────────────────────
|
||||
|
||||
# Hardcoded fallback models (used when no config.yaml or agent is available)
|
||||
# Also used as the OpenRouter model list — keep this curated to current, widely-used models.
|
||||
_FALLBACK_MODELS = [
|
||||
# OpenAI
|
||||
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"provider": "OpenAI", "id": "openai/gpt-5.4", "label": "GPT-5.4"},
|
||||
# Anthropic — 4.6 flagship + 4.5 generation
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-opus-4.6", "label": "Claude Opus 4.6"},
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
|
||||
{"provider": "Anthropic", "id": "anthropic/claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
||||
# Google — 3.x (latest preview) + 2.5 (stable GA)
|
||||
{"provider": "Google", "id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"provider": "Google", "id": "google/gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"provider": "Google", "id": "google/gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"provider": "Google", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"provider": "Google", "id": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
# DeepSeek
|
||||
{"provider": "DeepSeek", "id": "deepseek/deepseek-chat-v3-0324", "label": "DeepSeek V3"},
|
||||
{"provider": "DeepSeek", "id": "deepseek/deepseek-r1", "label": "DeepSeek R1"},
|
||||
# Qwen (Alibaba) — strong coding and general models
|
||||
{"provider": "Qwen", "id": "qwen/qwen3-coder", "label": "Qwen3 Coder"},
|
||||
{"provider": "Qwen", "id": "qwen/qwen3.6-plus", "label": "Qwen3.6 Plus"},
|
||||
# xAI
|
||||
{"provider": "xAI", "id": "x-ai/grok-4.20", "label": "Grok 4.20"},
|
||||
# Mistral
|
||||
{"provider": "Mistral", "id": "mistralai/mistral-large-latest", "label": "Mistral Large"},
|
||||
# MiniMax
|
||||
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7", "label": "MiniMax M2.7"},
|
||||
{"provider": "MiniMax", "id": "minimax/MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"},
|
||||
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"provider": "OpenAI", "id": "openai/o4-mini", "label": "o4-mini"},
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"id": "anthropic/claude-sonnet-4.6",
|
||||
"label": "Claude Sonnet 4.6",
|
||||
},
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"id": "anthropic/claude-sonnet-4-5",
|
||||
"label": "Claude Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
"provider": "Anthropic",
|
||||
"id": "anthropic/claude-haiku-4-5",
|
||||
"label": "Claude Haiku 4.5",
|
||||
},
|
||||
{"provider": "Other", "id": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{
|
||||
"provider": "Other",
|
||||
"id": "deepseek/deepseek-chat-v3-0324",
|
||||
"label": "DeepSeek V3",
|
||||
},
|
||||
{"provider": "Other", "id": "meta-llama/llama-4-scout", "label": "Llama 4 Scout"},
|
||||
]
|
||||
|
||||
# Provider display names for known Hermes provider IDs
|
||||
@@ -477,9 +463,6 @@ _PROVIDER_DISPLAY = {
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"lmstudio": "LM Studio",
|
||||
"mistralai": "Mistral",
|
||||
"qwen": "Qwen",
|
||||
"x-ai": "xAI",
|
||||
}
|
||||
|
||||
# Well-known models per provider (used to populate dropdown for direct API providers)
|
||||
@@ -492,7 +475,7 @@ _PROVIDER_MODELS = {
|
||||
],
|
||||
"openai": [
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
{"id": "o4-mini", "label": "o4-mini"},
|
||||
],
|
||||
"openai-codex": [
|
||||
{"id": "gpt-5.4", "label": "GPT-5.4"},
|
||||
@@ -504,11 +487,7 @@ _PROVIDER_MODELS = {
|
||||
{"id": "codex-mini-latest", "label": "Codex Mini (latest)"},
|
||||
],
|
||||
"google": [
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
],
|
||||
"deepseek": [
|
||||
{"id": "deepseek-chat-v3-0324", "label": "DeepSeek V3"},
|
||||
@@ -518,7 +497,7 @@ _PROVIDER_MODELS = {
|
||||
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"},
|
||||
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"},
|
||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini (via Nous)"},
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro (via Nous)"},
|
||||
],
|
||||
"zai": [
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
@@ -548,7 +527,7 @@ _PROVIDER_MODELS = {
|
||||
{"id": "gpt-4o", "label": "GPT-4o"},
|
||||
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6"},
|
||||
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
],
|
||||
# OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits)
|
||||
"opencode-zen": [
|
||||
@@ -575,11 +554,8 @@ _PROVIDER_MODELS = {
|
||||
{"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
|
||||
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
||||
{"id": "claude-3-5-haiku", "label": "Claude 3.5 Haiku"},
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
{"id": "gemini-3.1-pro", "label": "Gemini 3.1 Pro"},
|
||||
{"id": "gemini-3-flash", "label": "Gemini 3 Flash"},
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
{"id": "glm-5", "label": "GLM-5"},
|
||||
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
|
||||
@@ -599,29 +575,17 @@ _PROVIDER_MODELS = {
|
||||
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
|
||||
],
|
||||
# 'gemini' is the hermes_cli provider ID for Google AI Studio
|
||||
# Model IDs are bare — sent directly to:
|
||||
# https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
|
||||
"gemini": [
|
||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
|
||||
{"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
|
||||
],
|
||||
# Mistral — prefix used in OpenRouter model IDs (mistralai/mistral-large-latest)
|
||||
"mistralai": [
|
||||
{"id": "mistral-large-latest", "label": "Mistral Large"},
|
||||
{"id": "mistral-small-latest", "label": "Mistral Small"},
|
||||
],
|
||||
# Qwen (Alibaba) — prefix used in OpenRouter model IDs (qwen/qwen3-coder)
|
||||
"qwen": [
|
||||
{"id": "qwen3-coder", "label": "Qwen3 Coder"},
|
||||
{"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"},
|
||||
],
|
||||
# xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
|
||||
"x-ai": [
|
||||
{"id": "grok-4.20", "label": "Grok 4.20"},
|
||||
{"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
|
||||
{"id": "gemini-2.0-flash", "label": "Gemini 2.0 Flash"},
|
||||
],
|
||||
# 'lmstudio' — empty list here; the build loop detects live models via
|
||||
# provider_model_ids() when this list is empty. Ollama uses the same
|
||||
# pattern. Custom /v1/models fetching is handled separately for base_url
|
||||
# configs; this entry only exists so the "elif pid in _PROVIDER_MODELS"
|
||||
# branch is taken (enabling live model injection) rather than the generic
|
||||
# "unknown provider" fallback.
|
||||
"lmstudio": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -692,14 +656,10 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
|
||||
# The user has explicitly pointed at a base_url, so trust their routing config.
|
||||
if config_base_url:
|
||||
# Only strip the provider prefix when it's a known provider namespace
|
||||
# (e.g. "openai/gpt-5.4" → "gpt-5.4" for a custom OpenAI-compatible proxy).
|
||||
# Unknown prefixes (e.g. "zai-org/GLM-5.1" on DeepInfra) are intrinsic to
|
||||
# the model ID and must be preserved — stripping them causes model_not_found.
|
||||
if prefix in _PROVIDER_MODELS:
|
||||
return bare, config_provider, config_base_url
|
||||
# Unknown prefix (not a named provider) — pass full model_id through.
|
||||
return model_id, config_provider, config_base_url
|
||||
# Strip provider prefix (e.g. 'openai/gpt-5.4' -> 'gpt-5.4') so prefixed
|
||||
# model IDs from previous sessions don't break custom endpoint routing.
|
||||
bare_model = model_id.split('/', 1)[-1]
|
||||
return bare_model, config_provider, config_base_url
|
||||
# If prefix does NOT match config provider, the user picked a cross-provider model
|
||||
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
||||
# In this case always route through openrouter with the full provider/model string.
|
||||
@@ -725,15 +685,9 @@ def get_available_models() -> dict:
|
||||
'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
|
||||
}
|
||||
"""
|
||||
# Reload config from disk if config.yaml has changed since last load.
|
||||
# This ensures CLI model changes are picked up on page refresh without
|
||||
# a server restart, while avoiding clearing in-memory mocks during tests. (#585)
|
||||
try:
|
||||
_current_mtime = Path(_get_config_path()).stat().st_mtime
|
||||
except OSError:
|
||||
_current_mtime = 0.0
|
||||
if _current_mtime != _cfg_mtime:
|
||||
reload_config()
|
||||
# Reload config on every request so config.yaml changes take effect immediately
|
||||
reload_config()
|
||||
|
||||
active_provider = None
|
||||
default_model = DEFAULT_MODEL
|
||||
groups = []
|
||||
@@ -770,7 +724,7 @@ def get_available_models() -> dict:
|
||||
try:
|
||||
import json as _j
|
||||
|
||||
auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8"))
|
||||
auth_store = _j.loads(auth_store_path.read_text())
|
||||
active_provider = auth_store.get("active_provider")
|
||||
except Exception:
|
||||
logger.debug("Failed to load auth store from %s", auth_store_path)
|
||||
@@ -818,7 +772,7 @@ def get_available_models() -> dict:
|
||||
env_keys = {}
|
||||
if hermes_env_path.exists():
|
||||
try:
|
||||
for line in hermes_env_path.read_text(encoding="utf-8").splitlines():
|
||||
for line in hermes_env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
@@ -831,14 +785,11 @@ def get_available_models() -> dict:
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
):
|
||||
val = os.getenv(k)
|
||||
if val:
|
||||
@@ -851,8 +802,6 @@ def get_available_models() -> dict:
|
||||
detected_providers.add("openrouter")
|
||||
if all_env.get("GOOGLE_API_KEY"):
|
||||
detected_providers.add("google")
|
||||
if all_env.get("GEMINI_API_KEY"):
|
||||
detected_providers.add("gemini")
|
||||
if all_env.get("GLM_API_KEY"):
|
||||
detected_providers.add("zai")
|
||||
if all_env.get("KIMI_API_KEY"):
|
||||
@@ -866,7 +815,20 @@ def get_available_models() -> dict:
|
||||
if all_env.get("OPENCODE_GO_API_KEY"):
|
||||
detected_providers.add("opencode-go")
|
||||
|
||||
# 3. Fetch models from custom endpoint if base_url is configured
|
||||
# 3. Auto-detect LM Studio if it's running on localhost:1234 (no API key needed).
|
||||
# This ensures lmstudio appears in the dropdown even when authenticated=False.
|
||||
# Also auto-detect Ollama on localhost:11434.
|
||||
for _host, _port, _pid in [("localhost", 1234, "lmstudio"), ("localhost", 11434, "ollama")]:
|
||||
if _pid not in detected_providers:
|
||||
import socket as _socket
|
||||
try:
|
||||
with _socket.create_connection((_host, _port), timeout=2):
|
||||
detected_providers.add(_pid)
|
||||
logger.debug("Auto-detected %s on %s:%d", _pid, _host, _port)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3b. Fetch models from custom endpoint if base_url is configured
|
||||
auto_detected_models = []
|
||||
if cfg_base_url:
|
||||
try:
|
||||
@@ -1005,37 +967,18 @@ def get_available_models() -> dict:
|
||||
# 3b. Include models from custom_providers config entries.
|
||||
# These are explicitly configured and should always appear even when the
|
||||
# /v1/models endpoint is unreachable or returns a subset.
|
||||
#
|
||||
# Each entry may carry a `name` field (e.g. "Agent37"). When present we
|
||||
# use it as the dropdown section header instead of the generic "Custom"
|
||||
# label. Internally we key these providers as "custom:<slug>" so that
|
||||
# multiple named custom providers can coexist as separate groups.
|
||||
_custom_providers_cfg = cfg.get("custom_providers", [])
|
||||
# Maps "custom:<slug>" -> (display_name, [model_dicts])
|
||||
_named_custom_groups: dict = {}
|
||||
if isinstance(_custom_providers_cfg, list):
|
||||
_seen_custom_ids = {m["id"] for m in auto_detected_models}
|
||||
for _cp in _custom_providers_cfg:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_model = _cp.get("model", "")
|
||||
_cp_name = (_cp.get("name") or "").strip()
|
||||
if _cp_model and _cp_model not in _seen_custom_ids:
|
||||
_cp_label = _cp_model.split("/")[-1] if "/" in _cp_model else _cp_model
|
||||
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
|
||||
_seen_custom_ids.add(_cp_model)
|
||||
if _cp_name:
|
||||
# Named custom provider — own group keyed by slug
|
||||
_slug = "custom:" + _cp_name.lower().replace(" ", "-")
|
||||
if _slug not in _named_custom_groups:
|
||||
_named_custom_groups[_slug] = (_cp_name, [])
|
||||
detected_providers.add(_slug)
|
||||
_named_custom_groups[_slug][1].append(
|
||||
{"id": _cp_model, "label": _cp_label}
|
||||
)
|
||||
else:
|
||||
# Unnamed — falls into the generic "Custom" bucket
|
||||
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
|
||||
detected_providers.add("custom")
|
||||
detected_providers.add("custom")
|
||||
|
||||
# If the user configured a real model.provider, the base_url belongs to
|
||||
# THAT provider, not to a separate "Custom" group. hermes_cli reports
|
||||
@@ -1047,34 +990,10 @@ def get_available_models() -> dict:
|
||||
_has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
|
||||
if active_provider and active_provider != "custom" and not _has_custom_providers:
|
||||
detected_providers.discard("custom")
|
||||
# Also drop named custom slugs when active provider is a real named one
|
||||
# and there are no custom_providers entries to show.
|
||||
for _slug in list(detected_providers):
|
||||
if _slug.startswith("custom:") and not _has_custom_providers:
|
||||
detected_providers.discard(_slug)
|
||||
elif active_provider == "custom" and _has_custom_providers:
|
||||
# When the active provider is 'custom' and all custom_providers entries
|
||||
# are named (i.e. every entry produced a "custom:<slug>" key), the bare
|
||||
# "custom" bucket is empty noise — discard it so the dropdown only shows
|
||||
# the named groups. We keep "custom" if there are unnamed entries (they
|
||||
# were added to auto_detected_models and will render under the generic
|
||||
# "Custom" header via the else branch in the group builder).
|
||||
_has_unnamed = any(
|
||||
isinstance(_cp, dict) and not (_cp.get("name") or "").strip()
|
||||
for _cp in _custom_providers_cfg
|
||||
)
|
||||
if not _has_unnamed:
|
||||
detected_providers.discard("custom")
|
||||
|
||||
# 5. Build model groups
|
||||
if detected_providers:
|
||||
for pid in sorted(detected_providers):
|
||||
if pid.startswith("custom:") and pid in _named_custom_groups:
|
||||
# Named custom provider — use the stored display name and its own model list
|
||||
_nc_display, _nc_models = _named_custom_groups[pid]
|
||||
if _nc_models:
|
||||
groups.append({"provider": _nc_display, "models": _nc_models})
|
||||
continue
|
||||
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
|
||||
if pid == "openrouter":
|
||||
# OpenRouter uses provider/model format -- show the fallback list
|
||||
@@ -1087,29 +1006,41 @@ def get_available_models() -> dict:
|
||||
],
|
||||
}
|
||||
)
|
||||
elif pid in _PROVIDER_MODELS or pid in cfg.get("providers", {}):
|
||||
elif pid in _PROVIDER_MODELS:
|
||||
# For non-default providers, prefix model IDs with @provider:model
|
||||
# so resolve_model_provider() routes through that specific provider
|
||||
# via resolve_runtime_provider(requested=provider).
|
||||
# The default provider's models keep bare names for direct API routing.
|
||||
raw_models = _PROVIDER_MODELS.get(pid, [])
|
||||
|
||||
# Override or merge from config.yaml if user specified explicit models
|
||||
provider_cfg = cfg.get("providers", {}).get(pid, {})
|
||||
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
|
||||
cfg_models = provider_cfg["models"]
|
||||
if isinstance(cfg_models, dict):
|
||||
# config format is usually models: { "gpt-5.4": { context_length: ... } }
|
||||
raw_models = [{"id": k, "label": k} for k in cfg_models.keys()]
|
||||
elif isinstance(cfg_models, list):
|
||||
raw_models = [{"id": k, "label": k} for k in cfg_models]
|
||||
raw_models = _PROVIDER_MODELS[pid]
|
||||
# Special case: _PROVIDER_MODELS[pid] is an empty list but we may have
|
||||
# live models available (e.g. lmstudio on localhost:1234). Try to fetch
|
||||
# them via the agent's provider_model_ids() before falling back to [].
|
||||
if not raw_models and pid in ("lmstudio",):
|
||||
try:
|
||||
import sys as _sys, os as _os
|
||||
_agent_dir = os.getenv(
|
||||
"HERMES_WEBUI_AGENT_DIR",
|
||||
str(HOME / ".hermes" / "hermes-agent"),
|
||||
)
|
||||
_agent_dir = Path(_agent_dir).expanduser().resolve()
|
||||
if _agent_dir not in _sys.path:
|
||||
_sys.path.insert(0, str(_agent_dir))
|
||||
from hermes_cli.models import provider_model_ids as _pmi
|
||||
_live_ids = _pmi(pid)
|
||||
if _live_ids:
|
||||
raw_models = [{"id": mid, "label": mid.split("/")[-1] if "/" in mid else mid} for mid in _live_ids]
|
||||
logger.debug("Live models fetched for %s: %s", pid, _live_ids)
|
||||
except Exception as _e:
|
||||
logger.debug("Could not fetch live models for %s: %s", pid, _e)
|
||||
_active = (active_provider or "").lower()
|
||||
if _active and pid != _active:
|
||||
models = []
|
||||
for m in raw_models:
|
||||
mid = m["id"]
|
||||
# Don't double-prefix; use @provider: hint for bare names
|
||||
if mid.startswith("@") or "/" in mid:
|
||||
# Don't double-prefix if already @provider: tagged.
|
||||
# NOTE: "/" in model name does NOT mean it's a provider/model path —
|
||||
# local providers like LM Studio use "owner/model" names (e.g. "qwen/qwen3.5-35b-a3b").
|
||||
if mid.startswith("@"):
|
||||
models.append({"id": mid, "label": m["label"]})
|
||||
else:
|
||||
models.append({"id": f"@{pid}:{mid}", "label": m["label"]})
|
||||
@@ -1123,9 +1054,7 @@ def get_available_models() -> dict:
|
||||
)
|
||||
else:
|
||||
# Unknown provider -- use auto-detected models if available,
|
||||
# otherwise skip it for the model dropdown. Do NOT inject the
|
||||
# global default_model here: that would incorrectly imply the
|
||||
# provider can serve the default model (e.g. Alibaba -> gpt-5.4-mini).
|
||||
# otherwise fall back to default model placeholder
|
||||
if auto_detected_models:
|
||||
groups.append(
|
||||
{
|
||||
@@ -1133,15 +1062,26 @@ def get_available_models() -> dict:
|
||||
"models": auto_detected_models,
|
||||
}
|
||||
)
|
||||
else:
|
||||
groups.append(
|
||||
{
|
||||
"provider": provider_name,
|
||||
"models": [
|
||||
{
|
||||
"id": default_model,
|
||||
"label": default_model.split("/")[-1],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No providers detected. Show only the configured default model so the user
|
||||
# can at least send messages with their current setting. Avoid showing a
|
||||
# generic multi-provider list — those models wouldn't be routable anyway.
|
||||
if default_model:
|
||||
label = default_model.split("/")[-1] if "/" in default_model else default_model
|
||||
groups.append(
|
||||
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
|
||||
)
|
||||
label = default_model.split("/")[-1] if "/" in default_model else default_model
|
||||
groups.append(
|
||||
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
|
||||
)
|
||||
|
||||
# Ensure the user's configured default_model always appears in the dropdown.
|
||||
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
|
||||
@@ -1175,14 +1115,7 @@ def get_available_models() -> dict:
|
||||
injected = True
|
||||
break
|
||||
if not injected and groups:
|
||||
# Keep the default isolated rather than polluting the first
|
||||
# detected provider group.
|
||||
groups.append(
|
||||
{
|
||||
"provider": "Default",
|
||||
"models": [{"id": default_model, "label": label}],
|
||||
}
|
||||
)
|
||||
groups[0]["models"].insert(0, {"id": default_model, "label": label})
|
||||
elif not groups:
|
||||
groups.append(
|
||||
{
|
||||
@@ -1246,8 +1179,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
|
||||
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
|
||||
"check_for_updates": True, # check if webui/agent repos are behind upstream
|
||||
"theme": "dark", # light | dark | system
|
||||
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
|
||||
"theme": "dark", # active UI theme name (no enum gate -- allows custom themes)
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
"bot_name": os.getenv(
|
||||
"HERMES_WEBUI_BOT_NAME", "Hermes"
|
||||
@@ -1258,76 +1190,12 @@ _SETTINGS_DEFAULTS = {
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
|
||||
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
|
||||
_SETTINGS_SKIN_VALUES = {
|
||||
"default",
|
||||
"ares",
|
||||
"mono",
|
||||
"slate",
|
||||
"poseidon",
|
||||
"sisyphus",
|
||||
"charizard",
|
||||
}
|
||||
_SETTINGS_LEGACY_THEME_MAP = {
|
||||
# Legacy full themes now map onto the closest supported theme + accent skin pair.
|
||||
"slate": ("dark", "slate"),
|
||||
"solarized": ("dark", "poseidon"),
|
||||
"monokai": ("dark", "sisyphus"),
|
||||
"nord": ("dark", "slate"),
|
||||
"oled": ("dark", "default"),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_appearance(theme, skin) -> tuple[str, str]:
|
||||
"""Normalize a (theme, skin) pair, migrating legacy theme names.
|
||||
|
||||
Legacy migration table (from `_SETTINGS_LEGACY_THEME_MAP`):
|
||||
|
||||
slate → ("dark", "slate")
|
||||
solarized → ("dark", "poseidon")
|
||||
monokai → ("dark", "sisyphus")
|
||||
nord → ("dark", "slate")
|
||||
oled → ("dark", "default")
|
||||
|
||||
Unknown / custom theme names fall back to ("dark", "default"). This is a
|
||||
behavior change vs. the pre-PR-#627 state, where the `theme` field was
|
||||
open-ended ("no enum gate -- allows custom themes"). Users who set a
|
||||
custom CSS theme via `data-theme` will need to re-apply via skin or
|
||||
custom CSS — see CHANGELOG entry for details.
|
||||
|
||||
The same mapping is mirrored in `static/boot.js` (`_LEGACY_THEME_MAP`)
|
||||
so client and server normalize identically; keep them in sync.
|
||||
"""
|
||||
raw_theme = theme.strip().lower() if isinstance(theme, str) else ""
|
||||
raw_skin = skin.strip().lower() if isinstance(skin, str) else ""
|
||||
legacy = _SETTINGS_LEGACY_THEME_MAP.get(raw_theme)
|
||||
if legacy:
|
||||
next_theme, legacy_skin = legacy
|
||||
elif raw_theme in _SETTINGS_THEME_VALUES:
|
||||
next_theme, legacy_skin = raw_theme, "default"
|
||||
else:
|
||||
# Unknown themes used to exist; default to dark so upgrades stay visually stable.
|
||||
next_theme, legacy_skin = "dark", "default"
|
||||
next_skin = (
|
||||
raw_skin
|
||||
if raw_skin in _SETTINGS_SKIN_VALUES
|
||||
else legacy_skin
|
||||
)
|
||||
return next_theme, next_skin
|
||||
|
||||
|
||||
def load_settings() -> dict:
|
||||
"""Load settings from disk, merging with defaults for any missing keys."""
|
||||
settings = dict(_SETTINGS_DEFAULTS)
|
||||
stored = None
|
||||
try:
|
||||
settings_exists = SETTINGS_FILE.exists()
|
||||
except OSError:
|
||||
# PermissionError or other OS-level error (e.g. UID mismatch in Docker)
|
||||
# Treat as missing — start with defaults rather than crashing.
|
||||
logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE)
|
||||
settings_exists = False
|
||||
if settings_exists:
|
||||
if SETTINGS_FILE.exists():
|
||||
try:
|
||||
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(stored, dict):
|
||||
@@ -1340,10 +1208,6 @@ def load_settings() -> dict:
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
|
||||
settings["theme"], settings["skin"] = _normalize_appearance(
|
||||
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
|
||||
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
@@ -1368,10 +1232,6 @@ _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8}
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
current = load_settings()
|
||||
pending_theme = current.get("theme")
|
||||
pending_skin = current.get("skin")
|
||||
theme_was_explicit = False
|
||||
skin_was_explicit = False
|
||||
# Handle _set_password: hash and store as password_hash
|
||||
raw_pw = settings.pop("_set_password", None)
|
||||
if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
|
||||
@@ -1384,16 +1244,6 @@ def save_settings(settings: dict) -> dict:
|
||||
current["password_hash"] = None
|
||||
for k, v in settings.items():
|
||||
if k in _SETTINGS_ALLOWED_KEYS:
|
||||
if k == "theme":
|
||||
if isinstance(v, str) and v.strip():
|
||||
pending_theme = v
|
||||
theme_was_explicit = True
|
||||
continue
|
||||
if k == "skin":
|
||||
if isinstance(v, str) and v.strip():
|
||||
pending_skin = v
|
||||
skin_was_explicit = True
|
||||
continue
|
||||
# Validate enum-constrained keys
|
||||
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
|
||||
continue
|
||||
@@ -1406,13 +1256,6 @@ def save_settings(settings: dict) -> dict:
|
||||
if k in _SETTINGS_BOOL_KEYS:
|
||||
v = bool(v)
|
||||
current[k] = v
|
||||
theme_value = pending_theme
|
||||
skin_value = pending_skin
|
||||
if theme_was_explicit and not skin_was_explicit:
|
||||
raw_theme = pending_theme.strip().lower() if isinstance(pending_theme, str) else ""
|
||||
if raw_theme not in _SETTINGS_THEME_VALUES:
|
||||
skin_value = None
|
||||
current["theme"], current["skin"] = _normalize_appearance(theme_value, skin_value)
|
||||
|
||||
current["default_workspace"] = str(
|
||||
resolve_default_workspace(current.get("default_workspace"))
|
||||
@@ -1431,22 +1274,13 @@ def save_settings(settings: dict) -> dict:
|
||||
|
||||
|
||||
# Apply saved settings on startup (override env-derived defaults)
|
||||
# Exception: if HERMES_WEBUI_DEFAULT_WORKSPACE is explicitly set in the
|
||||
# environment, it wins over whatever settings.json has stored. Persisted
|
||||
# config must never shadow an explicit env-var override (Docker deployments
|
||||
# rely on this — otherwise deleting settings.json is the only escape).
|
||||
_startup_settings = load_settings()
|
||||
try:
|
||||
_settings_file_exists = SETTINGS_FILE.exists()
|
||||
except OSError:
|
||||
_settings_file_exists = False
|
||||
if _settings_file_exists:
|
||||
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")
|
||||
)
|
||||
DEFAULT_WORKSPACE = resolve_default_workspace(
|
||||
_startup_settings.get("default_workspace")
|
||||
)
|
||||
if _startup_settings.get("default_workspace") != str(DEFAULT_WORKSPACE):
|
||||
_startup_settings["default_workspace"] = str(DEFAULT_WORKSPACE)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user