diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 7f4c8e8d6..143961527 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2,6 +2,11 @@ OpenAI-compatible API server platform adapter. Exposes an HTTP server with endpoints: +- GET / — Hermes Web Console operator cockpit +- GET /api/gui/health — cockpit health payload +- GET /api/gui/browser/status — browser runtime status +- POST /api/gui/browser/heal — self-healing browser cleanup +- GET /api/gui/discovery — ecosystem discovery for compatible frontends - POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header) - POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id) - GET /v1/responses/{response_id} — Retrieve a stored response @@ -2303,6 +2308,30 @@ class APIServerAdapter(BasePlatformAdapter): # BasePlatformAdapter interface # ------------------------------------------------------------------ + def _register_routes(self, app: "web.Application") -> None: + """Register API and operator-cockpit routes on an aiohttp app.""" + from gateway.platforms.api_server_ui import maybe_register_web_console + + app.router.add_get("/health", self._handle_health) + app.router.add_get("/health/detailed", self._handle_health_detailed) + app.router.add_get("/v1/health", self._handle_health) + app.router.add_get("/v1/models", self._handle_models) + app.router.add_post("/v1/chat/completions", self._handle_chat_completions) + app.router.add_post("/v1/responses", self._handle_responses) + app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) + app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response) + app.router.add_get("/api/jobs", self._handle_list_jobs) + app.router.add_post("/api/jobs", self._handle_create_job) + app.router.add_get("/api/jobs/{job_id}", self._handle_get_job) + app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job) + app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job) + app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job) + app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job) + app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job) + app.router.add_post("/v1/runs", self._handle_runs) + app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events) + maybe_register_web_console(app) + async def connect(self) -> bool: """Start the aiohttp web server.""" if not AIOHTTP_AVAILABLE: @@ -2313,26 +2342,7 @@ class APIServerAdapter(BasePlatformAdapter): mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None] self._app = web.Application(middlewares=mws) self._app["api_server_adapter"] = self - self._app.router.add_get("/health", self._handle_health) - self._app.router.add_get("/health/detailed", self._handle_health_detailed) - self._app.router.add_get("/v1/health", self._handle_health) - self._app.router.add_get("/v1/models", self._handle_models) - self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions) - self._app.router.add_post("/v1/responses", self._handle_responses) - self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) - self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response) - # Cron jobs management API - self._app.router.add_get("/api/jobs", self._handle_list_jobs) - self._app.router.add_post("/api/jobs", self._handle_create_job) - self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job) - self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job) - self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job) - self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job) - self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job) - self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job) - # Structured event streaming - self._app.router.add_post("/v1/runs", self._handle_runs) - self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events) + self._register_routes(self._app) # Start background sweep to clean up orphaned (unconsumed) run streams sweep_task = asyncio.create_task(self._sweep_orphaned_runs()) try: diff --git a/gateway/platforms/api_server_ui.py b/gateway/platforms/api_server_ui.py new file mode 100644 index 000000000..db68fa51c --- /dev/null +++ b/gateway/platforms/api_server_ui.py @@ -0,0 +1,194 @@ +"""Thin operator web console for the API server. + +This keeps the UI intentionally small: an aiohttp-mounted cockpit that +surfaces Hermes health, browser runtime state, and ecosystem discovery +without introducing a second heavyweight frontend architecture. +""" + +from __future__ import annotations + +import json +from html import escape +from typing import Any, Dict + +from aiohttp import web + +from tools.browser_tool import browser_runtime_heal, browser_runtime_status + +_DISCOVERY_FRONTENDS = [ + "Open WebUI", + "LobeChat", + "LibreChat", + "AnythingLLM", + "NextChat", + "ChatBox", +] + + +def _adapter(request: web.Request): + return request.app["api_server_adapter"] + + +def _auth_or_none(request: web.Request): + adapter = _adapter(request) + return adapter._check_auth(request) + + +def _render_console_html(adapter) -> str: + health = { + "platform": "api_server", + "host": adapter._host, + "port": adapter._port, + "model": adapter._model_name, + "auth_required": bool(adapter._api_key), + } + health_json = escape(json.dumps(health, indent=2, ensure_ascii=False)) + return f''' + + + + + Hermes Web Console + + + +
+

Hermes Web Console operator cockpit

+

Thin web UI over the existing API server, browser runtime, and streaming endpoints.

+
+
+
+

Gateway Health

+
{health_json}
+
+
+

Browser Cockpit

+ + +
+ + +
+
Loading...
+
+
+

Ecosystem Discovery

+ +
Loading...
+
+
+ + +''' + + +async def handle_web_console_index(request: web.Request) -> web.Response: + return web.Response(text=_render_console_html(_adapter(request)), content_type="text/html") + + +async def handle_gui_health(request: web.Request) -> web.Response: + adapter = _adapter(request) + return web.json_response({ + "status": "ok", + "platform": "api_server", + "host": adapter._host, + "port": adapter._port, + "model": adapter._model_name, + "auth_required": bool(adapter._api_key), + }) + + +async def handle_browser_status(request: web.Request) -> web.Response: + auth_err = _auth_or_none(request) + if auth_err is not None: + return auth_err + return web.json_response(browser_runtime_status()) + + +async def handle_browser_heal(request: web.Request) -> web.Response: + auth_err = _auth_or_none(request) + if auth_err is not None: + return auth_err + return web.json_response(browser_runtime_heal()) + + +async def handle_discovery(request: web.Request) -> web.Response: + adapter = _adapter(request) + return web.json_response({ + "frontends": _DISCOVERY_FRONTENDS, + "operator_cockpit": { + "root": "/", + "health": "/api/gui/health", + "browser_status": "/api/gui/browser/status", + "browser_heal": "/api/gui/browser/heal", + }, + "openai_compatible": { + "models": "/v1/models", + "chat_completions": "/v1/chat/completions", + "responses": "/v1/responses", + "runs": "/v1/runs", + "run_events": "/v1/runs/{run_id}/events", + "model_name": adapter._model_name, + }, + }) + + +def maybe_register_web_console(app: web.Application) -> None: + app.router.add_get("/", handle_web_console_index) + app.router.add_get("/api/gui/health", handle_gui_health) + app.router.add_get("/api/gui/browser/status", handle_browser_status) + app.router.add_post("/api/gui/browser/heal", handle_browser_heal) + app.router.add_get("/api/gui/discovery", handle_discovery) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 03be84e02..91c3f7089 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -2218,6 +2218,70 @@ def cleanup_all_browsers() -> None: _command_timeout_resolved = False +def browser_runtime_status() -> Dict[str, Any]: + """Return a machine-readable snapshot of the current browser runtime.""" + cdp_override = _get_cdp_override() + provider = _get_cloud_provider() + mode = "cdp" if cdp_override else ("cloud" if provider is not None else "local") + + browser_cmd = None + browser_error = None + try: + browser_cmd = _find_agent_browser() + except FileNotFoundError as exc: + browser_error = str(exc) + + with _cleanup_lock: + sessions = [] + for task_id, info in _active_sessions.items(): + sessions.append({ + "task_id": task_id, + "session_name": info.get("session_name"), + "cloud_session_id": info.get("bb_session_id"), + "cdp_url": info.get("cdp_url"), + "last_activity": _session_last_activity.get(task_id), + }) + sessions.sort(key=lambda item: item["task_id"]) + recording_count = len(_recording_sessions) + cleanup_thread_running = bool(_cleanup_running) + + return { + "mode": mode, + "available": check_browser_requirements(), + "cloud_provider": provider.provider_name() if provider is not None else None, + "cdp_override": cdp_override or None, + "agent_browser": { + "available": browser_cmd is not None, + "command": browser_cmd, + "error": browser_error, + }, + "session_count": len(sessions), + "active_sessions": sessions, + "recording_count": recording_count, + "cleanup_thread_running": cleanup_thread_running, + "inactivity_timeout_seconds": BROWSER_SESSION_INACTIVITY_TIMEOUT, + "self_healing": { + "inactivity_cleanup": True, + "orphan_reaper": True, + "emergency_cleanup": True, + }, + } + + +def browser_runtime_heal() -> Dict[str, Any]: + """Run the browser layer's self-healing cleanup sequence.""" + before = browser_runtime_status() + cleanup_all_browsers() + _reap_orphaned_browser_sessions() + after = browser_runtime_status() + return { + "success": True, + "message": "Browser runtime cleanup completed.", + "before": before, + "after": after, + } + + # ============================================================================ # Requirements Check # ============================================================================