"""Minimal sgl-router Gateway class for e2e tests. sgl-router is a Rust binary (`experimental/sgl-router/target/release/sgl-router`) configured entirely through CLI flags. This Gateway execs the binary with `--worker-urls <...>` (static discovery) plus the model + policy flags. Supported lifecycles: - Regular mode: one model, N worker URLs, single policy. - PD mode: one model; prefill + decode URLs all go into one `--worker-urls` static list. Each worker is seeded as `WorkerMode::Plain` and its actual prefill/decode role + bootstrap port are resolved from `/server_info` introspection, after which the router isolates the PD pools at request time. Use as a context manager: with Gateway() as gw: gw.start_regular(model_id="...", tokenizer_path="...", worker_urls=[...]) resp = httpx.post(f"{gw.base_url}/v1/chat/completions", json=...) or pytest fixture style (see e2e_test/conftest.py). """ from __future__ import annotations import logging import os import signal import socket import subprocess import time from dataclasses import dataclass, field from pathlib import Path from typing import Any import httpx logger = logging.getLogger(__name__) # Repo-relative path to the release binary. Set ``SGL_ROUTER_BINARY`` to # override (e.g. a debug build, or a non-default ``CARGO_TARGET_DIR``). # This file is at `experimental/sgl-router/tests/e2e/infra/gateway.py`, # so four `.parent` hops to reach the sgl-router workspace root # (infra → e2e → tests → sgl-router). Cargo lands the binary at # `experimental/sgl-router/target/release/sgl-router`. A previous # version used three hops and pointed at `tests/target/`, which # would have broken any test that actually launches the router via # this helper. DEFAULT_BINARY = ( Path(__file__).resolve().parent.parent.parent.parent / "target" / "release" / "sgl-router" ) def _get_open_port() -> int: """Reserve an ephemeral TCP port in [20000, 55535]. The router itself doesn't have the ``port + 10000`` gRPC-derivation constraint that SGLang's launch_server does, but we cap the range anyway so the e2e helpers behave consistently across components. """ for _ in range(50): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] if 20000 <= port <= 55535: return port raise RuntimeError( "could not allocate an ephemeral port in [20000, 55535] after 50 tries" ) def _resolve_tokenizer_path(tokenizer_path: str) -> str: """Resolve a HuggingFace repo ID to a local ``tokenizer.json`` path. sgl-router's tokenizer loader treats the input as a filesystem path and inspects its extension; a bare HF id like ``Qwen/Qwen3-0.6B`` looks like a file with extension ``.6B`` and is rejected. When the HF Hub cache already has the tokenizer, point the loader at the on-disk ``tokenizer.json`` directly. Pass paths/URLs through unchanged. """ p = Path(tokenizer_path) if p.exists(): return str(p) try: from huggingface_hub import try_to_load_from_cache # type: ignore[import] cached = try_to_load_from_cache(tokenizer_path, "tokenizer.json") if cached and Path(cached).is_file(): return str(cached) except Exception as exc: # noqa: BLE001 # A cache miss is normal; log other failures (corrupt cache, # signature change) so a later tokenizer-load error is traceable # rather than mysterious. logger.debug("HF tokenizer cache lookup failed for %r: %s", tokenizer_path, exc) return tokenizer_path @dataclass class WorkerInfo: """Worker visible to the gateway via ``/v1/models``-style introspection. Mirrors SMG's WorkerInfo shape so test code reads the same. sgl-router does not currently surface a `/v1/workers` admin API — this is a placeholder for a future admin surface; current tests scrape `/metrics` for per-worker observability instead. """ id: str url: str model: str | None = None status: str = "unknown" metadata: dict[str, Any] = field(default_factory=dict) class Gateway: """Lifecycle-managed sgl-router instance for e2e tests. Not thread-safe; assume one Gateway per test (or per fixture scope). """ def __init__( self, host: str = "127.0.0.1", port: int | None = None, binary: Path | None = None, proxy_request_timeout_secs: int | None = None, stale_request_timeout_secs: int | None = None, ): self.host = host self.port = port or _get_open_port() self.base_url = f"http://{self.host}:{self.port}" # Resolve binary from env override, explicit arg, or repo default. env_binary = os.environ.get("SGL_ROUTER_BINARY") if binary is not None: self.binary = Path(binary) elif env_binary: self.binary = Path(env_binary) else: self.binary = DEFAULT_BINARY # Test-side overrides for the router's tunables. Both default to # `None`, in which case the router uses its production defaults # (60 s proxy timeout, 300 s stale-request timeout). Tests set # these short so per-request failures and stale-request expiry # surface within the test's wall-time budget. self.proxy_request_timeout_secs = proxy_request_timeout_secs self.stale_request_timeout_secs = stale_request_timeout_secs self.process: subprocess.Popen | None = None self._started: bool = False # Track child workers we spawned so __exit__ can tear them down. self._owned_workers: list[subprocess.Popen] = [] # ----- context manager ------------------------------------------------- def __enter__(self) -> "Gateway": return self def __exit__(self, *exc) -> None: self.shutdown() # ----- start ---------------------------------------------------------- def start_regular( self, *, model_id: str, tokenizer_path: str, worker_urls: list[str], policy: str = "round_robin", kv_indexer_endpoint: str | None = None, timeout: float = 60.0, ) -> None: """Start the router in regular (non-PD) mode. Args: model_id: The model identifier the router will dispatch under. tokenizer_path: Path or HF ID for the tokenizer the router uses for cache-aware tokenization. worker_urls: URLs of already-running ``sglang.launch_server`` instances. The router uses ``static_urls`` discovery; each worker's mode (plain) and any disaggregation metadata are learned from ``/server_info``. policy: Policy kind — ``round_robin``, ``random``, ``power_of_two``, or ``cache_aware``. kv_indexer_endpoint: Optional external KV Indexer gRPC endpoint. timeout: How long to wait for ``/readyz`` before giving up. """ self._launch( self._build_args( model_id=model_id, tokenizer_path=tokenizer_path, urls=list(worker_urls), policy=policy, kv_indexer_endpoint=kv_indexer_endpoint, ), timeout=timeout, ) def start_pd( self, *, model_id: str, tokenizer_path: str, prefill_urls: list[str], decode_urls: list[str], policy: str = "round_robin", timeout: float = 60.0, ) -> None: """Start the router in PD-disaggregated mode. All prefill + decode URLs go into one ``static_urls`` list. The router seeds each worker as ``WorkerMode::Plain`` and the manager's ``/server_info`` introspect step overrides mode + ``bootstrap_port`` from the worker's self-disclosure. Workers must have been launched with ``--disaggregation-mode`` and ``--disaggregation-bootstrap-port`` for the PD role to be picked up (see ``model_pool.spawn_worker``); modern SGLang is assumed. """ self._launch( self._build_args( model_id=model_id, tokenizer_path=tokenizer_path, urls=list(prefill_urls) + list(decode_urls), policy=policy, ), timeout=timeout, ) # ----- shutdown -------------------------------------------------------- def shutdown(self) -> None: """SIGTERM the router; SIGKILL after 30s. Idempotent.""" if self.process is not None and self.process.poll() is None: try: self.process.send_signal(signal.SIGTERM) try: self.process.wait(timeout=30) except subprocess.TimeoutExpired: self.process.kill() self.process.wait() except ProcessLookupError: pass self.process = None self._started = False # Tear down any owned upstream workers. for w in self._owned_workers: if w.poll() is None: try: w.send_signal(signal.SIGTERM) try: w.wait(timeout=30) except subprocess.TimeoutExpired: w.kill() w.wait() except ProcessLookupError: pass self._owned_workers.clear() # ----- HTTP introspection helpers ------------------------------------- def healthy(self, timeout: float = 5.0) -> bool: try: resp = httpx.get(f"{self.base_url}/healthz", timeout=timeout) return resp.status_code == 200 except (httpx.RequestError, httpx.TimeoutException): return False def ready(self, timeout: float = 5.0) -> bool: try: resp = httpx.get(f"{self.base_url}/readyz", timeout=timeout) return resp.status_code == 200 except (httpx.RequestError, httpx.TimeoutException): return False def metrics_text(self, timeout: float = 5.0) -> str | None: try: resp = httpx.get(f"{self.base_url}/metrics", timeout=timeout) if resp.status_code == 200: return resp.text return None except (httpx.RequestError, httpx.TimeoutException): return None # ----- internals ------------------------------------------------------ def _build_args( self, *, model_id: str, tokenizer_path: str, urls: list[str], policy: str, kv_indexer_endpoint: str | None = None, ) -> list[str]: resolved_tokenizer = _resolve_tokenizer_path(tokenizer_path) args = [ "--host", self.host, "--port", str(self.port), "--model-id", model_id, "--tokenizer-path", resolved_tokenizer, "--policy", policy, ] # Optional tunables — only pass them if a test overrode them, so # the router's production defaults apply otherwise. if self.proxy_request_timeout_secs is not None: args += ["--request-timeout-secs", str(self.proxy_request_timeout_secs)] if self.stale_request_timeout_secs is not None: args += [ "--stale-request-timeout-secs", str(self.stale_request_timeout_secs), ] if kv_indexer_endpoint is not None: args += ["--kv-indexer-endpoint", kv_indexer_endpoint] # `--worker-urls` is multi-valued; keep it last so clap doesn't # absorb a following flag as a URL. args += ["--worker-urls", *urls] return args def _launch(self, args: list[str], *, timeout: float) -> None: if not self.binary.exists(): raise RuntimeError( f"sgl-router binary not found at {self.binary}. " "Build it first: `cd experimental/sgl-router && cargo build --release` " "or set SGL_ROUTER_BINARY to the binary path." ) logger.info("sgl-router args: %s", args) self.process = subprocess.Popen( [str(self.binary), *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True, ) try: self._wait_ready(timeout=timeout) except Exception: self.shutdown() raise self._started = True def _wait_ready(self, *, timeout: float) -> None: deadline = time.time() + timeout last_exc: Exception | None = None while time.time() < deadline: if self.process is not None and self.process.poll() is not None: # Process exited early — surface stdout/stderr. This is the # primary startup-failure diagnostic, so if the read itself # fails, report that instead of blanking the output. try: out = b"" if self.process.stdout is not None: out = self.process.stdout.read() or b"" output = out.decode(errors="replace") except Exception as read_exc: # noqa: BLE001 output = f"" raise RuntimeError( f"sgl-router exited during startup with code " f"{self.process.returncode}. output:\n{output}", ) try: resp = httpx.get(f"{self.base_url}/readyz", timeout=2.0) if resp.status_code == 200: return except (httpx.RequestError, httpx.TimeoutException) as exc: last_exc = exc time.sleep(0.5) raise TimeoutError( f"sgl-router did not become ready at {self.base_url} within {timeout}s " f"(last error: {last_exc})" )