[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