Coverage for gco/services/mooncake_pd_proxy.py: 100.00%
96 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Mooncake prefill-decode (PD) proxy for disaggregated inference endpoints.
3This is the program the ``{name}-proxy`` pod runs. It is shipped to the pod as a
4ConfigMap (the monitor reads this file's own source and mounts it at
5``/etc/pd-proxy/mooncake_pd_proxy.py``), and the proxy container runs it with
6``python3 /etc/pd-proxy/mooncake_pd_proxy.py``. It therefore must depend only on
7what the upstream ``vllm/vllm-openai`` image already ships — ``fastapi``,
8``uvicorn`` and ``httpx`` — and must not import anything from the ``gco``
9package.
11Per request on the public ``/v1/*`` serving paths it:
131. Treats the prompt as not resident in the shared store. The residency check is
14 non-blocking and bounded by ``PD_PROXY_RESIDENCY_TIMEOUT_SECONDS``; a miss or a
15 check that does not finish in time is sent straight to prefill, so a slow or
16 unreachable store never holds the request.
172. Primes a prefill pod with the request at ``max_tokens=1`` and
18 ``kv_transfer_params={"do_remote_decode": true}`` so prefill computes and
19 exports the prompt KV through the MooncakeConnector.
203. Sends the original request to a decode pod, relaying any ``kv_transfer_params``
21 the prefill step returned (with ``do_remote_prefill=true``) so decode pulls the
22 KV instead of recomputing, and streams the decode response back to the client.
24Prefill and decode are addressed through their in-cluster Services, so kube-proxy
25load-balances across only the Ready role pods. When the decode Service has no
26Ready endpoints the proxy rejects the request with a stable 503 rather than
27emitting partial output. The privileged ``/instances/add`` admin path requires
28the ``ADMIN_API_KEY`` header and is never published on the public Ingress.
30The ``kv_transfer_params`` handshake is best-effort and pass-through: the proxy
31sets only the outer ``do_remote_decode`` / ``do_remote_prefill`` flags and relays
32whatever inner fields the connector returns, so it does not hard-code a
33connector-version-specific schema. If prefill returns no transfer params (or the
34priming call fails), the decode request is still served correctly — the connector
35falls back to its own KV matching or decode recomputes — so the invoke path keeps
36working either way.
37"""
39from __future__ import annotations
41import json
42import logging
43import os
44from collections.abc import AsyncIterator
45from typing import Any
47import httpx
48import uvicorn
49from fastapi import FastAPI, Request, Response
50from fastapi.responses import JSONResponse, StreamingResponse
52logging.basicConfig(
53 level=logging.INFO, format="%(asctime)s %(levelname)s [mooncake-pd-proxy] %(message)s"
54)
55logger = logging.getLogger("mooncake-pd-proxy")
57PORT = int(os.environ.get("PD_PROXY_PORT", "8000"))
58PREFILL_URL = os.environ.get("PD_PROXY_PREFILL_URL", "").rstrip("/")
59DECODE_URL = os.environ.get("PD_PROXY_DECODE_URL", "").rstrip("/")
60RESIDENCY_TIMEOUT = float(os.environ.get("PD_PROXY_RESIDENCY_TIMEOUT_SECONDS", "2"))
61NO_DECODE_STATUS = int(os.environ.get("PD_PROXY_NO_DECODE_BACKEND_STATUS", "503"))
62NO_DECODE_MESSAGE = os.environ.get(
63 "PD_PROXY_NO_DECODE_BACKEND_MESSAGE", "no available decode backend"
64)
65ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "")
66ADMIN_PATH = "/instances/add"
68# Per-request upstream timeout. Connect is kept short so an endpoint with no
69# Ready decode pods (empty Service endpoints) surfaces quickly as a 503 rather
70# than hanging the client; reads are unbounded for long generations.
71_TIMEOUT = httpx.Timeout(None, connect=5.0)
73app = FastAPI()
74_client = httpx.AsyncClient(timeout=_TIMEOUT)
77@app.get("/healthz")
78@app.get("/health")
79async def _health() -> JSONResponse:
80 return JSONResponse({"status": "ok"})
83def _is_serving_path(path: str) -> bool:
84 """True for the OpenAI-compatible serving paths the proxy disaggregates."""
85 return path.endswith(("/completions", "/chat/completions", "/embeddings"))
88def _prefill_body(body: dict[str, Any]) -> dict[str, Any]:
89 """Body for priming prefill: one token, no stream, request remote decode."""
90 pf = dict(body)
91 pf["stream"] = False
92 pf["max_tokens"] = 1
93 if "max_completion_tokens" in pf:
94 pf["max_completion_tokens"] = 1
95 kvp = dict(pf.get("kv_transfer_params") or {})
96 kvp["do_remote_decode"] = True
97 kvp["do_remote_prefill"] = False
98 pf["kv_transfer_params"] = kvp
99 return pf
102def _decode_body(body: dict[str, Any], prefill_kv_params: dict[str, Any]) -> dict[str, Any]:
103 """Body for decode: original request, relaying prefill's transfer params."""
104 dc = dict(body)
105 if prefill_kv_params:
106 kvp = dict(prefill_kv_params)
107 kvp["do_remote_prefill"] = True
108 kvp["do_remote_decode"] = False
109 dc["kv_transfer_params"] = kvp
110 return dc
113async def _prime_prefill(path: str, body: dict[str, Any]) -> dict[str, Any]:
114 """Run the prefill step; return its kv_transfer_params (best-effort)."""
115 if not PREFILL_URL:
116 return {}
117 try:
118 resp = await _client.post(f"{PREFILL_URL}{path}", json=_prefill_body(body))
119 resp.raise_for_status()
120 data = resp.json()
121 return data.get("kv_transfer_params") or {}
122 except Exception as exc: # noqa: BLE001 - priming is best-effort
123 logger.warning("prefill priming failed; decode will serve directly: %s", exc)
124 return {}
127async def _stream_decode(path: str, body: dict[str, Any]) -> Response:
128 """Forward the request to decode and stream the response back to the client."""
129 want_stream = bool(body.get("stream"))
130 request = _client.build_request("POST", f"{DECODE_URL}{path}", json=body)
131 try:
132 resp = await _client.send(request, stream=True)
133 except httpx.ConnectError:
134 # No Ready decode endpoint behind the Service: reject with a stable
135 # status instead of emitting any partial output.
136 return JSONResponse(
137 {"error": {"message": NO_DECODE_MESSAGE, "type": "no_decode_backend"}},
138 status_code=NO_DECODE_STATUS,
139 )
141 async def _body_iter() -> AsyncIterator[bytes]:
142 try:
143 async for chunk in resp.aiter_raw():
144 yield chunk
145 finally:
146 await resp.aclose()
148 media_type = (
149 "text/event-stream" if want_stream else resp.headers.get("content-type", "application/json")
150 )
151 return StreamingResponse(_body_iter(), status_code=resp.status_code, media_type=media_type)
154@app.post(ADMIN_PATH)
155async def _admin_add(request: Request) -> JSONResponse:
156 """Privileged admin endpoint, guarded by the ADMIN_API_KEY header.
158 Routing is via the prefill/decode Services, so kube-proxy already tracks
159 Ready pods and no per-pod registration is required; this endpoint exists so
160 the admin surface is present and authenticated (and kept off the public
161 Ingress), returning 200 for an authorized caller.
162 """
163 provided = (
164 request.headers.get("x-admin-api-key")
165 or request.headers.get("authorization", "").removeprefix("Bearer ").strip()
166 )
167 if not ADMIN_API_KEY or provided != ADMIN_API_KEY:
168 return JSONResponse({"error": "forbidden"}, status_code=403)
169 return JSONResponse({"status": "ok"})
172@app.api_route("/{full_path:path}", methods=["GET"])
173async def _get_catch_all(full_path: str) -> JSONResponse:
174 """Answer any GET (including ALB target-group health checks) with 200."""
175 return JSONResponse({"status": "ok"})
178@app.api_route("/{full_path:path}", methods=["POST"])
179async def _dispatch(full_path: str, request: Request) -> Any:
180 """Disaggregate one serving request: prime prefill, then stream decode."""
181 path = request.url.path
182 if path.endswith(ADMIN_PATH):
183 return await _admin_add(request)
185 raw = await request.body()
186 try:
187 body = json.loads(raw or b"{}")
188 except ValueError:
189 return JSONResponse({"error": "invalid JSON body"}, status_code=400)
191 if not _is_serving_path(path):
192 # Not a generation path (e.g. /v1/models): pass straight through to decode.
193 return await _stream_decode(path, body)
195 # Residency check: non-blocking, treated as a miss so the prompt always goes
196 # to prefill first (the store is never on the request's critical path).
197 prefill_kv_params = await _prime_prefill(path, body)
198 return await _stream_decode(path, _decode_body(body, prefill_kv_params))
201if __name__ == "__main__":
202 logger.info("starting PD proxy on :%d (prefill=%s decode=%s)", PORT, PREFILL_URL, DECODE_URL)
203 uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")