238 lines
9.4 KiB
YAML
238 lines
9.4 KiB
YAML
# services/hermes/model-gate-configmap.yaml
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: hermes-model-gate
|
|
namespace: hermes
|
|
data:
|
|
model_gate.py: |
|
|
#!/usr/bin/env python3
|
|
"""Normalize Jetson text requests and coordinate the titan-24 image handoff."""
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import ssl
|
|
import threading
|
|
import time
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
|
|
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080"))
|
|
HANDOFF_PORT = int(os.environ.get("HANDOFF_PORT", "8081"))
|
|
UPSTREAM_URL = os.environ.get("UPSTREAM_URL", "http://ollama.ai.svc.cluster.local:11434").rstrip("/")
|
|
LOCAL_IMAGE_URL = os.environ.get("LOCAL_IMAGE_URL", "http://hermes-local-image.hermes.svc.cluster.local:9004").rstrip("/")
|
|
HANDOFF_TIMEOUT_SEC = float(os.environ.get("HANDOFF_TIMEOUT_SEC", "1200"))
|
|
IMAGE_NAMESPACE = os.environ.get("IMAGE_NAMESPACE", "hermes")
|
|
IMAGE_DEPLOYMENT = os.environ.get("IMAGE_DEPLOYMENT", "hermes-local-image")
|
|
KUBE_HOST = os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc")
|
|
KUBE_PORT = os.environ.get("KUBERNETES_SERVICE_PORT_HTTPS", "443")
|
|
KUBE_TOKEN = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
|
KUBE_CA = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
|
|
_handoff_lock = threading.Lock()
|
|
_image_lane_warm = True
|
|
|
|
|
|
def _image_deployment_ready() -> bool:
|
|
"""Fail closed on transient network loss, but do not wait on a dead pod."""
|
|
|
|
token = KUBE_TOKEN.read_text(encoding="utf-8").strip()
|
|
url = (
|
|
f"https://{KUBE_HOST}:{KUBE_PORT}/apis/apps/v1/namespaces/"
|
|
f"{IMAGE_NAMESPACE}/deployments/{IMAGE_DEPLOYMENT}"
|
|
)
|
|
request = Request(url, headers={"Authorization": f"Bearer {token}"})
|
|
context = ssl.create_default_context(cafile=str(KUBE_CA))
|
|
with urlopen(request, timeout=5, context=context) as response:
|
|
payload = json.load(response)
|
|
return int((payload.get("status") or {}).get("readyReplicas") or 0) > 0
|
|
|
|
|
|
def _normalize_reasoning(body: bytes | None) -> bytes | None:
|
|
"""Translate routed aliases and clamp effort to the local server tier."""
|
|
|
|
if not body:
|
|
return body
|
|
try:
|
|
payload = json.loads(body)
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
return body
|
|
if not isinstance(payload, dict):
|
|
return body
|
|
|
|
changed = False
|
|
model = payload.get("model")
|
|
if isinstance(model, str) and model.startswith("route/local/qwen2.5-14b/"):
|
|
payload["model"] = "qwen2.5:14b-instruct-q4_0"
|
|
changed = True
|
|
elif not isinstance(model, str) or not model.strip():
|
|
# This gate serves one text model, so an omitted routed target has one
|
|
# unambiguous and safe default.
|
|
payload["model"] = "qwen2.5:14b-instruct-q4_0"
|
|
changed = True
|
|
# This non-thinking Qwen build rejects hosted-provider reasoning fields.
|
|
# Effort remains a Switchyard routing concern, not an Ollama request.
|
|
for key in ("reasoning_effort", "reasoning", "thinking", "output_config"):
|
|
value = payload.get(key)
|
|
if value is not None:
|
|
payload.pop(key, None)
|
|
changed = True
|
|
if not changed:
|
|
return body
|
|
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _wait_for_image_idle() -> tuple[bool, str]:
|
|
"""Keep Wolf handoff blocked until an in-flight FLUX render is released."""
|
|
|
|
deadline = time.monotonic() + HANDOFF_TIMEOUT_SEC
|
|
last_error = "local image service did not report idle"
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with urlopen(f"{LOCAL_IMAGE_URL}/health", timeout=5) as response:
|
|
payload = json.load(response)
|
|
if not bool(payload.get("busy")):
|
|
return True, ""
|
|
last_error = f"local image renderer is {payload.get('phase', 'busy')}"
|
|
except Exception as exc:
|
|
last_error = f"local image health unavailable: {exc}"
|
|
try:
|
|
if not _image_deployment_ready():
|
|
# A stopped/crashed process has no CUDA context left to drain.
|
|
return True, ""
|
|
except Exception as kube_exc:
|
|
last_error += f"; deployment state unavailable: {kube_exc}"
|
|
time.sleep(1)
|
|
return False, last_error
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
"""Proxy the non-preemptible titan-20 text fallback."""
|
|
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def _json(self, status: int, payload: dict) -> None:
|
|
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _proxy(self) -> None:
|
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
body = self.rfile.read(length) if length else None
|
|
body = _normalize_reasoning(body)
|
|
headers = {"Content-Type": self.headers.get("Content-Type", "application/json")}
|
|
if self.headers.get("Accept"):
|
|
headers["Accept"] = self.headers["Accept"]
|
|
request = Request(f"{UPSTREAM_URL}{self.path}", data=body, headers=headers, method=self.command)
|
|
try:
|
|
response = urlopen(request, timeout=1800)
|
|
except HTTPError as exc:
|
|
response = exc
|
|
except (TimeoutError, URLError) as exc:
|
|
self._json(503, {"error": {"message": f"local model upstream unavailable: {exc}", "type": "server_error"}})
|
|
return
|
|
|
|
self.send_response(response.status)
|
|
content_type = response.headers.get("Content-Type")
|
|
if content_type:
|
|
self.send_header("Content-Type", content_type)
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length:
|
|
self.send_header("Content-Length", content_length)
|
|
else:
|
|
self.send_header("Connection", "close")
|
|
self.close_connection = True
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
while True:
|
|
chunk = response.read(65536)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
response.close()
|
|
|
|
def do_GET(self) -> None:
|
|
if self.path == "/healthz":
|
|
self._json(200, {"status": "ok"})
|
|
return
|
|
if self.path == "/gate/status":
|
|
self._json(200, {"local_inference_allowed": True, "node": "titan-20"})
|
|
return
|
|
self._proxy()
|
|
|
|
def do_POST(self) -> None:
|
|
self._proxy()
|
|
|
|
def log_message(self, format_string: str, *args) -> None:
|
|
print(f"model-gate {self.address_string()} {format_string % args}", flush=True)
|
|
|
|
|
|
class HandoffHandler(BaseHTTPRequestHandler):
|
|
"""Ollama-compatible adapter for Ariadne's image/Wolf handoff contract."""
|
|
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def _json(self, status: int, payload: dict) -> None:
|
|
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _proxy(self) -> None:
|
|
global _image_lane_warm
|
|
if self.command == "GET" and self.path == "/api/ps":
|
|
models = [{"name": "flux-2-klein-4b-local"}] if _image_lane_warm else []
|
|
self._json(200, {"models": models})
|
|
return
|
|
if self.command != "POST" or self.path != "/api/generate":
|
|
self._json(404, {"error": "unsupported handoff operation"})
|
|
return
|
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
try:
|
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
except (ValueError, json.JSONDecodeError):
|
|
self._json(400, {"error": "invalid JSON"})
|
|
return
|
|
keep_alive = payload.get("keep_alive")
|
|
with _handoff_lock:
|
|
idle, error = _wait_for_image_idle()
|
|
if not idle:
|
|
self._json(503, {"error": error, "gpu_handoff_blocked": True})
|
|
return
|
|
if keep_alive == 0:
|
|
_image_lane_warm = False
|
|
self._json(200, {"response": "", "done": True})
|
|
return
|
|
_image_lane_warm = True
|
|
response = "READY" if str(payload.get("prompt") or "").strip() else ""
|
|
self._json(200, {"response": response, "done": True})
|
|
|
|
def do_GET(self) -> None:
|
|
if self.path == "/healthz":
|
|
self._json(200, {"status": "ok"})
|
|
return
|
|
self._proxy()
|
|
|
|
def do_POST(self) -> None:
|
|
self._proxy()
|
|
|
|
def log_message(self, format_string: str, *args) -> None:
|
|
print(f"gpu-handoff {self.address_string()} {format_string % args}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
handoff = ThreadingHTTPServer((LISTEN_HOST, HANDOFF_PORT), HandoffHandler)
|
|
threading.Thread(target=handoff.serve_forever, name="gpu-handoff", daemon=True).start()
|
|
ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler).serve_forever()
|