Signed-off-by: Vincent Gao <vincentbo@linux.alibaba.com> Co-authored-by: inkcherry <mingzhi.liu@amd.com> Co-authored-by: yangbodong22011 <13137470+yangbodong22011@users.noreply.github.com>
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""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"<failed to read router stdout: {read_exc}>"
|
|
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})"
|
|
)
|