from __future__ import annotations import argparse import asyncio import random import urllib.parse from dataclasses import dataclass from http import HTTPStatus from itertools import chain from typing import AsyncIterator import aiohttp import orjson import uvicorn from fastapi import FastAPI, HTTPException, Request from fastapi.responses import ORJSONResponse, Response, StreamingResponse _STREAM_CHUNK_SIZE = 1024 * 64 @dataclass class RouterConfig: host: str port: int prefill_urls: list[tuple[str, int]] decode_urls: list[str] prefill_policy: str = "round_robin" decode_policy: str = "manual" request_timeout_s: float = 1800.0 class RouterState: def __init__(self, config: RouterConfig): if not config.prefill_urls: raise ValueError("At least one prefill worker is required") if not config.decode_urls: raise ValueError("At least one decode worker is required") self.config = config self.prefill_cursor = 0 self.decode_cursor = 0 self.sticky_decode_map: dict[str, int] = {} def select_pair(self, headers: dict[str, str]) -> tuple[str, int, str]: prefill_url, bootstrap_port = self.config.prefill_urls[ self.prefill_cursor % len(self.config.prefill_urls) ] self.prefill_cursor += 1 decode_index = self._select_decode_index(headers) return prefill_url, bootstrap_port, self.config.decode_urls[decode_index] def _select_decode_index(self, headers: dict[str, str]) -> int: target_worker = headers.get("x-smg-target-worker") routing_key = headers.get("x-smg-routing-key") if ( self.config.decode_policy == "consistent_hashing" and target_worker is not None ): idx = int(target_worker) if 0 <= idx < len(self.config.decode_urls): return idx if self.config.decode_policy == "manual" and routing_key: cached = self.sticky_decode_map.get(routing_key) if cached is not None: return cached idx = self.decode_cursor % len(self.config.decode_urls) self.decode_cursor += 1 self.sticky_decode_map[routing_key] = idx return idx idx = self.decode_cursor % len(self.config.decode_urls) self.decode_cursor += 1 return idx @dataclass class DpRouterConfig: host: str port: int backend_urls: list[str] backend_policy: str = "round_robin" request_timeout_s: float = 1800.0 class DpRouterState: """DP (data-parallel) router: forward each request to exactly one backend.""" def __init__(self, config: DpRouterConfig): if not config.backend_urls: raise ValueError("At least one backend worker is required") self.config = config self.cursor = 0 self.sticky_map: dict[str, int] = {} def select_backend(self, headers: dict[str, str]) -> str: idx = self._select_index(headers) return self.config.backend_urls[idx] def _select_index(self, headers: dict[str, str]) -> int: target_worker = headers.get("x-smg-target-worker") routing_key = headers.get("x-smg-routing-key") if ( self.config.backend_policy == "consistent_hashing" and target_worker is not None ): idx = int(target_worker) if 0 <= idx < len(self.config.backend_urls): return idx if self.config.backend_policy == "manual" and routing_key: cached = self.sticky_map.get(routing_key) if cached is not None: return cached idx = self.cursor % len(self.config.backend_urls) self.cursor += 1 self.sticky_map[routing_key] = idx return idx idx = self.cursor % len(self.config.backend_urls) self.cursor += 1 return idx app = FastAPI() router_state: RouterState | None = None dp_state: DpRouterState | None = None @app.get("/health") async def health() -> Response: return Response(status_code=200) @app.get("/health_generate") async def health_generate() -> Response: if dp_state is not None: async with aiohttp.ClientSession() as session: tasks = [ session.get(f"{url}/health_generate") for url in dp_state.config.backend_urls ] for response in asyncio.as_completed(tasks): async with await response: pass return Response(status_code=200) state = _require_state() async with aiohttp.ClientSession() as session: tasks = [] for server in chain( (url for url, _ in state.config.prefill_urls), state.config.decode_urls, ): tasks.append(session.get(f"{server}/health_generate")) for response in asyncio.as_completed(tasks): async with await response: pass return Response(status_code=200) @app.get("/v1/models") async def models() -> ORJSONResponse: if dp_state is not None: async with aiohttp.ClientSession() as session: async with session.get(f"{dp_state.config.backend_urls[0]}/v1/models") as resp: payload = await resp.json() return ORJSONResponse(payload, status_code=resp.status) state = _require_state() async with aiohttp.ClientSession() as session: async with session.get(f"{state.config.prefill_urls[0][0]}/v1/models") as response: payload = await response.json() return ORJSONResponse(payload, status_code=response.status) @app.post("/v1/chat/completions") async def chat_completions(request: Request) -> Response: request_data = await request.json() headers = {key.lower(): value for key, value in request.headers.items()} return await _forward_to_backend( request_data=request_data, headers=headers, endpoint_name="v1/chat/completions", ) @app.post("/v1/completions") async def completions(request: Request) -> Response: request_data = await request.json() headers = {key.lower(): value for key, value in request.headers.items()} return await _forward_to_backend( request_data=request_data, headers=headers, endpoint_name="v1/completions", ) @app.post("/generate") async def generate(request: Request) -> Response: request_data = await request.json() headers = {key.lower(): value for key, value in request.headers.items()} return await _forward_to_backend( request_data=request_data, headers=headers, endpoint_name="generate", ) async def _forward_to_backend( *, request_data: dict, headers: dict[str, str], endpoint_name: str, ) -> Response: # DP mode: forward to a single backend if dp_state is not None: return await _forward_to_dp_backend( request_data=request_data, headers=headers, endpoint_name=endpoint_name, ) # PD mode: coordinate prefill + decode state = _require_state() prefill_server, bootstrap_port, decode_server = state.select_pair(headers) prefill_request, decode_request = _build_backend_requests( request_data=request_data, prefill_server=prefill_server, bootstrap_port=bootstrap_port, ) if request_data.get("stream", False): return StreamingResponse( _stream_generate( prefill_request=prefill_request, decode_request=decode_request, prefill_server=prefill_server, decode_server=decode_server, endpoint_name=endpoint_name, timeout_s=state.config.request_timeout_s, ), media_type="text/event-stream", ) async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=state.config.request_timeout_s) ) as session: prefill_response, decode_response = await asyncio.gather( session.post(f"{prefill_server}/{endpoint_name}", json=prefill_request), session.post(f"{decode_server}/{endpoint_name}", json=decode_request), ) async with prefill_response: await prefill_response.read() async with decode_response: body = await decode_response.read() return Response( content=body, status_code=decode_response.status, media_type=decode_response.content_type, ) async def _forward_to_dp_backend( *, request_data: dict, headers: dict[str, str], endpoint_name: str, ) -> Response: assert dp_state is not None backend_server = dp_state.select_backend(headers) cleaned = _strip_internal_fields(request_data) timeout_s = dp_state.config.request_timeout_s if request_data.get("stream", False): return StreamingResponse( _stream_dp_generate( request_data=cleaned, backend_server=backend_server, endpoint_name=endpoint_name, timeout_s=timeout_s, ), media_type="text/event-stream", ) async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=timeout_s) ) as session: async with session.post( f"{backend_server}/{endpoint_name}", json=cleaned ) as response: body = await response.read() return Response( content=body, status_code=response.status, media_type=response.content_type, ) async def _stream_dp_generate( *, request_data: dict, backend_server: str, endpoint_name: str, timeout_s: float, ) -> AsyncIterator[bytes]: async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=timeout_s) ) as session: async with session.post( f"{backend_server}/{endpoint_name}", json=request_data ) as response: if response.status != HTTPStatus.OK: payload = await response.read() yield payload return async for chunk in response.content.iter_chunked(_STREAM_CHUNK_SIZE): yield chunk async def _stream_generate( *, prefill_request: dict, decode_request: dict, prefill_server: str, decode_server: str, endpoint_name: str, timeout_s: float, ) -> AsyncIterator[bytes]: async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=timeout_s) ) as session: prefill_response, decode_response = await asyncio.gather( session.post(f"{prefill_server}/{endpoint_name}", json=prefill_request), session.post(f"{decode_server}/{endpoint_name}", json=decode_request), ) async with prefill_response, decode_response: if decode_response.status != HTTPStatus.OK: payload = await decode_response.read() yield payload return async for chunk in decode_response.content.iter_chunked(_STREAM_CHUNK_SIZE): yield chunk def _build_bootstrap_payload(prefill_server: str, bootstrap_port: int) -> dict[str, object]: parsed_url = urllib.parse.urlparse(prefill_server) hostname = parsed_url.hostname if hostname is None: raise HTTPException( status_code=500, detail=f"Unable to parse prefill hostname from {prefill_server}", ) return { "bootstrap_host": hostname, "bootstrap_port": bootstrap_port, "bootstrap_room": random.randint(0, 2**63 - 1), } def _build_backend_requests( *, request_data: dict, prefill_server: str, bootstrap_port: int, ) -> tuple[dict, dict]: prefill_priority = request_data.get("smg_prefill_priority") decode_priority = request_data.get("smg_decode_priority") prefill_request = _strip_internal_fields(request_data) decode_request = _strip_internal_fields(request_data) bootstrap_payload = _build_bootstrap_payload(prefill_server, bootstrap_port) prefill_request.update(bootstrap_payload) decode_request.update(bootstrap_payload) # session_params is only meaningful for the decode worker (streaming session # KV reuse). Sending it to the prefill worker causes the D side to # short-circuit with local-prefill on already-open sessions, returning # truncated responses while P's KV transfer gets aborted. prefill_request.pop("session_params", None) if prefill_priority is not None: prefill_request["priority"] = int(prefill_priority) if decode_priority is not None: decode_request["priority"] = int(decode_priority) return prefill_request, decode_request def _strip_internal_fields(request_data: dict) -> dict: cleaned = request_data.copy() cleaned.pop("smg_prefill_priority", None) cleaned.pop("smg_decode_priority", None) return cleaned def _require_state() -> RouterState: if router_state is None: raise HTTPException(status_code=500, detail="router not initialized") return router_state def main() -> None: parser = argparse.ArgumentParser(description="Minimal local PD / DP router") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8000) parser.add_argument( "--prefill", nargs=2, metavar=("URL", "BOOTSTRAP_PORT"), action="append", default=None, ) parser.add_argument( "--decode", action="append", default=None, ) parser.add_argument("--prefill-policy", default="round_robin") parser.add_argument("--decode-policy", default="manual") parser.add_argument( "--backend", action="append", default=None, help="Backend URL for DP (data-parallel) mode. Repeat for each worker.", ) parser.add_argument( "--backend-policy", default="round_robin", help="Routing policy for DP mode: round_robin, manual, consistent_hashing.", ) parser.add_argument("--request-timeout-s", type=float, default=1800.0) args = parser.parse_args() global router_state, dp_state if args.backend: # DP mode: simple forward to one of N backends dp_state = DpRouterState( DpRouterConfig( host=args.host, port=args.port, backend_urls=list(args.backend), backend_policy=args.backend_policy, request_timeout_s=args.request_timeout_s, ) ) elif args.prefill and args.decode: # PD mode: prefill/decode coordination router_state = RouterState( RouterConfig( host=args.host, port=args.port, prefill_urls=[(url, int(port)) for url, port in args.prefill], decode_urls=list(args.decode), prefill_policy=args.prefill_policy, decode_policy=args.decode_policy, request_timeout_s=args.request_timeout_s, ) ) else: parser.error("Either --backend (DP mode) or both --prefill and --decode (PD mode) are required") uvicorn.run(app, host=args.host, port=args.port, log_level="info") if __name__ == "__main__": main()