Files
hermes-agent/gateway/web_console/api/models_api.py
Alexander Whitestone 8e0f24db3f
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
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
2026-04-13 18:01:51 -04:00

236 lines
8.5 KiB
Python

"""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)