94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
727 lines
26 KiB
Python
727 lines
26 KiB
Python
"""
|
|
Tests that exercise the PyO3 boundary directly (no mocking of `_Router`).
|
|
|
|
These guard against drift between the Python `RouterArgs` dataclass, the
|
|
`Router.from_args` mapping, and the Rust `Router::new` signature in lib.rs.
|
|
The tests construct `_Router` without dispatching to remote workers, so they
|
|
run quickly and don't require GPU/network.
|
|
"""
|
|
|
|
import pytest
|
|
from sglang_router.router import (
|
|
Router,
|
|
backend_from_str,
|
|
build_control_plane_auth_config,
|
|
history_backend_from_str,
|
|
policy_from_str,
|
|
role_from_str,
|
|
)
|
|
from sglang_router.router_args import RouterArgs
|
|
from sglang_router.sglang_router_rs import (
|
|
BackendType,
|
|
HistoryBackendType,
|
|
PolicyType,
|
|
PyApiKeyEntry,
|
|
PyControlPlaneAuthConfig,
|
|
PyJwtConfig,
|
|
PyOracleConfig,
|
|
PyPostgresConfig,
|
|
PyRedisConfig,
|
|
PyRole,
|
|
)
|
|
from sglang_router.sglang_router_rs import Router as _Router
|
|
|
|
|
|
class TestEnumConversions:
|
|
"""All Python ↔ Rust enum conversion helpers cover every variant."""
|
|
|
|
def test_policy_from_str_covers_all_variants(self):
|
|
# Mirrors the PolicyType enum in lib.rs. Adding a variant on the Rust
|
|
# side without updating policy_from_str / _POLICY_CHOICES will fail here.
|
|
cases = {
|
|
"random": PolicyType.Random,
|
|
"round_robin": PolicyType.RoundRobin,
|
|
"cache_aware": PolicyType.CacheAware,
|
|
"power_of_two": PolicyType.PowerOfTwo,
|
|
"bucket": PolicyType.Bucket,
|
|
"manual": PolicyType.Manual,
|
|
"consistent_hashing": PolicyType.ConsistentHashing,
|
|
"prefix_hash": PolicyType.PrefixHash,
|
|
}
|
|
for s, expected in cases.items():
|
|
assert policy_from_str(s) == expected
|
|
|
|
def test_policy_from_str_none(self):
|
|
assert policy_from_str(None) is None
|
|
|
|
def test_backend_from_str(self):
|
|
assert backend_from_str("sglang") == BackendType.Sglang
|
|
assert backend_from_str("openai") == BackendType.Openai
|
|
assert backend_from_str("SGLANG") == BackendType.Sglang
|
|
assert backend_from_str(None) == BackendType.Sglang
|
|
assert backend_from_str(BackendType.Openai) == BackendType.Openai
|
|
with pytest.raises(ValueError, match="Unknown backend"):
|
|
backend_from_str("vllm")
|
|
|
|
def test_history_backend_from_str(self):
|
|
assert history_backend_from_str("memory") == HistoryBackendType.Memory
|
|
assert history_backend_from_str("none") == getattr(HistoryBackendType, "None")
|
|
assert history_backend_from_str("oracle") == HistoryBackendType.Oracle
|
|
assert history_backend_from_str("postgres") == HistoryBackendType.Postgres
|
|
assert history_backend_from_str("redis") == HistoryBackendType.Redis
|
|
assert history_backend_from_str(None) == HistoryBackendType.Memory
|
|
assert (
|
|
history_backend_from_str(HistoryBackendType.Redis)
|
|
== HistoryBackendType.Redis
|
|
)
|
|
with pytest.raises(ValueError, match="Unknown history backend"):
|
|
history_backend_from_str("dynamodb")
|
|
|
|
def test_role_from_str(self):
|
|
assert role_from_str("admin") == PyRole.Admin
|
|
assert role_from_str("ADMIN") == PyRole.Admin
|
|
assert role_from_str("user") == PyRole.User
|
|
# Unknown roles fall through to User
|
|
assert role_from_str("unknown") == PyRole.User
|
|
|
|
|
|
class TestPyOracleConfig:
|
|
"""PyOracleConfig PyO3 validation."""
|
|
|
|
def test_defaults(self):
|
|
cfg = PyOracleConfig()
|
|
assert cfg.pool_min == 1
|
|
assert cfg.pool_max == 16
|
|
assert cfg.pool_timeout_secs == 30
|
|
assert cfg.username is None
|
|
assert cfg.password is None
|
|
assert cfg.connect_descriptor is None
|
|
assert cfg.wallet_path is None
|
|
|
|
def test_invalid_pool_min_zero(self):
|
|
with pytest.raises(ValueError, match="pool_min must be at least 1"):
|
|
PyOracleConfig(pool_min=0)
|
|
|
|
def test_invalid_pool_max_below_min(self):
|
|
with pytest.raises(ValueError, match="pool_max must be >= pool_min"):
|
|
PyOracleConfig(pool_min=5, pool_max=2)
|
|
|
|
def test_full_config(self):
|
|
cfg = PyOracleConfig(
|
|
password="secret",
|
|
username="orcl",
|
|
connect_descriptor="dsn",
|
|
wallet_path="/path/to/wallet",
|
|
pool_min=2,
|
|
pool_max=20,
|
|
pool_timeout_secs=45,
|
|
)
|
|
assert cfg.username == "orcl"
|
|
assert cfg.pool_min == 2
|
|
assert cfg.pool_max == 20
|
|
assert cfg.pool_timeout_secs == 45
|
|
|
|
|
|
class TestPyPostgresConfig:
|
|
def test_defaults(self):
|
|
cfg = PyPostgresConfig()
|
|
assert cfg.db_url is None
|
|
assert cfg.pool_max == 16
|
|
|
|
def test_with_values(self):
|
|
cfg = PyPostgresConfig(db_url="postgres://localhost/db", pool_max=32)
|
|
assert cfg.db_url == "postgres://localhost/db"
|
|
assert cfg.pool_max == 32
|
|
|
|
|
|
class TestPyRedisConfig:
|
|
def test_defaults(self):
|
|
cfg = PyRedisConfig(url="redis://localhost:6379")
|
|
assert cfg.url == "redis://localhost:6379"
|
|
assert cfg.pool_max == 16
|
|
assert cfg.retention_days == 30
|
|
|
|
def test_persistent_retention(self):
|
|
cfg = PyRedisConfig(url="redis://localhost", retention_days=None)
|
|
assert cfg.retention_days is None
|
|
|
|
|
|
class TestPyApiKeyEntry:
|
|
def test_default_role_is_user(self):
|
|
entry = PyApiKeyEntry(id="k1", name="svc", key="secret")
|
|
assert entry.id == "k1"
|
|
assert entry.name == "svc"
|
|
assert entry.key == "secret"
|
|
assert entry.role == PyRole.User
|
|
|
|
def test_admin_role(self):
|
|
entry = PyApiKeyEntry(id="k1", name="svc", key="secret", role=PyRole.Admin)
|
|
assert entry.role == PyRole.Admin
|
|
|
|
|
|
class TestPyJwtConfig:
|
|
def test_defaults_have_role_claim(self):
|
|
# role_claim defaults to "roles" matching the smg-auth crate; without
|
|
# this surfaced through PyO3, OIDC role mapping silently breaks.
|
|
cfg = PyJwtConfig(issuer="https://issuer", audience="api")
|
|
assert cfg.issuer == "https://issuer"
|
|
assert cfg.audience == "api"
|
|
assert cfg.role_claim == "roles"
|
|
assert cfg.role_mapping == {}
|
|
assert cfg.jwks_uri is None
|
|
|
|
def test_custom_role_claim(self):
|
|
cfg = PyJwtConfig(
|
|
issuer="https://issuer",
|
|
audience="api",
|
|
role_claim="groups",
|
|
role_mapping={"AdminGroup": "admin"},
|
|
)
|
|
assert cfg.role_claim == "groups"
|
|
assert cfg.role_mapping == {"AdminGroup": "admin"}
|
|
|
|
|
|
class TestPyControlPlaneAuthConfig:
|
|
def test_default_audit_enabled(self):
|
|
# PyO3 default mirrors the smg-auth crate: ControlPlaneAuthConfig
|
|
# constructed without arguments has audit_enabled = true.
|
|
cfg = PyControlPlaneAuthConfig()
|
|
assert cfg.audit_enabled is True
|
|
assert cfg.api_keys == []
|
|
assert cfg.jwt is None
|
|
|
|
def test_with_jwt_and_keys(self):
|
|
# `PyJwtConfig` doesn't implement Python __eq__, so compare by field.
|
|
jwt = PyJwtConfig(issuer="i", audience="a")
|
|
keys = [PyApiKeyEntry(id="k", name="n", key="s", role=PyRole.Admin)]
|
|
cfg = PyControlPlaneAuthConfig(jwt=jwt, api_keys=keys, audit_enabled=False)
|
|
assert cfg.audit_enabled is False
|
|
assert cfg.jwt is not None
|
|
assert cfg.jwt.issuer == "i"
|
|
assert cfg.jwt.audience == "a"
|
|
assert len(cfg.api_keys) == 1
|
|
assert cfg.api_keys[0].id == "k"
|
|
|
|
|
|
class TestBuildControlPlaneAuthConfig:
|
|
def test_returns_none_when_no_auth(self):
|
|
assert build_control_plane_auth_config({}) is None
|
|
|
|
def test_returns_none_when_only_audit_set(self):
|
|
# Audit-only without keys/JWT shouldn't materialize a config object.
|
|
assert (
|
|
build_control_plane_auth_config({"control_plane_audit_enabled": True})
|
|
is None
|
|
)
|
|
|
|
def test_audit_default_when_unspecified(self):
|
|
# The Python wrapper has historically defaulted audit_enabled to False
|
|
# when the user doesn't pass control_plane_audit_enabled. Lock that in
|
|
# so a future change can't silently flip it.
|
|
cfg = build_control_plane_auth_config(
|
|
{
|
|
"control_plane_api_keys": [("id1", "Svc", "secret", "admin")],
|
|
}
|
|
)
|
|
assert cfg is not None
|
|
assert cfg.audit_enabled is False
|
|
assert len(cfg.api_keys) == 1
|
|
assert cfg.api_keys[0].role == PyRole.Admin
|
|
|
|
def test_jwt_role_claim_threaded_through(self):
|
|
# jwt_role_claim must reach PyJwtConfig — without this the helper
|
|
# silently drops the user's claim name.
|
|
cfg = build_control_plane_auth_config(
|
|
{
|
|
"jwt_issuer": "https://issuer",
|
|
"jwt_audience": "api",
|
|
"jwt_role_claim": "groups",
|
|
"jwt_role_mapping": {"Admins": "admin"},
|
|
}
|
|
)
|
|
assert cfg is not None
|
|
assert cfg.jwt is not None
|
|
assert cfg.jwt.role_claim == "groups"
|
|
assert cfg.jwt.role_mapping == {"Admins": "admin"}
|
|
|
|
def test_jwt_default_role_claim(self):
|
|
cfg = build_control_plane_auth_config(
|
|
{"jwt_issuer": "https://issuer", "jwt_audience": "api"}
|
|
)
|
|
assert cfg is not None and cfg.jwt is not None
|
|
assert cfg.jwt.role_claim == "roles"
|
|
|
|
def test_warns_when_jwt_incomplete(self, caplog):
|
|
# If the user sets jwt_role_claim/jwks_uri/role_mapping but forgets
|
|
# issuer/audience, the helper drops them silently. Emit a warning so
|
|
# users notice their JWT auth isn't actually enabled.
|
|
with caplog.at_level("WARNING", logger="sglang_router.router"):
|
|
cfg = build_control_plane_auth_config(
|
|
{"jwt_role_claim": "groups", "jwt_role_mapping": {"X": "admin"}}
|
|
)
|
|
assert cfg is None
|
|
assert any(
|
|
"jwt_issuer/jwt_audience missing" in record.message
|
|
for record in caplog.records
|
|
)
|
|
|
|
|
|
class TestParseControlPlaneApiKeys:
|
|
def test_valid(self):
|
|
result = RouterArgs._parse_control_plane_api_keys(
|
|
["k1:Service Account:admin:secret123", "k2:Read Only:user:secret456"]
|
|
)
|
|
assert result == [
|
|
("k1", "Service Account", "secret123", "admin"),
|
|
("k2", "Read Only", "secret456", "user"),
|
|
]
|
|
|
|
def test_invalid_format(self):
|
|
with pytest.raises(ValueError, match="Invalid API key format"):
|
|
RouterArgs._parse_control_plane_api_keys(["just-a-key"])
|
|
|
|
def test_invalid_role(self):
|
|
with pytest.raises(ValueError, match="Invalid role"):
|
|
RouterArgs._parse_control_plane_api_keys(["id:name:superuser:secret"])
|
|
|
|
def test_key_with_colons_preserved(self):
|
|
# The split limit of 4 means the key portion can itself contain colons.
|
|
result = RouterArgs._parse_control_plane_api_keys(
|
|
["id:name:user:sk-abc:def:ghi"]
|
|
)
|
|
assert result == [("id", "name", "sk-abc:def:ghi", "user")]
|
|
|
|
def test_empty(self):
|
|
assert RouterArgs._parse_control_plane_api_keys([]) == []
|
|
assert RouterArgs._parse_control_plane_api_keys(None) == []
|
|
|
|
|
|
class TestParseJwtRoleMapping:
|
|
def test_valid(self):
|
|
result = RouterArgs._parse_jwt_role_mapping(
|
|
["Gateway.Admin=admin", "Gateway.User=user"]
|
|
)
|
|
assert result == {"Gateway.Admin": "admin", "Gateway.User": "user"}
|
|
|
|
def test_invalid_format(self):
|
|
with pytest.raises(ValueError, match="Invalid role mapping format"):
|
|
RouterArgs._parse_jwt_role_mapping(["no-equals"])
|
|
|
|
def test_invalid_role(self):
|
|
with pytest.raises(ValueError, match="Invalid gateway role"):
|
|
RouterArgs._parse_jwt_role_mapping(["X=superuser"])
|
|
|
|
def test_empty(self):
|
|
assert RouterArgs._parse_jwt_role_mapping([]) == {}
|
|
|
|
|
|
class TestRouterFromArgsKitchenSink:
|
|
"""End-to-end tests of `Router.from_args(RouterArgs(...))`.
|
|
|
|
These instantiate a real PyO3 `_Router` (no mocking) so any drift between
|
|
the Python dataclass fields and the Rust constructor signature surfaces here.
|
|
"""
|
|
|
|
def test_minimal_regular_mode(self):
|
|
args = RouterArgs(
|
|
host="127.0.0.1",
|
|
port=30000,
|
|
worker_urls=["http://w1:8000"],
|
|
policy="round_robin",
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
|
|
def test_pd_mode(self):
|
|
args = RouterArgs(
|
|
pd_disaggregation=True,
|
|
prefill_urls=[("http://prefill1:8000", 9000)],
|
|
decode_urls=["http://decode1:8001"],
|
|
policy="cache_aware",
|
|
prefill_policy="power_of_two",
|
|
decode_policy="round_robin",
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
|
|
def test_all_policies_construct(self):
|
|
# Ensures every PolicyType the binding accepts is reachable through
|
|
# RouterArgs without exploding (e.g. unknown assignment_mode panics).
|
|
for policy in (
|
|
"random",
|
|
"round_robin",
|
|
"cache_aware",
|
|
"power_of_two",
|
|
"bucket",
|
|
"manual",
|
|
"consistent_hashing",
|
|
"prefix_hash",
|
|
):
|
|
args = RouterArgs(
|
|
worker_urls=["http://w1:8000"],
|
|
policy=policy,
|
|
pd_disaggregation=True,
|
|
prefill_urls=[("http://prefill1:8000", None)],
|
|
decode_urls=["http://decode1:8001"],
|
|
prefill_policy=policy,
|
|
decode_policy=policy,
|
|
)
|
|
Router.from_args(args)
|
|
|
|
def test_kitchen_sink_passes_every_field(self):
|
|
# Touches every dataclass field that maps directly to a parameter of
|
|
# Rust's Router::new (see lib.rs). PD-mode fields are exercised in
|
|
# test_pd_mode/test_all_policies_construct, and history-backend
|
|
# sub-configs are exercised in test_{oracle,postgres,redis}_history_backend.
|
|
# If you add a field on the Rust side without wiring it through
|
|
# RouterArgs/from_args, _Router(**args_dict) raises
|
|
# TypeError("got an unexpected keyword argument ...").
|
|
args = RouterArgs(
|
|
worker_urls=["http://w1:8000", "http://w2:8000"],
|
|
host="127.0.0.1",
|
|
port=30001,
|
|
policy="cache_aware",
|
|
worker_startup_timeout_secs=60,
|
|
worker_startup_check_interval=5,
|
|
cache_threshold=0.5,
|
|
balance_abs_threshold=32,
|
|
balance_rel_threshold=1.2,
|
|
eviction_interval_secs=30,
|
|
max_tree_size=2**20,
|
|
max_idle_secs=600,
|
|
assignment_mode="min_load",
|
|
max_payload_size=1024 * 1024,
|
|
bucket_adjust_interval_secs=10,
|
|
dp_aware=True,
|
|
enable_igw=False,
|
|
api_key="key123",
|
|
log_dir="/tmp/router-logs",
|
|
log_level="debug",
|
|
json_log=True,
|
|
service_discovery=False,
|
|
selector={"app": "worker"},
|
|
service_discovery_port=8080,
|
|
service_discovery_namespace="default",
|
|
prefill_selector={"role": "prefill"},
|
|
decode_selector={"role": "decode"},
|
|
bootstrap_port_annotation="custom.io/bootstrap-port",
|
|
prometheus_port=29000,
|
|
prometheus_host="127.0.0.1",
|
|
prometheus_duration_buckets=[0.1, 0.5, 1.0],
|
|
request_id_headers=["x-trace-id"],
|
|
request_timeout_secs=600,
|
|
shutdown_grace_period_secs=30,
|
|
max_concurrent_requests=128,
|
|
queue_size=50,
|
|
queue_timeout_secs=30,
|
|
rate_limit_tokens_per_second=64,
|
|
cors_allowed_origins=["http://localhost:3000"],
|
|
retry_max_retries=2,
|
|
retry_initial_backoff_ms=10,
|
|
retry_max_backoff_ms=1000,
|
|
retry_backoff_multiplier=2.0,
|
|
retry_jitter_factor=0.3,
|
|
disable_retries=False,
|
|
cb_failure_threshold=5,
|
|
cb_success_threshold=2,
|
|
cb_timeout_duration_secs=30,
|
|
cb_window_duration_secs=60,
|
|
disable_circuit_breaker=False,
|
|
health_failure_threshold=2,
|
|
health_success_threshold=1,
|
|
health_check_timeout_secs=3,
|
|
health_check_interval_secs=15,
|
|
health_check_endpoint="/healthz",
|
|
disable_health_check=False,
|
|
model_path="meta-llama/Llama-3-8B",
|
|
tokenizer_path=None,
|
|
chat_template=None,
|
|
tokenizer_cache_enable_l0=True,
|
|
tokenizer_cache_l0_max_entries=1000,
|
|
tokenizer_cache_enable_l1=True,
|
|
tokenizer_cache_l1_max_memory=1024 * 1024,
|
|
reasoning_parser="qwen3",
|
|
tool_call_parser=None,
|
|
mcp_config_path=None,
|
|
backend="sglang",
|
|
history_backend="memory",
|
|
client_cert_path=None,
|
|
client_key_path=None,
|
|
ca_cert_paths=[],
|
|
server_cert_path=None,
|
|
server_key_path=None,
|
|
enable_trace=True,
|
|
otlp_traces_endpoint="otel-collector:4317",
|
|
control_plane_api_keys=[("k1", "svc", "secret", "admin")],
|
|
control_plane_audit_enabled=False,
|
|
jwt_issuer="https://issuer",
|
|
jwt_audience="api",
|
|
jwt_jwks_uri="https://issuer/.well-known/jwks.json",
|
|
jwt_role_claim="groups",
|
|
jwt_role_mapping={"Admins": "admin"},
|
|
pool_idle_timeout_secs=20,
|
|
connect_timeout_secs=5,
|
|
pool_max_idle_per_host=100,
|
|
tcp_keepalive_secs=15,
|
|
enable_wasm=True,
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
# Confirm the new fields actually carry the right value into Rust —
|
|
# an isinstance check alone wouldn't catch a typo'd builder call like
|
|
# `.pool_idle_timeout_secs(self.connect_timeout_secs)`.
|
|
assert router._router.pool_idle_timeout_secs == 20
|
|
assert router._router.connect_timeout_secs == 5
|
|
assert router._router.pool_max_idle_per_host == 100
|
|
assert router._router.tcp_keepalive_secs == 15
|
|
assert router._router.enable_wasm is True
|
|
|
|
def test_oracle_history_backend(self):
|
|
args = RouterArgs(
|
|
worker_urls=["http://w1:8000"],
|
|
history_backend="oracle",
|
|
oracle_username="user",
|
|
oracle_password="pw",
|
|
oracle_connect_descriptor="dsn",
|
|
oracle_pool_min=2,
|
|
oracle_pool_max=8,
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
|
|
def test_postgres_history_backend(self):
|
|
args = RouterArgs(
|
|
worker_urls=["http://w1:8000"],
|
|
history_backend="postgres",
|
|
postgres_db_url="postgres://localhost/db",
|
|
postgres_pool_max=8,
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
|
|
def test_redis_history_backend(self):
|
|
args = RouterArgs(
|
|
worker_urls=["http://w1:8000"],
|
|
history_backend="redis",
|
|
redis_url="redis://localhost:6379",
|
|
redis_pool_max=8,
|
|
redis_retention_days=7,
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
|
|
def test_redis_persistent_retention(self):
|
|
# redis_retention_days < 0 means persistent (None on the Rust side).
|
|
args = RouterArgs(
|
|
worker_urls=["http://w1:8000"],
|
|
history_backend="redis",
|
|
redis_url="redis://localhost:6379",
|
|
redis_retention_days=-1,
|
|
)
|
|
Router.from_args(args)
|
|
|
|
|
|
class TestBootstrapPortAnnotation:
|
|
"""Regression: the wrapper must not silently override user-supplied values."""
|
|
|
|
def test_user_value_preserved_through_from_cli_args(self):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(
|
|
[
|
|
"--bootstrap-port-annotation",
|
|
"custom.io/bootstrap-port",
|
|
]
|
|
)
|
|
assert args.bootstrap_port_annotation == "custom.io/bootstrap-port"
|
|
|
|
def test_default_value(self):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args([])
|
|
assert args.bootstrap_port_annotation == "sglang.ai/bootstrap-port"
|
|
|
|
|
|
class TestNewBindingFields:
|
|
"""Round-trip checks for fields whose CLI flag, dataclass attribute, and
|
|
PyO3 constructor parameter were historically out of sync."""
|
|
|
|
def test_jwt_role_claim_default(self):
|
|
args = RouterArgs()
|
|
assert args.jwt_role_claim == "roles"
|
|
|
|
def test_audit_enabled_default_off(self):
|
|
# The Python wrapper defaults audit_enabled to False even though the
|
|
# Rust standalone binary defaults it to True (main.rs:614,
|
|
# disable_audit_logging = false → audit_enabled = true). The
|
|
# divergence is intentional: changing the wrapper default is a
|
|
# behavior change that needs an explicit migration.
|
|
args = RouterArgs()
|
|
assert args.control_plane_audit_enabled is False
|
|
|
|
def test_audit_enabled_via_cli_flag(self):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(["--control-plane-audit-enabled"])
|
|
assert args.control_plane_audit_enabled is True
|
|
|
|
def test_default_audit_via_cli_is_off(self):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args([])
|
|
assert args.control_plane_audit_enabled is False
|
|
|
|
def test_http_pool_defaults(self):
|
|
args = RouterArgs()
|
|
assert args.pool_idle_timeout_secs == 50
|
|
assert args.connect_timeout_secs == 10
|
|
assert args.pool_max_idle_per_host == 500
|
|
assert args.tcp_keepalive_secs == 30
|
|
|
|
def test_enable_wasm_default(self):
|
|
args = RouterArgs()
|
|
assert args.enable_wasm is False
|
|
|
|
def test_http_pool_via_cli(self):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(
|
|
[
|
|
"--pool-idle-timeout-secs",
|
|
"120",
|
|
"--connect-timeout-secs",
|
|
"20",
|
|
"--pool-max-idle-per-host",
|
|
"200",
|
|
"--tcp-keepalive-secs",
|
|
"45",
|
|
"--enable-wasm",
|
|
]
|
|
)
|
|
assert args.pool_idle_timeout_secs == 120
|
|
assert args.connect_timeout_secs == 20
|
|
assert args.pool_max_idle_per_host == 200
|
|
assert args.tcp_keepalive_secs == 45
|
|
assert args.enable_wasm is True
|
|
|
|
def test_jwt_role_claim_via_cli(self):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(
|
|
[
|
|
"--jwt-issuer",
|
|
"https://issuer",
|
|
"--jwt-audience",
|
|
"api",
|
|
"--jwt-role-claim",
|
|
"groups",
|
|
]
|
|
)
|
|
assert args.jwt_role_claim == "groups"
|
|
|
|
def test_jwt_role_claim_end_to_end(self):
|
|
# Full pipeline: CLI parser → RouterArgs → Router.from_args →
|
|
# `_Router(**args_dict)`. Pins the invariant that `jwt_role_claim` is
|
|
# consumed by `build_control_plane_auth_config` AND popped from
|
|
# args_dict before reaching the Rust constructor (otherwise _Router
|
|
# would raise TypeError on the unknown kwarg).
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(
|
|
[
|
|
"--jwt-issuer",
|
|
"https://issuer",
|
|
"--jwt-audience",
|
|
"api",
|
|
"--jwt-role-claim",
|
|
"groups",
|
|
"--jwt-role-mapping",
|
|
"Admins=admin",
|
|
]
|
|
)
|
|
router = Router.from_args(args)
|
|
assert isinstance(router._router, _Router)
|
|
|
|
|
|
class TestPolicyChoiceListConsistency:
|
|
"""Every policy in the binding's PolicyType must be a CLI choice on every
|
|
policy flag. Catches drift if someone hard-codes a list at one of the three
|
|
argparse `choices=` sites instead of using `_POLICY_CHOICES`."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"policy",
|
|
[
|
|
"random",
|
|
"round_robin",
|
|
"cache_aware",
|
|
"power_of_two",
|
|
"bucket",
|
|
"manual",
|
|
"consistent_hashing",
|
|
"prefix_hash",
|
|
],
|
|
)
|
|
def test_main_policy_accepts(self, policy):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(["--policy", policy])
|
|
assert args.policy == policy
|
|
|
|
@pytest.mark.parametrize(
|
|
"policy",
|
|
[
|
|
"random",
|
|
"round_robin",
|
|
"cache_aware",
|
|
"power_of_two",
|
|
"bucket",
|
|
"manual",
|
|
"consistent_hashing",
|
|
"prefix_hash",
|
|
],
|
|
)
|
|
def test_prefill_policy_accepts(self, policy):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(
|
|
[
|
|
"--pd-disaggregation",
|
|
"--prefill",
|
|
"http://p:8000",
|
|
"--decode",
|
|
"http://d:8001",
|
|
"--prefill-policy",
|
|
policy,
|
|
]
|
|
)
|
|
assert args.prefill_policy == policy
|
|
|
|
@pytest.mark.parametrize(
|
|
"policy",
|
|
[
|
|
"random",
|
|
"round_robin",
|
|
"cache_aware",
|
|
"power_of_two",
|
|
"bucket",
|
|
"manual",
|
|
"consistent_hashing",
|
|
"prefix_hash",
|
|
],
|
|
)
|
|
def test_decode_policy_accepts(self, policy):
|
|
from sglang_router.launch_router import parse_router_args
|
|
|
|
args = parse_router_args(
|
|
[
|
|
"--pd-disaggregation",
|
|
"--prefill",
|
|
"http://p:8000",
|
|
"--decode",
|
|
"http://d:8001",
|
|
"--decode-policy",
|
|
policy,
|
|
]
|
|
)
|
|
assert args.decode_policy == policy
|