572 lines
22 KiB
Python
572 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
SysDeck - Klanker Bridge Helper (AI Gateway)
|
|
Author: Jeremy Anderson (https://dcos.net)
|
|
|
|
UPSTREAM ATTRIBUTION: this helper is a REST *client* of klanker-gate —
|
|
the "Frosty Deno" LLM gateway by TykoDev
|
|
(https://github.com/TykoDev/klanker-gate, Apache-2.0), which the master
|
|
tarball vendors unmodified at /klanker-gate. klanker-gate is NOT
|
|
SysDeck code and no upstream code is contained here — see
|
|
klanker-gate/ATTRIBUTION.md and THIRD_PARTY.md.
|
|
|
|
v0.3.0 NEW MODULE. Client of the vendored klanker-gate service — the
|
|
Frosty Deno LLM gateway (Deno 2 + TypeScript, REST on 127.0.0.1:8080) —
|
|
so sysdeck ships an operator view of the local inference gateway:
|
|
providers, virtual keys, request logs, spend/cost rollups, cache and
|
|
runtime topology, plus systemd service control.
|
|
|
|
This helper is a thin stdlib-only REST client (urllib.request + json,
|
|
4s timeout — no requests library, no curl dependency), the same
|
|
contract as bridge/fester.py. Every read subcommand prints the
|
|
service's JSON response; `status` merges the public /healthz and
|
|
/api/version probes and enriches them with the local connection
|
|
facts. HTTP error bodies (401 auth errors, 404s) are JSON on this
|
|
service and are surfaced verbatim. Connection failures are graceful:
|
|
{"ok": false, "error": ...} with a remediation hint, exit code 0 —
|
|
never a traceback.
|
|
|
|
Authentication: operator routes under /api/* take
|
|
`Authorization: Bearer <FROSTY_ADMIN_TOKEN>` when the gateway has one
|
|
configured. The token is read from KLANKER_ADMIN_TOKEN here and sent
|
|
as a header ONLY — it is never echoed in any output, never placed in
|
|
a URL, and journal output is scrubbed of its value defensively.
|
|
|
|
Subcommands:
|
|
status GET /healthz + /api/version, merged + enriched
|
|
providers GET /api/providers (browser-safe list)
|
|
models GET /v1/models (aggregated catalog)
|
|
vkeys GET /api/virtual-keys
|
|
logs [--limit N] GET /api/logs?limit=N (recent request ring,
|
|
default 25)
|
|
analytics GET /api/analytics (rollups: requests, spend,
|
|
cache, latency; optional --window 1h|24h|7d)
|
|
runtime GET /api/runtime (workers/cache/postgres)
|
|
service <action> systemctl start|stop|restart|status|enable|disable
|
|
klanker-gate.service
|
|
journal [N] journalctl -u klanker-gate -n N --no-pager
|
|
(default 40, sanitized)
|
|
localstack probe local AI backends (ollama, llama.cpp,
|
|
koboldcpp, lmstudio, sglang, vllm) + wiring recipes
|
|
|
|
Usage:
|
|
python3 /usr/lib/sysdeck/bridge/klanker.py status
|
|
python3 /usr/lib/sysdeck/bridge/klanker.py providers
|
|
python3 /usr/lib/sysdeck/bridge/klanker.py logs --limit 50
|
|
python3 /usr/lib/sysdeck/bridge/klanker.py service restart
|
|
KLANKER_URL=http://10.0.0.5:8080 KLANKER_ADMIN_TOKEN=... \\
|
|
python3 /usr/lib/sysdeck/bridge/klanker.py analytics
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
# The vendored klanker-gate service binds REST here by default
|
|
# (apps/gateway/main.ts: PORT env, default 8080). Override with
|
|
# KLANKER_URL when it lives elsewhere.
|
|
KLANKER_URL = os.environ.get("KLANKER_URL", "http://127.0.0.1:8080").rstrip("/")
|
|
|
|
# Optional bearer token for the gateway's admin surface (/api/* takes
|
|
# Authorization: Bearer <FROSTY_ADMIN_TOKEN> when the operator set one).
|
|
# Header-only usage — NEVER printed, NEVER in a URL.
|
|
KLANKER_ADMIN_TOKEN = os.environ.get("KLANKER_ADMIN_TOKEN")
|
|
|
|
# The systemd unit the service subcommand wraps. The gateway itself
|
|
# ships no unit (docker-compose / `deno task gateway` are its native
|
|
# runners); operators who deploy it natively use this name, matching
|
|
# the fester-service convention.
|
|
KLANKER_SERVICE = "klanker-gate.service"
|
|
|
|
# Strict 4s timeout — the panel polls every 5s, so a hung request must
|
|
# never outlive one refresh cycle.
|
|
KLANKER_TIMEOUT = 4 # seconds
|
|
|
|
# Connection-level failure messages (callers print this and exit 0 —
|
|
# graceful, same contract as fester.py and the other bridge helpers).
|
|
UNREACHABLE_MSG = (
|
|
"klanker-gate service unreachable at {url} — start it with "
|
|
"`systemctl start klanker-gate` (arch/ packaging) or `deno task dev` "
|
|
"in the vendored klanker-gate tree, or set KLANKER_URL"
|
|
)
|
|
|
|
|
|
def _unreachable() -> dict:
|
|
"""Return the graceful offline response (remediation hint included)."""
|
|
return {"ok": False, "error": UNREACHABLE_MSG.format(url=KLANKER_URL)}
|
|
|
|
|
|
def _base_port() -> int:
|
|
"""Port of the base URL (8080 for the default vendored service)."""
|
|
try:
|
|
return urllib.parse.urlparse(KLANKER_URL).port or 8080
|
|
except ValueError:
|
|
return 8080
|
|
|
|
|
|
def _request(path: str) -> dict:
|
|
"""One HTTP GET against the gateway. Returns parsed JSON.
|
|
|
|
HTTPError bodies are JSON on this service — surface them verbatim
|
|
(a 401 "Missing or invalid admin token." is a fact the operator
|
|
needs to see). Connection-level failures raise URLError/OSError;
|
|
the _get wrapper translates those into the graceful offline
|
|
response.
|
|
"""
|
|
url = KLANKER_URL + path
|
|
headers = {"Accept": "application/json"}
|
|
if KLANKER_ADMIN_TOKEN:
|
|
# Sent as a header only — the token value never appears in
|
|
# `url`, in any error string, or in any printed JSON.
|
|
headers["Authorization"] = f"Bearer {KLANKER_ADMIN_TOKEN}"
|
|
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=KLANKER_TIMEOUT) as resp:
|
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
raw = exc.read().decode("utf-8", errors="replace")
|
|
if raw.strip():
|
|
return json.loads(raw)
|
|
except (OSError, ValueError):
|
|
pass
|
|
return {"ok": False, "error": f"HTTP {exc.code}: {exc.reason}"}
|
|
try:
|
|
return json.loads(raw) if raw.strip() else {"ok": False, "error": f"empty response from {url}"}
|
|
except json.JSONDecodeError:
|
|
return {"ok": False, "error": f"non-JSON response from {url}"}
|
|
|
|
|
|
def _get(path: str) -> dict:
|
|
"""GET <path> with graceful offline handling."""
|
|
try:
|
|
return _request(path)
|
|
except (urllib.error.URLError, OSError, ValueError):
|
|
return _unreachable()
|
|
|
|
|
|
# ── subcommands ──────────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_status(_args: list[str]) -> dict:
|
|
"""GET /healthz + /api/version, merged + enriched with local facts.
|
|
|
|
Both probes are public (no admin token required). The merge keeps
|
|
the gateway's own fields (status, version, timestamp) and layers:
|
|
port — port of the base URL (8080 default)
|
|
base_url — the URL this helper is talking to
|
|
deno — Deno runtime version from /api/version
|
|
transport — "rest"
|
|
auth — whether an admin token is configured HERE (boolean;
|
|
the token value itself is never reported)
|
|
"""
|
|
try:
|
|
data = _request("/healthz")
|
|
except (urllib.error.URLError, OSError, ValueError):
|
|
return _unreachable()
|
|
if not isinstance(data, dict) or data.get("status") != "ok":
|
|
# Non-healthy gateway (or an error body) — surface it verbatim.
|
|
if isinstance(data, dict) and data.get("ok") is not False:
|
|
data = dict(data)
|
|
data.setdefault("status", "error")
|
|
return data if isinstance(data, dict) else {"ok": False, "error": "non-object healthz response"}
|
|
|
|
out = dict(data)
|
|
# /api/version is best-effort — a healthy gateway always serves it,
|
|
# but a failure here must not sink the status probe.
|
|
version = _get("/api/version")
|
|
if isinstance(version, dict) and version.get("ok") is not False:
|
|
out["deno"] = version.get("deno")
|
|
if version.get("version"):
|
|
out["version"] = version["version"]
|
|
out["ok"] = True
|
|
out["port"] = _base_port()
|
|
out["base_url"] = KLANKER_URL
|
|
out["transport"] = "rest"
|
|
out["auth"] = bool(KLANKER_ADMIN_TOKEN)
|
|
return out
|
|
|
|
|
|
def cmd_logs(args: list[str]) -> dict:
|
|
"""GET /api/logs — the recent request ring, optionally limited.
|
|
|
|
Usage: logs [--limit N] (default 25, range 1-500)
|
|
"""
|
|
limit = 25
|
|
i = 0
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg == "--limit" and i + 1 < len(args):
|
|
raw = args[i + 1]
|
|
i += 2
|
|
try:
|
|
limit = int(raw)
|
|
except ValueError:
|
|
return {"ok": False, "error": f"limit must be an integer between 1 and 500: {raw!r}"}
|
|
if not 1 <= limit <= 500:
|
|
return {"ok": False, "error": f"limit must be an integer between 1 and 500: {limit}"}
|
|
else:
|
|
return {"ok": False, "error": f"unknown argument: {arg}"}
|
|
return _get(f"/api/logs?limit={limit}")
|
|
|
|
|
|
def cmd_analytics(args: list[str]) -> dict:
|
|
"""GET /api/analytics — rollups (requests, spend, cache, latency).
|
|
|
|
Usage: analytics [--window 1h|24h|7d] (default 24h)
|
|
"""
|
|
window = "24h"
|
|
i = 0
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg == "--window" and i + 1 < len(args):
|
|
window = args[i + 1]
|
|
i += 2
|
|
if window not in ("1h", "24h", "7d"):
|
|
return {"ok": False, "error": f"window must be one of 1h, 24h, 7d: {window!r}"}
|
|
else:
|
|
return {"ok": False, "error": f"unknown argument: {arg}"}
|
|
return _get(f"/api/analytics?window={window}")
|
|
|
|
|
|
# ── systemd service control ─────────────────────────────────────────
|
|
#
|
|
# Same pattern as bridge/jellyfin.py / bridge/mining.py: plain
|
|
# subprocess.run(["systemctl", ...]) with capture_output, check=False,
|
|
# a timeout, and a structured result. No `sudo` shell-out — the JS
|
|
# panel's bridgeCmd already passes superuser:'try' so cockpit prompts
|
|
# via polkit.
|
|
|
|
SERVICE_ACTIONS = ("start", "stop", "restart", "status", "enable", "disable")
|
|
|
|
|
|
def _have(binary: str) -> bool:
|
|
"""True if binary is on PATH."""
|
|
return shutil.which(binary) is not None
|
|
|
|
|
|
def _service_status() -> dict:
|
|
"""Read-only unit state: active/sub + enabled-at-boot."""
|
|
if not _have("systemctl"):
|
|
return {"ok": False, "error": "systemctl not on PATH"}
|
|
try:
|
|
r = subprocess.run(
|
|
["systemctl", "show", KLANKER_SERVICE,
|
|
"--property=ActiveState,SubState,UnitFileState,ActiveEnterTimestamp"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
props = dict(
|
|
line.split("=", 1)
|
|
for line in r.stdout.strip().splitlines()
|
|
if "=" in line
|
|
)
|
|
active = props.get("ActiveState", "unknown")
|
|
sub = props.get("SubState", "unknown")
|
|
enabled = props.get("UnitFileState", "unknown")
|
|
return {
|
|
"ok": True,
|
|
"service": KLANKER_SERVICE,
|
|
"active": active,
|
|
"sub": sub,
|
|
"enabled": enabled,
|
|
"running": active == "active",
|
|
}
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
|
|
def cmd_service(args: list[str]) -> dict:
|
|
"""systemctl wrapper for klanker-gate.service.
|
|
|
|
Usage: service <start|stop|restart|status|enable|disable>
|
|
"""
|
|
if not args:
|
|
return {"ok": False, "error": "action required: service <start|stop|restart|status|enable|disable>"}
|
|
action = args[0]
|
|
if action not in SERVICE_ACTIONS:
|
|
return {"ok": False, "error": f"unknown action: {action!r} (expected one of {', '.join(SERVICE_ACTIONS)})"}
|
|
if len(args) > 1:
|
|
return {"ok": False, "error": f"unknown argument: {args[1]}"}
|
|
|
|
if action == "status":
|
|
return _service_status()
|
|
|
|
if not _have("systemctl"):
|
|
return {"ok": False, "error": "systemctl not on PATH"}
|
|
timeout = 30 if action == "restart" else 15
|
|
try:
|
|
r = subprocess.run(
|
|
["systemctl", action, KLANKER_SERVICE],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
)
|
|
return {
|
|
"ok": r.returncode == 0,
|
|
"action": action,
|
|
"service": KLANKER_SERVICE,
|
|
"rc": r.returncode,
|
|
"output": (r.stdout or "").strip(),
|
|
"stderr": (r.stderr or "").strip(),
|
|
}
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
return {"ok": False, "action": action, "service": KLANKER_SERVICE,
|
|
"rc": 1, "stderr": str(exc)}
|
|
|
|
|
|
# ── journal ─────────────────────────────────────────────────────────
|
|
#
|
|
# journalctl tail, sanitized like the suite's other raw-output
|
|
# helpers (netsec/grafana _sanitize_output pattern): ANSI escapes and
|
|
# non-printable control chars are stripped, the output is capped, and
|
|
# — defensively — the admin token value is masked if it somehow ends
|
|
# up in a log line.
|
|
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
|
|
_JOURNAL_MAX_CHARS = 32_768 # 32 KB cap — the panel renders it monospace
|
|
|
|
|
|
def _sanitize_journal(text: str) -> str:
|
|
"""Strip ANSI/control chars, cap length, mask the admin token."""
|
|
if not text:
|
|
return ""
|
|
text = _ANSI_RE.sub("", text)
|
|
text = "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
|
|
if KLANKER_ADMIN_TOKEN:
|
|
# NEVER echo the token, even if the service logged it.
|
|
text = text.replace(KLANKER_ADMIN_TOKEN, "***")
|
|
if len(text) > _JOURNAL_MAX_CHARS:
|
|
text = text[:_JOURNAL_MAX_CHARS] + " ... (truncated)"
|
|
return text
|
|
|
|
|
|
def cmd_journal(args: list[str]) -> dict:
|
|
"""journalctl -u klanker-gate -n N --no-pager (default 40 lines).
|
|
|
|
Usage: journal [N] (range 1-1000)
|
|
"""
|
|
lines = 40
|
|
if args:
|
|
try:
|
|
lines = int(args[0])
|
|
except ValueError:
|
|
return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {args[0]!r}"}
|
|
if not 1 <= lines <= 1000:
|
|
return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {lines}"}
|
|
if len(args) > 1:
|
|
return {"ok": False, "error": f"unknown argument: {args[1]}"}
|
|
|
|
if not _have("journalctl"):
|
|
return {"ok": False, "error": "journalctl not on PATH"}
|
|
try:
|
|
r = subprocess.run(
|
|
["journalctl", "-u", "klanker-gate",
|
|
"-n", str(lines), "--no-pager"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
log = _sanitize_journal(r.stdout or "")
|
|
return {
|
|
"ok": r.returncode == 0,
|
|
"service": "klanker-gate",
|
|
"lines": lines,
|
|
"count": log.count("\n") + 1 if log else 0,
|
|
"log": log,
|
|
"stderr": (r.stderr or "").strip(),
|
|
}
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
|
|
# ── local stack wiring (probed) ──────────────────────────────────────
|
|
|
|
# The gateway is NOT SaaS-only: ollama / lmstudio / sglang are native
|
|
# keyless provider types upstream (packages/contracts/src/provider-
|
|
# registry.ts), and llama.cpp (llama-server) / koboldcpp / vLLM / TGI /
|
|
# any OpenAI-wire server plug in through the generic "openai-compatible"
|
|
# type. This catalog mirrors the web edition's LOCAL_BACKENDS and the
|
|
# upstream defaults (packages/providers/src/openai_compat.ts).
|
|
LOCAL_BACKENDS = [
|
|
{
|
|
"id": "ollama",
|
|
"name": "Ollama",
|
|
"provider_type": "ollama",
|
|
"base_url": "http://127.0.0.1:11434/v1",
|
|
"auth": "none",
|
|
"caps": "streaming, tools, embeddings",
|
|
"env_wiring": "OLLAMA_BASE_URL=http://127.0.0.1:11434/v1\n"
|
|
"OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text",
|
|
"note": "native provider type — keyless local daemon",
|
|
},
|
|
{
|
|
"id": "llamacpp",
|
|
"name": "llama.cpp (llama-server)",
|
|
"provider_type": "openai-compatible",
|
|
"base_url": "http://127.0.0.1:8081/v1",
|
|
"auth": "key optional",
|
|
"caps": "streaming, tools",
|
|
"env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1\n"
|
|
"OPENAI_COMPAT_DEFAULT_MODEL=qwen2.5-coder-7b",
|
|
"note": "llama-server DEFAULTS TO :8080 — the gateway's own port. "
|
|
"Run it elsewhere (8081 here) or move the gateway",
|
|
},
|
|
{
|
|
"id": "koboldcpp",
|
|
"name": "KoboldCpp",
|
|
"provider_type": "openai-compatible",
|
|
"base_url": "http://127.0.0.1:5001/v1",
|
|
"auth": "key optional",
|
|
"caps": "streaming, tools",
|
|
"env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:5001/v1",
|
|
"note": "koboldcpp serves the OpenAI wire on its main port",
|
|
},
|
|
{
|
|
"id": "lmstudio",
|
|
"name": "LM Studio",
|
|
"provider_type": "lmstudio",
|
|
"base_url": "http://127.0.0.1:1234/v1",
|
|
"auth": "none",
|
|
"caps": "streaming, tools, embeddings",
|
|
"env_wiring": "LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1",
|
|
"note": "native provider type",
|
|
},
|
|
{
|
|
"id": "sglang",
|
|
"name": "SGLang",
|
|
"provider_type": "sgl",
|
|
"base_url": "http://127.0.0.1:30000/v1",
|
|
"auth": "none",
|
|
"caps": "streaming, tools, embeddings",
|
|
"env_wiring": None,
|
|
"note": "native provider type — self-hosted serving framework",
|
|
},
|
|
{
|
|
"id": "vllm",
|
|
"name": "vLLM",
|
|
"provider_type": "openai-compatible",
|
|
"base_url": "http://127.0.0.1:8000/v1",
|
|
"auth": "key optional",
|
|
"caps": "streaming, tools",
|
|
"env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8000/v1",
|
|
"note": "via the generic openai-compatible account",
|
|
},
|
|
]
|
|
|
|
# Probes run in parallel threads (0.4s timeout each) so the whole
|
|
# subcommand answers in well under the 4s bridge budget.
|
|
LOCAL_PROBE_TIMEOUT = 0.4
|
|
|
|
|
|
def _probe_one(backend: dict) -> dict:
|
|
"""GET <base_url>/models with a 0.4s timeout; offline = reachable:False."""
|
|
url = backend["base_url"].rstrip("/") + "/models"
|
|
req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET")
|
|
started = time.monotonic()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=LOCAL_PROBE_TIMEOUT) as resp:
|
|
ok = 200 <= resp.status < 300
|
|
except (urllib.error.URLError, OSError, ValueError):
|
|
ok = False
|
|
out = dict(backend)
|
|
out["reachable"] = ok
|
|
out["latency_ms"] = round((time.monotonic() - started) * 1000) if ok else None
|
|
return out
|
|
|
|
|
|
def cmd_localstack(_args: list[str]) -> dict:
|
|
"""Probe the local AI-stack backends from this host and return the
|
|
wiring recipes (provider type, base URL, env / admin-API examples).
|
|
|
|
Always LIVE — the probes do not involve the gateway at all: green
|
|
means that local daemon answered /v1/models on this machine. This
|
|
is the wiring aid for an all-local inference stack (ollama,
|
|
llama.cpp, koboldcpp, LM Studio, SGLang, vLLM).
|
|
"""
|
|
workers = []
|
|
threads = []
|
|
for backend in LOCAL_BACKENDS:
|
|
worker = {"backend": backend, "result": None}
|
|
workers.append(worker)
|
|
|
|
def run(b=backend, w=worker):
|
|
w["result"] = _probe_one(b)
|
|
|
|
thread = threading.Thread(target=run)
|
|
thread.start()
|
|
threads.append(thread)
|
|
for thread in threads:
|
|
thread.join(timeout=LOCAL_PROBE_TIMEOUT + 0.2)
|
|
|
|
backends = [w["result"] or {**w["backend"], "reachable": False, "latency_ms": None} for w in workers]
|
|
reachable = sum(1 for b in backends if b.get("reachable"))
|
|
token_note = "$KLANKER_ADMIN_TOKEN"
|
|
example_lines = [
|
|
f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' "
|
|
"-H 'content-type: application/json' "
|
|
"-d '{\"id\":\"llama-server\",\"type\":\"openai-compatible\","
|
|
"\"baseUrl\":\"http://127.0.0.1:8081/v1\",\"enabled\":true,"
|
|
"\"models\":[\"qwen2.5-coder-7b\"]}'",
|
|
f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' "
|
|
"-H 'content-type: application/json' "
|
|
"-d '{\"id\":\"koboldcpp\",\"type\":\"openai-compatible\","
|
|
"\"baseUrl\":\"http://127.0.0.1:5001/v1\",\"enabled\":true,"
|
|
"\"models\":[\"mistral-nemo-12b\"]}'",
|
|
"# auto-discover the model catalog after registering:",
|
|
f"curl -s -X POST {KLANKER_URL}/api/providers/llama-server/refresh-models "
|
|
f"-H 'Authorization: Bearer {token_note}'",
|
|
]
|
|
return {
|
|
"ok": True,
|
|
"source": "live",
|
|
"backends": backends,
|
|
"count": len(backends),
|
|
"reachable": reachable,
|
|
"base_url": KLANKER_URL,
|
|
"admin_register_example": "\n".join(example_lines),
|
|
"note": (
|
|
f"probed from this host (0.4s timeout each): {reachable}/{len(backends)} "
|
|
"local backends answered /v1/models. ollama + lmstudio register via env; "
|
|
"llama.cpp + koboldcpp + vllm register as openai-compatible accounts "
|
|
"(env registers ONE such account — use POST /api/providers for several). "
|
|
"Port note: llama-server defaults to :8080, the gateway's own port"
|
|
),
|
|
}
|
|
|
|
|
|
# ── dispatch table ───────────────────────────────────────────────────
|
|
|
|
COMMANDS = {
|
|
"status": lambda _args: cmd_status(_args),
|
|
"providers": lambda _args: _get("/api/providers"),
|
|
"models": lambda _args: _get("/v1/models"),
|
|
"vkeys": lambda _args: _get("/api/virtual-keys"),
|
|
"logs": cmd_logs,
|
|
"analytics": cmd_analytics,
|
|
"runtime": lambda _args: _get("/api/runtime"),
|
|
"service": cmd_service,
|
|
"journal": cmd_journal,
|
|
"localstack": cmd_localstack,
|
|
}
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if not argv or argv[0] in ("-h", "--help"):
|
|
print(__doc__)
|
|
return 0
|
|
cmd = COMMANDS.get(argv[0])
|
|
if not cmd:
|
|
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
|
|
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
|
|
return 2
|
|
print(json.dumps(cmd(argv[1:]), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|