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
236 lines
8.5 KiB
Python
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)
|