Prhub

#52528 [Bugfix][Frontend] Guard remaining before-validators against non-object JSON bodies

原始 PR 作者 Kaif10 合并时间 2026-08-17 10:27 文件变更 7 提交数 1 评论 3 代码增减 +97 / -0

执行摘要

为遗漏 before 校验器补非对象 body 防护,500 转 422

PR body 明确指出:mode="before" 的模型校验器直接调用 data.get(...),当客户端发送非对象 JSON body(list、string、number、bool)时,data 不是 dict,校验器抛出 AttributeError: 'list' object has no attribute 'get',表现为 HTTP 500 而非 422 校验错误。此前 #51654 已为 chat_completion/protocol.py 添加了 isinstance(data, dict) guard,但遗漏了其余入口的相同模式。作者对 vllm/entrypoints/** 做了全量扫描,确认还有 13 个 before-validator 缺失该防护,需要以完全相同的写法和位置补齐,保持 422 状态码与其余 Pydantic 校验一致。

值得精读。虽然 diff 本身极为机械(每个校验器加 2 行 guard),但 PR 的价值在于方法论:对 vllm/entrypoints/** 做全量扫描确认遗漏清单、与 #42961 / #44537 的分层辨析、以及「guard 不吞真实错误」的回归测试设计,都是同类入口协议修复的范本。重点关注 #51654 的延续方式与测试参数化写法。

讨论亮点

关键讨论

  1. fork PR 自动审查被禁用claude[bot] 评论说明本 PR 来自 fork,自动 review 关闭,维护者可评论 @claude review 触发一次性审查。最终未触发,由维护者 DarkLight1337 直接批准合并。
  2. 与 #42961 分层方案的关系(来自 PR description 的设计辨析):作者明确说明 #42961 在 serve/utils/api_utils.py::validate_json_request 依赖层做 body 类型检查并返回 400,而本 PR 完成 #51654 确立的 validator 层方案、保持 422 一致;两者互补——若 #42961 落地,本 guard 仍正确,且继续保护不经过 HTTP 依赖的 batch / offline 直连构造路径。
  3. guard 不能吞掉对象 body 的真实字段错误:测试设计专门用 test_completion_request_still_validates_dict_bodiestest_tokenize_chat_request_still_validates_dict_bodies 验证:非 dict 短路只影响畸形请求,dict body 上的字段级 VLLMValidationError(如空 prompt、add_generation_prompt 冲突)仍照常抛出,防止防御性代码掩盖正常校验语义。

实现拆解

变更入口

vllm/entrypoints/** 下所有 @model_validator(mode="before") 装饰的校验器做全量扫描,找出缺失非 dict 防护的 13 个校验器。

实现步骤

  1. 全量扫描确认遗漏清单:作者逐一核对所有 before-validator,确认已带 guard 的有 openai/run_batch.pypooling/embed/protocol.pyresponses/protocol.py::check_tool_usagecohere/protocol.py 使用 field_validator(接收字段值而非请求体)不受影响。
  2. 逐文件补 guard:在 6 个源码文件的每个遗漏校验器函数体开头插入 if not isinstance(data, dict): return data,位置与 #51654 完全一致。涉及文件与函数如下:
    • vllm/entrypoints/openai/completion/protocol.py(6 个):validate_response_formatcheck_structured_outputs_countcheck_logprobsvalidate_stream_optionsvalidate_prompt_and_prompt_embedsvalidate_prompt_list_length
    • vllm/entrypoints/openai/responses/protocol.py(3 个):validate_backgroundvalidate_promptinput_item_parsing
    • vllm/entrypoints/pooling/base/protocol.py(1 个):ChatRequestOptionsMixin.check_generation_prompt(覆盖 embed / classify / pooling chat 请求)
    • vllm/entrypoints/serve/tokenize/protocol.py(1 个):check_generation_prompt
    • vllm/entrypoints/speech_to_text/transcription/protocol.py(1 个):validate_transcription_request
    • vllm/entrypoints/speech_to_text/translation/protocol.py(1 个):validate_stream_options
  3. 测试配套:新增 tests/entrypoints/unit_tests/test_non_object_body_validation.py(71 行,带 skip_global_cleanup 标记)。核心是 test_request_models_reject_non_object_body,对 8 个请求模型 × 5 种非对象 payload(非法 JSON 字符串、list、42、None、True)共 40 组参数化,断言全部抛 pydantic.ValidationError。另有两个回归测试 test_completion_request_still_validates_dict_bodiestest_tokenize_chat_request_still_validates_dict_bodies,确认对象 body 上的字段级错误仍正常抛 VLLMValidationError,guard 不会吞掉真实错误。
  4. 验证结果:模型层 model_validate(payload) 从 40/40 的 AttributeError 变为 0;FastAPI TestClient 驱动下 HTTP 500 从 32/40 变为 0,剩余 8 个 null body 场景本就因 FastAPI 拦截返回 422;sibling 套件无回归。completion/test_prompt_validation.py 的 2 个失败在未修改的 main 上同样复现,属环境问题与本次变更无关。
文件 模块 状态 重要度
vllm/entrypoints/openai/completion/protocol.py 补全接口 modified 5.8
vllm/entrypoints/openai/responses/protocol.py 响应接口 modified 5.19
tests/entrypoints/unit_tests/test_non_object_body_validation.py 入口测试 added 6.42
vllm/entrypoints/pooling/base/protocol.py 池化接口 modified 4.49
vllm/entrypoints/serve/tokenize/protocol.py 分词接口 modified 4.49
vllm/entrypoints/speech_to_text/transcription/protocol.py 转写接口 modified 4.49
vllm/entrypoints/speech_to_text/translation/protocol.py 翻译接口 modified 4.49

关键符号

validate_response_format check_structured_outputs_count check_logprobs validate_stream_options validate_prompt_and_prompt_embeds validate_prompt_list_length validate_background validate_prompt input_item_parsing check_generation_prompt validate_transcription_request test_request_models_reject_non_object_body test_completion_request_still_validates_dict_bodies test_tokenize_chat_request_still_validates_dict_bodies

关键源码片段

vllm/entrypoints/openai/completion/protocol.py core-logic

本 PR 影响最大的源码文件,6 个 before-validator 补齐 guard,覆盖 response_format、structured_outputs、logprobs、stream_options、prompt 等核心请求字段的校验入口。

@model_validator(mode="before")
@classmethod
def validate_response_format(cls, data):
    # 非 dict 请求体(list、str、int、bool 等)直接放行,
    # 交给 Pydantic 字段校验统一抛 422;避免此处调用
    # data.get(...) 时抛 AttributeError 变成 HTTP 500。
    # guard 写法与 #51654 中 chat completion 的修复保持一致。
    if not isinstance(data, dict):
        return data
    response_format = data.get("response_format")
    if response_format is None:
        return data
​
    rf_type = (
        response_format.get("type")
        if isinstance(response_format, dict)
        else getattr(response_format, "type", None)
    )
​
    if rf_type == "json_schema":
        json_schema = (
            response_format.get("json_schema")
            if isinstance(response_format, dict)
            else getattr(response_format, "json_schema", None)
        )
        if json_schema is None:
            raise VLLMValidationError(
                "When response_format type is 'json_schema', the "
                "'json_schema' field must be provided.",
                parameter="response_format",
            )
​
    if rf_type == "structural_tag":
        validate_structural_tag_response_format(response_format)
​
    return data
@model_validator(mode="before")
@classmethod
def check_logprobs(cls, data):
    # 同样的非 dict 短路防护:logprob 相关字段存在大量比较运算,
    # 非对象 body 会在 data.get() 处提前崩溃,必须先判类型。
    if not isinstance(data, dict):
        return data
    if data.get("logprob_token_ids") and data.get("use_beam_search"):
        raise VLLMValidationError(
            "`logprob_token_ids` is not supported with beam search.",
            parameter="logprob_token_ids",
        )
​
    # 其余 logprobs 字段比较逻辑省略,模式与此一致
    return data
vllm/entrypoints/openai/responses/protocol.py core-logic

Responses API 的 3 个 before-validator(validate_background、validate_prompt、input_item_parsing)补齐 guard,其中 input_item_parsing 涉及输入项的复杂解析,短路后由 Pydantic 统一拒绝。

@model_validator(mode="before")
@classmethod
def validate_background(cls, data):
    # 非 dict 请求体直接放行,避免 AttributeError 变成 HTTP 500;
    # 背景任务与 store 的组合校验只在对象 body 上有意义。
    if not isinstance(data, dict):
        return data
    if not data.get("background"):
        return data
    if not data.get("store", True):
        raise VLLMValidationError(
            "background can only be used when `store` is true",
            parameter="background",
        )
    return data
@model_validator(mode="before")
@classmethod
def input_item_parsing(cls, data):
    """解析缺失必填字段或 Pydantic 无法在 Union 中消歧的 input 项。"""
    # 非 dict 请求体直接返回,交由 Pydantic 字段校验抛 422
    if not isinstance(data, dict):
        return data
    input_data = data.get("input")
​
    # None、字符串或字节流直接返回,交给 Pydantic 处理
    if input_data is None or isinstance(input_data, (str, bytes)):
        return data
​
    # 迭代器(如 ValidatorIterator)统一转成 list
    if not isinstance(input_data, list):
        try:
            input_data = list(input_data)
        except TypeError:
            # 不可迭代则保持原样,交给 Pydantic 处理
            return data
​
    processed_input = []
    for item in input_data:
        if not isinstance(item, dict):
            processed_input.append(item)
            continue
​
        item_type = item.get("type")
        if item_type == "function_call":
            # function_call 需要补 id 等字段,先尝试按强模型解析,
            # 失败则保留原 dict 交给 Pydantic 统一报错
            try:
                processed_input.append(ResponseFunctionToolCall(**item))
            except ValidationError:
                logger.debug(
                    "Failed to parse function_call to ResponseFunctionToolCall, "
                    "leaving for Pydantic validation"
                )
                processed_input.append(item)
        # 后续 reasoning、message(role=assistant) 分支与此对称(省略)
    return data
tests/entrypoints/unit_tests/test_non_object_body_validation.py test-coverage

新增测试是本 PR 的核心验证资产:40 组参数化覆盖 8 个请求模型 × 5 种非对象 payload,另有 2 个回归测试确认 guard 不吞掉对象 body 的真实字段错误。

REQUEST_MODELS = [
    CompletionRequest,
    ResponsesRequest,
    EmbeddingChatRequest,
    ClassificationChatRequest,
    PoolingChatRequest,
    TokenizeChatRequest,
    TranscriptionRequest,
    TranslationRequest,
]
​
​
@pytest.mark.parametrize("request_model", REQUEST_MODELS, ids=lambda m: m.__name__)
@pytest.mark.parametrize(
    "payload",
    [
        "this is not valid json{{",
        ["not", "an", "object"],
        42,
        None,
        True,
    ],
)
def test_request_models_reject_non_object_body(request_model, payload):
    # 8 个请求模型 × 5 种非对象 body = 40 组,
    # 全部必须干净地抛 pydantic.ValidationError,而不是 AttributeError。
    with pytest.raises(ValidationError):
        request_model.model_validate(payload)
​
​
def test_completion_request_still_validates_dict_bodies():
    """guard 不得吞掉对象 body 上的真实字段级错误。"""
    with pytest.raises(VLLMValidationError, match="prompt"):
        CompletionRequest.model_validate({"model": "qwen", "prompt": ""})

评论区精华

fork PR 自动审查被禁用 other

claude[bot] 评论说明该 PR 来自 fork,自动化 review 被禁用,维护者可评论 @claude review 触发一次性审查。

结论:未触发自动审查,由维护者 DarkLight1337 直接批准合并。 · 已解决

与 #42961 分层方案的关系 设计

作者在 PR description 中辨析:#42961 在 serve/utils/api_utils.py 的 validate_json_request 依赖层做 body 类型检查并返回 400,而本 PR 完成 #51654 确立的 validator 层方案、保持 422 一致;两者互补,若 #42961 落地本 guard 仍正确,且继续保护不经过 HTTP 依赖的 batch / offline 直连路径。

结论:维持 validator 层方案,与 #42961 定位为互补关系而非重复。 · 已解决

guard 不能吞掉对象 body 的真实字段错误 测试

测试设计专门用 test_completion_request_still_validates_dict_bodies 与 test_tokenize_chat_request_still_validates_dict_bodies 验证:非 dict 短路只影响畸形请求,dict body 上的字段级 VLLMValidationError(空 prompt、add_generation_prompt 冲突)仍照常抛出。

结论:测试覆盖确认 guard 不影响正常校验路径。 · 已解决

风险与影响

技术风险

  • 依赖 Pydantic 后置校验兜底:guard 对非 dict 直接 return data,能否得到 422 完全取决于 Pydantic 后续字段校验。当前 8 个请求模型均有必填字段,测试已验证;但若未来某模型所有字段都带默认值,非对象 body 可能绕过校验被接受,需要在新增请求模型时注意。
  • 合法非 dict 输入路径的行为变化:batch / offline 或其它直接构造模型的调用方若以非 dict(如已有模型实例、dataclass 实例)传入,guard 会跳过 before 校验逻辑,与旧行为不同。该模式已在 #51654 中先例化,且测试确认 dict body 行为不变,风险可控。
  • 同类问题可能复发:防护是逐函数手写,未来新增 before-validator 若忘记 guard 会再次出现 500。建议后续考虑统一 helper 或 lint 规则防回归。
  • 本地验证环境受限:作者在 CPU-only Windows 主机开发,未运行 mypy、typos、SPDX 等 pre-commit 钩子,仅 ruff 通过;CI 已触发(Buildkite #84120),但本次材料未包含 CI 结果,合并前依赖 CI 兜底。

影响范围

  • 用户侧:completion、responses、embed / classify / pooling、tokenize、transcription、translation 六个 API 族在收到非对象 JSON body 时从 HTTP 500 变为 422,错误语义与 chat completion 完全对齐,客户端可据此正确重试或纠错。
  • 系统侧:仅触及请求校验前置阶段,不涉及推理路径、模型加载或性能热点;对有效请求只多一次 isinstance 检查,开销可忽略。
  • 团队侧:确立了「before-validator 一律先判 dict」的防御性写法,为 #42961 的 HTTP 依赖层方案保留了兼容空间,降低了后续入口开发时的认知成本。
多入口一致性变更(6 文件 13 校验器) 非 dict 短路依赖 Pydantic 后续校验 本地未跑 mypy / typos / SPDX 同类问题可能随新校验器复发

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论