From 719d77efd968f81b4d538e910a977d6bac3df7c5 Mon Sep 17 00:00:00 2001
From: Leonid Pershin
Date: Sun, 23 Aug 2026 07:21:13 +0300
Subject: [PATCH] Add opt-in /assistent/chat-eval for AssistentChat via Debug
API.
Lets agents POST/GET a real Assistent turn (tunnel or SSH) without folding it into cheap /snapshot; documents VRAM/Sqlite side effects.
Co-authored-by: Cursor
---
docs/cli.md | 17 +-
src/gpu_rent/debug_api.py | 176 +++++++++++-
src/gpu_rent/debug_assistent.py | 483 +++++++++++++++++++++++++++++++-
tests/test_debug_api.py | 106 +++++++
4 files changed, 766 insertions(+), 16 deletions(-)
diff --git a/docs/cli.md b/docs/cli.md
index ace98d2..9144c65 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -139,18 +139,33 @@ Exit 0 → можно `up`. Exit 1 → причина в таблице / кра
| `/assistent/memory` | `assistent.sqlite` + counts |
| `/assistent/api` | `AssistentListPersonas/Models/Memory/Chats` через туннель (или SSH) |
| `/assistent/api?chat_smoke=1` | + 1-token Ollama `/api/chat` (кратко грузит модель) |
+| `/assistent/chat-eval` | **Opt-in** `GetNewSession` → `AssistentChat` (полный ответ ассистента) |
| `/assistent/logs` | journalctl swarmui: build/load/Sqlite |
| `/assistent?logs=1` | Сводка + journal |
Симптомы → куда смотреть: поле `playbook` в `/assistent`.
+**`/assistent/chat-eval`** — не входит в `/snapshot`. Может загрузить chat-модель в VRAM и записать чат в Sqlite (если DLL на месте). Предпочтительно `POST` с JSON; для агентов удобен и `GET` с query.
+
+```bash
+# POST (предпочтительно)
+curl -sS -X POST http://127.0.0.1:17821/assistent/chat-eval \
+ -H "Content-Type: application/json" \
+ -d "{\"message\":\"какой checkpoint и какие steps/cfg должны быть\",\"persona\":\"leonid\",\"timeout\":120}"
+
+# GET (агенты / быстрый smoke)
+curl -sS "http://127.0.0.1:17821/assistent/chat-eval?message=ping&timeout=90"
+```
+
+Ответ: `ok`, `ms` / `chat_ms`, `model` / `preferred`, `reply` / `reply_prose`, `patch` (prompt/steps/cfg/aspect/actions…), `errors`, `hints`. Туннель `:swarmui_local_port` (обычно 17801), иначе SSH на VM `:7801`.
+
После `up --no-tunnel` процесс завершается и sidecar гаснет — держи отдельно:
```text
gpu-rent debug
```
-Мутаций конфига/GPU нет (restart/hold/stop — как раньше через CLI). `chat_smoke` только пингует Ollama.
+Мутаций конфига/GPU нет (restart/hold/stop — как раньше через CLI). `chat_smoke` только пингует Ollama; `chat-eval` — полноценный AssistentChat (opt-in).
---
diff --git a/src/gpu_rent/debug_api.py b/src/gpu_rent/debug_api.py
index 62d5fdf..3a1ad81 100644
--- a/src/gpu_rent/debug_api.py
+++ b/src/gpu_rent/debug_api.py
@@ -1,6 +1,8 @@
-"""Localhost read-only debug HTTP sidecar for gpu-rent.
+"""Localhost debug HTTP sidecar for gpu-rent.
-Bind only 127.0.0.1. Started from `up` / `tunnel` / `debug`. No mutations.
+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
@@ -28,14 +30,49 @@ 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.0.0",
+ "version": "1.1.0",
"description": (
- "Read-only localhost diagnostics for installer progress and "
- "SwarmUI / Comfy / Ollama / Assistent. No mutations."
+ "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"}],
@@ -57,7 +94,9 @@ _OPENAPI: dict[str, Any] = {
],
}
},
- "/snapshot": {"get": {"summary": "Cheap aggregate (no full diag)"}},
+ "/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"}},
@@ -132,6 +171,38 @@ _OPENAPI: dict[str, Any] = {
"/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"},
+ },
+ }
+ }
+ },
+ },
+ },
+ },
},
}
@@ -306,27 +377,51 @@ def _make_handler(hub: DebugHub):
)
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'{p} — '
- f'{_OPENAPI["paths"][p]["get"]["summary"]}'
+ f'{p} — {self._path_summary(p)}'
for p in links
)
html = (
""
"gpu-rent debug"
f"gpu-rent Debug API
"
- f"base {hub.base_url} · read-only · 127.0.0.1
"
+ f"base {hub.base_url} · mostly read-only · 127.0.0.1
"
f"Agent: start at /openapi.json "
- f"then /snapshot.
"
+ f"then /snapshot. "
+ f"Opt-in chat: /assistent/chat-eval (not in snapshot).
"
f""
)
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
+ def _read_json_body(self) -> dict[str, Any]:
+ length = int(self.headers.get("Content-Length") or 0)
+ if length <= 0:
+ return {}
+ raw = self.rfile.read(min(length, 1_000_000))
+ if not raw:
+ return {}
+ try:
+ data = json.loads(raw.decode("utf-8", "replace"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid JSON body: {exc}") from exc
+ if data is None:
+ return {}
+ if not isinstance(data, dict):
+ raise ValueError("JSON body must be an object")
+ return data
+
def do_GET(self) -> None: # noqa: N802
try:
- self._dispatch()
+ self._dispatch("GET")
except Exception as exc:
self._json(
500,
@@ -337,19 +432,47 @@ def _make_handler(hub: DebugHub):
},
)
- def _dispatch(self) -> None:
+ def do_POST(self) -> None: # noqa: N802
+ try:
+ self._dispatch("POST")
+ except Exception as exc:
+ self._json(
+ 500,
+ {
+ "ok": False,
+ "error": str(exc)[:300],
+ "trace": traceback.format_exc()[-800:],
+ },
+ )
+
+ def _dispatch(self, method: str = "GET") -> None:
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
qs = parse_qs(parsed.query)
if path == "/":
+ if method != "GET":
+ self._json(405, {"ok": False, "error": "GET only"})
+ return
self._html_index()
return
if path == "/openapi.json":
+ if method != "GET":
+ self._json(405, {"ok": False, "error": "GET only"})
+ return
doc = dict(_OPENAPI)
doc["servers"] = [{"url": hub.base_url}]
self._json(200, doc)
return
+ if method == "POST" and path != "/assistent/chat-eval":
+ self._json(
+ 405,
+ {
+ "ok": False,
+ "error": "POST only allowed for /assistent/chat-eval",
+ },
+ )
+ return
if path == "/progress":
self._json(200, hub.progress())
return
@@ -534,6 +657,30 @@ def _make_handler(hub: DebugHub):
CACHE_TTL_LOGS,
lambda: debug_assistent.collect_assistent_logs(hub.cfg),
)
+ elif sub == "chat-eval":
+ body: dict[str, Any] = {}
+ if method == "POST":
+ try:
+ body = self._read_json_body()
+ except ValueError as exc:
+ self._json(400, {"ok": False, "error": str(exc)})
+ return
+
+ def _q(name: str) -> str | None:
+ if name in body and body[name] is not None:
+ return body[name]
+ vals = qs.get(name)
+ return vals[0] if vals else None
+
+ # Never cache — live AssistentChat / VRAM side effects.
+ payload = debug_assistent.run_assistent_chat_eval(
+ hub.cfg,
+ message=_q("message"),
+ persona=_q("persona"),
+ pack=_q("pack"),
+ model=_q("model"),
+ timeout=_q("timeout"),
+ )
else:
self._json(
404,
@@ -549,6 +696,7 @@ def _make_handler(hub: DebugHub):
"/assistent/api",
"/assistent/wanted",
"/assistent/logs",
+ "/assistent/chat-eval",
],
},
)
@@ -611,6 +759,7 @@ def start_debug_server(
f" {base}/openapi.json",
f" {base}/snapshot",
f" {base}/assistent",
+ f" {base}/assistent/chat-eval (opt-in; not in snapshot)",
]
if log:
for line in lines:
@@ -646,7 +795,8 @@ def run_debug_blocking(
server.set_step("idle_debug")
try:
if log:
- log("Debug API слушает (Ctrl+C — выход). Мутаций нет.")
+ log("Debug API слушает (Ctrl+C — выход). "
+ "Мутаций конфига/GPU нет; /assistent/chat-eval — opt-in AssistentChat.")
while True:
time.sleep(3600)
except KeyboardInterrupt:
diff --git a/src/gpu_rent/debug_assistent.py b/src/gpu_rent/debug_assistent.py
index cdd0c5c..7b0f4b8 100644
--- a/src/gpu_rent/debug_assistent.py
+++ b/src/gpu_rent/debug_assistent.py
@@ -1,12 +1,14 @@
-"""Deep read-only Assistent diagnostics for the debug HTTP sidecar.
+"""Deep Assistent diagnostics for the debug HTTP sidecar.
Probes extension compile/load markers, overlay personas, ollama-roles,
-sqlite, live SwarmUI Assistent* APIs, and optional 1-token Ollama chat smoke.
+sqlite, live SwarmUI Assistent* APIs, optional 1-token Ollama chat smoke,
+and opt-in AssistentChat evaluation (/assistent/chat-eval).
"""
from __future__ import annotations
import json
+import re
import time
import urllib.request
from typing import Any
@@ -16,6 +18,43 @@ from gpu_rent import debug_checks
from gpu_rent.llm_runtime import normalize_runtime
from gpu_rent.state import load_state
+# Opt-in AssistentChat eval defaults (not used by /snapshot).
+DEFAULT_CHAT_EVAL_MESSAGE = "какой checkpoint и какие steps/cfg должны быть"
+DEFAULT_CHAT_EVAL_TIMEOUT = 120.0
+MAX_CHAT_EVAL_TIMEOUT = 300.0
+MIN_CHAT_EVAL_TIMEOUT = 15.0
+
+# Mirrors swarm-assistent src/patch.js DEFAULT_PATCH_KEYS (sparse subset for eval).
+_PATCH_KEYS = frozenset(
+ {
+ "prompt",
+ "negative",
+ "loras",
+ "width",
+ "height",
+ "steps",
+ "cfg",
+ "seed",
+ "sigma_shift",
+ "sampler",
+ "scheduler",
+ "actions",
+ "generate",
+ "ask",
+ "aspect",
+ "images",
+ "batch",
+ "pack",
+ "persona",
+ "controls",
+ "look_at",
+ "vision_from",
+ "vision_slots",
+ "variants",
+ }
+)
+_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
+
# Remote filesystem probe — one SSH round-trip.
_REMOTE_FS = r'''
from pathlib import Path
@@ -296,6 +335,445 @@ def _compact_api(name: str, data: Any) -> Any:
return {"keys": list(data.keys())[:20]}
+def extract_assistent_patch(text: str | None) -> dict[str, Any]:
+ """Pull last fenced JSON patch from Assistent reply (mirrors SA.extractPatch)."""
+ if not text:
+ return {"prose": text or "", "patch": None}
+ last_patch: dict[str, Any] | None = None
+ prose = text
+ for match in _FENCE_RE.finditer(text):
+ try:
+ obj = json.loads(match.group(1).strip())
+ except (json.JSONDecodeError, TypeError, ValueError):
+ continue
+ if not isinstance(obj, dict):
+ continue
+ if not any(k in obj and obj[k] is not None for k in _PATCH_KEYS):
+ continue
+ patch = dict(obj)
+ acts = [str(a) for a in patch["actions"]] if isinstance(patch.get("actions"), list) else []
+ if patch.get("generate") is True or "generate" in acts:
+ patch["generate"] = True
+ if isinstance(patch.get("ask"), str):
+ patch["ask"] = [patch["ask"]]
+ last_patch = patch
+ prose = (text[: match.start()] + text[match.end() :]).strip()
+ return {"prose": prose, "patch": last_patch}
+
+
+def _summarize_patch(patch: dict[str, Any] | None) -> dict[str, Any] | None:
+ if not isinstance(patch, dict):
+ return None
+ keys = (
+ "prompt",
+ "negative",
+ "steps",
+ "cfg",
+ "aspect",
+ "actions",
+ "generate",
+ "ask",
+ "width",
+ "height",
+ "seed",
+ "sigma_shift",
+ "pack",
+ "persona",
+ "images",
+ )
+ out: dict[str, Any] = {}
+ for k in keys:
+ if k in patch and patch[k] is not None:
+ val = patch[k]
+ if k == "prompt" and isinstance(val, str) and len(val) > 800:
+ out[k] = val[:800] + "…"
+ else:
+ out[k] = val
+ return out or None
+
+
+def _clamp_chat_eval_timeout(raw: float | int | str | None) -> float:
+ try:
+ val = float(raw) if raw is not None else DEFAULT_CHAT_EVAL_TIMEOUT
+ except (TypeError, ValueError):
+ val = DEFAULT_CHAT_EVAL_TIMEOUT
+ return max(MIN_CHAT_EVAL_TIMEOUT, min(val, MAX_CHAT_EVAL_TIMEOUT))
+
+
+def _resolve_chat_model(
+ cfg: Config,
+ base: str | None,
+ sid: str | None,
+ *,
+ model: str | None,
+) -> tuple[str | None, str | None]:
+ """Return (model, preferred_from_list). Explicit model wins."""
+ preferred: str | None = None
+ if base and sid:
+ call = _api_call(
+ base,
+ "AssistentListModels",
+ {"session_id": sid, "baseUrl": "http://127.0.0.1:11434"},
+ timeout=25.0,
+ )
+ if call.get("ok") and isinstance(call.get("data"), dict):
+ preferred = call["data"].get("preferred")
+ if isinstance(preferred, str):
+ preferred = preferred.strip() or None
+ if not preferred:
+ roles = collect_assistent_roles(cfg)
+ preferred = (roles.get("roles") or {}).get("default_chat")
+ if isinstance(preferred, str):
+ preferred = preferred.strip() or None
+ chosen = (model or "").strip() or preferred
+ return chosen, preferred
+
+
+def run_assistent_chat_eval(
+ cfg: Config,
+ *,
+ message: str | None = None,
+ persona: str | None = None,
+ pack: str | None = None,
+ model: str | None = None,
+ timeout: float | int | str | None = None,
+) -> dict[str, Any]:
+ """Opt-in AssistentChat round-trip via local Swarm tunnel, else SSH :7801.
+
+ May load the chat model into VRAM and write chat history if Sqlite works.
+ Not part of /snapshot.
+ """
+ text = (message or DEFAULT_CHAT_EVAL_MESSAGE).strip() or DEFAULT_CHAT_EVAL_MESSAGE
+ persona_id = (persona or "").strip() or "neutral"
+ pack_name = (pack or "").strip() or "ordinary"
+ t_chat = _clamp_chat_eval_timeout(timeout)
+ hints: list[str] = [
+ "opt-in AssistentChat eval — may load VRAM; may SaveChat if Sqlite works",
+ "not included in /snapshot",
+ ]
+ errors: list[str] = []
+ t0 = time.perf_counter()
+
+ base, via = _swarm_base(cfg)
+ if not base:
+ return _assistent_chat_eval_via_ssh(
+ cfg,
+ message=text,
+ persona=persona_id,
+ pack=pack_name,
+ model=(model or "").strip() or None,
+ timeout=t_chat,
+ hints=hints,
+ t0=t0,
+ )
+
+ sid, err, ms_sess = _session_id(base)
+ if not sid:
+ return {
+ "ok": False,
+ "via": via,
+ "ms": round((time.perf_counter() - t0) * 1000, 1),
+ "session_ms": round(ms_sess, 1),
+ "error": f"GetNewSession failed: {err}",
+ "errors": [f"GetNewSession: {err}"],
+ "hints": hints,
+ "message": text,
+ "persona": persona_id,
+ "pack": pack_name,
+ }
+
+ chosen, preferred = _resolve_chat_model(cfg, base, sid, model=model)
+ if not chosen:
+ return {
+ "ok": False,
+ "via": via,
+ "ms": round((time.perf_counter() - t0) * 1000, 1),
+ "session_ms": round(ms_sess, 1),
+ "error": "no model (AssistentListModels.preferred / default_chat)",
+ "errors": ["model required"],
+ "hints": hints + ["GET /assistent/roles + /ollama — нет preferred chat model"],
+ "message": text,
+ "persona": persona_id,
+ "pack": pack_name,
+ "preferred": preferred,
+ }
+
+ payload = {
+ "session_id": sid,
+ "baseUrl": "http://127.0.0.1:11434",
+ "model": chosen,
+ "pack": pack_name,
+ "persona": persona_id,
+ "includeBase": True,
+ "messages": [{"role": "user", "content": text}],
+ "context_json": json.dumps(
+ {
+ "persona": persona_id,
+ "debug_eval": True,
+ "has_vision_image": False,
+ "images_in_request": False,
+ },
+ ensure_ascii=False,
+ ),
+ "skills": [],
+ }
+ ok, data, ms_call = _http_json(
+ f"{base}/API/AssistentChat",
+ method="POST",
+ body=payload,
+ timeout=t_chat,
+ )
+ return _finish_chat_eval(
+ ok=ok,
+ data=data,
+ ms_call=ms_call,
+ ms_total=(time.perf_counter() - t0) * 1000,
+ via=via,
+ session_ms=ms_sess,
+ message=text,
+ persona=persona_id,
+ pack=pack_name,
+ model=chosen,
+ preferred=preferred,
+ hints=hints,
+ errors=errors,
+ timeout=t_chat,
+ )
+
+
+def _finish_chat_eval(
+ *,
+ ok: bool,
+ data: Any,
+ ms_call: float,
+ ms_total: float,
+ via: str,
+ session_ms: float,
+ message: str,
+ persona: str,
+ pack: str,
+ model: str | None,
+ preferred: str | None,
+ hints: list[str],
+ errors: list[str],
+ timeout: float,
+) -> dict[str, Any]:
+ reply = None
+ raw_preview = None
+ err: str | None = None
+ if not ok:
+ err = str(data)[:400]
+ errors.append(err)
+ elif isinstance(data, dict):
+ if data.get("error"):
+ err = str(data.get("error"))[:400]
+ errors.append(err)
+ ok = False
+ reply = data.get("reply")
+ if reply is not None:
+ reply = str(reply)
+ raw = data.get("raw")
+ if isinstance(raw, dict):
+ raw_preview = {k: raw[k] for k in list(raw.keys())[:12]}
+ elif raw is not None:
+ raw_preview = str(raw)[:400]
+ blob = json.dumps(data, ensure_ascii=False).lower()
+ if "sqlite" in blob or "savechat" in blob:
+ hints.append("response mentions Sqlite/SaveChat — check /assistent/memory")
+ else:
+ err = f"unexpected AssistentChat body: {str(data)[:200]}"
+ errors.append(err)
+ ok = False
+
+ extracted = extract_assistent_patch(reply)
+ if err and "sqlite" in err.lower():
+ hints.append(
+ "Sqlite — Microsoft.Data.Sqlite рядом с extension DLL (seed-extensions ≥0.13.1)"
+ )
+ if err and ("timeout" in err.lower() or "timed out" in err.lower()):
+ hints.append(f"timeout {timeout:.0f}s — увеличь timeout (cap {MAX_CHAT_EVAL_TIMEOUT:.0f})")
+
+ success = bool(ok and reply is not None and not err)
+ out: dict[str, Any] = {
+ "ok": success,
+ "via": via,
+ "ms": round(ms_total, 1),
+ "chat_ms": round(ms_call, 1),
+ "session_ms": round(session_ms, 1),
+ "timeout_sec": timeout,
+ "message": message,
+ "persona": persona,
+ "pack": pack,
+ "model": model,
+ "preferred": preferred,
+ "reply": reply,
+ "reply_prose": extracted.get("prose"),
+ "patch": _summarize_patch(extracted.get("patch")),
+ "raw": raw_preview,
+ "errors": errors,
+ "hints": hints,
+ "error": err,
+ }
+ if isinstance(data, dict) and success:
+ for k in ("system_chars", "prompt_eval_count", "civitai_results"):
+ if k in data:
+ out[k] = data[k]
+ if data.get("model"):
+ out["model"] = data.get("model")
+ return out
+
+
+def _assistent_chat_eval_via_ssh(
+ cfg: Config,
+ *,
+ message: str,
+ persona: str,
+ pack: str,
+ model: str | None,
+ timeout: float,
+ hints: list[str],
+ t0: float,
+) -> dict[str, Any]:
+ state = load_state()
+ if not debug_checks.ssh_ready(cfg, state):
+ return {
+ **debug_checks._ssh_fail(state.phase),
+ "ok": False,
+ "ms": round((time.perf_counter() - t0) * 1000, 1),
+ "message": message,
+ "persona": persona,
+ "pack": pack,
+ "model": model,
+ "errors": [debug_checks.SSH_UNAVAILABLE],
+ "hints": hints + ["туннель SwarmUI закрыт и SSH нет — gpu-rent tunnel / up"],
+ }
+ host = debug_checks.ssh_host(state)
+ assert host is not None
+ # Resolve model on laptop if possible (roles via SSH fs), else let remote ListModels.
+ chosen = model
+ preferred = None
+ if not chosen:
+ roles = collect_assistent_roles(cfg)
+ preferred = (roles.get("roles") or {}).get("default_chat")
+ if isinstance(preferred, str) and preferred.strip():
+ chosen = preferred.strip()
+ preferred = chosen
+
+ payload_model = chosen or ""
+ # Remote script: GetNewSession → optional ListModels → AssistentChat
+ script = f'''
+import json, urllib.request, time
+MSG = {json.dumps(message, ensure_ascii=False)}
+PERSONA = {json.dumps(persona, ensure_ascii=False)}
+PACK = {json.dumps(pack, ensure_ascii=False)}
+MODEL = {json.dumps(payload_model, ensure_ascii=False)}
+TIMEOUT = {float(timeout)}
+
+def post(path, payload, timeout=TIMEOUT):
+ t0 = time.time()
+ req = urllib.request.Request(
+ "http://127.0.0.1:7801" + path,
+ data=json.dumps(payload).encode(),
+ headers={{"Content-Type": "application/json"}},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ raw = resp.read().decode("utf-8", "replace")
+ ms = (time.time() - t0) * 1000
+ try:
+ data = json.loads(raw)
+ except Exception:
+ data = raw[:500]
+ return True, data, ms
+ except Exception as e:
+ return False, str(e), (time.time() - t0) * 1000
+
+ok, sess, ms = post("/API/GetNewSession", {{}}, timeout=20)
+if not ok or not isinstance(sess, dict) or not sess.get("session_id"):
+ print(json.dumps({{"ok": False, "error": sess, "session_ms": ms, "via": "ssh"}}))
+ raise SystemExit(0)
+sid = sess["session_id"]
+preferred = None
+model = MODEL.strip()
+if not model:
+ cok, mdata, _ = post("/API/AssistentListModels", {{
+ "session_id": sid, "baseUrl": "http://127.0.0.1:11434"
+ }}, timeout=25)
+ if cok and isinstance(mdata, dict):
+ preferred = mdata.get("preferred")
+ model = (preferred or "").strip()
+if not model:
+ print(json.dumps({{
+ "ok": False, "error": "no model", "session_ms": ms, "via": "ssh",
+ "preferred": preferred
+ }}))
+ raise SystemExit(0)
+payload = {{
+ "session_id": sid,
+ "baseUrl": "http://127.0.0.1:11434",
+ "model": model,
+ "pack": PACK,
+ "persona": PERSONA,
+ "includeBase": True,
+ "messages": [{{"role": "user", "content": MSG}}],
+ "context_json": json.dumps({{
+ "persona": PERSONA, "debug_eval": True,
+ "has_vision_image": False, "images_in_request": False
+ }}, ensure_ascii=False),
+ "skills": [],
+}}
+cok, data, cms = post("/API/AssistentChat", payload, timeout=TIMEOUT)
+print(json.dumps({{
+ "ok": cok, "data": data, "chat_ms": cms, "session_ms": ms,
+ "via": "ssh", "model": model, "preferred": preferred or model
+}}, ensure_ascii=False))
+'''
+ try:
+ from gpu_rent.ssh_ops import run_ssh
+
+ out = run_ssh(
+ cfg,
+ host,
+ "python3 - <<'PY'\n" + script + "\nPY",
+ check=False,
+ timeout=int(timeout) + 40,
+ ).strip()
+ remote = json.loads(out.splitlines()[-1])
+ except Exception as exc:
+ return {
+ "ok": False,
+ "via": "ssh",
+ "ms": round((time.perf_counter() - t0) * 1000, 1),
+ "message": message,
+ "persona": persona,
+ "pack": pack,
+ "model": model,
+ "error": str(exc)[:240],
+ "errors": [str(exc)[:240]],
+ "hints": hints,
+ }
+
+ data = remote.get("data")
+ ok = bool(remote.get("ok"))
+ return _finish_chat_eval(
+ ok=ok,
+ data=data if ok or isinstance(data, dict) else remote.get("error") or data,
+ ms_call=float(remote.get("chat_ms") or 0),
+ ms_total=(time.perf_counter() - t0) * 1000,
+ via="ssh",
+ session_ms=float(remote.get("session_ms") or 0),
+ message=message,
+ persona=persona,
+ pack=pack,
+ model=remote.get("model") or chosen,
+ preferred=remote.get("preferred") or preferred,
+ hints=hints,
+ errors=[],
+ timeout=timeout,
+ )
+
+
_FS_CACHE: tuple[float, str, dict[str, Any]] | None = None
_FS_TTL = 8.0
@@ -888,5 +1366,6 @@ def collect_assistent_deep(
"memory_broken": "GET /assistent/memory + /assistent/api (ListMemory)",
"personas_missing": "GET /assistent/overlay + ListPersonas in /assistent/api",
"chat_smoke": "GET /assistent/api?chat_smoke=1",
+ "chat_eval": "POST /assistent/chat-eval (opt-in AssistentChat; not in /snapshot)",
},
}
diff --git a/tests/test_debug_api.py b/tests/test_debug_api.py
index 8c127cd..5592286 100644
--- a/tests/test_debug_api.py
+++ b/tests/test_debug_api.py
@@ -144,6 +144,13 @@ def test_debug_server_routes(monkeypatch, tmp_path):
code, data = get("/openapi.json")
assert "/assistent/extension" in data["paths"]
assert "/assistent/api" in data["paths"]
+ assert "/assistent/chat-eval" in data["paths"]
+ assert "post" in data["paths"]["/assistent/chat-eval"]
+
+ code, data = get("/assistent/chat-eval")
+ assert "ok" in data
+ assert data.get("error") == debug_checks.SSH_UNAVAILABLE or not data.get("ok")
+ assert "hints" in data
with pytest.raises(Exception):
urllib.request.urlopen(f"http://127.0.0.1:{port}/nope", timeout=2)
@@ -151,6 +158,105 @@ def test_debug_server_routes(monkeypatch, tmp_path):
debug_api.stop_debug_server(srv)
+def test_extract_assistent_patch():
+ from gpu_rent.debug_assistent import extract_assistent_patch
+
+ bare = extract_assistent_patch("просто текст без json")
+ assert bare["patch"] is None
+ assert "просто текст" in bare["prose"]
+
+ text = (
+ "Ок, вот параметры:\n"
+ "```json\n"
+ '{"prompt":"a cat","steps":4,"cfg":1.0,"aspect":"1:1","actions":["generate"]}\n'
+ "```\n"
+ )
+ got = extract_assistent_patch(text)
+ assert got["patch"] is not None
+ assert got["patch"]["steps"] == 4
+ assert got["patch"]["cfg"] == 1.0
+ assert got["patch"]["aspect"] == "1:1"
+ assert got["patch"]["generate"] is True
+ assert "Ок" in got["prose"]
+ assert "```" not in got["prose"]
+
+ # last fence wins
+ multi = (
+ "```json\n{\"steps\":1}\n```\n"
+ "mid\n"
+ "```json\n{\"steps\":8,\"cfg\":2}\n```"
+ )
+ got2 = extract_assistent_patch(multi)
+ assert got2["patch"]["steps"] == 8
+ assert got2["patch"]["cfg"] == 2
+
+
+def test_chat_eval_mocked_http(monkeypatch, tmp_path):
+ from gpu_rent import debug_assistent
+
+ _auth_env(monkeypatch, tmp_path)
+ cfg = load_config(require_auth=True)
+
+ monkeypatch.setattr(
+ debug_assistent,
+ "_swarm_base",
+ lambda cfg: ("http://127.0.0.1:17801", "local"),
+ )
+ monkeypatch.setattr(
+ debug_assistent,
+ "_session_id",
+ lambda base: ("sess-1", None, 5.0),
+ )
+
+ calls: list[tuple[str, dict]] = []
+
+ def fake_http(url, *, method="GET", body=None, timeout=12.0):
+ if url.endswith("/API/AssistentListModels"):
+ return True, {"preferred": "qwen3-vl:8b", "models": ["qwen3-vl:8b"]}, 10.0
+ if url.endswith("/API/AssistentChat"):
+ calls.append((url, body or {}))
+ reply = (
+ "Используй turbo.\n"
+ '```json\n{"prompt":"portrait","steps":4,"cfg":1,"aspect":"3:4",'
+ '"actions":["generate"]}\n```'
+ )
+ return True, {"success": True, "reply": reply, "model": "qwen3-vl:8b"}, 42.0
+ return False, "unexpected " + url, 1.0
+
+ monkeypatch.setattr(debug_assistent, "_http_json", fake_http)
+
+ out = debug_assistent.run_assistent_chat_eval(
+ cfg,
+ message="какой checkpoint?",
+ persona="leonid",
+ timeout=60,
+ )
+ assert out["ok"] is True
+ assert out["via"] == "local"
+ assert out["model"] == "qwen3-vl:8b"
+ assert out["preferred"] == "qwen3-vl:8b"
+ assert out["persona"] == "leonid"
+ assert out["patch"]["steps"] == 4
+ assert out["patch"]["cfg"] == 1
+ assert out["patch"]["aspect"] == "3:4"
+ assert "turbo" in (out["reply_prose"] or "").lower() or "turbo" in (out["reply"] or "").lower()
+ assert calls and calls[0][1]["session_id"] == "sess-1"
+ assert calls[0][1]["messages"][0]["content"] == "какой checkpoint?"
+ assert calls[0][1]["includeBase"] is True
+
+
+def test_chat_eval_timeout_clamp():
+ from gpu_rent.debug_assistent import (
+ MAX_CHAT_EVAL_TIMEOUT,
+ MIN_CHAT_EVAL_TIMEOUT,
+ _clamp_chat_eval_timeout,
+ )
+
+ assert _clamp_chat_eval_timeout(5) == MIN_CHAT_EVAL_TIMEOUT
+ assert _clamp_chat_eval_timeout(9999) == MAX_CHAT_EVAL_TIMEOUT
+ assert _clamp_chat_eval_timeout("90") == 90.0
+
+
def test_assistent_compact_and_roles_local(monkeypatch, tmp_path):
from gpu_rent import debug_assistent