[Rust Frontend] Align Rust allowed_token_ids validation with Python (#46348)

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 23, 2026 at 16:32 UTC 2d721ab5d82a2a2505480924c615187bd5b793c1
6 files changed +138 -27
rust/src/server/src/error.rs
+12 -2
@@ -102,7 +102,7 @@ fn is_request_validation_error(error: &vllm_text::Error) -> bool {
102 vllm_text::Error::PromptTooLong { .. }
103 | vllm_text::Error::EmptyPromptTokenIds { .. }
104 | vllm_text::Error::Logprobs(_)
105 - | vllm_text::Error::OutOfVocab(_)
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.
@@ -188,7 +188,7 @@ mod tests {
188
189 #[test]
190 fn out_of_vocab_validation_maps_to_invalid_request() {
191 - let error = vllm_text::Error::OutOfVocab(vllm_text::OutOfVocabError {
191 + let error = vllm_text::Error::TokenIds(vllm_text::TokenIdsError::OutOfVocab {
192 parameter: "logprob_token_ids",
193 token_ids: vec![1000],
194 vocab_size: 1000,
@@ -197,6 +197,16 @@ mod tests {
197 assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
198 }
199
200 + #[test]
201 + fn empty_allowed_token_ids_maps_to_invalid_request() {
202 + let error = vllm_text::Error::TokenIds(vllm_text::TokenIdsError::EmptyAllowedTokenIds);
203 + let api_error = text_submit_error("failed to submit completion request", error);
204 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
205 + let response = api_error.to_error_response();
206 + assert_eq!(response.error.error_type, "invalid_request_error");
207 + assert!(response.error.message.contains("allowed_token_ids"));
208 + }
209 +
210 #[test]
211 fn other_submit_errors_stay_internal() {
212 let error = vllm_text::Error::Tokenizer("backend exploded".to_string());
rust/src/server/src/routes/tests.rs
+72
@@ -2237,6 +2237,42 @@ async fn invalid_request_returns_openai_error() {
2237 assert_eq!(json["error"]["type"], "invalid_request_error");
2238 }
2239
2240 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2241 +#[serial]
2242 +async fn chat_completions_empty_allowed_token_ids_returns_openai_error() {
2243 + let mut app = test_app().await;
2244 + let response = app
2245 + .call(
2246 + Request::builder()
2247 + .method("POST")
2248 + .uri("/v1/chat/completions")
2249 + .header("content-type", "application/json")
2250 + .body(Body::from(
2251 + json!({
2252 + "model": "Qwen/Qwen1.5-0.5B-Chat",
2253 + "stream": false,
2254 + "messages": [{"role": "user", "content": "hello"}],
2255 + "allowed_token_ids": []
2256 + })
2257 + .to_string(),
2258 + ))
2259 + .expect("build request"),
2260 + )
2261 + .await
2262 + .expect("call app");
2263 +
2264 + assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2265 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
2266 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
2267 + assert_eq!(json["error"]["type"], "invalid_request_error");
2268 + assert!(
2269 + json["error"]["message"]
2270 + .as_str()
2271 + .expect("message string")
2272 + .contains("allowed_token_ids should not be empty")
2273 + );
2274 +}
2275 +
2276 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2277 #[serial]
2278 async fn non_stream_chat_returns_json_response() {
@@ -2972,6 +3008,42 @@ async fn completions_invalid_request_returns_openai_error() {
3008 assert_eq!(json["error"]["type"], "invalid_request_error");
3009 }
3010
3011 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3012 +#[serial]
3013 +async fn completions_empty_allowed_token_ids_returns_openai_error() {
3014 + let mut app = test_app().await;
3015 + let response = app
3016 + .call(
3017 + Request::builder()
3018 + .method("POST")
3019 + .uri("/v1/completions")
3020 + .header("content-type", "application/json")
3021 + .body(Body::from(
3022 + json!({
3023 + "model": "Qwen/Qwen1.5-0.5B-Chat",
3024 + "prompt": "hello",
3025 + "stream": false,
3026 + "allowed_token_ids": []
3027 + })
3028 + .to_string(),
3029 + ))
3030 + .expect("build request"),
3031 + )
3032 + .await
3033 + .expect("call app");
3034 +
3035 + assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3036 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
3037 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
3038 + assert_eq!(json["error"]["type"], "invalid_request_error");
3039 + assert!(
3040 + json["error"]["message"]
3041 + .as_str()
3042 + .expect("message string")
3043 + .contains("allowed_token_ids should not be empty")
3044 + );
3045 +}
3046 +
3047 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3048 #[serial]
3049 async fn non_stream_completions_return_json_response() {
rust/src/text/src/error.rs
+2 -2
@@ -3,7 +3,7 @@ use vllm_engine_core_client::Error as EngineCoreError;
3 use vllm_llm::Error as LlmError;
4
5 pub use crate::lower::logprobs::LogprobsError;
6 -pub use crate::lower::token_ids::OutOfVocabError;
6 +pub use crate::lower::token_ids::TokenIdsError;
7
8 #[derive(Debug, Error)]
9 pub enum Error {
@@ -19,7 +19,7 @@ pub enum Error {
19 #[error(transparent)]
20 Logprobs(#[from] LogprobsError),
21 #[error(transparent)]
22 - OutOfVocab(#[from] OutOfVocabError),
22 + TokenIds(#[from] TokenIdsError),
23 #[error("`thinking_token_budget` must be a non-negative integer or -1 for unlimited.")]
24 InvalidThinkingTokenBudget,
25 #[error("text request stream `{request_id}` closed before terminal output")]
rust/src/text/src/lib.rs
+1 -1
@@ -7,7 +7,7 @@
7 use std::mem::take;
8
9 pub use backend::{DynTextBackend, SamplingHints, SamplingLimits, TextBackend};
10 -pub use error::{Error, LogprobsError, OutOfVocabError, Result};
10 +pub use error::{Error, LogprobsError, Result, TokenIdsError};
11 use futures::Stream;
12 pub use lower::{
13 PreparedTextRequest, lower_sampling_params, lower_text_request, resolve_max_tokens,
rust/src/text/src/lower.rs
+24 -7
@@ -274,7 +274,7 @@ mod tests {
274 use super::*;
275 use crate::backend::hf::HfTextBackend;
276 use crate::backend::{SamplingHints, TextBackend as _};
277 - use crate::error::{LogprobsError, OutOfVocabError};
277 + use crate::error::{LogprobsError, TokenIdsError};
278 use crate::request::{Prompt, TextRequest};
279
280 /// Stub tokenizer that returns empty token IDs — sufficient for tests that
@@ -555,7 +555,7 @@ mod tests {
555
556 assert!(matches!(
557 error,
558 - Error::OutOfVocab(OutOfVocabError {
558 + Error::TokenIds(TokenIdsError::OutOfVocab {
559 parameter: "prompt",
560 token_ids,
561 vocab_size: 2000,
@@ -857,7 +857,7 @@ mod tests {
857
858 assert!(matches!(
859 error,
860 - Error::OutOfVocab(OutOfVocabError {
860 + Error::TokenIds(TokenIdsError::OutOfVocab {
861 parameter: "logprob_token_ids",
862 token_ids,
863 vocab_size: 1000,
@@ -878,7 +878,7 @@ mod tests {
878
879 assert!(matches!(
880 error,
881 - Error::OutOfVocab(OutOfVocabError {
881 + Error::TokenIds(TokenIdsError::OutOfVocab {
882 parameter: "stop_token_ids",
883 token_ids,
884 vocab_size: 1000,
@@ -899,7 +899,7 @@ mod tests {
899
900 assert!(matches!(
901 error,
902 - Error::OutOfVocab(OutOfVocabError {
902 + Error::TokenIds(TokenIdsError::OutOfVocab {
903 parameter: "allowed_token_ids",
904 token_ids,
905 vocab_size: 2000,
@@ -907,6 +907,23 @@ mod tests {
907 ));
908 }
909
910 + #[test]
911 + fn lower_sampling_params_rejects_empty_allowed_token_ids() {
912 + let error = lower_sampling_params_with_limits(
913 + SamplingParams {
914 + allowed_token_ids: Some(vec![]),
915 + ..Default::default()
916 + },
917 + sample_sampling_limits(),
918 + )
919 + .unwrap_err();
920 +
921 + assert!(matches!(
922 + error,
923 + Error::TokenIds(TokenIdsError::EmptyAllowedTokenIds)
924 + ));
925 + }
926 +
927 #[test]
928 fn lower_sampling_params_rejects_out_of_vocab_bad_words() {
929 let tokenizer = FixedTokenizer {
@@ -926,7 +943,7 @@ mod tests {
943
944 assert!(matches!(
945 error,
929 - Error::OutOfVocab(OutOfVocabError {
946 + Error::TokenIds(TokenIdsError::OutOfVocab {
947 parameter: "bad_words",
948 token_ids,
949 vocab_size: 2000,
@@ -947,7 +964,7 @@ mod tests {
964
965 assert!(matches!(
966 error,
950 - Error::OutOfVocab(OutOfVocabError {
967 + Error::TokenIds(TokenIdsError::OutOfVocab {
968 parameter: "logit_bias",
969 token_ids,
970 vocab_size: 1000,
rust/src/text/src/lower/token_ids.rs
+27 -15
@@ -6,21 +6,25 @@ use vllm_engine_core_client::protocol::EngineCoreSamplingParams;
6 use crate::SamplingLimits;
7
8 #[derive(Debug, Error)]
9 -#[error(
10 - "token_id(s) {token_ids:?} in {parameter} contain out-of-vocab token ids. \
11 - Vocabulary size: {vocab_size}"
12 -)]
13 -pub struct OutOfVocabError {
14 - pub parameter: &'static str,
15 - pub token_ids: Vec<u32>,
16 - pub vocab_size: usize,
9 +pub enum TokenIdsError {
10 + #[error("allowed_token_ids should not be empty")]
11 + EmptyAllowedTokenIds,
12 + #[error(
13 + "token_id(s) {token_ids:?} in {parameter} contain out-of-vocab token ids. \
14 + Vocabulary size: {vocab_size}"
15 + )]
16 + OutOfVocab {
17 + parameter: &'static str,
18 + token_ids: Vec<u32>,
19 + vocab_size: usize,
20 + },
21 }
22
23 fn validate_param(
24 parameter: &'static str,
25 token_ids: impl IntoIterator<Item = u32>,
26 vocab_size: usize,
23 -) -> Result<(), OutOfVocabError> {
27 +) -> Result<(), TokenIdsError> {
28 let invalid_token_ids: Vec<_> = token_ids
29 .into_iter()
30 .filter(|&token_id| token_id as usize >= vocab_size)
@@ -29,7 +33,7 @@ fn validate_param(
33 return Ok(());
34 }
35
32 - Err(OutOfVocabError {
36 + Err(TokenIdsError::OutOfVocab {
37 parameter,
38 token_ids: invalid_token_ids,
39 vocab_size,
@@ -41,7 +45,7 @@ fn validate_param(
45 pub(crate) fn validate_prompt_token_ids(
46 prompt_token_ids: &[u32],
47 limits: &SamplingLimits,
44 -) -> Result<(), OutOfVocabError> {
48 +) -> Result<(), TokenIdsError> {
49 validate_param(
50 "prompt",
51 prompt_token_ids.iter().copied(),
@@ -54,7 +58,7 @@ pub(crate) fn validate_prompt_token_ids(
58 pub(crate) fn validate_vocab_range(
59 params: &EngineCoreSamplingParams,
60 limits: &SamplingLimits,
57 -) -> Result<(), OutOfVocabError> {
61 +) -> Result<(), TokenIdsError> {
62 validate_param(
63 "stop_token_ids",
64 params.stop_token_ids.iter().copied(),
@@ -62,6 +66,9 @@ pub(crate) fn validate_vocab_range(
66 )?;
67
68 if let Some(token_ids) = params.allowed_token_ids.as_deref() {
69 + if token_ids.is_empty() {
70 + return Err(TokenIdsError::EmptyAllowedTokenIds);
71 + }
72 validate_param(
73 "allowed_token_ids",
74 token_ids.iter().copied(),
@@ -104,8 +111,13 @@ mod tests {
111 fn validate_vocab_range_rejects_out_of_vocab_ids() {
112 let error = validate_param("logprob_token_ids", [5_u32, 1000, 1001], 1000).unwrap_err();
113
107 - assert_eq!(error.parameter, "logprob_token_ids");
108 - assert_eq!(error.token_ids, vec![1000, 1001]);
109 - assert_eq!(error.vocab_size, 1000);
114 + assert!(matches!(
115 + error,
116 + TokenIdsError::OutOfVocab {
117 + parameter: "logprob_token_ids",
118 + token_ids,
119 + vocab_size: 1000,
120 + } if token_ids == vec![1000, 1001]
121 + ));
122 }
123 }