"""Localhost debug HTTP sidecar for gpu-rent. Bind only 127.0.0.1. Started from `up` / `tunnel` / `debug`. Mostly read-only; `/assistent/chat-eval` is an opt-in AssistentChat probe (may load VRAM / persist chat) and is not part of `/snapshot`. """ from __future__ import annotations import json import threading import time import traceback from collections import deque from collections.abc import Callable from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse from gpu_rent.config import Config from gpu_rent import debug_checks Log = Callable[[str], None] DEFAULT_PORT = 17821 EVENT_CAPACITY = 500 CACHE_TTL_HEALTH = 5.0 CACHE_TTL_DIAG = 30.0 CACHE_TTL_LOGS = 10.0 CACHE_TTL_DEFAULT = 8.0 _CHAT_EVAL_PARAMS = [ { "name": "message", "in": "query", "schema": {"type": "string"}, "description": "User turn (default: short RU checkpoint/steps/cfg ask)", }, { "name": "persona", "in": "query", "schema": {"type": "string"}, "description": "Persona id (default neutral)", }, { "name": "pack", "in": "query", "schema": {"type": "string"}, "description": "Pack id (default ordinary)", }, { "name": "model", "in": "query", "schema": {"type": "string"}, "description": "Ollama chat model override (else preferred / default_chat)", }, { "name": "timeout", "in": "query", "schema": {"type": "number", "default": 120}, "description": "Seconds, capped (15–300)", }, ] _OPENAPI: dict[str, Any] = { "openapi": "3.0.3", "info": { "title": "gpu-rent Debug API", "version": "1.1.0", "description": ( "Localhost diagnostics for installer progress and " "SwarmUI / Comfy / Ollama / Assistent. Mostly read-only; " "POST/GET /assistent/chat-eval is opt-in AssistentChat " "(may load VRAM / write chat) and is skipped from /snapshot." ), }, "servers": [{"url": "http://127.0.0.1:17821"}], "paths": { "/": {"get": {"summary": "HTML index of endpoints"}}, "/openapi.json": {"get": {"summary": "This OpenAPI document"}}, "/health": {"get": {"summary": "ok, phase, failed checks summary"}}, "/progress": {"get": {"summary": "Current up/tunnel step + last log"}}, "/events": { "get": { "summary": "Installer log ring buffer", "parameters": [ { "name": "since", "in": "query", "schema": {"type": "integer"}, "description": "Return events with seq > since", } ], } }, "/snapshot": { "get": {"summary": "Cheap aggregate (no full diag, no chat-eval)"} }, "/status": {"get": {"summary": "JSON mirror of gpu-rent status"}}, "/state": {"get": {"summary": "state.json without secrets"}}, "/config": {"get": {"summary": "Config with tokens/passwords redacted"}}, "/checks": {"get": {"summary": "Local + VM service checks"}}, "/logs": { "get": { "summary": "journalctl via SSH", "parameters": [ { "name": "unit", "in": "query", "schema": {"type": "string"}, "description": "swarm|ollama|killer|cloud-init|all", }, { "name": "lines", "in": "query", "schema": {"type": "integer", "default": 80}, }, ], } }, "/diag": {"get": {"summary": "Full swarm_diag (expensive, cached ~30s)"}}, "/gpu": {"get": {"summary": "nvidia-smi / CUDA / torch probe"}}, "/swarm": {"get": {"summary": "SwarmUI backend_status + ListBackends"}}, "/ollama": {"get": {"summary": "Ollama tags + ps"}}, "/assistent": { "get": { "summary": "Deep Assistent bundle (extension/roles/api/hints)", "parameters": [ { "name": "chat_smoke", "in": "query", "schema": {"type": "boolean"}, "description": "1-token Ollama /api/chat with preferred model", }, { "name": "logs", "in": "query", "schema": {"type": "boolean"}, "description": "Include filtered swarmui journal lines", }, ], } }, "/assistent/extension": { "get": {"summary": "DLL/Sqlite deps/Tab assets under Extensions/swarm-assistent"} }, "/assistent/overlay": { "get": {"summary": "Overlay personas + _base/assistant.json vs local seed"} }, "/assistent/roles": { "get": {"summary": "ollama-roles.json vs /api/tags (default_chat)"} }, "/assistent/memory": { "get": {"summary": "assistent.sqlite presence + counts + Sqlite DLL hint"} }, "/assistent/api": { "get": { "summary": "Live AssistentListPersonas/Models/Memory/Chats smoke", "parameters": [ { "name": "chat_smoke", "in": "query", "schema": {"type": "boolean"}, "description": "Also run 1-token Ollama chat", } ], } }, "/assistent/wanted": {"get": {"summary": "Wanted-models queue on data volume"}}, "/assistent/logs": { "get": {"summary": "journalctl swarmui filtered for Assistent/Sqlite/build"} }, "/assistent/chat-eval": { "get": { "summary": ( "Opt-in AssistentChat eval (GetNewSession→AssistentChat); " "may load VRAM / write chat — not in /snapshot" ), "parameters": _CHAT_EVAL_PARAMS, }, "post": { "summary": ( "Same as GET; prefer JSON body " "{message,persona,pack,model,timeout}" ), "requestBody": { "required": False, "content": { "application/json": { "schema": { "type": "object", "properties": { "message": {"type": "string"}, "persona": {"type": "string"}, "pack": {"type": "string"}, "model": {"type": "string"}, "timeout": {"type": "number"}, }, } } }, }, }, }, }, } @dataclass class DebugHub: """Shared state for the sidecar process.""" cfg: Config port: int started_at: float = field(default_factory=time.time) step: str = "starting" step_started_at: float = field(default_factory=time.time) _events: deque[dict[str, Any]] = field(default_factory=lambda: deque(maxlen=EVENT_CAPACITY)) _seq: int = 0 _lock: threading.Lock = field(default_factory=threading.Lock) _cache: dict[str, tuple[float, Any]] = field(default_factory=dict) @property def base_url(self) -> str: return f"http://127.0.0.1:{self.port}" def set_step(self, step: str) -> None: with self._lock: if step != self.step: self.step = step self.step_started_at = time.time() def push_event(self, message: str) -> None: text = message if text.startswith("\r"): text = text[1:].rstrip() if not text: return # Progress lines: update step hint, keep one event per distinct text. text = text.rstrip() if not text: return with self._lock: self._seq += 1 self._events.append( { "seq": self._seq, "ts": time.time(), "msg": text[:2000], } ) # Infer step from log line (best-effort). lower = text.lower() for needle, label in ( ("жду ready backend", "wait_backend_idle"), ("backend ready", "backend_ready"), ("проверка gpu", "verify_gpu"), ("проверка на vm", "verify_stack"), ("проверка туннеля", "verify_tunnel"), ("bootstrap", "bootstrap"), ("civitai", "seed_models"), ("ollama", "ollama"), ("extensions", "extensions"), ("туннель", "tunnel"), ("создаю", "provisioning"), ("doctor", "doctor"), ): if needle in lower: if self.step != label: self.step = label self.step_started_at = time.time() break def events_since(self, since: int = 0) -> list[dict[str, Any]]: with self._lock: return [e for e in self._events if int(e["seq"]) > since] def events_tail(self, n: int = 30) -> list[dict[str, Any]]: with self._lock: items = list(self._events) return items[-n:] def progress(self) -> dict[str, Any]: with self._lock: last = self._events[-1] if self._events else None step = self.step step_age = int(time.time() - self.step_started_at) uptime = int(time.time() - self.started_at) return { "ok": True, "step": step, "step_age_sec": step_age, "uptime_sec": uptime, "last_log": last, } def cached(self, key: str, ttl: float, factory: Callable[[], Any]) -> Any: now = time.time() with self._lock: hit = self._cache.get(key) if hit and now - hit[0] < ttl: return hit[1] value = factory() with self._lock: self._cache[key] = (now, value) return value @dataclass class DebugServer: hub: DebugHub httpd: ThreadingHTTPServer thread: threading.Thread @property def base_url(self) -> str: return self.hub.base_url @property def port(self) -> int: return self.hub.port def set_step(self, step: str) -> None: self.hub.set_step(step) def push_event(self, message: str) -> None: self.hub.push_event(message) def wrap_log(self, log: Log) -> Log: def _tee(msg: str) -> None: try: self.hub.push_event(msg) except Exception: pass log(msg) return _tee def stop(self) -> None: try: self.httpd.shutdown() except Exception: pass try: self.httpd.server_close() except Exception: pass self.thread.join(timeout=5.0) _active: DebugServer | None = None _active_lock = threading.Lock() def get_active() -> DebugServer | None: with _active_lock: return _active def _make_handler(hub: DebugHub): class Handler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args: Any) -> None: return # quiet — do not spam CLI def _send(self, code: int, body: bytes, content_type: str) -> None: self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def _json(self, code: int, payload: Any) -> None: body = json.dumps(payload, ensure_ascii=False, indent=2, default=str).encode( "utf-8" ) self._send(code, body, "application/json; charset=utf-8") def _path_summary(self, path: str) -> str: ops = _OPENAPI["paths"].get(path) or {} for method in ("get", "post"): if method in ops and isinstance(ops[method], dict): return str(ops[method].get("summary") or method) return "" def _html_index(self) -> None: links = sorted(p for p in _OPENAPI["paths"] if p != "/") rows = "\n".join( f'
base {hub.base_url} · mostly read-only · 127.0.0.1
Agent: start at /openapi.json "
f"then /snapshot. "
f"Opt-in chat: /assistent/chat-eval (not in snapshot).