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>
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""Allowlist: only /v1/*, /health (and /metrics when enabled) are public."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
from app import build_apps
|
|
from conftest import raw_asgi
|
|
|
|
DEV_ENDPOINTS = [
|
|
"/sleep",
|
|
"/wake_up",
|
|
"/collective_rpc",
|
|
"/reset_prefix_cache",
|
|
"/is_sleeping",
|
|
]
|
|
ADMIN_PATHS = ["/admin/status", "/admin/wake/text", "/admin/sleep/text?level=1"]
|
|
|
|
|
|
async def test_dev_endpoints_404_on_public(pub, backend):
|
|
for path in DEV_ENDPOINTS:
|
|
r = await pub.post(path)
|
|
assert r.status_code == 404, path
|
|
assert r.json()["error"]["code"] == "not_found"
|
|
for path in DEV_ENDPOINTS:
|
|
r = await pub.get(path)
|
|
assert r.status_code == 404, path
|
|
assert backend.calls == []
|
|
|
|
|
|
async def test_admin_not_on_public(pub, backend):
|
|
for path in ADMIN_PATHS:
|
|
r = await pub.request("POST", path)
|
|
assert r.status_code == 404, path
|
|
r = await pub.get(path)
|
|
assert r.status_code == 404, path
|
|
assert backend.calls == []
|
|
|
|
|
|
async def test_docs_and_openapi_404(pub):
|
|
for path in ("/docs", "/redoc", "/openapi.json", "/"):
|
|
r = await pub.get(path)
|
|
assert r.status_code == 404, path
|
|
|
|
|
|
async def test_dotted_paths_404(pub):
|
|
for path in ("/v1/../sleep", "/v1/..%2fsleep", "/health/../sleep", "/./sleep"):
|
|
r = await pub.post(path)
|
|
assert r.status_code == 404, path
|
|
|
|
|
|
async def test_raw_traversal_404(stack, backend):
|
|
"""Percent-encoded traversal that a normal client would normalise."""
|
|
cases = [
|
|
("POST", "/v1%2f..%2fsleep"),
|
|
("POST", "/v1%2F..%2Fsleep"),
|
|
("GET", "/v1/%2e%2e/wake_up"),
|
|
("POST", "/v1/%2e%2e%2fcollective_rpc"),
|
|
("POST", "/v1//../sleep"),
|
|
("GET", "/v1/models/../../is_sleeping"),
|
|
]
|
|
for method, raw in cases:
|
|
status, body = await raw_asgi(stack.public, method, raw,
|
|
headers=[("content-type", "application/json")],
|
|
body=b"{}")
|
|
assert status == 404, (method, raw, body)
|
|
assert backend.calls == []
|
|
|
|
|
|
async def test_normalised_traversal_still_reaches_v1(stack, backend):
|
|
"""A traversal that lands inside /v1 must still work (not over-block)."""
|
|
status, body = await raw_asgi(
|
|
stack.public, "POST", "/v1/../v1/chat/completions",
|
|
headers=[("content-type", "application/json")],
|
|
body=b'{"model": "text"}',
|
|
)
|
|
assert status == 200, body
|
|
assert backend.count("POST vllm-text/v1/chat/completions") == 1
|
|
|
|
|
|
async def test_public_health(pub, backend):
|
|
r = await pub.get("/health")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "ok"
|
|
assert set(body["services"]) == {"text", "ocr", "embed"}
|
|
# liveness must not depend on backend state
|
|
backend.services["vllm-text"]["reachable"] = False
|
|
r = await pub.get("/health")
|
|
assert r.status_code == 200
|
|
|
|
|
|
async def test_public_models_lists_all_three(pub, backend):
|
|
r = await pub.get("/v1/models")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["object"] == "list"
|
|
assert [m["id"] for m in body["data"]] == [
|
|
"Qwen3.6-35B-A3B-FP8", "OvisOCR2", "Qwen3-Embedding-8B",
|
|
]
|
|
# answered by the router itself, no backend contact
|
|
assert backend.calls == []
|
|
|
|
|
|
async def test_metrics_disabled_by_default(pub):
|
|
assert (await pub.get("/metrics")).status_code == 404
|
|
|
|
|
|
async def test_metrics_enabled_when_configured(stack, pub):
|
|
stack.cfg.metrics_enabled = True
|
|
r = await pub.get("/metrics")
|
|
assert r.status_code == 200
|
|
assert "vllm_router_service_depth" in r.text
|
|
|
|
|
|
async def test_unknown_http_method_on_v1(stack):
|
|
status, _ = await raw_asgi(stack.public, "PROPFIND", "/v1/chat/completions")
|
|
assert status in (404, 405)
|
|
|
|
|
|
async def test_admin_listener_is_a_separate_app(stack):
|
|
"""The admin app answers /admin/*; the public app must not."""
|
|
status, body = await raw_asgi(stack.admin, "GET", "/admin/status")
|
|
assert status == 200
|
|
status, _ = await raw_asgi(stack.public, "GET", "/admin/status")
|
|
assert status == 404
|
|
|
|
|
|
async def test_request_body_cap(cfg, backend):
|
|
"""ROUTER_MAX_BODY_BYTES must be wired as middleware (413, not OOM)."""
|
|
cfg.max_body_bytes = 64
|
|
public_app, _admin, manager = build_apps(cfg, transport=backend)
|
|
ok_body = b'{"model": "text", "messages": []}'
|
|
assert len(ok_body) < 64
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=public_app),
|
|
base_url="http://router.test") as client:
|
|
r = await client.post("/v1/chat/completions", content=ok_body,
|
|
headers={"content-type": "application/json"})
|
|
assert r.status_code == 200
|
|
r = await client.post("/v1/chat/completions", content=ok_body + b" " * 512,
|
|
headers={"content-type": "application/json"})
|
|
assert r.status_code == 413
|
|
finally:
|
|
await manager.proxy_client.aclose()
|
|
await manager.ctrl_client.aclose()
|