litellm_config.yaml - client-visible model_list reduced to capability names: syslog-auto, gpu-dense, strix-moe, gpu-vision - retired qwen3.6-27B-code, qwen3.8-27B-uncensored, qwen3.6-35B-udq4 (and the already-dead gemma-4-12b) - fallbacks and model_cost re-keyed to the surviving names - upstream litellm_params.model set to the capability names; the backends ignore the model field (verified HTTP 200 on all three hosts), so this needs no llama.cpp relaunch and loses no KV warmth - verified live: /v1/models advertises exactly the four names, each returns 200 through nginx gpu_roster.yaml - launch args replaced with the VERIFIED live command lines, read from the running processes via the PVE guest agent (acerpve VM 101 = .8, ocupve VM 103 = .110) and on amdpve (.15) - .8: model_path corrected to Qwen3.8-27B-Uncensored-Q4_K_M.gguf, max_concurrent 2 -> 1, context 262144 -> 131072, full arg list recorded (incl. --parallel 1 and the new --slot-save-path / --metrics added 2026-09-12) - .15: model_path corrected to Carnice-Qwen3.6-MoE-35B-A3B-Q4_K_M.gguf, full arg list recorded - keys renamed to the capability names; hosts.current_model aligned README.md / dashboard - README dense entry corrected (1 slot, 131K ctx, actual model file) - dashboard picker now uses capability names; fixed the "Gemma 4 12B" / "12B VLM" labels (the RTX 5070 serves a 9B Qwen3.5) and the gpu-vision -> gpu-light id mapping scripts/ - added the three operational monitors as tracked files (they were untracked): gpu-monitor.py, gpu-self-heal.py, litellm-health-check.sh - cleared their references to retired model names, which were causing failed calls every benchmark cycle (150 failed gemma-4-12b calls in the last 7 days); gpu-monitor.py's .110 entry also wrongly listed .8's model Intentionally NOT changed - LITELLM-MIGRATION-PLAN.md: historical planning document (June 14), not a live-state claim - backups/, graphify-out/, litellm_config.yaml.backup: historical artifacts - unrelated untracked files (router.py, docker-compose.yml.pre-1991-20260911, nginx/default.conf, dashboard/gpu-monitor.html, scripts/gitea-logger.sh): out of scope for this change
392 lines
14 KiB
Python
392 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""GPU Fleet Monitor — Comprehensive monitoring server.
|
|
|
|
Monitors every subsystem in the inference harness:
|
|
- GPU sidecars (VRAM, temp, util, power per card)
|
|
- Router (model routing, circuit breakers, queue health)
|
|
- LiteLLM (proxy health, key count, model sync)
|
|
- Strix Halo (llama-server status, CPU load, context)
|
|
- Dashboard (CT 116 harness-dashboard)
|
|
|
|
Endpoints:
|
|
/ → GPU fleet HTML dashboard
|
|
/gpu-data → JSON GPU metrics (for dashboard API)
|
|
/health → Monitor self-health check
|
|
|
|
Run: python3 gpu-monitor-server.py
|
|
Default port: 9100
|
|
"""
|
|
|
|
import json, subprocess, http.server, threading, time, os
|
|
from datetime import datetime, timezone
|
|
|
|
DASHBOARD_PATH = "/root/dashboard/gpu-fleet.html"
|
|
PORT = 9100
|
|
|
|
# Cached data, refreshed every 15s
|
|
cache = {}
|
|
cache_lock = threading.Lock()
|
|
|
|
# Alert thresholds
|
|
THRESHOLDS = {
|
|
"temp_c": {"warning": 80, "critical": 90},
|
|
"vram_pct": {"warning": 90, "critical": 95},
|
|
"gpu_util_pct": {"warning": 95, "critical": 98},
|
|
}
|
|
|
|
|
|
def http_get(url, timeout=5):
|
|
"""HTTP GET with timeout, returns parsed JSON or error dict."""
|
|
import urllib.request
|
|
try:
|
|
resp = urllib.request.urlopen(url, timeout=timeout)
|
|
return json.loads(resp.read())
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def poll_sidecar(host, port=8090):
|
|
"""Poll a GPU sidecar for raw metrics.
|
|
Falls back to SSH-based nvidia-smi if sidecar is unreachable."""
|
|
result = http_get(f"http://{host}:{port}/health", timeout=5)
|
|
if "error" not in result:
|
|
return result
|
|
# Fallback: poll via SSH (explicit key path for non-interactive environments)
|
|
try:
|
|
proc = subprocess.run(
|
|
["/usr/bin/ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
|
"-i", "/root/.ssh/id_ed25519", host,
|
|
"nvidia-smi", "--query-gpu=name,temperature.gpu,utilization.gpu,"
|
|
"utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed",
|
|
"--format=csv,noheader"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if proc.returncode == 0:
|
|
parts = [p.strip() for p in proc.stdout.strip().split(",")]
|
|
if len(parts) >= 8:
|
|
def cv(v):
|
|
try: return float(v.replace("MiB","").replace("W","").replace("%","").strip())
|
|
except: return 0.0
|
|
def mv(v):
|
|
try: return int(v.replace("MiB","").strip())
|
|
except: return 0
|
|
return {
|
|
"gpu_name": parts[0], "temp_c": cv(parts[1]),
|
|
"gpu_util_pct": cv(parts[2]), "mem_util_pct": cv(parts[3]),
|
|
"vram_used_mb": mv(parts[4]), "vram_total_mb": mv(parts[5]),
|
|
"power_w": cv(parts[6]), "power_limit_w": cv(parts[7]),
|
|
"fan_pct": cv(parts[8])
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
return result
|
|
|
|
|
|
def poll_router():
|
|
"""Poll router unified health through nginx (port 80)."""
|
|
return http_get("http://192.168.68.116/health/unified", timeout=5)
|
|
|
|
|
|
def poll_router_health():
|
|
"""Poll router basic health through nginx."""
|
|
return http_get("http://192.168.68.116/health", timeout=5)
|
|
|
|
|
|
def poll_litellm_health():
|
|
"""Poll LiteLLM — checks reachability via nginx proxy.
|
|
|
|
LiteLLM health endpoints require authentication. We check if the
|
|
service responds at all (even 401 = service is up). Also try the
|
|
/key/list endpoint which confirms the proxy is fully functional.
|
|
"""
|
|
import urllib.request
|
|
import urllib.error
|
|
urls_to_try = [
|
|
"http://192.168.68.116/litellm/health",
|
|
"http://192.168.68.116/litellm/ui/",
|
|
]
|
|
last_error = None
|
|
for url in urls_to_try:
|
|
try:
|
|
resp = urllib.request.urlopen(url, timeout=5)
|
|
body = resp.read().decode()[:500]
|
|
# Any response (even 401) means the proxy is routing to LiteLLM
|
|
return {"reachable": True, "status_code": resp.getcode(),
|
|
"endpoint": url, "body_preview": body[:200]}
|
|
except urllib.error.HTTPError as e:
|
|
# HTTP error means the service IS reachable but returned error
|
|
return {"reachable": True, "status_code": e.code,
|
|
"endpoint": url, "note": f"HTTP {e.code} (requires auth)"}
|
|
except Exception as e:
|
|
last_error = str(e)
|
|
return {"error": last_error or "all endpoints failed"}
|
|
|
|
|
|
def poll_strix():
|
|
"""Poll Strix Halo — process check + CPU load + llama-server health."""
|
|
try:
|
|
# Check llama-server process
|
|
proc = subprocess.run(
|
|
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
|
"192.168.68.15",
|
|
"pgrep -f llama-server > /dev/null 2>&1 && echo running || echo stopped"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
status = proc.stdout.strip()
|
|
|
|
# Get CPU load
|
|
load = subprocess.run(
|
|
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
|
"192.168.68.15",
|
|
"uptime | awk -F'load average:' '{print $2}' | tr -d ' '"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
cpu_load = load.stdout.strip() if load.returncode == 0 else "unknown"
|
|
|
|
# Try llama-server health endpoint
|
|
llama_health = http_get("http://192.168.68.15:8080/health", timeout=3)
|
|
|
|
return {
|
|
"status": status if status else "unknown",
|
|
"cpu_load": cpu_load,
|
|
"llama_health": llama_health if "error" not in llama_health else None
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
def poll_dashboard():
|
|
"""Check if harness-dashboard on CT 116 is serving."""
|
|
return http_get("http://192.168.68.116/dashboard/", timeout=5)
|
|
|
|
|
|
def check_alerts(gpu_data):
|
|
"""Check GPU metrics against thresholds, return alert list."""
|
|
alerts = []
|
|
name = gpu_data.get("name", "unknown")
|
|
for metric, thresholds in THRESHOLDS.items():
|
|
# Map metric names from sidecar/router formats
|
|
value = None
|
|
if metric == "vram_pct":
|
|
# Compute from vram_used_mb / vram_total_mb
|
|
used = gpu_data.get("vram_used_mb", 0)
|
|
total = gpu_data.get("vram_total_mb", 1)
|
|
value = (used / total) * 100
|
|
elif metric == "gpu_util_pct":
|
|
value = gpu_data.get("gpu_util_pct", 0)
|
|
elif metric == "temp_c":
|
|
value = gpu_data.get("temp_c", 0)
|
|
|
|
if value is not None:
|
|
if value >= thresholds["critical"]:
|
|
alerts.append({"gpu": name, "metric": metric, "level": "critical",
|
|
"value": round(value, 1), "threshold": thresholds["critical"]})
|
|
elif value >= thresholds["warning"]:
|
|
alerts.append({"gpu": name, "metric": metric, "level": "warning",
|
|
"value": round(value, 1), "threshold": thresholds["warning"]})
|
|
return alerts
|
|
|
|
|
|
def compute_summary(router_data, gpus, litellm_data, strix_data):
|
|
"""Compute fleet-wide health summary."""
|
|
# Count models available
|
|
models_available = 0
|
|
models_total = 0
|
|
if "available_models" in router_data:
|
|
models_available = len(router_data["available_models"])
|
|
models_total = models_available # router reports all expected
|
|
|
|
# Count circuit breakers open
|
|
cb_open = 0
|
|
if "circuit_breaker" in router_data:
|
|
for model, cb in router_data["circuit_breaker"].items():
|
|
if cb.get("open", 0):
|
|
cb_open += 1
|
|
|
|
# Count GPU errors
|
|
gpu_errors = sum(1 for g in gpus if "error" in g)
|
|
|
|
# Fleet status
|
|
if gpu_errors > 0 or cb_open > 0:
|
|
fleet_status = "degraded"
|
|
elif not router_data or "error" in router_data:
|
|
fleet_status = "degraded"
|
|
else:
|
|
fleet_status = "healthy"
|
|
|
|
litellm_ok = ("error" not in litellm_data) or litellm_data.get("reachable", False)
|
|
return {
|
|
"fleet_status": fleet_status,
|
|
"models_available": models_available,
|
|
"models_total": models_total,
|
|
"circuit_breakers_open": cb_open,
|
|
"gpu_count": len(gpus),
|
|
"gpu_errors": gpu_errors,
|
|
"strix_running": strix_data.get("status") == "running",
|
|
"router_reachable": "error" not in router_data,
|
|
"litellm_reachable": litellm_ok,
|
|
}
|
|
|
|
|
|
def refresh_cache():
|
|
"""Refresh all fleet data from every subsystem."""
|
|
global cache
|
|
|
|
# 1. GPU sidecars
|
|
gpu_map = {
|
|
"192.168.68.8": {"name": "Dense (RTX 3090)", "models": ["gpu-dense"], "hostname": "ct8"},
|
|
"192.168.68.110": {"name": "Light/Vision (RTX 5070)", "models": ["gpu-vision"], "hostname": "ct110"},
|
|
}
|
|
|
|
gpus = []
|
|
all_alerts = []
|
|
for host, info in gpu_map.items():
|
|
data = poll_sidecar(host)
|
|
if "error" not in data:
|
|
data["name"] = info["name"]
|
|
data["models"] = info["models"]
|
|
data["hostname"] = info["hostname"]
|
|
all_alerts.extend(check_alerts(data))
|
|
else:
|
|
data = {"name": info["name"], "hostname": info["hostname"], "error": data["error"]}
|
|
all_alerts.append({"gpu": info["name"], "metric": "connectivity",
|
|
"level": "critical", "value": data["error"]})
|
|
gpus.append(data)
|
|
|
|
# 2. Router
|
|
router = poll_router()
|
|
router_basic = poll_router_health()
|
|
router["_basic"] = router_basic
|
|
|
|
# 3. LiteLLM
|
|
litellm = poll_litellm_health()
|
|
|
|
# 4. Strix Halo
|
|
strix = poll_strix()
|
|
|
|
# 5. Dashboard
|
|
dashboard = poll_dashboard()
|
|
|
|
# 6. Send Zulip DM for critical alerts (debounced 5 min)
|
|
critical = [a for a in all_alerts if a.get("level") == "critical"]
|
|
if critical and _debounce_alert():
|
|
msg = "🔴 **GPU Fleet Alert**\n"
|
|
for a in critical:
|
|
msg += f"• {a['gpu']}: {a['metric']} = {a.get('value', a.get('actual', '?'))}\n"
|
|
_send_zulip_dm(msg)
|
|
|
|
# 7. Summary
|
|
summary = compute_summary(router, gpus, litellm, strix)
|
|
|
|
with cache_lock:
|
|
cache = {
|
|
"gpus": gpus,
|
|
"strix": strix,
|
|
"router": router,
|
|
"litellm": litellm,
|
|
"dashboard": {"reachable": "error" not in dashboard},
|
|
"summary": summary,
|
|
"alerts": all_alerts,
|
|
"updated": datetime.now(timezone.utc).isoformat(),
|
|
"monitor_version": "2.0.0",
|
|
}
|
|
|
|
|
|
_last_alert_time = 0
|
|
_ALERT_COOLDOWN = 300 # 5 min between DMs
|
|
|
|
def _send_zulip_dm(msg):
|
|
"""Send a DM to the owner via Zulip."""
|
|
try:
|
|
site = "https://chat.sysloggh.net"
|
|
email = "abiba-bot@chat.sysloggh.net"
|
|
key = "cKTDMZAPW08dk3zl05sStzO7HRztzyn8"
|
|
owner = 9
|
|
auth_b64 = base64.b64encode(f"{email}:{key}".encode()).decode()
|
|
data = urllib.parse.urlencode({"type": "private", "to": f"[{owner}]", "content": msg}).encode()
|
|
req = urllib.request.Request(f"{site}/api/v1/messages", data=data,
|
|
headers={"Authorization": f"Basic {auth_b64}"}, method="POST")
|
|
urllib.request.urlopen(req, timeout=5)
|
|
except Exception as e:
|
|
print(f"[alert] DM failed: {e}")
|
|
|
|
def _debounce_alert():
|
|
"""Returns True if enough time has passed since last alert."""
|
|
global _last_alert_time
|
|
now = time.time()
|
|
if now - _last_alert_time > _ALERT_COOLDOWN:
|
|
_last_alert_time = now
|
|
return True
|
|
return False
|
|
|
|
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path == "/gpu-data":
|
|
with cache_lock:
|
|
data = json.dumps(cache, indent=2)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
self.wfile.write(data.encode())
|
|
elif self.path == "/health":
|
|
with cache_lock:
|
|
healthy = bool(cache and "summary" in cache)
|
|
status = {"status": "healthy" if healthy else "starting",
|
|
"updated": cache.get("updated", "never") if cache else "never"}
|
|
code = 200 if healthy else 503
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(status).encode())
|
|
elif self.path in ("/", "/index.html"):
|
|
try:
|
|
with open(DASHBOARD_PATH) as f:
|
|
html = f.read()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html")
|
|
self.end_headers()
|
|
self.wfile.write(html.encode())
|
|
except FileNotFoundError:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
self.wfile.write(b"Dashboard not found")
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def log_message(self, format, *args):
|
|
pass # Suppress request logs
|
|
|
|
|
|
def background_refresh():
|
|
"""Refresh data every 15 seconds."""
|
|
while True:
|
|
try:
|
|
refresh_cache()
|
|
except Exception as e:
|
|
print(f"[WARN] Refresh failed: {e}", flush=True)
|
|
time.sleep(15)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"Starting Comprehensive GPU Fleet Monitor v2.0.0 on port {PORT}...")
|
|
print(f" Dashboard: http://localhost:{PORT}/")
|
|
print(f" API: http://localhost:{PORT}/gpu-data")
|
|
print(f" Health: http://localhost:{PORT}/health")
|
|
print(f" Polling: router (nginx:80), sidecars (:8090), strix, litellm, dashboard")
|
|
|
|
# Initial fetch
|
|
refresh_cache()
|
|
|
|
# Start background refresher
|
|
t = threading.Thread(target=background_refresh, daemon=True)
|
|
t.start()
|
|
|
|
# Start HTTP server
|
|
server = http.server.HTTPServer(("0.0.0.0", PORT), DashboardHandler)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nShutting down...")
|