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
+
+
+
+
+
+
+ Gateway Health
+ {health_json}
+
+
+
+ Ecosystem Discovery
+
+ GET /v1/models — OpenAI-compatible model discovery
+ POST /v1/chat/completions — chat frontend compatibility
+ POST /v1/responses — stateful responses API
+ POST /v1/runs + GET /v1/runs/{{run_id}}/events — SSE lifecycle stream
+ GET /api/gui/browser/status — browser runtime status
+ POST /api/gui/browser/heal — cleanup + orphan reaper
+
+ 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
# ============================================================================