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

Signed-off-by: Clinton Thomas <1033162+KernelClint@users.noreply.github.com> Co-authored-by: Lucas Bourtoule <35483370+dhalf@users.noreply.github.com>

Clinton Thomas committed Sep 12, 2026 at 11:12 UTC 22f6e4eccb674b534f62810c66838317169b6b98
4 files changed +175 -11
rust/src/engine-core-client/src/protocol/structured_outputs.rs
+88 -2
@@ -104,6 +104,41 @@ impl StructuredOutputsParams {
104 backend: StructuredOutputBackend::default(),
105 }
106 }
107 +
108 + pub fn validate(&self) -> Result<()> {
109 + match &self.constraint {
110 + StructuredOutputConstraint::Grammar(grammar) if grammar.trim().is_empty() => {
111 + Err(Error::InvalidStructuredOutputsParams {
112 + message: "structured_outputs.grammar cannot be an empty string".to_string(),
113 + })
114 + }
115 + StructuredOutputConstraint::Json(Value::String(value)) => {
116 + if value.trim().is_empty() {
117 + return Err(Error::InvalidStructuredOutputsParams {
118 + message: "structured_outputs.json cannot be an empty string".to_string(),
119 + });
120 + }
121 + serde_json::from_str::<Value>(value).map_err(|_| {
122 + Error::InvalidStructuredOutputsParams {
123 + message: "Invalid JSON grammar specification.".to_string(),
124 + }
125 + })?;
126 + Ok(())
127 + }
128 + StructuredOutputConstraint::Choice(choice) if choice.is_empty() => {
129 + Err(Error::InvalidStructuredOutputsParams {
130 + message: "structured_outputs.choice cannot be empty".to_string(),
131 + })
132 + }
133 + StructuredOutputConstraint::StructuralTag(tag) if tag.trim().is_empty() => {
134 + Err(Error::InvalidStructuredOutputsParams {
135 + message: "structured_outputs.structural_tag cannot be an empty string"
136 + .to_string(),
137 + })
138 + }
139 + _ => Ok(()),
140 + }
141 + }
142 }
143
144 /// Wire-compatible structured-output payload used by Python engine-core.
@@ -176,7 +211,7 @@ impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams {
211 }
212 insert_constraint!("structural_tag", raw.structural_tag.map(StructuralTag));
213
179 - Ok(Self {
214 + let params = Self {
215 constraint: constraint.map(|(_, c)| c).ok_or_else(|| {
216 Error::InvalidStructuredOutputsParams {
217 message: "missing structured output constraint".to_string(),
@@ -188,7 +223,9 @@ impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams {
223 whitespace_pattern: raw.whitespace_pattern,
224 },
225 backend: raw.backend,
191 - })
226 + };
227 + params.validate()?;
228 + Ok(params)
229 }
230 }
231
@@ -289,6 +326,55 @@ mod tests {
326 assert!(error.to_string().contains("json_object must be true"));
327 }
328
329 + #[test]
330 + fn structured_outputs_rejects_empty_json_string() {
331 + let error = serde_json::from_value::<StructuredOutputsParams>(serde_json::json!({
332 + "json": " ",
333 + }))
334 + .unwrap_err();
335 +
336 + assert!(error.to_string().contains("json cannot be an empty string"));
337 + }
338 +
339 + #[test]
340 + fn validate_rejects_empty_grammar_from_constructor() {
341 + let error = StructuredOutputsParams::grammar(" ").validate().unwrap_err();
342 + assert!(error.to_string().contains("grammar cannot be an empty string"));
343 + }
344 +
345 + #[test]
346 + fn validate_rejects_empty_json_string_from_constructor() {
347 + let error = StructuredOutputsParams::json(Value::String(" ".to_string()))
348 + .validate()
349 + .unwrap_err();
350 + assert!(error.to_string().contains("json cannot be an empty string"));
351 + }
352 +
353 + #[test]
354 + fn validate_rejects_malformed_json_string() {
355 + let error = StructuredOutputsParams::json(Value::String("not json".to_string()))
356 + .validate()
357 + .unwrap_err();
358 + assert!(error.to_string().contains("Invalid JSON grammar specification."));
359 + }
360 +
361 + #[test]
362 + fn validate_rejects_empty_choice() {
363 + let error = StructuredOutputsParams::choice(vec![]).validate().unwrap_err();
364 + assert!(error.to_string().contains("choice cannot be empty"));
365 + }
366 +
367 + #[test]
368 + fn validate_rejects_empty_structural_tag() {
369 + let error = StructuredOutputsParams::structural_tag("\n\t").validate().unwrap_err();
370 + assert!(error.to_string().contains("structural_tag cannot be an empty string"));
371 + }
372 +
373 + #[test]
374 + fn validate_accepts_non_empty_grammar() {
375 + assert!(StructuredOutputsParams::grammar("root ::= \"a\"").validate().is_ok());
376 + }
377 +
378 #[test]
379 fn structured_outputs_serializes_through_raw_shape() {
380 let params = StructuredOutputsParams {
rust/src/server/src/grpc/convert.rs
+18
@@ -4,6 +4,7 @@
4 //! Conversion between gRPC protobuf types and internal `vllm-text`
5 //! request/response types.
6
7 +use thiserror_ext::AsReport as _;
8 use tonic::Status;
9 use uuid::Uuid;
10 use vllm_engine_core_client::protocol::output::StopReason;
@@ -259,6 +260,9 @@ fn convert_structured_output(
260 StructuredOutputsParams::structural_tag(tag.clone())
261 }
262 };
263 + params
264 + .validate()
265 + .map_err(|error| Status::invalid_argument(error.to_report_string()))?;
266 Ok(Some(params))
267 }
268
@@ -546,6 +550,20 @@ mod tests {
550 assert_eq!(text.sampling_params.temperature, Some(0.0));
551 }
552
553 + #[test]
554 + fn grpc_rejects_empty_grammar_before_engine() {
555 + use super::pb::decoding_parameters::StructuredOutput;
556 + let req = pb::GenerateRequest {
557 + decoding: Some(pb::DecodingParameters {
558 + structured_output: Some(StructuredOutput::Grammar(" ".to_string())),
559 + ..Default::default()
560 + }),
561 + ..base_request()
562 + };
563 + let err = to_text_request(req, false, &["test-model".to_string()]).unwrap_err();
564 + assert!(err.message().contains("grammar cannot be an empty string"));
565 + }
566 +
567 #[test]
568 fn absent_seed_is_none() {
569 let req = pb::GenerateRequest {
rust/src/server/src/routes/openai/chat_completions/convert.rs
+56 -1
@@ -437,7 +437,7 @@ mod tests {
437 use std::collections::HashMap;
438 use std::sync::Arc;
439
440 - use axum::http::HeaderMap;
440 + use axum::http::{HeaderMap, StatusCode};
441 use expect_test::expect;
442 use llm_multimodal::ImageDetail;
443 use serde_json::json;
@@ -583,6 +583,61 @@ mod tests {
583 assert_eq!(prepared.chat_request.decode_options.min_tokens, 0);
584 }
585
586 + #[test]
587 + fn prepare_chat_request_rejects_empty_json_schema() {
588 + let mut request = base_request();
589 + request.response_format = Some(ResponseFormat::JsonSchema {
590 + json_schema: JsonSchemaFormat {
591 + name: "answer".to_string(),
592 + description: None,
593 + schema: json!(" "),
594 + strict: None,
595 + },
596 + });
597 +
598 + let error = prepare_chat_request(
599 + request,
600 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
601 + ResolvedRequestContext::default(),
602 + )
603 + .unwrap_err();
604 +
605 + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
606 + assert!(
607 + error
608 + .to_error_response()
609 + .error
610 + .message
611 + .contains("json cannot be an empty string")
612 + );
613 + }
614 +
615 + #[test]
616 + fn prepare_chat_request_rejects_whitespace_only_structured_outputs_grammar() {
617 + let request: ChatCompletionRequest = serde_json::from_value(json!({
618 + "model": "Qwen/Qwen1.5-0.5B-Chat",
619 + "messages": [{"role": "user", "content": "hello"}],
620 + "structured_outputs": {"grammar": " \t\n"},
621 + }))
622 + .expect("parse structured_outputs");
623 +
624 + let error = prepare_chat_request(
625 + request,
626 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
627 + ResolvedRequestContext::default(),
628 + )
629 + .unwrap_err();
630 +
631 + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
632 + assert!(
633 + error
634 + .to_error_response()
635 + .error
636 + .message
637 + .contains("grammar cannot be an empty string")
638 + );
639 + }
640 +
641 #[test]
642 fn prepare_chat_request_defaults_function_tool_fields() {
643 let request: ChatCompletionRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/utils/structured_outputs.rs
+13 -8
@@ -3,6 +3,7 @@
3
4 use serde::{Deserialize, Serialize};
5 use serde_json::Value;
6 +use thiserror_ext::AsReport as _;
7 use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams;
8
9 use crate::error::ApiError;
@@ -80,12 +81,12 @@ pub fn convert_from_response_format(
81 let Some(fmt) = response_format else {
82 return Ok(None);
83 };
83 - match fmt {
84 - ResponseFormat::Text => Ok(None),
85 - ResponseFormat::JsonObject => Ok(Some(StructuredOutputsParams::json_object())),
86 - ResponseFormat::JsonSchema { json_schema } => Ok(Some(StructuredOutputsParams::json(
87 - json_schema.schema.clone(),
88 - ))),
84 + let params = match fmt {
85 + ResponseFormat::Text => return Ok(None),
86 + ResponseFormat::JsonObject => StructuredOutputsParams::json_object(),
87 + ResponseFormat::JsonSchema { json_schema } => {
88 + StructuredOutputsParams::json(json_schema.schema.clone())
89 + }
90 ResponseFormat::StructuralTag { .. } => {
91 // The Python frontend dumps the entire response_format object (including the
92 // `type` field) as a JSON string for the engine-core backend.
@@ -95,9 +96,13 @@ pub fn convert_from_response_format(
96 Some("response_format"),
97 )
98 })?;
98 - Ok(Some(StructuredOutputsParams::structural_tag(tag_json)))
99 + StructuredOutputsParams::structural_tag(tag_json)
100 }
100 - }
101 + };
102 + params.validate().map_err(|error| {
103 + ApiError::invalid_request(error.to_report_string(), Some("response_format"))
104 + })?;
105 + Ok(Some(params))
106 }
107
108 /// Convert raw `response_format` and/or `structured_outputs` JSON blobs into