[Rust Frontend] Align sampling validation with Python (#47494)

Signed-off-by: reidliu41 <reid201711@gmail.com>

Reid committed Jul 28, 2026 at 20:02 UTC 912d6b619de0f2a44df74704973cff90fa0fb4e9
6 files changed +260 -2
rust/src/server/src/error.rs
+16
@@ -141,6 +141,22 @@ mod tests {
141 assert!(response.error.message.contains("max_tokens=4"));
142 }
143
144 + #[test]
145 + fn sampling_params_validation_maps_to_invalid_request() {
146 + let api_error = text_submit_error(
147 + "failed to submit completion request",
148 + vllm_text::Error::SamplingParams(vllm_text::SamplingParamsError::OutOfRange {
149 + parameter: "top_p",
150 + value: 0.0,
151 + expected: "(0, 1]",
152 + }),
153 + );
154 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
155 + let response = api_error.to_error_response();
156 + assert_eq!(response.error.error_type, "invalid_request_error");
157 + assert!(response.error.message.contains("top_p"));
158 + }
159 +
160 #[test]
161 fn chat_wrapped_prompt_too_long_maps_to_invalid_request() {
162 let error = vllm_chat::Error::Text(vllm_text::Error::PromptTooLong {
rust/src/server/src/grpc/tests.rs
+29
@@ -648,6 +648,35 @@ async fn unary_generate_min_tokens_above_max_tokens_returns_invalid_argument() {
648 server_task.abort();
649 }
650
651 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
652 +#[serial]
653 +async fn unary_generate_invalid_sampling_params_returns_invalid_argument() {
654 + let (mut client, server_task, _engine_task) = grpc_test_server(
655 + b"engine-grpc-invalid-sampling",
656 + default_stream_output_specs(),
657 + )
658 + .await;
659 +
660 + let status = client
661 + .generate(pb::GenerateRequest {
662 + request_id: "test-invalid-sampling".to_string(),
663 + model: "test-model".to_string(),
664 + prompt: Some(pb::generate_request::Prompt::Text("hi".to_string())),
665 + sampling: Some(pb::RandomSampling {
666 + top_p: 2.0,
667 + ..Default::default()
668 + }),
669 + ..Default::default()
670 + })
671 + .await
672 + .expect_err("should fail when top_p is out of range");
673 +
674 + assert_eq!(status.code(), tonic::Code::InvalidArgument);
675 + assert!(status.message().contains("top_p"));
676 +
677 + server_task.abort();
678 +}
679 +
680 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
681 #[serial]
682 async fn streaming_generate_yields_incremental_responses() {
rust/src/text/src/error.rs
+4
@@ -6,6 +6,7 @@ use vllm_engine_core_client::Error as EngineCoreError;
6 use vllm_llm::Error as LlmError;
7
8 pub use crate::lower::logprobs::LogprobsError;
9 +pub use crate::lower::sampling::SamplingParamsError;
10 pub use crate::lower::token_ids::TokenIdsError;
11
12 #[derive(Debug, Error)]
@@ -23,6 +24,8 @@ pub enum Error {
24 Logprobs(#[from] LogprobsError),
25 #[error(transparent)]
26 TokenIds(#[from] TokenIdsError),
27 + #[error(transparent)]
28 + SamplingParams(#[from] SamplingParamsError),
29 #[error(
30 "`min_tokens` must be less than or equal to `max_tokens`, \
31 got min_tokens={min_tokens}, max_tokens={max_tokens}"
@@ -50,6 +53,7 @@ impl Error {
53 | Self::EmptyPromptTokenIds { .. }
54 | Self::Logprobs(_)
55 | Self::TokenIds(_)
56 + | Self::SamplingParams(_)
57 | Self::MinTokensExceedsMaxTokens { .. }
58 | Self::InvalidThinkingTokenBudget
59 | Self::InvalidRepetitionDetection { .. }
rust/src/text/src/lib.rs
+1 -1
@@ -10,7 +10,7 @@
10 use std::mem::take;
11
12 pub use backend::{DynTextBackend, SamplingHints, SamplingLimits, TextBackend};
13 -pub use error::{Error, LogprobsError, Result, TokenIdsError};
13 +pub use error::{Error, LogprobsError, Result, SamplingParamsError, TokenIdsError};
14 use futures::Stream;
15 pub use lower::{
16 PreparedTextRequest, lower_sampling_params, lower_text_request, resolve_max_tokens,
rust/src/text/src/lower.rs
+118 -1
@@ -4,9 +4,11 @@
4 use std::collections::BTreeSet;
5
6 pub(crate) mod logprobs;
7 +pub(crate) mod sampling;
8 pub(crate) mod token_ids;
9
10 use logprobs::validate_logprobs;
11 +use sampling::validate_resolved_sampling_params;
12 use token_ids::{validate_prompt_token_ids, validate_vocab_range};
13 use vllm_engine_core_client::protocol::sampling::{
14 EngineCoreSamplingParams, RepetitionDetectionParams,
@@ -186,6 +188,7 @@ pub fn lower_sampling_params(
188 skip_reading_prefix_cache,
189 extra_args: vllm_xargs,
190 };
191 + validate_resolved_sampling_params(&params)?;
192 validate_vocab_range(&params, &sampling_limits)?;
193 Ok(params)
194 }
@@ -319,7 +322,7 @@ mod tests {
322 use super::*;
323 use crate::backend::hf::HfTextBackend;
324 use crate::backend::{SamplingHints, TextBackend as _};
322 - use crate::error::{LogprobsError, TokenIdsError};
325 + use crate::error::{LogprobsError, SamplingParamsError, TokenIdsError};
326 use crate::request::{Prompt, TextRequest};
327
328 fn stub_tokenizer() -> TestTokenizer {
@@ -482,6 +485,120 @@ mod tests {
485 assert!(message.contains("min_count=1"));
486 }
487
488 + #[test]
489 + fn lower_sampling_params_rejects_invalid_sampling_ranges() {
490 + let cases = [
491 + (
492 + "temperature",
493 + SamplingParams {
494 + temperature: Some(5.0),
495 + ..SamplingParams::default()
496 + },
497 + ),
498 + (
499 + "top_p",
500 + SamplingParams {
501 + top_p: Some(0.0),
502 + ..SamplingParams::default()
503 + },
504 + ),
505 + (
506 + "min_p",
507 + SamplingParams {
508 + min_p: Some(2.0),
509 + ..SamplingParams::default()
510 + },
511 + ),
512 + (
513 + "repetition_penalty",
514 + SamplingParams {
515 + repetition_penalty: Some(0.0),
516 + ..SamplingParams::default()
517 + },
518 + ),
519 + (
520 + "frequency_penalty",
521 + SamplingParams {
522 + frequency_penalty: Some(100.0),
523 + ..SamplingParams::default()
524 + },
525 + ),
526 + (
527 + "presence_penalty",
528 + SamplingParams {
529 + presence_penalty: Some(100.0),
530 + ..SamplingParams::default()
531 + },
532 + ),
533 + ];
534 +
535 + for (expected_parameter, sampling_params) in cases {
536 + let error =
537 + lower_sampling_params_with_limits(sampling_params, sample_sampling_limits())
538 + .unwrap_err();
539 +
540 + assert!(
541 + matches!(
542 + error,
543 + Error::SamplingParams(SamplingParamsError::OutOfRange {
544 + parameter,
545 + ..
546 + }) if parameter == expected_parameter
547 + ),
548 + "{expected_parameter} should be rejected"
549 + );
550 + }
551 + }
552 +
553 + #[test]
554 + fn lower_sampling_params_rejects_non_finite_sampling_values() {
555 + for (expected_parameter, sampling_params) in [
556 + (
557 + "temperature",
558 + SamplingParams {
559 + temperature: Some(f32::INFINITY),
560 + ..SamplingParams::default()
561 + },
562 + ),
563 + (
564 + "repetition_penalty",
565 + SamplingParams {
566 + repetition_penalty: Some(f32::NAN),
567 + ..SamplingParams::default()
568 + },
569 + ),
570 + ] {
571 + let error =
572 + lower_sampling_params_with_limits(sampling_params, sample_sampling_limits())
573 + .unwrap_err();
574 +
575 + assert!(
576 + matches!(
577 + error,
578 + Error::SamplingParams(SamplingParamsError::NotFinite {
579 + parameter,
580 + ..
581 + }) if parameter == expected_parameter
582 + ),
583 + "{expected_parameter} should reject non-finite values"
584 + );
585 + }
586 + }
587 +
588 + #[test]
589 + fn lower_sampling_params_accepts_python_compatible_repetition_penalty_above_two() {
590 + let params = lower_sampling_params_with_limits(
591 + SamplingParams {
592 + repetition_penalty: Some(2.5),
593 + ..SamplingParams::default()
594 + },
595 + sample_sampling_limits(),
596 + )
597 + .unwrap();
598 +
599 + assert_eq!(params.repetition_penalty, 2.5);
600 + }
601 +
602 #[test]
603 fn lower_text_request_applies_python_style_eos_hints() {
604 let prepared = lower_text_request(
rust/src/text/src/lower/sampling.rs new
+92
@@ -0,0 +1,92 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use thiserror::Error;
5 +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams;
6 +
7 +#[derive(Debug, Error, PartialEq)]
8 +pub enum SamplingParamsError {
9 + #[error("{parameter} must be a finite number, got {value}")]
10 + NotFinite { parameter: &'static str, value: f32 },
11 + #[error("{parameter} must be in {expected}, got {value}")]
12 + OutOfRange {
13 + parameter: &'static str,
14 + value: f32,
15 + expected: &'static str,
16 + },
17 +}
18 +
19 +fn validate_frequency_penalty(value: f32) -> Result<(), SamplingParamsError> {
20 + validate_closed_range("frequency_penalty", value, -2.0, 2.0, "[-2, 2]")
21 +}
22 +
23 +fn validate_presence_penalty(value: f32) -> Result<(), SamplingParamsError> {
24 + validate_closed_range("presence_penalty", value, -2.0, 2.0, "[-2, 2]")
25 +}
26 +
27 +fn validate_temperature(value: f32) -> Result<(), SamplingParamsError> {
28 + validate_finite("temperature", value)?;
29 + validate_closed_range("temperature", value, 0.0, 2.0, "[0, 2]")
30 +}
31 +
32 +fn validate_top_p(value: f32) -> Result<(), SamplingParamsError> {
33 + if value > 0.0 && value <= 1.0 {
34 + return Ok(());
35 + }
36 + Err(SamplingParamsError::OutOfRange {
37 + parameter: "top_p",
38 + value,
39 + expected: "(0, 1]",
40 + })
41 +}
42 +
43 +fn validate_min_p(value: f32) -> Result<(), SamplingParamsError> {
44 + validate_closed_range("min_p", value, 0.0, 1.0, "[0, 1]")
45 +}
46 +
47 +fn validate_repetition_penalty(value: f32) -> Result<(), SamplingParamsError> {
48 + validate_finite("repetition_penalty", value)?;
49 + if value > 0.0 {
50 + return Ok(());
51 + }
52 + Err(SamplingParamsError::OutOfRange {
53 + parameter: "repetition_penalty",
54 + value,
55 + expected: "(0, inf)",
56 + })
57 +}
58 +
59 +pub(crate) fn validate_resolved_sampling_params(
60 + params: &EngineCoreSamplingParams,
61 +) -> Result<(), SamplingParamsError> {
62 + validate_temperature(params.temperature)?;
63 + validate_top_p(params.top_p)?;
64 + validate_min_p(params.min_p)?;
65 + validate_frequency_penalty(params.frequency_penalty)?;
66 + validate_presence_penalty(params.presence_penalty)?;
67 + validate_repetition_penalty(params.repetition_penalty)
68 +}
69 +
70 +fn validate_finite(parameter: &'static str, value: f32) -> Result<(), SamplingParamsError> {
71 + if value.is_finite() {
72 + return Ok(());
73 + }
74 + Err(SamplingParamsError::NotFinite { parameter, value })
75 +}
76 +
77 +fn validate_closed_range(
78 + parameter: &'static str,
79 + value: f32,
80 + min: f32,
81 + max: f32,
82 + expected: &'static str,
83 +) -> Result<(), SamplingParamsError> {
84 + if value >= min && value <= max {
85 + return Ok(());
86 + }
87 + Err(SamplingParamsError::OutOfRange {
88 + parameter,
89 + value,
90 + expected,
91 + })
92 +}