[Bugfix][Rust Frontend] Reject min_tokens above max_tokens (#46733)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: reidliu41 <reid201711@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Reid committed
Jun 26, 2026 at 11:41 UTC
d350fa8dddc6ed1a3a5710473d485ad0d02a6127
6 files changed
+153
-28
rust/src/chat/src/error.rs
+11
@@ -74,6 +74,17 @@ pub enum Error {
74
75
pub type Result<T> = std::result::Result<T, Error>;
76
77
+impl Error {
78
+ /// Whether this error represents invalid user request parameters.
79
+ pub fn is_request_validation_error(&self) -> bool {
80
+ match self {
81
+ Self::PromptTooLong { .. } => true,
82
+ Self::Text(error) => error.is_request_validation_error(),
83
+ _ => false,
84
+ }
85
+ }
86
+}
87
+
88
/// Format the available-parser suffix used in user-facing error messages.
89
fn available_parser_hint(available_names: &[String]) -> String {
90
if available_names.is_empty() {
rust/src/server/src/error.rs
+20
-21
@@ -78,7 +78,7 @@ impl IntoResponse for ApiError {
78
/// the client's fault and map to HTTP 400, mirroring the Python frontend.
79
/// Everything else stays an internal 500.
80
pub fn text_submit_error(context: &'static str, error: vllm_text::Error) -> ApiError {
81
- if is_request_validation_error(&error) {
81
+ if error.is_request_validation_error() {
82
return invalid_request!("{error}");
83
}
84
server_error!("{}: {}", context, error.to_report_string())
@@ -87,27 +87,10 @@ pub fn text_submit_error(context: &'static str, error: vllm_text::Error) -> ApiE
87
/// Like [`text_submit_error`], for the chat pipeline (which both wraps the
88
/// text errors and raises its own prompt-length variant).
89
pub fn chat_submit_error(context: &'static str, error: vllm_chat::Error) -> ApiError {
90
- match &error {
91
- vllm_chat::Error::PromptTooLong { .. } => invalid_request!("{error}"),
92
- vllm_chat::Error::Text(text_error) if is_request_validation_error(text_error) => {
93
- invalid_request!("{error}")
94
- }
95
- _ => server_error!("{}: {}", context, error.to_report_string()),
90
+ if error.is_request_validation_error() {
91
+ return invalid_request!("{error}");
92
}
97
-}
98
-
99
-fn is_request_validation_error(error: &vllm_text::Error) -> bool {
100
- matches!(
101
- error,
102
- vllm_text::Error::PromptTooLong { .. }
103
- | vllm_text::Error::EmptyPromptTokenIds { .. }
104
- | vllm_text::Error::Logprobs(_)
105
- | vllm_text::Error::TokenIds(_)
106
- | vllm_text::Error::InvalidThinkingTokenBudget
107
- // An empty tokenized prompt detected later, at request prepare
108
- // time, surfaces through the transparent Llm wrapper.
109
- | vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds { .. })
110
- )
93
+ server_error!("{}: {}", context, error.to_report_string())
94
}
95
96
#[cfg(test)]
@@ -140,6 +123,22 @@ mod tests {
123
assert!(response.error.message.contains("thinking_token_budget"));
124
}
125
126
+ #[test]
127
+ fn min_tokens_above_max_tokens_maps_to_invalid_request() {
128
+ let api_error = text_submit_error(
129
+ "failed to submit completion request",
130
+ vllm_text::Error::MinTokensExceedsMaxTokens {
131
+ min_tokens: 5,
132
+ max_tokens: 4,
133
+ },
134
+ );
135
+ assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
136
+ let response = api_error.to_error_response();
137
+ assert_eq!(response.error.error_type, "invalid_request_error");
138
+ assert!(response.error.message.contains("min_tokens=5"));
139
+ assert!(response.error.message.contains("max_tokens=4"));
140
+ }
141
+
142
#[test]
143
fn chat_wrapped_prompt_too_long_maps_to_invalid_request() {
144
let error = vllm_chat::Error::Text(vllm_text::Error::PromptTooLong {
rust/src/server/src/grpc/mod.rs
+13
-7
@@ -56,12 +56,9 @@ impl pb::generate_server::Generate for GenerateServiceImpl {
56
info!(%request_id, "grpc generate (unary)");
57
58
let stream = self.state.chat.text().generate(text_request).await;
59
- let stream = stream.map_err(|e| Status::internal(e.to_report_string()))?;
59
+ let stream = stream.map_err(text_error_to_status)?;
60
61
- let collected = stream
62
- .collect_output()
63
- .await
64
- .map_err(|e| Status::internal(e.to_report_string()))?;
61
+ let collected = stream.collect_output().await.map_err(text_error_to_status)?;
62
63
// Build the single aggregated response.
64
let prompt_info = convert::to_prompt_info(
@@ -104,7 +101,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl {
101
info!(%request_id, "grpc generate (stream)");
102
103
let stream = self.state.chat.text().generate(text_request).await;
107
- let stream = stream.map_err(|e| Status::internal(e.to_report_string()))?;
104
+ let stream = stream.map_err(text_error_to_status)?;
105
106
let (tx, rx) = mpsc::channel(32);
107
@@ -112,7 +109,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl {
109
futures::pin_mut!(stream);
110
while let Some(event) = stream.next().await {
111
let response = match event {
115
- Err(e) => Err(Status::internal(e.to_report_string())),
112
+ Err(e) => Err(text_error_to_status(e)),
113
Ok(DecodedTextEvent::Start {
114
prompt_token_ids,
115
prompt_logprobs,
@@ -155,3 +152,12 @@ impl pb::generate_server::Generate for GenerateServiceImpl {
152
Ok(Response::new(Box::pin(response_stream)))
153
}
154
}
155
+
156
+fn text_error_to_status(error: vllm_text::Error) -> Status {
157
+ let message = error.to_report_string();
158
+ if error.is_request_validation_error() {
159
+ Status::invalid_argument(message)
160
+ } else {
161
+ Status::internal(message)
162
+ }
163
+}
rust/src/server/src/grpc/tests.rs
+59
@@ -425,6 +425,34 @@ async fn unary_generate_missing_prompt_returns_invalid_argument() {
425
server_task.abort();
426
}
427
428
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
429
+#[serial]
430
+async fn unary_generate_min_tokens_above_max_tokens_returns_invalid_argument() {
431
+ let (mut client, server_task, _engine_task) =
432
+ grpc_test_server(b"engine-grpc-min-above-max", default_stream_output_specs()).await;
433
+
434
+ let status = client
435
+ .generate(pb::GenerateRequest {
436
+ request_id: "test-min-above-max".to_string(),
437
+ model: "test-model".to_string(),
438
+ prompt: Some(pb::generate_request::Prompt::Text("hi".to_string())),
439
+ stopping: Some(pb::StoppingCriteria {
440
+ max_new_tokens: 4,
441
+ min_new_tokens: 5,
442
+ ..Default::default()
443
+ }),
444
+ ..Default::default()
445
+ })
446
+ .await
447
+ .expect_err("should fail when min_new_tokens exceeds max_new_tokens");
448
+
449
+ assert_eq!(status.code(), tonic::Code::InvalidArgument);
450
+ assert!(status.message().contains("min_tokens=5"));
451
+ assert!(status.message().contains("max_tokens=4"));
452
+
453
+ server_task.abort();
454
+}
455
+
456
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
457
#[serial]
458
async fn streaming_generate_yields_incremental_responses() {
@@ -513,6 +541,37 @@ async fn streaming_generate_missing_prompt_returns_invalid_argument() {
541
server_task.abort();
542
}
543
544
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
545
+#[serial]
546
+async fn streaming_generate_min_tokens_above_max_tokens_returns_invalid_argument() {
547
+ let (mut client, server_task, _engine_task) = grpc_test_server(
548
+ b"engine-grpc-stream-min-above-max",
549
+ default_stream_output_specs(),
550
+ )
551
+ .await;
552
+
553
+ let status = client
554
+ .generate_stream(pb::GenerateRequest {
555
+ request_id: "test-stream-min-above-max".to_string(),
556
+ model: "test-model".to_string(),
557
+ prompt: Some(pb::generate_request::Prompt::Text("hi".to_string())),
558
+ stopping: Some(pb::StoppingCriteria {
559
+ max_new_tokens: 4,
560
+ min_new_tokens: 5,
561
+ ..Default::default()
562
+ }),
563
+ ..Default::default()
564
+ })
565
+ .await
566
+ .expect_err("should fail when min_new_tokens exceeds max_new_tokens");
567
+
568
+ assert_eq!(status.code(), tonic::Code::InvalidArgument);
569
+ assert!(status.message().contains("min_tokens=5"));
570
+ assert!(status.message().contains("max_tokens=4"));
571
+
572
+ server_task.abort();
573
+}
574
+
575
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
576
#[serial]
577
async fn unary_generate_with_sampling_params() {
rust/src/text/src/error.rs
+23
@@ -20,6 +20,11 @@ pub enum Error {
20
Logprobs(#[from] LogprobsError),
21
#[error(transparent)]
22
TokenIds(#[from] TokenIdsError),
23
+ #[error(
24
+ "`min_tokens` must be less than or equal to `max_tokens`, \
25
+ got min_tokens={min_tokens}, max_tokens={max_tokens}"
26
+ )]
27
+ MinTokensExceedsMaxTokens { min_tokens: u32, max_tokens: u32 },
28
#[error("`thinking_token_budget` must be a non-negative integer or -1 for unlimited.")]
29
InvalidThinkingTokenBudget,
30
#[error("text request stream `{request_id}` closed before terminal output")]
@@ -32,6 +37,24 @@ pub enum Error {
37
38
pub type Result<T> = std::result::Result<T, Error>;
39
40
+impl Error {
41
+ /// Whether this error represents invalid user request parameters.
42
+ pub fn is_request_validation_error(&self) -> bool {
43
+ match self {
44
+ Self::PromptTooLong { .. }
45
+ | Self::EmptyPromptTokenIds { .. }
46
+ | Self::Logprobs(_)
47
+ | Self::TokenIds(_)
48
+ | Self::MinTokensExceedsMaxTokens { .. }
49
+ | Self::InvalidThinkingTokenBudget
50
+ // An empty tokenized prompt detected later, at request prepare
51
+ // time, surfaces through the transparent Llm wrapper.
52
+ | Self::Llm(LlmError::EmptyPromptTokenIds { .. }) => true,
53
+ _ => false,
54
+ }
55
+ }
56
+}
57
+
58
impl From<vllm_tokenizer::TokenizerError> for Error {
59
fn from(error: vllm_tokenizer::TokenizerError) -> Self {
60
Self::Tokenizer(error.0)
rust/src/text/src/lower.rs
+27
@@ -128,6 +128,12 @@ pub fn lower_sampling_params(
128
prompt_len,
129
)?;
130
let min_tokens = min_tokens.unwrap_or(0);
131
+ if min_tokens > max_tokens {
132
+ return Err(Error::MinTokensExceedsMaxTokens {
133
+ min_tokens,
134
+ max_tokens,
135
+ });
136
+ }
137
let thinking_token_budget = normalize_thinking_token_budget(thinking_token_budget)?;
138
let frequency_penalty = frequency_penalty.unwrap_or(0.0);
139
let presence_penalty = presence_penalty.unwrap_or(0.0);
@@ -414,6 +420,27 @@ mod tests {
420
));
421
}
422
423
+ #[test]
424
+ fn lower_sampling_params_rejects_min_tokens_above_resolved_max_tokens() {
425
+ let error = lower_sampling_params_with_limits(
426
+ SamplingParams {
427
+ max_tokens: Some(4),
428
+ min_tokens: Some(5),
429
+ ..SamplingParams::default()
430
+ },
431
+ sample_sampling_limits(),
432
+ )
433
+ .unwrap_err();
434
+
435
+ assert!(matches!(
436
+ error,
437
+ Error::MinTokensExceedsMaxTokens {
438
+ min_tokens: 5,
439
+ max_tokens: 4,
440
+ }
441
+ ));
442
+ }
443
+
444
#[test]
445
fn lower_text_request_applies_python_style_eos_hints() {
446
let prepared = lower_text_request(