feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui. React + TypeScript frontend with Vite, Python aiohttp backend API. Components: - web_console/ — React frontend (chat, sessions, memory, settings, skills, gateway config, cron, workspace, tools, browser, insights pages) - gateway/web_console/ — Python backend API (23 endpoints, SSE event bus, 11 service modules) - gateway/platforms/api_server_ui.py — embedded browser UI for API server - gateway/platforms/api_server.py — route registration refactored into _register_routes(), web console mounted via maybe_register_web_console() - run-gui.sh / setup-gui.sh — one-command launch and setup scripts - tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing) - tests/web_console/ — 13 backend test files (51 passing) - docs/plans/ — implementation plan, API schema, frontend architecture Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to cron/scheduler.py (pre-existing import bug). Closes #325
This commit is contained in:
235
gateway/web_console/api/models_api.py
Normal file
235
gateway/web_console/api/models_api.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Models API routes for the Hermes Web Console backend.
|
||||
|
||||
Provides:
|
||||
GET /api/gui/models/catalog — authenticated providers with curated models
|
||||
GET /api/gui/models/active — currently active model + provider
|
||||
POST /api/gui/models/switch — live model/provider switch (session or global)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_model_config() -> dict:
|
||||
"""Read model/provider/base_url/providers from config.yaml."""
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
result = {
|
||||
"model": "",
|
||||
"provider": "openrouter",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"user_providers": None,
|
||||
}
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
result["model"] = model_cfg.get("name", "") or model_cfg.get("default", "")
|
||||
result["provider"] = model_cfg.get("provider", "openrouter")
|
||||
result["base_url"] = model_cfg.get("base_url", "")
|
||||
elif isinstance(model_cfg, str):
|
||||
result["model"] = model_cfg
|
||||
result["user_providers"] = cfg.get("providers")
|
||||
except Exception:
|
||||
logger.debug("Failed to read model config", exc_info=True)
|
||||
return result
|
||||
|
||||
|
||||
async def handle_get_models_catalog(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/models/catalog — authenticated providers with curated models."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
from hermes_cli.providers import get_label
|
||||
|
||||
cfg = _read_model_config()
|
||||
current_model = cfg["model"]
|
||||
current_provider = cfg["provider"]
|
||||
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=cfg["user_providers"],
|
||||
max_models=20,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("list_authenticated_providers failed: %s", exc)
|
||||
providers = []
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"current_model": current_model,
|
||||
"current_provider": current_provider,
|
||||
"current_provider_label": get_label(current_provider),
|
||||
"providers": providers,
|
||||
})
|
||||
|
||||
|
||||
async def handle_get_models_active(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/models/active — current model + provider + metadata."""
|
||||
from hermes_cli.providers import get_label
|
||||
from agent.models_dev import get_model_info, get_model_capabilities
|
||||
|
||||
cfg = _read_model_config()
|
||||
current_model = cfg["model"]
|
||||
current_provider = cfg["provider"]
|
||||
provider_label = get_label(current_provider)
|
||||
|
||||
# Try to get rich metadata from models.dev
|
||||
metadata: dict = {}
|
||||
try:
|
||||
mi = get_model_info(current_provider, current_model)
|
||||
if mi:
|
||||
metadata["context_window"] = mi.context_window or 0
|
||||
metadata["max_output"] = mi.max_output or 0
|
||||
if mi.has_cost_data():
|
||||
metadata["cost"] = mi.format_cost()
|
||||
metadata["capabilities"] = mi.format_capabilities()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"model": current_model,
|
||||
"provider": current_provider,
|
||||
"provider_label": provider_label,
|
||||
**metadata,
|
||||
})
|
||||
|
||||
|
||||
async def handle_post_models_switch(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/models/switch — live model/provider switch."""
|
||||
from hermes_cli.model_switch import switch_model as _switch_model
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
|
||||
|
||||
model_input = body.get("model", "").strip()
|
||||
explicit_provider = body.get("provider", "").strip()
|
||||
persist_global = body.get("global", False)
|
||||
|
||||
if not model_input and not explicit_provider:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Provide 'model' and/or 'provider'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
cfg = _read_model_config()
|
||||
|
||||
result = _switch_model(
|
||||
raw_input=model_input,
|
||||
current_provider=cfg["provider"],
|
||||
current_model=cfg["model"],
|
||||
current_base_url=cfg["base_url"],
|
||||
current_api_key=cfg["api_key"],
|
||||
is_global=persist_global,
|
||||
explicit_provider=explicit_provider,
|
||||
user_providers=cfg["user_providers"],
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": result.error_message,
|
||||
}, status=400)
|
||||
|
||||
# Validate provider compatibility (specifically for Codex)
|
||||
codex_compatible = ("openai", "openai-codex", "custom")
|
||||
# Guard 1: Currently on Codex — new model must be OpenAI-compatible
|
||||
if cfg.get("provider") == "openai-codex":
|
||||
if result.target_provider not in codex_compatible:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": f"The '{result.new_model}' model is not supported when using Codex with a ChatGPT account. Please select an OpenAI model or change your active provider to {result.target_provider}."
|
||||
}, status=400)
|
||||
# Guard 2: Switching TO Codex — resolved model must be OpenAI-compatible
|
||||
if result.target_provider == "openai-codex":
|
||||
if cfg.get("provider") not in codex_compatible and result.target_provider not in codex_compatible:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": f"Cannot switch to Codex with model '{result.new_model}' (resolved provider: {result.target_provider}). Only OpenAI models are compatible with Codex."
|
||||
}, status=400)
|
||||
|
||||
# Persist to config.yaml if global
|
||||
if persist_global:
|
||||
try:
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
file_cfg = yaml.safe_load(f) or {}
|
||||
else:
|
||||
file_cfg = {}
|
||||
|
||||
# Ensure model is a dict before setting properties
|
||||
model_cfg = file_cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
model_cfg = {"default": model_cfg}
|
||||
elif not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
|
||||
model_cfg["default"] = result.new_model
|
||||
model_cfg["provider"] = result.target_provider
|
||||
model_cfg["name"] = result.new_model # Keep name for backward compatibility in UI
|
||||
if result.base_url:
|
||||
model_cfg["base_url"] = result.base_url
|
||||
|
||||
file_cfg["model"] = model_cfg
|
||||
|
||||
from hermes_cli.config import save_config
|
||||
save_config(file_cfg)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist model switch: %s", exc)
|
||||
|
||||
# Build rich response
|
||||
response: dict = {
|
||||
"ok": True,
|
||||
"new_model": result.new_model,
|
||||
"provider": result.target_provider,
|
||||
"provider_label": result.provider_label or result.target_provider,
|
||||
"provider_changed": result.provider_changed,
|
||||
"is_global": persist_global,
|
||||
}
|
||||
|
||||
# Add model metadata
|
||||
mi = result.model_info
|
||||
if mi:
|
||||
if mi.context_window:
|
||||
response["context_window"] = mi.context_window
|
||||
if mi.max_output:
|
||||
response["max_output"] = mi.max_output
|
||||
if mi.has_cost_data():
|
||||
response["cost"] = mi.format_cost()
|
||||
response["capabilities"] = mi.format_capabilities()
|
||||
|
||||
# Cache status
|
||||
cache_enabled = (
|
||||
("openrouter" in (result.base_url or "").lower()
|
||||
and "claude" in result.new_model.lower())
|
||||
or result.api_mode == "anthropic_messages"
|
||||
)
|
||||
response["cache_enabled"] = cache_enabled
|
||||
|
||||
if result.warning_message:
|
||||
response["warning"] = result.warning_message
|
||||
|
||||
if result.resolved_via_alias:
|
||||
response["resolved_via_alias"] = result.resolved_via_alias
|
||||
|
||||
return web.json_response(response)
|
||||
|
||||
|
||||
def register_models_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/models/catalog", handle_get_models_catalog)
|
||||
app.router.add_get("/api/gui/models/active", handle_get_models_active)
|
||||
app.router.add_post("/api/gui/models/switch", handle_post_models_switch)
|
||||
Reference in New Issue
Block a user