refactor(web_server_config): _select() builder for schema overrides; small defensive-layer collapses
CONFIG_SCHEMA / _SCHEMA_OVERRIDES JSON md5-identical to base (key order kept). tr_hug layout pass on all three modules (ast.dump-verified).
This commit is contained in:
@@ -55,19 +55,17 @@ def _timezone_options() -> List[str]:
|
||||
return ["UTC"]
|
||||
|
||||
|
||||
def _select(description: str, *options: str, **extra: Any) -> Dict[str, Any]:
|
||||
return {"type": "select", "description": description, "options": list(options), **extra}
|
||||
|
||||
|
||||
# Manual overrides for fields that need select options or custom types.
|
||||
_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"timezone": {
|
||||
"type": "select",
|
||||
"description": "IANA timezone (e.g. America/New_York). Blank uses the system timezone.",
|
||||
"options": _timezone_options(),
|
||||
"searchable": True,
|
||||
"clearable": True,
|
||||
},
|
||||
"memory.provider": {
|
||||
"type": "select",
|
||||
"description": "Memory provider plugin",
|
||||
"options": _memory_provider_options(),
|
||||
},
|
||||
"timezone": _select(
|
||||
"IANA timezone (e.g. America/New_York). Blank uses the system timezone.",
|
||||
*_timezone_options(), searchable=True, clearable=True,
|
||||
),
|
||||
"memory.provider": _select("Memory provider plugin", *_memory_provider_options()),
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
|
||||
@@ -78,21 +76,13 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"description": "Context window override (0 = auto-detect from model metadata)",
|
||||
"category": "general",
|
||||
},
|
||||
"terminal.backend": {
|
||||
"type": "select",
|
||||
"description": "Terminal execution backend",
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"],
|
||||
},
|
||||
"terminal.vercel_runtime": {
|
||||
"type": "select",
|
||||
"description": "Vercel Sandbox runtime",
|
||||
"options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
|
||||
},
|
||||
"terminal.modal_mode": {
|
||||
"type": "select",
|
||||
"description": "Modal sandbox mode",
|
||||
"options": ["sandbox", "function"],
|
||||
},
|
||||
"terminal.backend": _select(
|
||||
"Terminal execution backend",
|
||||
"local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity",
|
||||
),
|
||||
# sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
|
||||
"terminal.vercel_runtime": _select("Vercel Sandbox runtime", "node24", "node22", "python3.13"),
|
||||
"terminal.modal_mode": _select("Modal sandbox mode", "sandbox", "function"),
|
||||
"proxy.enabled": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
@@ -101,109 +91,54 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
),
|
||||
"category": "security",
|
||||
},
|
||||
"proxy.credential_source": {
|
||||
"type": "select",
|
||||
"description": "Where iron-proxy loads real upstream secrets at start time",
|
||||
"options": ["env", "bitwarden"],
|
||||
"category": "security",
|
||||
},
|
||||
"proxy.credential_source": _select(
|
||||
"Where iron-proxy loads real upstream secrets at start time", "env", "bitwarden", category="security"
|
||||
),
|
||||
"proxy.enforce_on_docker": {
|
||||
"type": "boolean",
|
||||
"description": "Refuse Docker sandboxes when egress is enabled but not configured/running",
|
||||
"category": "security",
|
||||
},
|
||||
"tts.provider": {
|
||||
"type": "select",
|
||||
"description": "Text-to-speech provider",
|
||||
"options": ["edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "gemini", "neutts", "kittentts", "piper"],
|
||||
},
|
||||
"stt.provider": {
|
||||
"type": "select",
|
||||
"description": "Speech-to-text provider",
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"options": ["local", "groq", "openai", "xai", "elevenlabs"],
|
||||
},
|
||||
"stt.local.model": {
|
||||
"type": "select",
|
||||
"description": "Local faster-whisper model size",
|
||||
"options": ["tiny", "base", "small", "medium", "large-v3"],
|
||||
},
|
||||
"stt.groq.model": {
|
||||
"type": "select",
|
||||
"description": "Groq Whisper model",
|
||||
"options": ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"],
|
||||
},
|
||||
"stt.openai.model": {
|
||||
"type": "select",
|
||||
"description": "OpenAI transcription model",
|
||||
"options": ["whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"],
|
||||
},
|
||||
"stt.elevenlabs.model_id": {
|
||||
"type": "select",
|
||||
"description": "ElevenLabs Scribe model",
|
||||
"options": ["scribe_v2", "scribe_v1"],
|
||||
},
|
||||
"display.skin": {
|
||||
"type": "select",
|
||||
"description": "CLI visual theme",
|
||||
"options": ["default", "ares", "mono", "slate"],
|
||||
},
|
||||
"dashboard.theme": {
|
||||
"type": "select",
|
||||
"description": "Web dashboard visual theme",
|
||||
"options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"],
|
||||
},
|
||||
"display.resume_display": {
|
||||
"type": "select",
|
||||
"description": "How resumed sessions display history",
|
||||
"options": ["minimal", "full", "off"],
|
||||
},
|
||||
"display.busy_input_mode": {
|
||||
"type": "select",
|
||||
"description": "Input behavior while agent is running",
|
||||
"options": ["interrupt", "queue", "steer"],
|
||||
},
|
||||
"approvals.mode": {
|
||||
"type": "select",
|
||||
"description": "Dangerous command approval mode",
|
||||
"options": ["manual", "smart", "off"],
|
||||
},
|
||||
"context.engine": {
|
||||
"type": "select",
|
||||
"description": "Context management engine",
|
||||
"options": ["default", "custom"],
|
||||
},
|
||||
"human_delay.mode": {
|
||||
"type": "select",
|
||||
"description": "Simulated typing delay mode",
|
||||
"options": ["off", "typing", "fixed"],
|
||||
},
|
||||
"logging.level": {
|
||||
"type": "select",
|
||||
"description": "Log level for agent.log",
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
},
|
||||
"agent.service_tier": {
|
||||
"type": "select",
|
||||
"description": "Fast mode: fast = always, auto = first N seconds of each turn, cold = first turn only",
|
||||
"options": ["", "normal", "fast", "auto", "cold"],
|
||||
},
|
||||
"delegation.reasoning_effort": {
|
||||
"type": "select",
|
||||
"description": "Reasoning effort for delegated subagents",
|
||||
"options": ["", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
},
|
||||
"updates.non_interactive_local_changes": {
|
||||
"type": "select",
|
||||
"description": (
|
||||
"When the chat app / gateway updates Hermes (no terminal prompt), "
|
||||
"what to do with uncommitted local source edits. 'stash' keeps them "
|
||||
"and re-applies them after the update; 'discard' throws them away. "
|
||||
"Terminal updates always ask, regardless of this setting."
|
||||
),
|
||||
"options": ["stash", "discard"],
|
||||
},
|
||||
"tts.provider": _select(
|
||||
"Text-to-speech provider",
|
||||
"edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "gemini", "neutts", "kittentts", "piper",
|
||||
),
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"stt.provider": _select("Speech-to-text provider", "local", "groq", "openai", "xai", "elevenlabs"),
|
||||
"stt.local.model": _select("Local faster-whisper model size", "tiny", "base", "small", "medium", "large-v3"),
|
||||
"stt.groq.model": _select(
|
||||
"Groq Whisper model", "whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"
|
||||
),
|
||||
"stt.openai.model": _select(
|
||||
"OpenAI transcription model", "whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"
|
||||
),
|
||||
"stt.elevenlabs.model_id": _select("ElevenLabs Scribe model", "scribe_v2", "scribe_v1"),
|
||||
"display.skin": _select("CLI visual theme", "default", "ares", "mono", "slate"),
|
||||
"dashboard.theme": _select(
|
||||
"Web dashboard visual theme", "default", "midnight", "ember", "mono", "cyberpunk", "rose"
|
||||
),
|
||||
"display.resume_display": _select("How resumed sessions display history", "minimal", "full", "off"),
|
||||
"display.busy_input_mode": _select("Input behavior while agent is running", "interrupt", "queue", "steer"),
|
||||
"approvals.mode": _select("Dangerous command approval mode", "manual", "smart", "off"),
|
||||
"context.engine": _select("Context management engine", "default", "custom"),
|
||||
"human_delay.mode": _select("Simulated typing delay mode", "off", "typing", "fixed"),
|
||||
"logging.level": _select("Log level for agent.log", "DEBUG", "INFO", "WARNING", "ERROR"),
|
||||
"agent.service_tier": _select(
|
||||
"Fast mode: fast = always, auto = first N seconds of each turn, cold = first turn only",
|
||||
"", "normal", "fast", "auto", "cold",
|
||||
),
|
||||
"delegation.reasoning_effort": _select(
|
||||
"Reasoning effort for delegated subagents",
|
||||
"", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
|
||||
),
|
||||
"updates.non_interactive_local_changes": _select(
|
||||
"When the chat app / gateway updates Hermes (no terminal prompt), "
|
||||
"what to do with uncommitted local source edits. 'stash' keeps them "
|
||||
"and re-applies them after the update; 'discard' throws them away. "
|
||||
"Terminal updates always ask, regardless of this setting.",
|
||||
"stash", "discard",
|
||||
),
|
||||
"updates.refresh_cua_driver": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
@@ -342,13 +277,10 @@ def _custom_provider_options(kind: str, builtin_names: List[str], cfg: Dict[str,
|
||||
from tools.transcription_tools import BUILTIN_STT_PROVIDERS as _runtime_builtins
|
||||
|
||||
def _add(name: Any) -> None:
|
||||
if not isinstance(name, str):
|
||||
return
|
||||
stripped = name.strip()
|
||||
key = stripped.lower()
|
||||
if stripped and key not in seen:
|
||||
stripped = name.strip() if isinstance(name, str) else ""
|
||||
if stripped and stripped.lower() not in seen:
|
||||
names.append(stripped)
|
||||
seen.add(key)
|
||||
seen.add(stripped.lower())
|
||||
|
||||
section = cfg.get(kind)
|
||||
if not isinstance(section, dict):
|
||||
@@ -474,12 +406,11 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
user_providers = cfg.get("providers")
|
||||
user_provider = resolve_user_provider(prov_in, user_providers if isinstance(user_providers, dict) else {})
|
||||
custom_provider = resolve_custom_provider(prov_in, get_compatible_custom_providers(cfg))
|
||||
if user_provider is not None:
|
||||
return user_provider.id, model_in
|
||||
if custom_provider is not None:
|
||||
return custom_provider.id, model_in
|
||||
declared = resolve_user_provider(
|
||||
prov_in, user_providers if isinstance(user_providers, dict) else {}
|
||||
) or resolve_custom_provider(prov_in, get_compatible_custom_providers(cfg))
|
||||
if declared is not None:
|
||||
return declared.id, model_in
|
||||
|
||||
is_custom_provider_slug = canonical == "custom" or canonical.startswith("custom:")
|
||||
if canonical not in _KNOWN_PROVIDER_NAMES and not is_custom_provider_slug and "/" in model_in:
|
||||
@@ -736,12 +667,14 @@ def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str,
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
|
||||
def _slot(slot: str) -> dict:
|
||||
slot_cfg = aux.get(slot)
|
||||
return slot_cfg if isinstance(slot_cfg, dict) else {}
|
||||
|
||||
if task == "__reset__":
|
||||
# Reset every slot to provider="auto", model="" — keeps other fields intact.
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg = _slot(slot)
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
slot_cfg.pop("base_url", None)
|
||||
@@ -759,9 +692,7 @@ def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str,
|
||||
for slot in targets:
|
||||
if slot not in _AUX_TASK_SLOTS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg = _slot(slot)
|
||||
prev_provider = str(slot_cfg.get("provider") or "").strip().lower()
|
||||
slot_cfg["provider"] = provider
|
||||
slot_cfg["model"] = model
|
||||
|
||||
@@ -240,17 +240,16 @@ def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0)
|
||||
return {"hex": default_hex, "alpha": default_alpha}
|
||||
if isinstance(value, str):
|
||||
return {"hex": value, "alpha": default_alpha}
|
||||
if isinstance(value, dict):
|
||||
hex_val = value.get("hex", default_hex)
|
||||
alpha_val = value.get("alpha", default_alpha)
|
||||
if not isinstance(hex_val, str):
|
||||
return None
|
||||
try:
|
||||
alpha_f = float(alpha_val)
|
||||
except (TypeError, ValueError):
|
||||
alpha_f = default_alpha
|
||||
return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))}
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
hex_val = value.get("hex", default_hex)
|
||||
if not isinstance(hex_val, str):
|
||||
return None
|
||||
try:
|
||||
alpha_f = float(value.get("alpha", default_alpha))
|
||||
except (TypeError, ValueError):
|
||||
alpha_f = default_alpha
|
||||
return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))}
|
||||
|
||||
|
||||
_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = {
|
||||
@@ -261,8 +260,7 @@ _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = {
|
||||
"letterSpacing": "0",
|
||||
}
|
||||
_THEME_DEFAULT_LAYOUT: Dict[str, str] = {
|
||||
"radius": "0.5rem",
|
||||
"density": "comfortable",
|
||||
"radius": "0.5rem", "density": "comfortable"
|
||||
}
|
||||
_THEME_OVERRIDE_KEYS = {
|
||||
"card", "cardForeground", "popover", "popoverForeground",
|
||||
@@ -278,8 +276,7 @@ _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"}
|
||||
# Component-style buckets: each camelCase property under a bucket emits
|
||||
# ``--component-<bucket>-<kebab-property>`` on :root, consumed by shell components.
|
||||
_THEME_COMPONENT_BUCKETS = {
|
||||
"card", "header", "footer", "sidebar", "tab",
|
||||
"progress", "badge", "backdrop", "page",
|
||||
"card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page"
|
||||
}
|
||||
_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"}
|
||||
|
||||
|
||||
@@ -99,11 +99,8 @@ def _memory_provider_import_name(dep: str) -> str:
|
||||
|
||||
def _dependency_importable(dep: str) -> bool:
|
||||
import_name = _memory_provider_import_name(dep)
|
||||
if not import_name:
|
||||
return False
|
||||
try:
|
||||
__import__(import_name)
|
||||
return True
|
||||
return bool(import_name) and __import__(import_name) is not None
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@@ -216,10 +213,8 @@ def _normalize_memory_provider_schema(name: str, provider: Any) -> List[Dict[str
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
except Exception:
|
||||
_log.debug("Failed to read JSON config from %s", path, exc_info=True)
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user