[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(