Prhub

#54821 [Frontend][Rust] Reject empty structured-output values

原始 PR 作者 KernelClint 合并时间 2026-09-12 23:12 文件变更 4 提交数 3 评论 21 代码增减 +175 / -11

执行摘要

Rust 前端在请求边界拒绝空的结构化输出约束

PR body 明确说明动机:空白(whitespace-only)的 json / grammar 结构化输出值会被 Rust 前端放行,不在请求边界被拦截,最终在语法编译阶段失败,成为「per-request error」而不是干净的 400,与前端对其他结构化输出输入的处理不一致;同时强调「(No engine abort; grammar-compile failures are carried per-request to the scheduler.)」,并指出 gRPC 路径此前绕过了仅覆盖 Serde 的守卫(thanks @arpera and Codex for catching it)。该改动从 #51450 中按 @arpera 的请求拆分出来,以便独立落地,目标是「aligns the Rust frontend with the Python frontend's input contract」。分支名 fix/ghsa-85xf-rust-empty-structured-output 也暗示其与一条 GHSA 编号的安全类问题相关。

值得精读,但不必逐行通读。建议重点关注设计决策而非代码体量:(1) 校验为何放在 validate() 而不是可失败的构造函数——这是 WIRE → 领域对象 转换中「保持构造函数可用、把可失败性集中在边界」的典型取舍;(2) 同一份校验如何被 Serde、OpenAI response_format、gRPC 三条路径复用,以及两次漏检是如何被发现的,这对任何多入口的服务端都具备借鉴价值;(3) 描述与注释中「engine abort」被更正为「per-request 失败」的过程,揭示了 vLLM 中语法编译错误实际经由 scheduler 逐请求返回的错误传播链。如果只关心结论,读「关键源码片段」中的 validate() 与 gRPC 调用点即可。

讨论亮点
  • P1:gRPC 路径绕过校验(最核心的交锋)。Codex 在 structured_outputs.rs 的 diff 上指出:「this guard is never reached: rust/src/server/src/grpc/convert.rs::convert_structured_output calls StructuredOutputsParams::grammar(grammar.clone()), whose constructor goes directly through unchecked from_constraint, while only Serde deserialization invokes this TryFrom」,并建议「route the gRPC conversion through the same validation or a shared fallible constructor」。@arpera 明确表态:「This is right suggestion. @KernelClint, please address it.」最终实现把内联 matches! 检查重构为共享的 validate(),并在 gRPC 转换处显式调用。
  • 描述纠错:@arpera 指出「It looks like description is a bit wrong... There is no engine abortion, please fix it.」作者回复确认「Grammar-compile failures are carried per-request to the scheduler, so an empty constraint fails only that request, not the engine」,并同步清理了注释与 commit message 中「aborts the engine」的措辞。
  • 第三方 agentic review 的额外收获:@arpera 贴出 agent 审查报告后,作者承认「The OpenAI response_format path now runs the same validation as the other paths, so an empty or blank schema is rejected with a 400 instead of slipping through」,并把校验范围从「仅空 grammar」扩展到空 choice 列表、空 structural tag 与格式错误的 JSON schema。
  • 测试覆盖争论:@vadiklyutiy 先问「may we add a unit tests for that?」,再具体化要求「Could we add a regression test for the Rust OpenAI structured_outputs path with a whitespace-only grammar, e.g. "grammar": " \t\n"? The PR tests the validator and gRPC conversion, but not the Serde/HTTP path that motivated the fix. It should assert a 400 before scheduling.」作者补测后线程收束。
  • 合并流程:@vadiklyutiy 在 approval 中写道「LGTM I'd prefer to also get approval from @BugenZhao or @njhill as owners of rust frontend」,最终由 @vadiklyutiy 合并。

实现拆解

  1. 统一校验入口下沉到协议层:在 rust/src/engine-core-client/src/protocol/structured_outputs.rs 的 StructuredOutputsParams 上新增 pub fn validate(&self) -> Result<()>,按 StructuredOutputConstraint 分支拦截空白 Grammar、字符串形态且空白或不可解析的 Json、空 Choice、空白 StructuralTag,其余(json_object、非空 JSON 对象、regex)放行。这样做的原因是:构造函数 from_constraint 保持不可失败,内部调用点无需改造,只在真正跨越请求边界时插入可失败校验。
  2. Serde / HTTP 请求体路径接入:impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams 由「直接 Ok(Self { .. })」改为先构造 params、再 params.validate()?,因此 structured_outputs 字段的反序列化失败会自然变成 400。
  3. gRPC 路径补齐:rust/src/server/src/grpc/convert.rs 的 convert_structured_output 在 Ok(Some(params)) 之前调用 params.validate().map_err(|error| Status::invalid_argument(error.to_report_string()))?,并引入 thiserror_ext::AsReport。这是 Codex 标记 P1 的漏检点——该函数使用无校验的 StructuredOutputsParams::grammar(...) 等构造函数,不走 TryFrom。
  4. OpenAI response_format 路径补齐:rust/src/server/src/routes/openai/utils/structured_outputs.rs 的 convert_from_response_format 由「match 分支内直接返回 Ok(Some(...))」改为先得到 params,最后统一 params.validate().map_err(|error| ApiError::invalid_request(error.to_report_string(), Some("response_format")))?,使空白 json_schema、空 choice、空 structural_tag 也返回 400。
  5. 测试配套(全部写在源文件 #[cfg(test)] mod tests 中,仓库中无独立测试文件变更):structured_outputs.rs 新增 7 个用例(structured_outputs_rejects_empty_json_string、validate_rejects_empty_grammar_from_constructor、validate_rejects_empty_json_string_from_constructor、validate_rejects_malformed_json_string、validate_rejects_empty_choice、validate_rejects_empty_structural_tag、validate_accepts_non_empty_grammar);grpc/convert.rs 新增 grpc_rejects_empty_grammar_before_engine;routes/openai/chat_completions/convert.rs 新增 prepare_chat_request_rejects_empty_json_schema 与 prepare_chat_request_rejects_whitespace_only_structured_outputs_grammar,断言 StatusCode::BAD_REQUEST 与错误文案。第 5 步的 HTTP 用例是回应 @vadiklyutiy「The PR tests the validator and gRPC conversion, but not the Serde/HTTP path that motivated the fix」而补的。
文件 模块 状态 重要度
rust/src/engine-core-client/src/protocol/structured_outputs.rs 协议校验 modified 8.51
rust/src/server/src/grpc/convert.rs 请求入口 modified 6.79
rust/src/server/src/routes/openai/utils/structured_outputs.rs 请求入口 modified 6.44
rust/src/server/src/routes/openai/chat_completions/convert.rs 聊天路由 modified 7.06

关键符号

StructuredOutputsParams::validate impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams::try_from convert_structured_output convert_from_response_format deserialize_structured_outputs prepare_chat_request_rejects_empty_json_schema prepare_chat_request_rejects_whitespace_only_structured_outputs_grammar grpc_rejects_empty_grammar_before_engine

关键源码片段

rust/src/engine-core-client/src/protocol/structured_outputs.rs core-logic

核心文件:新增共享的 `StructuredOutputsParams::validate()`,并在 `TryFrom<WireStructuredOutputsParams>` 末尾接入,是 Serde / HTTP 路径的校验落点,同时承载 7 个新增单测。

共享校验方法(本 PR 的核心新增逻辑):

impl StructuredOutputsParams {
    /// 请求边界的统一校验入口:Serde 反序列化、OpenAI response_format、
    /// gRPC 转换三条路径都会复用它,保证「非法约束不入调度」。
    pub fn validate(&self) -> Result<()> {
        match &self.constraint {
            // grammar 只做空白检查,真正的语法编译依旧交给 EngineCore;
            // 这里拦住空串是为了返回干净的 400,而不是让它变成一次请求失败。
            StructuredOutputConstraint::Grammar(grammar) if grammar.trim().is_empty() => {
                Err(Error::InvalidStructuredOutputsParams {
                    message: "structured_outputs.grammar cannot be an empty string".to_string(),
                })
            }
            // json 约束允许两种形态:直接给 JSON 对象,或给「字符串形式的 schema」。
            // 后者必须非空且自身可被解析,否则语义上等价于空语法。
            StructuredOutputConstraint::Json(Value::String(value)) => {
                if value.trim().is_empty() {
                    return Err(Error::InvalidStructuredOutputsParams {
                        message: "structured_outputs.json cannot be an empty string".to_string(),
                    });
                }
                serde_json::from_str::<Value>(value).map_err(|_| {
                    Error::InvalidStructuredOutputsParams {
                        message: "Invalid JSON grammar specification.".to_string(),
                    }
                })?;
                Ok(())
            }
            // 空候选列表无法构造任何合法 token 集合,直接拒绝。
            StructuredOutputConstraint::Choice(choice) if choice.is_empty() => {
                Err(Error::InvalidStructuredOutputsParams {
                    message: "structured_outputs.choice cannot be empty".to_string(),
                })
            }
            // structural_tag 整体是一段 JSON 字符串,空白同样视为无效输入。
            StructuredOutputConstraint::StructuralTag(tag) if tag.trim().is_empty() => {
                Err(Error::InvalidStructuredOutputsParams {
                    message: "structured_outputs.structural_tag cannot be an empty string"
                        .to_string(),
                })
            }
            // json_object、非空 json 对象以及 regex 走默认放行分支。
            _ => Ok(()),
        }
    }
}

Serde / HTTP 请求体路径的接入点:

impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams {
    type Error = Error;
​
    fn try_from(raw: WireStructuredOutputsParams) -> Result<Self> {
        use StructuredOutputConstraint::*;
        // 上文由 insert_constraint! 宏逐个收集 json / regex / choice / grammar /
        // structural_tag,并在出现第二个约束时报「multiple structured output constraints」
        // (细节从略,本 PR 未改动该逻辑)。
​
        let params = Self {
            // 缺失任何约束时的原有文案保持不变,避免影响既有测试与客户端的错误提示。
            constraint: constraint.map(|(_, c)| c).ok_or_else(|| {
                Error::InvalidStructuredOutputsParams {
                    message: "missing structured output constraint".to_string(),
                }
            })?,
            options: StructuredOutputOptions { /* 各字段由 raw 原样透传 */ },
            backend: raw.backend,
        };
        // 关键变更:请求体反序列化成功后立即校验;失败即 400,
        // 不会再落到语法编译阶段才以「按请求失败」的形式暴露。
        params.validate()?;
        Ok(params)
    }
}
rust/src/server/src/grpc/convert.rs core-logic

gRPC 入口:`convert_structured_output` 在返回参数前显式调用 `validate()`,修复 Codex 标记的 P1 漏检路径(构造函数不经过 `TryFrom`),并新增 `grpc_rejects_empty_grammar_before_engine` 用例。

/// 把 gRPC 的结构化输出 oneof 映射为 engine-core 参数对象。
/// 早前这条路径直接使用无校验的构造函数(`from_constraint`),
/// 绕过了 Serde 侧的 `TryFrom` 校验,是 Codex 标记的 P1 漏检点。
fn convert_structured_output(
    d: &pb::DecodingParameters,
) -> Result<Option<StructuredOutputsParams>, Status> {
    let so = match d.structured_output.as_ref() {
        None => return Ok(None),
        Some(so) => so,
    };
    use pb::decoding_parameters::StructuredOutput;
    let params = match so {
        // Json 分支自身已把字符串解析为 serde_json::Value,
        // 解析失败会先以 "invalid json schema" 返回,不依赖本轮新增校验。
        StructuredOutput::Json(schema) => {
            let json: serde_json::Value = serde_json::from_str(schema)
                .map_err(|e| Status::invalid_argument(format!("invalid json schema: {e}")))?;
            StructuredOutputsParams::json(json)
        }
        StructuredOutput::Regex(regex) => StructuredOutputsParams::regex(regex.clone()),
        StructuredOutput::Choice(choices) => {
            StructuredOutputsParams::choice(choices.choices.clone())
        }
        // 空字符串 / 纯空白语法在这里不再被无条件放行。
        StructuredOutput::Grammar(grammar) => StructuredOutputsParams::grammar(grammar.clone()),
        StructuredOutput::JsonObject(true) => StructuredOutputsParams::json_object(),
        StructuredOutput::JsonObject(false) => return Ok(None),
        StructuredOutput::StructuralTag(tag) => {
            StructuredOutputsParams::structural_tag(tag.clone())
        }
    };
    // 与 HTTP 路径共用同一个 `validate()`,把失败前置为 INVALID_ARGUMENT,
    // 而不是让引擎在生成阶段以 per-request 错误的形式返回。
    params
        .validate()
        .map_err(|error| Status::invalid_argument(error.to_report_string()))?;
    Ok(Some(params))
}
rust/src/server/src/routes/openai/utils/structured_outputs.rs entrypoint

OpenAI `response_format` 入口:`convert_from_response_format` 改为先构造参数再统一 `validate()`,补上 agentic review 指出的空 / 空白 schema 漏检路径。

/// 把类型化的 `response_format` 与原始 `structured_outputs` blob 转换为
/// engine-core 的 `StructuredOutputsParams`(对齐 Python 前端的转换语义)。
pub fn convert_from_response_format(
    response_format: Option<&ResponseFormat>,
    structured_outputs: &Option<serde_json::Value>,
) -> Result<Option<StructuredOutputsParams>, ApiError> {
    // 显式 structured_outputs 走 Serde,校验已在 `TryFrom` 内部完成。
    if let Some(raw) = structured_outputs {
        return Ok(Some(deserialize_structured_outputs(raw)?));
    }
​
    let Some(fmt) = response_format else {
        return Ok(None);
    };
    // 改动点:不再在 match 分支里直接 Ok(Some(...)),
    // 而是先构造出 params,再在函数末尾统一校验。
    let params = match fmt {
        ResponseFormat::Text => return Ok(None),
        ResponseFormat::JsonObject => StructuredOutputsParams::json_object(),
        ResponseFormat::JsonSchema { json_schema } => {
            // schema 为字符串时,空白或非 JSON 文本会被 validate() 拦下。
            StructuredOutputsParams::json(json_schema.schema.clone())
        }
        ResponseFormat::StructuralTag { .. } => {
            // Python 前端会把整个 response_format(含 type 字段)序列化成字符串下发。
            let tag_json = serde_json::to_string(fmt).map_err(|e| {
                ApiError::invalid_request(
                    format!("failed to serialize structural_tag: {e}"),
                    Some("response_format"),
                )
            })?;
            StructuredOutputsParams::structural_tag(tag_json)
        }
    };
    // 非法约束在这里变成 400,而不是等到语法编译阶段才暴露给调用方。
    params.validate().map_err(|error| {
        ApiError::invalid_request(error.to_report_string(), Some("response_format"))
    })?;
    Ok(Some(params))
}

评论区精华

gRPC 转换路径绕过 Serde-only 校验(P1) 正确性

Codex 在 `structured_outputs.rs` 的 diff 上标记 P1:「For a gRPC request with `DecodingParameters.structured_output.grammar` set to an empty or whitespace-only string, this guard is never reached: `convert_structured_output` calls `StructuredOutputsParams::grammar(...)`, whose constructor goes directly through unchecked `from_constraint`, while only Serde deserialization invokes this `TryFrom`」,并建议「route the gRPC conversion through the same validation or a shared fallible constructor」。@arpera 回复「This is right suggestion. @KernelClint, please address it.」;@vadiklyutiy 追问「is it fixed? If so, pls mark as resolved」。

结论:采纳。实现从内联 `matches!` 检查重构为共享的 `StructuredOutputsParams::validate()`,`TryFrom` 与 gRPC 的 `convert_structured_output` 都调用它;作者在回复中确认「The gRPC path you and Codex flagged is handled, both TryFrom and grpc/convert.rs call the shared validate()」。 · 已解决

PR 描述中「aborts the engine」的表述不准确 documentation

@arpera:「It looks like description is a bit wrong... There is no engine abortion, please fix it.」作者回应:「Grammar-compile failures are carried per-request to the scheduler, so an empty constraint fails only that request, not the engine.」

结论:修正了 PR 描述,并同步去掉注释与 commit message 中「aborts the engine」的措辞;PR body 最终保留「(No engine abort; grammar-compile failures are carried per-request to the scheduler.)」这一澄清。 · 已解决

OpenAI `response_format` 路径同样漏校验 正确性

@arpera 贴出第三方 agentic review 报告(pr54821_agentic_review.md)供作者判断;作者随后承认「The OpenAI `response_format` path now runs the same validation as the other paths, so an empty or blank schema is rejected with a 400 instead of slipping through. Added a test for it.」

结论:`convert_from_response_format` 改为先构造 `params` 再统一 `validate()`,并把校验范围扩展到空 choice 列表、空 structural tag 与格式错误的 JSON schema,同时删除重复测试、改用 guide 要求的错误格式化方式。 · 已解决

缺少覆盖 Serde / HTTP 路径的回归测试 测试

@vadiklyutiy 先问「may we add a unit tests for that?」,随后具体化为「Could we add a regression test for the Rust OpenAI `structured_outputs` path with a whitespace-only grammar, e.g. `"grammar": " \t\n"`? The PR tests the validator and gRPC conversion, but not the Serde/HTTP path that motivated the fix. It should assert a 400 before scheduling.」

结论:作者补充 `prepare_chat_request_rejects_whitespace_only_structured_outputs_grammar` 与 `prepare_chat_request_rejects_empty_json_schema` 两个用例,断言 `StatusCode::BAD_REQUEST` 与对应错误文案;`cargo test -p vllm-engine-core-client structured_outputs` 报告 10 passed。 · 已解决

合并前是否需要 Rust 前端 owner 批准 other

@vadiklyutiy 在 approval 中写道:「LGTM I'd prefer to also get approval from @BugenZhao or @njhill as owners of rust frontend」;此前 @BugenZhao 在 Issue 评论中触发过 `@codex review`,但没有留下 approval 记录。

结论:该偏好未在可见讨论中被显式满足即完成合并(merged_by: vadiklyutiy),属于流程性关注点而非代码问题。 · 流程性未确认

风险与影响

  1. 行为兼容性收紧:convert_from_response_format 现在对 ResponseFormat::JsonSchema { json_schema } 也执行 validate(),若调用方把 schema 传成字符串(例如 " " 或非 JSON 文本),此前会被放行、如今直接 400。这是有意的对齐,但对依赖旧宽松行为的客户端属于 breaking 的行为变化。
  2. 校验入口一致性依赖人工维护:validate() 需要在每条新建参数对象的路径上被显式调用(TryFrom、convert_structured_output、convert_from_response_format)。构造函数仍是不可失败的 from_constraint,任何新增入口若忘记调用就会重现本次的漏检——Codex 与 agentic review 恰好各抓到一处,说明这类遗漏概率不低。
  3. 校验范围不对称:validate() 覆盖 grammar / json / choice / structural_tag,但 regex 未做空白检查(走 _ => Ok(())),若目标是完全对齐 Python 前端的输入契约,这一点可能需要后续确认(材料中未讨论,属于待观察项)。
  4. 测试层级偏单元:新增用例都落在源文件的 mod tests 内,routes/openai/chat_completions/convert.rs 的两个用例是调用 prepare_chat_request 的函数级验证,并未真正跑通 axum 路由与 HTTP 栈;「400 before scheduling」更多是断言语义上的保证,缺少端到端集成测试。
  5. wire 类型反序列化也被收紧:TryFrom<WireStructuredOutputsParams> 现在会校验,意味着 engine-core(Python)侧下发的 _backend wire 参数若含空约束,Rust 侧会直接反序列化失败;由于 Python 前端本身已在边界校验,实际风险较低,但属于新增的失败面。

对用户:使用 Rust 前端的 OpenAI 兼容 HTTP 与 gRPC 客户端,遇到空 grammar / 空 json schema 等非法结构化输出输入时,会立即收到 400(HTTP)或 INVALID_ARGUMENT(gRPC),错误文案为 structured_outputs.grammar cannot be an empty string 之类,而不再需要等到生成阶段才失败;行为与 Python 前端契约一致。
对系统:非法约束在进入调度器之前被拦截,减少了 EngineCore 侧语法编译的无谓开销与「per-request 失败」噪声;但并非 runtime/内核路径变更,对吞吐、延迟与 KV 缓存等核心机制没有影响。
对团队:Rust 前端的输入校验模式被显式确立(协议类型提供 validate(),各入口显式调用),后续新增 structured-output 相关入口应沿用;同时 PR 展示了 codex bot + arpera 自建 agentic review 的组合审查能抓到人类 reviewer 未第一时间发现的漏检路径,可作为 Rust 前端后续审查流程的参考。
影响范围:仅限 Rust 前端服务端与 engine-core-client 协议层,不涉及 Python 前端、模型执行与内核。

请求边界新增拒绝路径 多入口校验一致性依赖显式调用 response_format 行为收紧 regex 未纳入校验 缺少端到端集成测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论