Prhub

#24719 [sgl-model-gateway] Close PyO3 binding gaps and add regression tests

原始 PR 作者 Kangyan-Zhou 合并时间 2026-05-14 08:43 文件变更 4 提交数 1 评论 1 代码增减 +864 / -31

执行摘要

补齐 PyO3 绑定缺口并添加回归测试

The Python sglang_router wrapper did not surface several RouterConfig fields the Rust binary exposes, plus a few CLI knobs were inert. This PR closes the actionable gaps without changing user-visible defaults.

值得精读,特别是 PyO3 绑定模式和测试策略。该 PR 展示了如何安全扩展跨语言绑定,并通过直接调用底层 Rust 类的测试防止接口漂移。对于即将参与绑定开发的工程师,是很好的 reference。

讨论亮点

无用户评论,但 PR 作者在 body 中说明了关键设计决策:

  • role_claim 放在 PyJwtConfig 签名末尾以避免破坏现有 4 参数位置调用者。
  • 测试直接使用 _Router 而非 mock,确保任何绑定偏移都会被测试捕获。
  • 新增字段默认值与 Rust 二进制现有默认值一致,不改变行为。

实现拆解

  1. Rust 绑定层(lib.rs):扩展 PyJwtConfig 结构体,新增 role_claim: String 字段,并转发到 to_auth_jwt_config;在 Router 结构中添加 5 个 #[pyo3(get)] 字段(pool_idle_timeout_secsconnect_timeout_secspool_max_idle_per_hosttcp_keepalive_secsenable_wasm),通过 builder 链式调用填入 RouterConfig

  2. Python 数据类(router_args.py):在 RouterArgs 中添加对应字段及默认值;集中定义 _POLICY_CHOICES 元组,统一 --policy--prefill-policy--decode-policy 的 choices 为全部 8 种策略;暴露 --bootstrap-port-annotation--jwt-role-claim--pool-idle-timeout-secs 等 CLI 参数;移除 from_cli_args 中对 bootstrap_port_annotation 的静默覆盖。

  3. Python 包装逻辑(router.py):更新 build_control_plane_auth_config,读取 jwt_role_claim 并传入 PyJwtConfig(role_claim=...);添加 JWT 部分配置检测,当设置相关字段但缺少 jwt_issuer/jwt_audience 时输出警告;在 Router.from_args 中弹出 jwt_role_claim 避免传递给 Rust 构造函数。

  4. 全面回归测试(test_pyo3_binding.py):新增 726 行测试文件,直接使用 _Router 而非 mock。覆盖所有枚举转换函数(policy_from_strbackend_from_strhistory_backend_from_strrole_from_str)的每种变体;验证所有配置类(PyOracleConfigPyPostgresConfigPyRedisConfigPyJwtConfigPyControlPlaneAuthConfig)的默认值和验证逻辑;通过 Router.from_args(RouterArgs(**every_field)) 构造并断言新字段值到达 Rust 端;端到端测试 --jwt-role-claim 从 CLI 解析到底层 _Router 的完整链路。

文件 模块 状态 重要度
sgl-model-gateway/bindings/python/tests/test_pyo3_binding.py 测试覆盖 added 8.02
sgl-model-gateway/bindings/python/src/sglang_router/router_args.py 配置层 modified 6.97
sgl-model-gateway/bindings/python/src/sglang_router/router.py 路由逻辑 modified 6.67
sgl-model-gateway/bindings/python/src/lib.rs 绑定层 modified 6.72

关键符号

policy_from_str build_control_plane_auth_config Router.from_args add_cli_args PyJwtConfig::new PyJwtConfig::to_auth_jwt_config

关键源码片段

sgl-model-gateway/bindings/python/tests/test_pyo3_binding.py test-coverage

新增 726 行 PyO3 边界测试,覆盖所有枚举转换、配置类验证和端到端 CLI 链路,是确保绑定一致性的关键。

from sglang_router.router import (
    policy_from_str,
    backend_from_str,
    history_backend_from_str,
    role_from_str,
)
from sglang_router.sglang_router_rs import (
    PolicyType,
    BackendType,
    HistoryBackendType,
    PyRole,
)class TestEnumConversions:
    """确保 Python ↔ Rust 枚举转换覆盖所有变体。"""
​
    def test_policy_from_str_covers_all_variants(self):
        # 此映射必须与 Rust 端 PolicyType 枚举保持同步。
        # 如果 Rust 端增加新变体而 Python 端未更新,本测试将立即失败。
        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")
        # ... 其他后端 ...
        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
        # 未知角色回退为 User
        assert role_from_str("unknown") == PyRole.User
sgl-model-gateway/bindings/python/src/lib.rs data-contract

Rust 端绑定,添加 PyJwtConfig.role_claim 字段和 Router 中 5 个新字段,是数据契约的根源。

use std::collections::HashMap;#[pyclass]
#[derive(Clone, Debug, PartialEq)]
pub struct PyJwtConfig {
    #[pyo3(get, set)]
    pub issuer: String,
    #[pyo3(get, set)]
    pub audience: String,
    #[pyo3(get, set)]
    pub jwks_uri: Option<String>,
    #[pyo3(get, set)]
    pub role_mapping: HashMap<String, String>,
    // 新增字段,默认 "roles",兼容旧有 4 参数位置调用
    #[pyo3(get, set)]
    pub role_claim: String,
}#[pymethods]
impl PyJwtConfig {
    #[new]
    // role_claim 放在最后并加上默认值,使得现有 4 参数调用继续正常
    #[pyo3(signature = (
        issuer,
        audience,
        jwks_uri = None,
        role_mapping = HashMap::new(),
        role_claim = String::from("roles"),
    ))]
    fn new(
        issuer: String,
        audience: String,
        jwks_uri: Option<String>,
        role_mapping: HashMap<String, String>,
        role_claim: String,
    ) -> Self {
        PyJwtConfig {
            issuer,
            audience,
            jwks_uri,
            role_mapping,
            role_claim,
        }
    }
}impl PyJwtConfig {
    pub fn to_auth_jwt_config(&self) -> auth::JwtConfig {
        let mut config = auth::JwtConfig::new(&self.issuer, &self.audience);
        // 将 role_claim 传递给底层 auth 配置
        config.role_claim = self.role_claim.clone();        if let Some(ref uri) = self.jwks_uri {
            config = config.with_jwks_uri(uri);
        }        for (idp_role, gateway_role) in &self.role_mapping {
            let role = match gateway_role.to_lowercase().as_str() {
                "admin" => auth::Role::Admin,
                _ => auth::Role::User,
            };
            config = config.with_role_mapping(idp_role, role);
        }        config
    }
}

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 绑定默认值同步风险:新增字段默认值在 Python 端和 Rust 端硬编码,若 Rust 端 RouterConfig 未来调整默认值,Python 端需手动同步。测试覆盖了新建时传入值验证,但未覆盖默认值同步。
  2. CLI 覆盖移除行为变更:原代码中 from_cli_args 静默覆盖用户提供的 bootstrap_port_annotation,移除后依赖该覆盖的用户可能行为变化,但该覆盖本身是错误,更可能符合预期。
  3. Rust 二进制策略子集未统一:src/main.rs 的 value_parser 仍接受较少策略,造成 CLI 表面不一致,但属故意推迟。

影响范围:限于 sgl-model-gateway/bindings/python/ 模块。
对用户:新增 5 个 CLI 选项(--pool-idle-timeout-secs 等)和 --jwt-role-claim,默认行为不变;--policy 等现在接受 bucket 和 consistent_hashing。
对开发者:新增全面测试套件,确保 PyO3 绑定与 Rust 端同步,减少后续开发中绑定偏移风险。
对系统:无性能影响。

绑定默认值同步依赖 CLI 覆盖移除行为变更 Rust 二进制策略子集未统一

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论