Files
club-3090/tools/model-switch/server.py
T
John Shojaei c22a9d2d84 tools: add HTTP model-switch service (thin wrapper over switch.sh)
Adds a stdlib HTTP control plane that wraps scripts/switch.sh so a harness
can POST /switch and block until the new model is serving. Introduces no new
orchestration logic — switch.sh stays the single source of truth (registry
lookup, down/up, readiness).

- tools/model-switch/server.py: GET /healthz|/status|/models, POST /switch
  ({slug}|{model}); registry-validated; /health readiness (works with or
  without VLLM_API_KEY); single-flight lock; refuses to start unauthenticated
  on a non-loopback bind.
- scripts/systemd/club3090-model-switch.service: host daemon unit.
- scripts/tests/test-model-switch.sh: hermetic HTTP/auth/validation contract.
- docs/EXAMPLES.md, .env.example: usage + config.

Mirrors the existing stdlib HTTP style (services/studio/*); zero new deps.
Experimental/opt-in per the repo's staging convention.
2026-07-03 17:44:17 -07:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""model-switch — a tiny host HTTP wrapper around scripts/switch.sh.
Only one model fits in VRAM at a time on a 12 GPU rig, so switching between
models (e.g. Qwen3.6-27B <-> Gemma-4-31B) for cross-model experiments means
tearing one down and booting another. This exposes that over HTTP so a harness
can POST /switch and block until the new model is serving.
It adds NO orchestration logic: `scripts/switch.sh` remains the single source of
truth (registry lookup, down->up, readiness). This is a thin wrapper — the same
role `tools/serve-cockpit` plays as a TUI, done as an HTTP endpoint. stdlib-only
(http.server), matching services/studio/*/server.py.
Endpoints (Bearer auth on all but /healthz, when a token is configured):
GET /healthz -> {"ok": true}
GET /status -> {"current_model", "ready", "port", "container"}
GET /models -> {"available": [{"slug","model","status","port"}, ...]}
POST /switch -> body {"slug": "<registry-slug>"} OR {"model": "<model-id>"}
blocks until ready; 200 {"ok","slug","model","took_s"}
400 unknown/ambiguous · 401 bad token · 409 in-progress · 500 {ok:false,detail}
Config (env; systemd loads them from the repo-root .env):
CLUB3090_API_TOKEN control-endpoint bearer token (falls back to VLLM_API_KEY).
If neither is set, the endpoint is UNAUTHENTICATED (loopback only).
MODEL_SWITCH_PORT listen port (default 8099)
MODEL_SWITCH_BIND bind address (default 127.0.0.1)
PORT model http port used for the readiness probe / status (else the
target slug's registry default_port)
SWITCH_SCRIPT path to switch.sh (default <repo>/scripts/switch.sh; overridable for tests)
Run: python3 tools/model-switch/server.py (or the club3090-model-switch systemd unit)
"""
from __future__ import annotations
import functools
import hmac
import json
import os
import re
import subprocess
import sys
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from scripts.lib.profiles.compose_registry import ( # noqa: E402
COMPOSE_REGISTRY,
curated_default_target,
model_of_slug,
)
BIND = os.environ.get("MODEL_SWITCH_BIND", "127.0.0.1")
PORT = int(os.environ.get("MODEL_SWITCH_PORT", "8099"))
# Control-endpoint token: dedicated var first, then reuse VLLM_API_KEY so a
# secured rig needs no second secret. Empty -> endpoint is open (loopback only).
CONTROL_TOKEN = os.environ.get("CLUB3090_API_TOKEN") or os.environ.get("VLLM_API_KEY", "")
# Token for talking to the MODEL's own OpenAI API (/v1/models is auth-gated when set).
MODEL_TOKEN = os.environ.get("VLLM_API_KEY", "")
SWITCH_SCRIPT = os.environ.get("SWITCH_SCRIPT") or str(REPO_ROOT / "scripts" / "switch.sh")
SWITCH_TIMEOUT_S = int(os.environ.get("MODEL_SWITCH_TIMEOUT_S", "600"))
DOCKER_BIN = os.environ.get("DOCKER_BIN", "docker") # overridable for tests
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "::ffff:127.0.0.1"}
# Only one switch may run at a time (a switch tears down + boots a model).
_switch_lock = threading.Lock()
MODEL_IDS = sorted({e["model"] for e in COMPOSE_REGISTRY.values()})
class SwitchError(Exception):
def __init__(self, code: int, message: str, **extra):
super().__init__(message)
self.code = code
self.payload = {"error": message, **extra}
def _topology() -> str:
"""dual/single from visible GPU count (overridable via CLUB3090_TOPOLOGY)."""
forced = os.environ.get("CLUB3090_TOPOLOGY")
if forced:
return forced
try:
out = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True, timeout=10).stdout
n = sum(1 for line in out.splitlines() if line.startswith("GPU "))
except Exception:
n = 2
return "single" if n <= 1 else ("dual" if n == 2 else "multi")
def _as_str(body: dict, key: str) -> str:
"""Return body[key] as a stripped string, or '' — 400 on a non-string value."""
v = body.get(key)
if v is None:
return ""
if not isinstance(v, str):
raise SwitchError(400, f"{key!r} must be a string")
return v.strip()
def resolve_slug(body) -> str:
"""Resolve a request body to a concrete registry slug, or raise SwitchError(400)."""
if not isinstance(body, dict):
raise SwitchError(400, "request body must be a JSON object")
slug = _as_str(body, "slug")
if slug:
if slug not in COMPOSE_REGISTRY:
raise SwitchError(400, f"unknown slug {slug!r}", available=sorted(COMPOSE_REGISTRY))
return slug
model = _as_str(body, "model")
if model:
if model not in MODEL_IDS:
matches = [m for m in MODEL_IDS if m.startswith(model)]
if len(matches) == 1:
model = matches[0]
elif len(matches) > 1:
raise SwitchError(400, f"ambiguous model {model!r}", candidates=matches)
else:
raise SwitchError(400, f"unknown model {model!r}", available=MODEL_IDS)
target = curated_default_target(model, _topology())
if not target:
raise SwitchError(400, f"no functional default slug for {model!r} at {_topology()}")
return target
raise SwitchError(400, "provide 'slug' or 'model'")
def _docker_ps() -> list[dict]:
try:
out = subprocess.run(
[DOCKER_BIN, "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}"],
capture_output=True, text=True, timeout=10,
).stdout
except Exception:
return []
rows = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) == 3:
rows.append({"name": parts[0], "image": parts[1], "ports": parts[2]})
return rows
def _running_model() -> tuple[str | None, int | None]:
"""(container_name, host_port) of the running model server, or (None, None)."""
for r in _docker_ps():
if "vllm" in r["image"] or "llama" in r["image"] or r["name"].startswith(
("vllm-", "llama-cpp-", "beellama-", "ik-llama-")
):
# Ports like "127.0.0.1:8010->8000/tcp" or "0.0.0.0:8010->8000/tcp".
for chunk in r["ports"].split(","):
if "->" in chunk:
hostpart = chunk.split("->", 1)[0].strip()
try:
return r["name"], int(hostpart.rsplit(":", 1)[-1])
except ValueError:
continue
return r["name"], None
return None, None
@functools.lru_cache(maxsize=None)
def _compose_container(slug: str) -> str | None:
"""The default container_name a slug's compose creates (for same-config detection)."""
entry = COMPOSE_REGISTRY.get(slug) or {}
try:
txt = (REPO_ROOT / entry.get("compose_path", "")).read_text()
except OSError:
return None
m = re.search(r'container_name:\s*"?(?:\$\{[^:}]*:-)?([A-Za-z0-9._-]+)\}?"?', txt)
return m.group(1) if m else None
def _slug_of_container(name: str | None) -> str | None:
if not name:
return None
for slug in COMPOSE_REGISTRY:
if _compose_container(slug) == name:
return slug
return None
def _get_json(url: str, token: str = "") -> dict | None:
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=3) as r:
return json.loads(r.read() or b"{}")
except Exception:
return None
def _is_ready(port: int) -> bool:
try:
with urllib.request.urlopen(f"http://localhost:{port}/health", timeout=3) as r:
return r.status == 200
except Exception:
return False
def status() -> dict:
container, port = _running_model()
if not container:
return {"current_model": None, "current_slug": None, "ready": False, "port": None, "container": None}
ready = _is_ready(port) if port else False
current_model = None
if port:
data = _get_json(f"http://localhost:{port}/v1/models", MODEL_TOKEN)
if data and data.get("data"):
current_model = data["data"][0].get("id")
return {"current_model": current_model, "current_slug": _slug_of_container(container),
"ready": ready, "port": port, "container": container}
def do_switch(slug: str) -> dict:
"""Run switch.sh <slug>, blocking until ready. Raises SwitchError on failure."""
entry = COMPOSE_REGISTRY[slug]
port = os.environ.get("PORT") or str(entry["default_port"])
env = dict(os.environ)
# Probe the unauthenticated /health (not the auth-gated /v1/models) so readiness
# works whether or not the model has VLLM_API_KEY set.
env["READY_URL"] = f"http://localhost:{port}/health"
env.setdefault("READY_TIMEOUT", str(SWITCH_TIMEOUT_S))
t0 = time.time()
try:
p = subprocess.run(
["bash", SWITCH_SCRIPT, slug],
cwd=str(REPO_ROOT), env=env, capture_output=True, text=True,
timeout=SWITCH_TIMEOUT_S + 60,
)
except subprocess.TimeoutExpired:
raise SwitchError(500, "switch timed out", slug=slug)
if p.returncode != 0:
raise SwitchError(500, "switch failed", slug=slug, detail=(p.stderr or p.stdout or "")[-800:])
return {"ok": True, "slug": slug, "model": model_of_slug(slug), "took_s": round(time.time() - t0, 1)}
class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, obj: dict) -> None:
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _authed(self) -> bool:
if not CONTROL_TOKEN:
return True # open (warned at startup); loopback bind is the guard
got = self.headers.get("Authorization", "")
pfx = "Bearer "
return got.startswith(pfx) and hmac.compare_digest(got[len(pfx):], CONTROL_TOKEN)
def do_GET(self):
if self.path == "/healthz":
return self._json(200, {"ok": True})
if not self._authed():
return self._json(401, {"error": "unauthorized"})
if self.path == "/status":
return self._json(200, status())
if self.path == "/models":
avail = [
{"slug": s, "model": e["model"], "status": e["status"], "port": e["default_port"]}
for s, e in sorted(COMPOSE_REGISTRY.items())
]
return self._json(200, {"available": avail})
return self._json(404, {"error": "not found"})
def do_POST(self):
if not self._authed():
return self._json(401, {"error": "unauthorized"})
if self.path != "/switch":
return self._json(404, {"error": "not found"})
try:
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}")
except Exception as e:
return self._json(400, {"error": f"bad body: {e}"})
try:
slug = resolve_slug(body)
except SwitchError as e:
return self._json(e.code, e.payload)
# Fast no-op ONLY when the requested slug's compose is the exact one already
# running + ready — compared by container, so a different quant/topology/engine
# of the same model id still triggers a real switch (no silent skip).
cur = status()
if cur["ready"] and cur["container"] and _compose_container(slug) == cur["container"]:
return self._json(200, {"ok": True, "slug": slug, "model": model_of_slug(slug),
"status": "already-running", "took_s": 0})
if not _switch_lock.acquire(blocking=False):
return self._json(409, {"error": "a switch is already in progress"})
try:
result = do_switch(slug)
except SwitchError as e:
return self._json(e.code, {"ok": False, **e.payload})
finally:
_switch_lock.release()
return self._json(200, result)
def log_message(self, *a): # quiet; systemd journal captures stderr
pass
def main() -> None:
if not CONTROL_TOKEN:
if BIND not in LOOPBACK_HOSTS:
raise SystemExit(
f"model-switch: REFUSING to start — MODEL_SWITCH_BIND={BIND!r} is non-loopback and "
"no CLUB3090_API_TOKEN/VLLM_API_KEY is set; that would expose the destructive "
"/switch endpoint unauthenticated. Set a token, or bind to 127.0.0.1.")
print("model-switch: WARNING — no CLUB3090_API_TOKEN/VLLM_API_KEY set; "
"control endpoint is UNAUTHENTICATED (loopback only).", flush=True)
srv = ThreadingHTTPServer((BIND, PORT), Handler)
print(f"model-switch: serving on {BIND}:{PORT} (auth={'on' if CONTROL_TOKEN else 'OFF'})", flush=True)
srv.serve_forever()
if __name__ == "__main__":
main()