Three always-running vLLM services (text TP=2 GPU0+1, ocr + embed on GPU2, sleep mode) behind a FastAPI router that auto-wakes models on request. Tiered idle (sleep 15 min / offload 3 h), depth-aware 503s with Retry-After, persisted wake-intent recovery, admin API on 127.0.0.1:8010. Routine control via vllmctl is pure HTTP — no docker on the request path. Verified: 91 router unit tests + 15-test E2E on real hardware (measurements in CALIBRATION.md; design record in .claude/memory/router-front-door-plan.md). Old nginx stack files removed before git init; design survives in .claude/memory/sleep-mode-implementation-plan.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
6.1 KiB
Python
144 lines
6.1 KiB
Python
"""Admin listener (the :8010 socket) -- `vllmctl` / debugging surface.
|
|
|
|
This app is mounted on a *separate socket*, never on the public one (plan
|
|
review C5: a public /admin/sleep would be a trivial remote DoS). It shares
|
|
the exact same ServiceManager, locks and depth tracking as the request path.
|
|
|
|
The socket binds 0.0.0.0 *inside the container*; the host-side "localhost
|
|
only" restriction comes from compose publishing `127.0.0.1:8010:8010`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from config import DEPTH_AWAKE
|
|
from services import ServiceManager
|
|
|
|
log = logging.getLogger("vllm_router.admin")
|
|
|
|
|
|
def build_admin_app(manager: ServiceManager) -> FastAPI:
|
|
cfg = manager.cfg
|
|
app = FastAPI(title="vllm-router admin", version="1.0.0",
|
|
docs_url=None, redoc_url=None, openapi_url=None)
|
|
|
|
def service_or_404(key: str) -> tuple[Any, JSONResponse | None]:
|
|
# Accepts the service key ("text") or the model name / alias
|
|
# ("OvisOCR2", "ocr", "qwen3.6-35b-a3b-fp8"), case-insensitively.
|
|
resolved = key.strip().lower()
|
|
if resolved not in manager.services:
|
|
resolved = cfg.index.get(key.strip().lower(), "")
|
|
svc = manager.services.get(resolved)
|
|
if svc is None:
|
|
body = {"error": {"message": f"Unknown service or model '{key}'.",
|
|
"type": "invalid_request_error", "code": "not_found",
|
|
"known": sorted(manager.services)}}
|
|
return None, JSONResponse(body, status_code=404)
|
|
return svc, None
|
|
|
|
@app.get("/admin/status")
|
|
async def admin_status() -> JSONResponse:
|
|
status = await manager.status(live_probe=True)
|
|
return JSONResponse({
|
|
"router": {
|
|
"uptime_s": status["uptime_s"],
|
|
"public_port": cfg.public_port,
|
|
"admin_port": cfg.admin_port,
|
|
"idle": {
|
|
"enabled": cfg.idle_enabled,
|
|
"sleep_min": cfg.idle_sleep_min,
|
|
"offload_min": cfg.idle_offload_min,
|
|
},
|
|
},
|
|
"services": status["services"],
|
|
"api": "http://127.0.0.1:%d/v1" % cfg.public_port,
|
|
})
|
|
|
|
@app.post("/admin/wake/{key}")
|
|
async def admin_wake(key: str) -> JSONResponse:
|
|
svc, err = service_or_404(key)
|
|
if err is not None:
|
|
return err
|
|
outcome = await manager.ensure_awake(svc.cfg.key)
|
|
svc.last_activity = time.monotonic() # idle clock restarts
|
|
body = {
|
|
"service": svc.cfg.key,
|
|
"model": svc.cfg.model,
|
|
"ok": outcome.ok,
|
|
"depth": outcome.depth,
|
|
"reason": outcome.reason,
|
|
"wake_in_progress": svc.wake_in_progress,
|
|
}
|
|
if outcome.latency_s is not None:
|
|
body["latency_s"] = round(outcome.latency_s, 2)
|
|
if not outcome.ok:
|
|
policy = cfg.policy(outcome.depth)
|
|
body["retry_after_s"] = policy.retry_after_s
|
|
body["estimated_wake_seconds"] = policy.est_wake_s
|
|
body["error"] = {"type": "model_waking", "code": "model_waking",
|
|
"sleep_depth": outcome.depth,
|
|
"estimated_wake_seconds": policy.est_wake_s}
|
|
if outcome.detail:
|
|
body["error"]["message"] = outcome.detail
|
|
log.info("admin_wake service=%s ok=false depth=%s reason=%s",
|
|
svc.cfg.key, outcome.depth, outcome.reason)
|
|
return JSONResponse(body, status_code=503,
|
|
headers={"Retry-After": str(policy.retry_after_s)})
|
|
log.info("admin_wake service=%s ok=true depth=%s reason=%s",
|
|
svc.cfg.key, outcome.depth, outcome.reason)
|
|
return JSONResponse(body)
|
|
|
|
@app.post("/admin/sleep/{key}")
|
|
async def admin_sleep(key: str, request: Request) -> JSONResponse:
|
|
svc, err = service_or_404(key)
|
|
if err is not None:
|
|
return err
|
|
level = 1
|
|
if request.query_params.get("level"):
|
|
try:
|
|
level = int(request.query_params["level"])
|
|
except ValueError:
|
|
return JSONResponse(
|
|
{"error": {"message": "level must be 1 or 2",
|
|
"type": "invalid_request_error"}}, status_code=400)
|
|
if level not in (1, 2):
|
|
return JSONResponse(
|
|
{"error": {"message": "level must be 1 or 2",
|
|
"type": "invalid_request_error"}}, status_code=400)
|
|
result = await manager.sleep_service(svc.cfg.key, level, reason="admin")
|
|
status_code = 200 if result.get("ok") else 409
|
|
result["level_requested"] = level
|
|
result["model"] = svc.cfg.model
|
|
log.info("admin_sleep service=%s level=%d ok=%s reason=%s",
|
|
svc.cfg.key, level, result.get("ok"), result.get("reason"))
|
|
return JSONResponse(result, status_code=status_code)
|
|
|
|
@app.get("/health")
|
|
@app.get("/admin/health", include_in_schema=False)
|
|
async def admin_health() -> JSONResponse:
|
|
return JSONResponse({"status": "ok",
|
|
"awake": [k for k, s in manager.services.items()
|
|
if s.depth == DEPTH_AWAKE],
|
|
"wake_recovery_pending": [k for k, s in manager.services.items()
|
|
if s.pending_reload]})
|
|
|
|
@app.exception_handler(Exception)
|
|
async def internal_error(_request: Request, exc: Exception) -> JSONResponse:
|
|
if manager.shutting_down:
|
|
return JSONResponse(
|
|
{"error": {"type": "router_shutting_down", "code": "router_shutting_down",
|
|
"message": "Router is shutting down; retry shortly"}},
|
|
status_code=503, headers={"Retry-After": "5"})
|
|
return JSONResponse(
|
|
{"error": {"type": "internal_error", "code": "internal_error",
|
|
"message": f"Unhandled router error: {type(exc).__name__}"}},
|
|
status_code=500)
|
|
|
|
return app
|