[Rust Frontend] Simplify unit tests with shared `TestTokenizer` (#47125)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jun 30, 2026 at 22:34 UTC 7a327f0b4f8f6886df77996d0e35ce547c9c2bd1
28 files changed +682 -828
rust/Cargo.lock
+1
@@ -5310,6 +5310,7 @@ dependencies = [
5310 "vllm-llm",
5311 "vllm-metrics",
5312 "vllm-text",
5313 + "vllm-tokenizer",
5314 "zeromq",
5315 ]
5316
rust/src/chat/Cargo.toml
+1
@@ -50,6 +50,7 @@ tokio.workspace = true
50 tracing-subscriber.workspace = true
51 uuid.workspace = true
52 vllm-engine-core-client = { workspace = true, features = ["test-util"] }
53 +vllm-tokenizer = { workspace = true, features = ["test-utils"] }
54 zeromq.workspace = true
55
56 [lints]
rust/src/chat/src/backend/hf.rs
+3 -26
@@ -154,7 +154,8 @@ mod tests {
154 use thiserror_ext::AsReport as _;
155 use vllm_text::Prompt;
156 use vllm_text::backend::hf::TokenizerSource;
157 - use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
157 + use vllm_text::tokenizer::DynTokenizer;
158 + use vllm_tokenizer::test_utils::TestTokenizer;
159
160 use super::HfChatBackend;
161 use crate::backend::{ChatBackend, LoadModelBackendsOptions, NewChatOutputProcessorOptions};
@@ -196,32 +197,8 @@ mod tests {
197 }
198 }
199
199 - struct TestTokenizer;
200 -
201 - impl Tokenizer for TestTokenizer {
202 - fn encode(
203 - &self,
204 - _text: &str,
205 - _add_special_tokens: bool,
206 - ) -> vllm_text::tokenizer::Result<Vec<u32>> {
207 - Ok(Vec::new())
208 - }
209 -
210 - fn decode(
211 - &self,
212 - _token_ids: &[u32],
213 - _skip_special_tokens: bool,
214 - ) -> vllm_text::tokenizer::Result<String> {
215 - Ok(String::new())
216 - }
217 -
218 - fn token_to_id(&self, _token: &str) -> Option<u32> {
219 - None
220 - }
221 - }
222 -
200 fn test_tokenizer() -> DynTokenizer {
224 - Arc::new(TestTokenizer)
201 + Arc::new(TestTokenizer::new())
202 }
203
204 fn backend_for_selection(
rust/src/chat/src/multimodal.rs
+10 -56
@@ -563,7 +563,7 @@ mod tests {
563
564 use llm_multimodal::TokenId;
565 use vllm_engine_core_client::protocol::tensor::WireArrayData;
566 - use vllm_text::tokenizer::{IncrementalDecoder, Tokenizer, TokenizerError};
566 + use vllm_tokenizer::test_utils::TestTokenizer;
567
568 use super::*;
569
@@ -574,60 +574,14 @@ mod tests {
574 const LLAMA4_TILE_X_SEPARATOR_ID: u32 = 200093;
575 const LLAMA4_TILE_Y_SEPARATOR_ID: u32 = 200094;
576
577 - struct TestTokenizer;
578 -
579 - impl Tokenizer for TestTokenizer {
580 - fn encode(
581 - &self,
582 - text: &str,
583 - _add_special_tokens: bool,
584 - ) -> std::result::Result<Vec<u32>, TokenizerError> {
585 - Ok(match text {
586 - "<|image|>" => vec![LLAMA4_IMAGE_ID],
587 - text => text.bytes().map(u32::from).collect(),
588 - })
589 - }
590 -
591 - fn decode(
592 - &self,
593 - _token_ids: &[u32],
594 - _skip_special_tokens: bool,
595 - ) -> std::result::Result<String, TokenizerError> {
596 - Ok(String::new())
597 - }
598 -
599 - fn token_to_id(&self, token: &str) -> Option<u32> {
600 - match token {
601 - "<|image_start|>" => Some(LLAMA4_IMAGE_START_ID),
602 - "<|image_end|>" => Some(LLAMA4_IMAGE_END_ID),
603 - "<|image|>" => Some(LLAMA4_IMAGE_ID),
604 - "<|patch|>" => Some(LLAMA4_PATCH_ID),
605 - "<|tile_x_separator|>" => Some(LLAMA4_TILE_X_SEPARATOR_ID),
606 - "<|tile_y_separator|>" => Some(LLAMA4_TILE_Y_SEPARATOR_ID),
607 - _ => None,
608 - }
609 - }
610 -
611 - fn id_to_token(&self, id: u32) -> Option<String> {
612 - match id {
613 - LLAMA4_IMAGE_START_ID => Some("<|image_start|>".to_string()),
614 - LLAMA4_IMAGE_END_ID => Some("<|image_end|>".to_string()),
615 - LLAMA4_IMAGE_ID => Some("<|image|>".to_string()),
616 - LLAMA4_PATCH_ID => Some("<|patch|>".to_string()),
617 - LLAMA4_TILE_X_SEPARATOR_ID => Some("<|tile_x_separator|>".to_string()),
618 - LLAMA4_TILE_Y_SEPARATOR_ID => Some("<|tile_y_separator|>".to_string()),
619 - _ => None,
620 - }
621 - }
622 -
623 - fn create_decode_stream(
624 - &self,
625 - _prompt_token_ids: &[u32],
626 - _skip_special_tokens: bool,
627 - _min_bytes_to_buffer: usize,
628 - ) -> Box<dyn IncrementalDecoder + '_> {
629 - unreachable!("not used")
630 - }
577 + fn llama4_tokenizer() -> TestTokenizer {
578 + TestTokenizer::new()
579 + .with_regular_token("<|image_start|>", LLAMA4_IMAGE_START_ID)
580 + .with_regular_token("<|image_end|>", LLAMA4_IMAGE_END_ID)
581 + .with_regular_token("<|image|>", LLAMA4_IMAGE_ID)
582 + .with_regular_token("<|patch|>", LLAMA4_PATCH_ID)
583 + .with_regular_token("<|tile_x_separator|>", LLAMA4_TILE_X_SEPARATOR_ID)
584 + .with_regular_token("<|tile_y_separator|>", LLAMA4_TILE_Y_SEPARATOR_ID)
585 }
586
587 fn test_info(model_type: &str, config: serde_json::Value) -> MultimodalModelInfo {
@@ -635,7 +589,7 @@ mod tests {
589 model_id: format!("{model_type}-test"),
590 model_type: Some(model_type.to_string()),
591 config,
638 - tokenizer: TokenizerResolver(Arc::new(TestTokenizer)),
592 + tokenizer: TokenizerResolver(Arc::new(llama4_tokenizer())),
593 };
594 let spec = context
595 .resolve_model_spec()
rust/src/chat/src/output/default/mod.rs
+7 -34
@@ -189,46 +189,19 @@ impl ChatOutputProcessor for DefaultChatOutputProcessor {
189 mod tests {
190 use std::sync::Arc;
191
192 - use vllm_tokenizer::Tokenizer;
192 + use vllm_tokenizer::test_utils::TestTokenizer;
193
194 use super::DefaultChatOutputProcessor;
195 use crate::Error;
196 use crate::parser::ParserSelection;
197 use crate::request::ChatRequest;
198
199 - struct FakeTokenizer;
200 -
201 - impl Tokenizer for FakeTokenizer {
202 - fn encode(
203 - &self,
204 - text: &str,
205 - _add_special_tokens: bool,
206 - ) -> vllm_tokenizer::Result<Vec<u32>> {
207 - Ok(text.chars().map(u32::from).collect())
208 - }
209 -
210 - fn decode(
211 - &self,
212 - token_ids: &[u32],
213 - _skip_special_tokens: bool,
214 - ) -> vllm_tokenizer::Result<String> {
215 - Ok(token_ids
216 - .iter()
217 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
218 - .collect())
219 - }
220 -
221 - fn token_to_id(&self, token: &str) -> Option<u32> {
222 - match token {
223 - "<|channel>" => Some(1),
224 - "<channel|>" => Some(2),
225 - _ => None,
226 - }
227 - }
228 - }
229 -
230 - fn tokenizer() -> Arc<FakeTokenizer> {
231 - Arc::new(FakeTokenizer)
199 + fn tokenizer() -> Arc<TestTokenizer> {
200 + Arc::new(
201 + TestTokenizer::new()
202 + .with_regular_token("<|channel>", 256)
203 + .with_regular_token("<channel|>", 257),
204 + )
205 }
206
207 #[test]
rust/src/chat/src/parser/reasoning/tests.rs
+2 -25
@@ -1,32 +1,9 @@
1 use std::sync::Arc;
2
3 -use vllm_tokenizer::Tokenizer;
3 +use vllm_tokenizer::test_utils::TestTokenizer;
4
5 use super::{ReasoningParserFactory, names};
6
7 -struct FakeTokenizer;
8 -
9 -impl Tokenizer for FakeTokenizer {
10 - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
11 - Ok(text.chars().map(u32::from).collect())
12 - }
13 -
14 - fn decode(
15 - &self,
16 - token_ids: &[u32],
17 - _skip_special_tokens: bool,
18 - ) -> vllm_tokenizer::Result<String> {
19 - Ok(token_ids
20 - .iter()
21 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
22 - .collect())
23 - }
24 -
25 - fn token_to_id(&self, _token: &str) -> Option<u32> {
26 - None
27 - }
28 -}
29 -
7 #[test]
8 fn factory_contains_and_lists_registered_parsers() {
9 let factory = ReasoningParserFactory::new();
@@ -107,7 +84,7 @@ fn factory_resolves_minimax_m3_before_generic_minimax() {
84
85 #[test]
86 fn factory_rejects_unknown_parser_names() {
110 - let tokenizer = Arc::new(FakeTokenizer);
87 + let tokenizer = Arc::new(TestTokenizer::new());
88 let factory = ReasoningParserFactory::new();
89 let error = match factory.create("missing", tokenizer) {
90 Ok(_) => panic!("expected parser lookup to fail"),
rust/src/chat/src/parser/unified.rs
+6 -31
@@ -75,39 +75,14 @@ impl UnifiedParserFactory {
75 mod tests {
76 use std::sync::Arc;
77
78 - use vllm_tokenizer::Tokenizer;
78 + use vllm_tokenizer::test_utils::TestTokenizer;
79
80 use super::{UnifiedParserFactory, names};
81
82 - struct FakeTokenizer;
83 -
84 - impl Tokenizer for FakeTokenizer {
85 - fn encode(
86 - &self,
87 - text: &str,
88 - _add_special_tokens: bool,
89 - ) -> vllm_tokenizer::Result<Vec<u32>> {
90 - Ok(text.chars().map(u32::from).collect())
91 - }
92 -
93 - fn decode(
94 - &self,
95 - token_ids: &[u32],
96 - _skip_special_tokens: bool,
97 - ) -> vllm_tokenizer::Result<String> {
98 - Ok(token_ids
99 - .iter()
100 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
101 - .collect())
102 - }
103 -
104 - fn token_to_id(&self, token: &str) -> Option<u32> {
105 - match token {
106 - "<|channel>" => Some(1),
107 - "<channel|>" => Some(2),
108 - _ => None,
109 - }
110 - }
82 + fn tokenizer() -> TestTokenizer {
83 + TestTokenizer::new()
84 + .with_regular_token("<|channel>", 256)
85 + .with_regular_token("<channel|>", 257)
86 }
87
88 #[test]
@@ -119,6 +94,6 @@ mod tests {
94 factory.resolve_name_for_model("google/gemma-4-27b-it"),
95 Some(names::GEMMA4)
96 );
122 - factory.create(names::GEMMA4, &[], Arc::new(FakeTokenizer)).unwrap();
97 + factory.create(names::GEMMA4, &[], Arc::new(tokenizer())).unwrap();
98 }
99 }
rust/src/chat/tests/chat.rs
+34 -103
@@ -21,15 +21,17 @@ use vllm_engine_core_client::protocol::{
21 use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
22 use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig};
23 use vllm_llm::Llm;
24 -use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
24 +use vllm_text::tokenizer::DynTokenizer;
25 use vllm_text::{
26 DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTokenLogprob, Prompt,
27 TextBackend,
28 };
29 +use vllm_tokenizer::test_utils::TestTokenizer;
30 use zeromq::prelude::{SocketRecv, SocketSend};
31 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
32
33 const SPECIAL_STOP_TOKEN_ID: u32 = 256;
34 +const UNKNOWN_DECODE_TOKEN_ID: u32 = 10_000;
35
36 fn request_output(
37 request_id: &str,
@@ -158,45 +160,18 @@ async fn connect_chat_llm_with_ipc(
160 struct FakeChatBackend {
161 has_template: bool,
162 model_id: String,
163 + tokenizer: DynTokenizer,
164 }
165
163 -#[derive(Debug)]
164 -struct FakeChatTokenizer;
165 -
166 -impl Tokenizer for FakeChatTokenizer {
167 - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
168 - Ok(text.bytes().map(u32::from).collect())
169 - }
170 -
171 - fn decode(
172 - &self,
173 - token_ids: &[u32],
174 - skip_special_tokens: bool,
175 - ) -> vllm_tokenizer::Result<String> {
176 - let bytes = token_ids
177 - .iter()
178 - .filter_map(|id| {
179 - if skip_special_tokens && *id == SPECIAL_STOP_TOKEN_ID {
180 - None
181 - } else {
182 - Some(*id as u8)
183 - }
184 - })
185 - .collect::<Vec<_>>();
186 - Ok(String::from_utf8_lossy(&bytes).into_owned())
187 - }
188 -
189 - fn token_to_id(&self, token: &str) -> Option<u32> {
190 - match token {
191 - "<think>" => Some(0xF001),
192 - "</think>" => Some(0xF002),
193 - "<|START_THINKING|>" => Some(0xF003),
194 - "<|END_THINKING|>" => Some(0xF004),
195 - "◁think▷" => Some(0xF005),
196 - "◁/think▷" => Some(0xF006),
197 - _ => None,
198 - }
199 - }
166 +fn fake_chat_tokenizer() -> TestTokenizer {
167 + TestTokenizer::new()
168 + .with_special_token("<stop>", SPECIAL_STOP_TOKEN_ID)
169 + .with_regular_token("<think>", 0xF001)
170 + .with_regular_token("</think>", 0xF002)
171 + .with_regular_token("<|START_THINKING|>", 0xF003)
172 + .with_regular_token("<|END_THINKING|>", 0xF004)
173 + .with_regular_token("◁think▷", 0xF005)
174 + .with_regular_token("◁/think▷", 0xF006)
175 }
176
177 impl fmt::Debug for FakeChatBackend {
@@ -210,6 +185,7 @@ impl FakeChatBackend {
185 Self {
186 has_template: true,
187 model_id: "test-model".to_string(),
188 + tokenizer: Arc::new(fake_chat_tokenizer()),
189 }
190 }
191
@@ -217,6 +193,7 @@ impl FakeChatBackend {
193 Self {
194 has_template: false,
195 model_id: "test-model".to_string(),
196 + tokenizer: Arc::new(fake_chat_tokenizer()),
197 }
198 }
199
@@ -224,13 +201,19 @@ impl FakeChatBackend {
201 Self {
202 has_template: true,
203 model_id: model_id.into(),
204 + tokenizer: Arc::new(fake_chat_tokenizer()),
205 }
206 }
207 +
208 + fn with_tokenizer(mut self, tokenizer: DynTokenizer) -> Self {
209 + self.tokenizer = tokenizer;
210 + self
211 + }
212 }
213
214 impl TextBackend for FakeChatBackend {
215 fn tokenizer(&self) -> DynTokenizer {
233 - Arc::new(FakeChatTokenizer)
216 + Arc::clone(&self.tokenizer)
217 }
218
219 fn model_id(&self) -> &str {
@@ -282,65 +265,6 @@ impl ChatRenderer for FakeChatBackend {
265 }
266 }
267
285 -#[derive(Clone, Debug)]
286 -struct FailingDecodeBackend {
287 - inner: FakeChatBackend,
288 -}
289 -
290 -#[derive(Debug)]
291 -struct FailingDecodeTokenizer;
292 -
293 -impl Tokenizer for FailingDecodeTokenizer {
294 - fn encode(&self, text: &str, add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
295 - FakeChatTokenizer.encode(text, add_special_tokens)
296 - }
297 -
298 - fn decode(
299 - &self,
300 - token_ids: &[u32],
301 - skip_special_tokens: bool,
302 - ) -> vllm_tokenizer::Result<String> {
303 - if token_ids.contains(&(b'i' as u32)) {
304 - return Err(vllm_tokenizer::TokenizerError("decode failed".to_string()));
305 - }
306 - FakeChatTokenizer.decode(token_ids, skip_special_tokens)
307 - }
308 -
309 - fn token_to_id(&self, token: &str) -> Option<u32> {
310 - FakeChatTokenizer.token_to_id(token)
311 - }
312 -}
313 -
314 -impl TextBackend for FailingDecodeBackend {
315 - fn tokenizer(&self) -> DynTokenizer {
316 - Arc::new(FailingDecodeTokenizer)
317 - }
318 -
319 - fn model_id(&self) -> &str {
320 - self.inner.model_id()
321 - }
322 -}
323 -
324 -impl ChatBackend for FailingDecodeBackend {
325 - fn chat_renderer(&self) -> DynChatRenderer {
326 - Arc::new(self.clone())
327 - }
328 -
329 - fn new_chat_output_processor(
330 - &self,
331 - _request: &mut ChatRequest,
332 - _options: NewChatOutputProcessorOptions<'_>,
333 - ) -> vllm_chat::Result<DynChatOutputProcessor> {
334 - Ok(Box::new(DefaultChatOutputProcessor::plain_text_only()))
335 - }
336 -}
337 -
338 -impl ChatRenderer for FailingDecodeBackend {
339 - fn render(&self, request: &ChatRequest) -> vllm_chat::Result<RenderedPrompt> {
340 - self.inner.render(request)
341 - }
342 -}
343 -
268 /// Skip `LogprobsDelta` events that carry only token_ids (no logprobs),
269 /// returning the next semantically interesting event.
270 async fn next_semantic<S>(stream: &mut S) -> Option<Result<ChatEvent, vllm_chat::Error>>
@@ -738,7 +662,12 @@ async fn chat_stream_reports_decode_failure_as_error_event() {
662 send_outputs(
663 push,
664 EngineCoreOutputs {
741 - outputs: vec![request_output("chat-4", vec![b'i' as u32], None, None)],
665 + outputs: vec![request_output(
666 + "chat-4",
667 + vec![UNKNOWN_DECODE_TOKEN_ID],
668 + None,
669 + None,
670 + )],
671 ..Default::default()
672 },
673 )
@@ -747,9 +676,8 @@ async fn chat_stream_reports_decode_failure_as_error_event() {
676 },
677 );
678
750 - let backend: Arc<dyn ChatTextBackend> = Arc::new(FailingDecodeBackend {
751 - inner: FakeChatBackend::new(),
752 - });
679 + let backend: Arc<dyn ChatTextBackend> =
680 + Arc::new(FakeChatBackend::new().with_tokenizer(Arc::new(TestTokenizer::new())));
681 let chat = connect_chat_llm_with_ipc(
682 EngineCoreClientConfig::new_single(handshake_address),
683 &ipc,
@@ -769,7 +697,10 @@ async fn chat_stream_reports_decode_failure_as_error_event() {
697
698 match timeout(Duration::from_secs(2), stream.next()).await.unwrap() {
699 Some(Err(vllm_chat::Error::Text(vllm_text::Error::Tokenizer(message)))) => {
772 - assert_eq!(message, "decode failed");
700 + assert_eq!(
701 + message,
702 + format!("test tokenizer cannot decode unknown token id {UNKNOWN_DECODE_TOKEN_ID}")
703 + );
704 }
705 other => panic!("unexpected event after close: {other:?}"),
706 }
rust/src/parser/Cargo.toml
+1
@@ -23,6 +23,7 @@ expect-test.workspace = true
23 futures.workspace = true
24 openai-protocol.workspace = true
25 tool-parser.workspace = true
26 +vllm-tokenizer = { workspace = true, features = ["test-utils"] }
27
28 [[bench]]
29 name = "deepseek_v3"
rust/src/parser/benches/utils/adapter.rs
+4
@@ -27,6 +27,10 @@ impl Tokenizer for BenchTokenizer {
27 fn token_to_id(&self, _token: &str) -> Option<u32> {
28 Some(u32::MAX)
29 }
30 +
31 + fn id_to_token(&self, _id: u32) -> Option<String> {
32 + Some("\u{FFFD}".to_string())
33 + }
34 }
35
36 /// Bench-only adapter that exposes a unified parser through the tool-parser
rust/src/parser/src/reasoning/seed_oss.rs
+15 -12
@@ -49,11 +49,14 @@ mod tests {
49 use std::sync::Arc;
50
51 use super::SeedOssReasoningParser;
52 - use crate::reasoning::{ReasoningParser, tests::FakeTokenizer};
52 + use crate::reasoning::{
53 + ReasoningParser,
54 + tests::{SEED_THINK_END_ID, SEED_THINK_START_ID, fake_tokenizer},
55 + };
56
57 #[test]
58 fn without_prompt_markers_expects_start_token() {
56 - let tokenizer = Arc::new(FakeTokenizer);
59 + let tokenizer = Arc::new(fake_tokenizer());
60 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
61
62 let delta = parser.push("implicit reasoning</seed:think>answer").unwrap();
@@ -66,10 +69,10 @@ mod tests {
69
70 #[test]
71 fn picks_up_prompt_start_boundary() {
69 - let tokenizer = Arc::new(FakeTokenizer);
72 + let tokenizer = Arc::new(fake_tokenizer());
73 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
71 - // Prompt prefills `<seed:think>` (id 10), opening reasoning before the stream.
72 - parser.initialize(&[10]).unwrap();
74 + // Prompt prefills `<seed:think>`, opening reasoning before the stream.
75 + parser.initialize(&[SEED_THINK_START_ID]).unwrap();
76
77 let delta = parser.push("reason</seed:think>answer").unwrap();
78 assert_eq!(delta.reasoning.as_deref(), Some("reason"));
@@ -78,10 +81,10 @@ mod tests {
81
82 #[test]
83 fn respects_prompt_end_boundary() {
81 - let tokenizer = Arc::new(FakeTokenizer);
84 + let tokenizer = Arc::new(fake_tokenizer());
85 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
83 - // Prompt already closed reasoning with `</seed:think>` (id 11).
84 - parser.initialize(&[11]).unwrap();
86 + // Prompt already closed reasoning with `</seed:think>`.
87 + parser.initialize(&[SEED_THINK_END_ID]).unwrap();
88
89 let delta = parser.push("answer").unwrap();
90 assert_eq!(delta.reasoning, None);
@@ -91,7 +94,7 @@ mod tests {
94 #[test]
95 fn handles_explicit_start_token() {
96 // An explicit start delimiter must not leak into reasoning text.
94 - let tokenizer = Arc::new(FakeTokenizer);
97 + let tokenizer = Arc::new(fake_tokenizer());
98 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
99
100 let delta = parser.push("<seed:think>reason</seed:think>answer").unwrap();
@@ -103,7 +106,7 @@ mod tests {
106 fn streams_explicit_start_token_across_pushes() {
107 // Start token, reasoning body, end token, and content arrive in separate
108 // streaming deltas.
106 - let tokenizer = Arc::new(FakeTokenizer);
109 + let tokenizer = Arc::new(fake_tokenizer());
110 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
111
112 let mut reasoning = String::new();
@@ -131,9 +134,9 @@ mod tests {
134
135 #[test]
136 fn handles_partial_delimiters_across_pushes() {
134 - let tokenizer = Arc::new(FakeTokenizer);
137 + let tokenizer = Arc::new(fake_tokenizer());
138 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
136 - parser.initialize(&[10]).unwrap();
139 + parser.initialize(&[SEED_THINK_START_ID]).unwrap();
140
141 // Closing delimiter `</seed:think>` arrives in two halves.
142 let first = parser.push("reason</seed:").unwrap();
rust/src/parser/src/reasoning/step3p5.rs
+20 -17
@@ -127,14 +127,17 @@ mod tests {
127 use std::sync::Arc;
128
129 use super::Step3p5ReasoningParser;
130 - use crate::reasoning::{ReasoningParser, tests::FakeTokenizer};
130 + use crate::reasoning::{
131 + ReasoningParser,
132 + tests::{THINK_START_ID, fake_tokenizer},
133 + };
134
135 #[test]
136 fn picks_up_prompt_start_boundary() {
134 - let tokenizer = Arc::new(FakeTokenizer);
137 + let tokenizer = Arc::new(fake_tokenizer());
138 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
136 - // Prompt prefills `<think>` (id 1), opening reasoning before the stream.
137 - parser.initialize(&[1]).unwrap();
139 + // Prompt prefills `<think>`, opening reasoning before the stream.
140 + parser.initialize(&[THINK_START_ID]).unwrap();
141
142 let delta = parser.push("This is a reasoning section</think>This is the rest").unwrap();
143 assert_eq!(
@@ -146,7 +149,7 @@ mod tests {
149
150 #[test]
151 fn handles_unterminated_reasoning() {
149 - let tokenizer = Arc::new(FakeTokenizer);
152 + let tokenizer = Arc::new(fake_tokenizer());
153 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
154
155 let pushed = parser.push("<think>reason without end").unwrap();
@@ -159,7 +162,7 @@ mod tests {
162
163 #[test]
164 fn handles_empty_input() {
162 - let tokenizer = Arc::new(FakeTokenizer);
165 + let tokenizer = Arc::new(fake_tokenizer());
166 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
167
168 let pushed = parser.push("").unwrap();
@@ -172,9 +175,9 @@ mod tests {
175 fn complex_newline_pattern_trims_only_single_framing_newline_each_side() {
176 // Only the immediately-adjacent framing `\n` is dropped on each side of
177 // `</think>`; surrounding newlines remain part of reasoning/content.
175 - let tokenizer = Arc::new(FakeTokenizer);
178 + let tokenizer = Arc::new(fake_tokenizer());
179 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
177 - parser.initialize(&[1]).unwrap();
180 + parser.initialize(&[THINK_START_ID]).unwrap();
181
182 let delta = parser
183 .push("\n This is a \n reasoning section\n\n\n</think>\n\nThis is the rest")
@@ -188,7 +191,7 @@ mod tests {
191
192 #[test]
193 fn drops_framing_newlines_in_single_push() {
191 - let tokenizer = Arc::new(FakeTokenizer);
194 + let tokenizer = Arc::new(fake_tokenizer());
195 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
196
197 let delta = parser.push("<think>reason\n</think>\nanswer").unwrap();
@@ -198,7 +201,7 @@ mod tests {
201
202 #[test]
203 fn drops_framing_newlines_across_pushes() {
201 - let tokenizer = Arc::new(FakeTokenizer);
204 + let tokenizer = Arc::new(fake_tokenizer());
205 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
206
207 // The trailing `\n` from the first push is held until we know whether
@@ -219,7 +222,7 @@ mod tests {
222
223 #[test]
224 fn replays_held_newline_when_more_reasoning_follows() {
222 - let tokenizer = Arc::new(FakeTokenizer);
225 + let tokenizer = Arc::new(fake_tokenizer());
226 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
227
228 let first = parser.push("<think>reason\n").unwrap();
@@ -232,7 +235,7 @@ mod tests {
235
236 #[test]
237 fn finish_flushes_held_newline_in_unterminated_stream() {
235 - let tokenizer = Arc::new(FakeTokenizer);
238 + let tokenizer = Arc::new(fake_tokenizer());
239 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
240
241 let first = parser.push("<think>reason\n").unwrap();
@@ -245,7 +248,7 @@ mod tests {
248
249 #[test]
250 fn preserves_inner_newlines_in_reasoning() {
248 - let tokenizer = Arc::new(FakeTokenizer);
251 + let tokenizer = Arc::new(fake_tokenizer());
252 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
253
254 let delta = parser.push("<think>line1\nline2</think>tail").unwrap();
@@ -257,7 +260,7 @@ mod tests {
260 fn trims_only_one_trailing_reasoning_newline() {
261 // Only the single framing newline immediately before `</think>` is
262 // dropped; earlier newlines in the reasoning body are preserved.
260 - let tokenizer = Arc::new(FakeTokenizer);
263 + let tokenizer = Arc::new(fake_tokenizer());
264 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
265
266 let delta = parser.push("<think>reason\n\n</think>answer").unwrap();
@@ -269,7 +272,7 @@ mod tests {
272 fn drops_only_first_content_newline_after_transition() {
273 // The leading-`\n` drop applies only to the first content delta after
274 // `</think>`; later deltas pass through untouched.
272 - let tokenizer = Arc::new(FakeTokenizer);
275 + let tokenizer = Arc::new(fake_tokenizer());
276 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
277
278 let first = parser.push("<think>reason</think>").unwrap();
@@ -288,7 +291,7 @@ mod tests {
291
292 #[test]
293 fn passes_through_clean_boundary_without_framing_newlines() {
291 - let tokenizer = Arc::new(FakeTokenizer);
294 + let tokenizer = Arc::new(fake_tokenizer());
295 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
296
297 let delta = parser.push("<think>reason</think>tail").unwrap();
@@ -298,7 +301,7 @@ mod tests {
301
302 #[test]
303 fn handles_empty_reasoning_section() {
301 - let tokenizer = Arc::new(FakeTokenizer);
304 + let tokenizer = Arc::new(fake_tokenizer());
305 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
306
307 let delta = parser.push("<think></think>answer").unwrap();
rust/src/parser/src/reasoning/tests.rs
+49 -61
@@ -1,54 +1,42 @@
1 use std::sync::Arc;
2
3 -use vllm_tokenizer::Tokenizer;
3 +use vllm_tokenizer::test_utils::TestTokenizer;
4
5 use super::{
6 DeepSeekR1ReasoningParser, DelimitedReasoningParser, MiniMaxM3ReasoningParser,
7 Qwen3ReasoningParser, ReasoningParser,
8 };
9
10 -pub(crate) struct FakeTokenizer;
11 -
12 -impl Tokenizer for FakeTokenizer {
13 - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
14 - Ok(text.chars().map(u32::from).collect())
15 - }
16 -
17 - fn decode(
18 - &self,
19 - token_ids: &[u32],
20 - _skip_special_tokens: bool,
21 - ) -> vllm_tokenizer::Result<String> {
22 - Ok(token_ids
23 - .iter()
24 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
25 - .collect())
26 - }
27 -
28 - fn token_to_id(&self, token: &str) -> Option<u32> {
29 - match token {
30 - "<think>" => Some(1),
31 - "</think>" => Some(2),
32 - "<|START_THINKING|>" => Some(3),
33 - "<|END_THINKING|>" => Some(4),
34 - "◁think▷" => Some(5),
35 - "◁/think▷" => Some(6),
36 - "<mm:think>" => Some(8),
37 - "</mm:think>" => Some(9),
38 - "<seed:think>" => Some(10),
39 - "</seed:think>" => Some(11),
40 - _ => None,
41 - }
42 - }
43 -
44 - fn is_special_id(&self, token_id: u32) -> bool {
45 - token_id == 7
46 - }
10 +pub(crate) const THINK_START_ID: u32 = 256;
11 +pub(crate) const THINK_END_ID: u32 = 257;
12 +pub(crate) const START_THINKING_ID: u32 = 258;
13 +pub(crate) const END_THINKING_ID: u32 = 259;
14 +pub(crate) const MINIMAX_THINK_START_ID: u32 = 260;
15 +pub(crate) const MINIMAX_THINK_END_ID: u32 = 261;
16 +pub(crate) const SPECIAL_BOUNDARY_ID: u32 = 262;
17 +pub(crate) const MM_THINK_START_ID: u32 = 263;
18 +pub(crate) const MM_THINK_END_ID: u32 = 264;
19 +pub(crate) const SEED_THINK_START_ID: u32 = 265;
20 +pub(crate) const SEED_THINK_END_ID: u32 = 266;
21 +
22 +pub(crate) fn fake_tokenizer() -> TestTokenizer {
23 + TestTokenizer::new()
24 + .with_regular_token("<think>", THINK_START_ID)
25 + .with_regular_token("</think>", THINK_END_ID)
26 + .with_regular_token("<|START_THINKING|>", START_THINKING_ID)
27 + .with_regular_token("<|END_THINKING|>", END_THINKING_ID)
28 + .with_regular_token("◁think▷", MINIMAX_THINK_START_ID)
29 + .with_regular_token("◁/think▷", MINIMAX_THINK_END_ID)
30 + .with_special_token("<special-boundary>", SPECIAL_BOUNDARY_ID)
31 + .with_regular_token("<mm:think>", MM_THINK_START_ID)
32 + .with_regular_token("</mm:think>", MM_THINK_END_ID)
33 + .with_regular_token("<seed:think>", SEED_THINK_START_ID)
34 + .with_regular_token("</seed:think>", SEED_THINK_END_ID)
35 }
36
37 #[test]
38 fn delimited_content_only_stream() {
51 - let tokenizer = Arc::new(FakeTokenizer);
39 + let tokenizer = Arc::new(fake_tokenizer());
40 let mut parser =
41 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
42
@@ -60,7 +48,7 @@ fn delimited_content_only_stream() {
48
49 #[test]
50 fn delimited_single_chunk_with_reasoning_and_content() {
63 - let tokenizer = Arc::new(FakeTokenizer);
51 + let tokenizer = Arc::new(fake_tokenizer());
52 let mut parser =
53 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
54
@@ -71,7 +59,7 @@ fn delimited_single_chunk_with_reasoning_and_content() {
59
60 #[test]
61 fn delimited_partial_tokens_across_chunks() {
74 - let tokenizer = Arc::new(FakeTokenizer);
62 + let tokenizer = Arc::new(fake_tokenizer());
63 let mut parser =
64 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
65
@@ -83,10 +71,10 @@ fn delimited_partial_tokens_across_chunks() {
71
72 #[test]
73 fn delimited_finish_flushes_buffer() {
86 - let tokenizer = Arc::new(FakeTokenizer);
74 + let tokenizer = Arc::new(fake_tokenizer());
75 let mut parser =
76 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
89 - parser.initialize(&[1]);
77 + parser.initialize(&[THINK_START_ID]);
78
79 let delta = parser.push("unfinished</thi");
80 assert_eq!(delta.reasoning.as_deref(), Some("unfinished"));
@@ -96,7 +84,7 @@ fn delimited_finish_flushes_buffer() {
84
85 #[test]
86 fn qwen3_without_prompt_markers_expects_start_token() {
99 - let tokenizer = Arc::new(FakeTokenizer);
87 + let tokenizer = Arc::new(fake_tokenizer());
88 let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
89
90 let delta = parser.push("reason</think>answer").unwrap();
@@ -106,9 +94,9 @@ fn qwen3_without_prompt_markers_expects_start_token() {
94
95 #[test]
96 fn qwen3_prompt_end_marker_starts_in_content() {
109 - let tokenizer = Arc::new(FakeTokenizer);
97 + let tokenizer = Arc::new(fake_tokenizer());
98 let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
111 - parser.initialize(&[2]).unwrap();
99 + parser.initialize(&[THINK_END_ID]).unwrap();
100
101 let delta = parser.push("answer").unwrap();
102 assert_eq!(delta.reasoning, None);
@@ -117,7 +105,7 @@ fn qwen3_prompt_end_marker_starts_in_content() {
105
106 #[test]
107 fn qwen3_tolerates_old_and_new_formats() {
120 - let tokenizer = Arc::new(FakeTokenizer);
108 + let tokenizer = Arc::new(fake_tokenizer());
109
110 let mut old_parser = Qwen3ReasoningParser::new(tokenizer.clone()).unwrap();
111 let old = old_parser.push("<think>reason</think>answer").unwrap();
@@ -125,7 +113,7 @@ fn qwen3_tolerates_old_and_new_formats() {
113 assert_eq!(old.content.as_deref(), Some("answer"));
114
115 let mut new_parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
128 - new_parser.initialize(&[1]).unwrap();
116 + new_parser.initialize(&[THINK_START_ID]).unwrap();
117 let new = new_parser.push("reason</think>answer").unwrap();
118 assert_eq!(new.reasoning.as_deref(), Some("reason"));
119 assert_eq!(new.content.as_deref(), Some("answer"));
@@ -133,10 +121,10 @@ fn qwen3_tolerates_old_and_new_formats() {
121
122 #[test]
123 fn qwen3_stops_scanning_at_last_special_token() {
136 - let tokenizer = Arc::new(FakeTokenizer);
124 + let tokenizer = Arc::new(fake_tokenizer());
125 let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
126
139 - parser.initialize(&[1, 7]).unwrap();
127 + parser.initialize(&[THINK_START_ID, SPECIAL_BOUNDARY_ID]).unwrap();
128
129 let delta = parser.push("answer").unwrap();
130 assert_eq!(delta.reasoning, None);
@@ -145,7 +133,7 @@ fn qwen3_stops_scanning_at_last_special_token() {
133
134 #[test]
135 fn deepseek_r1_defaults_to_reasoning_without_prompt_boundary() {
148 - let tokenizer = Arc::new(FakeTokenizer);
136 + let tokenizer = Arc::new(fake_tokenizer());
137 let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap();
138
139 let delta = parser.push("reason</think>answer").unwrap();
@@ -155,10 +143,10 @@ fn deepseek_r1_defaults_to_reasoning_without_prompt_boundary() {
143
144 #[test]
145 fn deepseek_r1_stops_scanning_at_last_special_token() {
158 - let tokenizer = Arc::new(FakeTokenizer);
146 + let tokenizer = Arc::new(fake_tokenizer());
147 let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap();
148
161 - parser.initialize(&[2, 7]).unwrap();
149 + parser.initialize(&[THINK_END_ID, SPECIAL_BOUNDARY_ID]).unwrap();
150
151 let delta = parser.push("reason</think>answer").unwrap();
152 assert_eq!(delta.reasoning.as_deref(), Some("reason"));
@@ -167,7 +155,7 @@ fn deepseek_r1_stops_scanning_at_last_special_token() {
155
156 #[test]
157 fn minimax_m3_handles_explicit_think_delimiters() {
170 - let tokenizer = Arc::new(FakeTokenizer);
158 + let tokenizer = Arc::new(fake_tokenizer());
159 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
160
161 let delta = parser.push("<mm:think>reason</mm:think>answer").unwrap();
@@ -177,7 +165,7 @@ fn minimax_m3_handles_explicit_think_delimiters() {
165
166 #[test]
167 fn minimax_m3_drops_leading_end_marker() {
180 - let tokenizer = Arc::new(FakeTokenizer);
168 + let tokenizer = Arc::new(fake_tokenizer());
169 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
170
171 let delta = parser.push("</mm:think>answer").unwrap();
@@ -187,7 +175,7 @@ fn minimax_m3_drops_leading_end_marker() {
175
176 #[test]
177 fn minimax_m3_preserves_non_leading_end_marker() {
190 - let tokenizer = Arc::new(FakeTokenizer);
178 + let tokenizer = Arc::new(fake_tokenizer());
179 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
180
181 let delta = parser.push("XXX</mm:think>YYY").unwrap();
@@ -197,7 +185,7 @@ fn minimax_m3_preserves_non_leading_end_marker() {
185
186 #[test]
187 fn minimax_m3_drops_split_leading_end_marker() {
200 - let tokenizer = Arc::new(FakeTokenizer);
188 + let tokenizer = Arc::new(fake_tokenizer());
189 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
190
191 assert!(parser.push("</mm").unwrap().is_empty());
@@ -208,9 +196,9 @@ fn minimax_m3_drops_split_leading_end_marker() {
196
197 #[test]
198 fn minimax_m3_uses_prompt_prefilled_start_marker() {
211 - let tokenizer = Arc::new(FakeTokenizer);
199 + let tokenizer = Arc::new(fake_tokenizer());
200 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
213 - parser.initialize(&[8]).unwrap();
201 + parser.initialize(&[MM_THINK_START_ID]).unwrap();
202
203 let delta = parser.push("reason</mm:think>answer").unwrap();
204 assert_eq!(delta.reasoning.as_deref(), Some("reason"));
@@ -219,9 +207,9 @@ fn minimax_m3_uses_prompt_prefilled_start_marker() {
207
208 #[test]
209 fn minimax_m3_uses_prompt_prefilled_end_marker() {
222 - let tokenizer = Arc::new(FakeTokenizer);
210 + let tokenizer = Arc::new(fake_tokenizer());
211 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
224 - parser.initialize(&[9]).unwrap();
212 + parser.initialize(&[MM_THINK_END_ID]).unwrap();
213
214 let delta = parser.push("answer").unwrap();
215 assert_eq!(delta.reasoning, None);
rust/src/parser/src/unified/combined.rs
+6 -31
@@ -124,42 +124,17 @@ impl UnifiedParser for CombinedParser {
124 mod tests {
125 use std::sync::Arc;
126
127 - use vllm_tokenizer::Tokenizer;
127 + use vllm_tokenizer::test_utils::TestTokenizer;
128
129 use super::CombinedParser;
130 use crate::reasoning::{Qwen3ReasoningParser, ReasoningDelta, ReasoningParser};
131 use crate::tool::{Qwen3XmlToolParser, Tool, ToolParser};
132 use crate::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
133
134 - struct FakeTokenizer;
135 -
136 - impl Tokenizer for FakeTokenizer {
137 - fn encode(
138 - &self,
139 - text: &str,
140 - _add_special_tokens: bool,
141 - ) -> vllm_tokenizer::Result<Vec<u32>> {
142 - Ok(text.chars().map(u32::from).collect())
143 - }
144 -
145 - fn decode(
146 - &self,
147 - token_ids: &[u32],
148 - _skip_special_tokens: bool,
149 - ) -> vllm_tokenizer::Result<String> {
150 - Ok(token_ids
151 - .iter()
152 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
153 - .collect())
154 - }
155 -
156 - fn token_to_id(&self, token: &str) -> Option<u32> {
157 - match token {
158 - "<think>" => Some(1),
159 - "</think>" => Some(2),
160 - _ => None,
161 - }
162 - }
134 + fn tokenizer() -> TestTokenizer {
135 + TestTokenizer::new()
136 + .with_regular_token("<think>", 256)
137 + .with_regular_token("</think>", 257)
138 }
139
140 fn test_tools() -> Vec<Tool> {
@@ -273,7 +248,7 @@ mod tests {
248
249 #[test]
250 fn combined_parser_emits_reasoning_and_text() {
276 - let tokenizer = Arc::new(FakeTokenizer);
251 + let tokenizer = Arc::new(tokenizer());
252 let reasoning = Qwen3ReasoningParser::create(tokenizer).unwrap();
253 let mut parser = CombinedParser::new(Some(reasoning), None);
254
rust/src/parser/src/unified/gemma4.rs
+15 -66
@@ -515,7 +515,7 @@ mod tests {
515
516 use serde_json::{Value, json};
517 use thiserror_ext::AsReport;
518 - use vllm_tokenizer::Tokenizer;
518 + use vllm_tokenizer::test_utils::TestTokenizer;
519 use winnow::combinator::{eof, terminated};
520 use winnow::error::ErrMode;
521 use winnow::prelude::*;
@@ -527,66 +527,15 @@ mod tests {
527 use crate::tool::Tool;
528 use crate::unified::{UnifiedParserEvent, parsing_failed};
529
530 - struct FakeTokenizer;
530 + const CHANNEL_START_ID: u32 = 256;
531 + const CHANNEL_END_ID: u32 = 257;
532 + const TURN_BOUNDARY_ID: u32 = 258;
533
532 - impl Tokenizer for FakeTokenizer {
533 - fn encode(
534 - &self,
535 - text: &str,
536 - _add_special_tokens: bool,
537 - ) -> vllm_tokenizer::Result<Vec<u32>> {
538 - Ok(text.chars().map(u32::from).collect())
539 - }
540 -
541 - fn decode(
542 - &self,
543 - token_ids: &[u32],
544 - _skip_special_tokens: bool,
545 - ) -> vllm_tokenizer::Result<String> {
546 - Ok(token_ids
547 - .iter()
548 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
549 - .collect())
550 - }
551 -
552 - fn token_to_id(&self, token: &str) -> Option<u32> {
553 - match token {
554 - CHANNEL_START => Some(100),
555 - CHANNEL_END => Some(101),
556 - _ => None,
557 - }
558 - }
559 -
560 - fn is_special_id(&self, token_id: u32) -> bool {
561 - matches!(token_id, 100..=105)
562 - }
563 - }
564 -
565 - struct MissingTokenTokenizer;
566 -
567 - impl Tokenizer for MissingTokenTokenizer {
568 - fn encode(
569 - &self,
570 - text: &str,
571 - _add_special_tokens: bool,
572 - ) -> vllm_tokenizer::Result<Vec<u32>> {
573 - Ok(text.chars().map(u32::from).collect())
574 - }
575 -
576 - fn decode(
577 - &self,
578 - token_ids: &[u32],
579 - _skip_special_tokens: bool,
580 - ) -> vllm_tokenizer::Result<String> {
581 - Ok(token_ids
582 - .iter()
583 - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
584 - .collect())
585 - }
586 -
587 - fn token_to_id(&self, _token: &str) -> Option<u32> {
588 - None
589 - }
534 + fn tokenizer() -> TestTokenizer {
535 + TestTokenizer::new()
536 + .with_special_token(CHANNEL_START, CHANNEL_START_ID)
537 + .with_special_token(CHANNEL_END, CHANNEL_END_ID)
538 + .with_special_token("<turn-boundary>", TURN_BOUNDARY_ID)
539 }
540
541 trait UnifiedParserTestExt {
@@ -716,12 +665,12 @@ mod tests {
665 }
666
667 fn test_parser() -> Gemma4UnifiedParser {
719 - Gemma4UnifiedParser::new(&test_tools(), Arc::new(FakeTokenizer)).unwrap()
668 + Gemma4UnifiedParser::new(&test_tools(), Arc::new(tokenizer())).unwrap()
669 }
670
671 #[test]
672 fn gemma4_create_requires_channel_start_token() {
724 - let error = match Gemma4UnifiedParser::new(&test_tools(), Arc::new(MissingTokenTokenizer)) {
673 + let error = match Gemma4UnifiedParser::new(&test_tools(), Arc::new(TestTokenizer::new())) {
674 Ok(_) => panic!("expected missing token error"),
675 Err(error) => error,
676 };
@@ -1046,7 +995,7 @@ mod tests {
995 #[test]
996 fn gemma4_initialize_open_channel_prompt_starts_in_reasoning() {
997 let mut parser = test_parser();
1049 - parser.initialize(&[100, 3000, 3001]).unwrap();
998 + parser.initialize(&[CHANNEL_START_ID, 3000, 3001]).unwrap();
999
1000 let output = parser.parse_complete("reason<channel|>answer").unwrap();
1001
@@ -1057,7 +1006,7 @@ mod tests {
1006 #[test]
1007 fn gemma4_initialize_turn_prompt_starts_in_text() {
1008 let mut parser = test_parser();
1060 - parser.initialize(&[104, 3000, 3001]).unwrap();
1009 + parser.initialize(&[TURN_BOUNDARY_ID, 3000, 3001]).unwrap();
1010
1011 let output = parser.parse_complete("<|channel>thought\nreason<channel|>answer").unwrap();
1012
@@ -1068,7 +1017,7 @@ mod tests {
1017 #[test]
1018 fn gemma4_initialize_special_token_caps_boundary_scan() {
1019 let mut parser = test_parser();
1071 - parser.initialize(&[100, 3000, 104, 3001]).unwrap();
1020 + parser.initialize(&[CHANNEL_START_ID, 3000, TURN_BOUNDARY_ID, 3001]).unwrap();
1021
1022 let output = parser.parse_complete("answer").unwrap();
1023
@@ -1079,7 +1028,7 @@ mod tests {
1028 #[test]
1029 fn gemma4_initialize_closed_channel_prompt_starts_in_text() {
1030 let mut parser = test_parser();
1082 - parser.initialize(&[100, 3000, 3001, 101]).unwrap();
1031 + parser.initialize(&[CHANNEL_START_ID, 3000, 3001, CHANNEL_END_ID]).unwrap();
1032
1033 let output = parser.parse_complete("answer").unwrap();
1034
rust/src/server/Cargo.toml
+1
@@ -64,6 +64,7 @@ tempfile.workspace = true
64 tokio = { workspace = true, features = ["test-util"] }
65 tower.workspace = true
66 vllm-engine-core-client = { workspace = true, features = ["test-util"] }
67 +vllm-tokenizer = { workspace = true, features = ["test-utils"] }
68 zeromq.workspace = true
69
70 [lints]
rust/src/server/src/grpc/tests.rs
+3 -30
@@ -24,8 +24,9 @@ use vllm_engine_core_client::protocol::{
24 use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
25 use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId};
26 use vllm_llm::Llm;
27 -use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
27 +use vllm_text::tokenizer::DynTokenizer;
28 use vllm_text::{Prompt, TextBackend};
29 +use vllm_tokenizer::test_utils::TestTokenizer;
30 use zeromq::prelude::{SocketRecv, SocketSend};
31 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
32
@@ -155,37 +156,9 @@ fn test_llm(client: EngineCoreClient) -> Llm {
156 #[derive(Clone, Debug)]
157 struct FakeTextBackend;
158
158 -#[derive(Debug)]
159 -struct FakeTokenizer;
160 -
161 -impl Tokenizer for FakeTokenizer {
162 - fn encode(
163 - &self,
164 - text: &str,
165 - _add_special_tokens: bool,
166 - ) -> vllm_text::tokenizer::Result<Vec<u32>> {
167 - Ok(text.bytes().map(u32::from).collect())
168 - }
169 -
170 - fn decode(
171 - &self,
172 - token_ids: &[u32],
173 - _skip_special_tokens: bool,
174 - ) -> vllm_text::tokenizer::Result<String> {
175 - Ok(
176 - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>())
177 - .into_owned(),
178 - )
179 - }
180 -
181 - fn token_to_id(&self, token: &str) -> Option<u32> {
182 - token.bytes().next().map(u32::from)
183 - }
184 -}
185 -
159 impl TextBackend for FakeTextBackend {
160 fn tokenizer(&self) -> DynTokenizer {
188 - Arc::new(FakeTokenizer)
161 + Arc::new(TestTokenizer::new())
162 }
163
164 fn model_id(&self) -> &str {
rust/src/server/src/routes/http_client_tests.rs
+3 -30
@@ -24,8 +24,9 @@ use vllm_engine_core_client::protocol::{
24 use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
25 use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId};
26 use vllm_llm::Llm;
27 -use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
27 +use vllm_text::tokenizer::DynTokenizer;
28 use vllm_text::{Prompt, TextBackend};
29 +use vllm_tokenizer::test_utils::TestTokenizer;
30 use zeromq::prelude::{SocketRecv, SocketSend};
31 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
32
@@ -151,37 +152,9 @@ fn test_llm(client: EngineCoreClient) -> Llm {
152 #[derive(Clone, Debug)]
153 struct FakeChatBackend;
154
154 -#[derive(Debug)]
155 -struct FakeChatTokenizer;
156 -
157 -impl Tokenizer for FakeChatTokenizer {
158 - fn encode(
159 - &self,
160 - text: &str,
161 - _add_special_tokens: bool,
162 - ) -> vllm_text::tokenizer::Result<Vec<u32>> {
163 - Ok(text.bytes().map(u32::from).collect())
164 - }
165 -
166 - fn decode(
167 - &self,
168 - token_ids: &[u32],
169 - _skip_special_tokens: bool,
170 - ) -> vllm_text::tokenizer::Result<String> {
171 - Ok(
172 - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>())
173 - .into_owned(),
174 - )
175 - }
176 -
177 - fn token_to_id(&self, token: &str) -> Option<u32> {
178 - token.bytes().next().map(u32::from)
179 - }
180 -}
181 -
155 impl TextBackend for FakeChatBackend {
156 fn tokenizer(&self) -> DynTokenizer {
184 - Arc::new(FakeChatTokenizer)
157 + Arc::new(TestTokenizer::new())
158 }
159
160 fn model_id(&self) -> &str {
rust/src/server/src/routes/openai/completions/convert.rs
+15 -39
@@ -194,7 +194,7 @@ mod tests {
194 use axum::http::HeaderMap;
195 use serde_json::json;
196 use vllm_text::Prompt;
197 - use vllm_text::tokenizer::Tokenizer;
197 + use vllm_tokenizer::test_utils::TestTokenizer;
198
199 use super::prepare_completion_request;
200 use crate::lora::LoraModelResolution;
@@ -212,32 +212,8 @@ mod tests {
212 }
213 }
214
215 - #[derive(Debug)]
216 - struct TestTokenizer;
217 -
218 - impl Tokenizer for TestTokenizer {
219 - fn encode(
220 - &self,
221 - text: &str,
222 - _add_special_tokens: bool,
223 - ) -> vllm_text::tokenizer::Result<Vec<u32>> {
224 - Ok(text.bytes().map(u32::from).collect())
225 - }
226 -
227 - fn decode(
228 - &self,
229 - token_ids: &[u32],
230 - _skip_special_tokens: bool,
231 - ) -> vllm_text::tokenizer::Result<String> {
232 - Ok(
233 - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>())
234 - .into_owned(),
235 - )
236 - }
237 -
238 - fn token_to_id(&self, _token: &str) -> Option<u32> {
239 - None
240 - }
215 + fn test_tokenizer() -> TestTokenizer {
216 + TestTokenizer::new()
217 }
218
219 fn base_request_json() -> serde_json::Value {
@@ -297,7 +273,7 @@ mod tests {
273 request,
274 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
275 ResolvedRequestContext::default(),
300 - &TestTokenizer,
276 + &test_tokenizer(),
277 )
278 .expect("prepare");
279
@@ -340,7 +316,7 @@ mod tests {
316 request,
317 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
318 ResolvedRequestContext::default(),
343 - &TestTokenizer,
319 + &test_tokenizer(),
320 )
321 .expect("prepare")
322 .text_request
@@ -374,7 +350,7 @@ mod tests {
350 request,
351 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
352 ResolvedRequestContext::default(),
377 - &TestTokenizer,
353 + &test_tokenizer(),
354 )
355 .expect("prepare");
356
@@ -399,7 +375,7 @@ mod tests {
375 request,
376 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
377 ResolvedRequestContext::default(),
402 - &TestTokenizer,
378 + &test_tokenizer(),
379 )
380 .expect("prepare");
381
@@ -422,7 +398,7 @@ mod tests {
398 request,
399 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
400 ResolvedRequestContext::default(),
425 - &TestTokenizer,
401 + &test_tokenizer(),
402 )
403 .expect("prepare");
404
@@ -446,7 +422,7 @@ mod tests {
422 request,
423 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
424 ResolvedRequestContext::default(),
449 - &TestTokenizer,
425 + &test_tokenizer(),
426 )
427 .expect("prepare");
428
@@ -471,7 +447,7 @@ mod tests {
447 request,
448 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
449 ResolvedRequestContext::default(),
474 - &TestTokenizer,
450 + &test_tokenizer(),
451 )
452 .expect("prepare");
453
@@ -498,7 +474,7 @@ mod tests {
474 request,
475 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
476 ResolvedRequestContext::default(),
501 - &TestTokenizer,
477 + &test_tokenizer(),
478 )
479 .expect("prepare");
480
@@ -523,7 +499,7 @@ mod tests {
499 request,
500 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
501 ResolvedRequestContext::default(),
526 - &TestTokenizer,
502 + &test_tokenizer(),
503 )
504 .expect("prepare");
505
@@ -549,7 +525,7 @@ mod tests {
525 request,
526 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
527 ResolvedRequestContext::default(),
552 - &TestTokenizer,
528 + &test_tokenizer(),
529 )
530 .expect("prepare");
531 assert_eq!(prepared.text_request.sampling_params.logprobs, Some(1));
@@ -574,7 +550,7 @@ mod tests {
550 request,
551 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
552 request_context(&headers, None),
577 - &TestTokenizer,
553 + &test_tokenizer(),
554 )
555 .expect("prepare");
556 assert_eq!(prepared.text_request.data_parallel_rank, Some(3));
@@ -593,7 +569,7 @@ mod tests {
569 request,
570 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
571 ResolvedRequestContext::default(),
596 - &TestTokenizer,
572 + &test_tokenizer(),
573 )
574 .expect("prepare");
575 assert_eq!(prepared.text_request.data_parallel_rank, None);
rust/src/server/src/routes/tests.rs
+23 -146
@@ -40,8 +40,9 @@ use vllm_engine_core_client::{
40 };
41 use vllm_llm::Llm;
42 use vllm_metrics::METRICS;
43 -use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
43 +use vllm_text::tokenizer::DynTokenizer;
44 use vllm_text::{Prompt, TextBackend};
45 +use vllm_tokenizer::test_utils::TestTokenizer;
46 use zeromq::prelude::{SocketRecv, SocketSend};
47 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
48
@@ -417,82 +418,20 @@ struct FakeChatBackend {
418 }
419
420 /// Synthetic BOS id used when `add_special_tokens` is true in tests.
420 -const FAKE_BOS_TOKEN_ID: u32 = 1;
421 -
422 -#[derive(Debug)]
423 -struct FakeChatTokenizer;
424 -
425 -impl Tokenizer for FakeChatTokenizer {
426 - fn encode(
427 - &self,
428 - text: &str,
429 - add_special_tokens: bool,
430 - ) -> vllm_text::tokenizer::Result<Vec<u32>> {
431 - let mut token_ids = Vec::new();
432 - if add_special_tokens {
433 - token_ids.push(FAKE_BOS_TOKEN_ID);
434 - }
435 - let mut rest = text;
436 - while !rest.is_empty() {
437 - if let Some(stripped) = rest.strip_prefix("<image>") {
438 - token_ids.push(999);
439 - rest = stripped;
440 - continue;
441 - }
442 - if let Some(stripped) = rest.strip_prefix("<|image_pad|>") {
443 - token_ids.push(151655);
444 - rest = stripped;
445 - continue;
446 - }
447 -
448 - let ch = rest.chars().next().expect("rest is not empty");
449 - let mut buf = [0; 4];
450 - token_ids.extend(ch.encode_utf8(&mut buf).bytes().map(u32::from));
451 - rest = &rest[ch.len_utf8()..];
452 - }
453 - Ok(token_ids)
454 - }
455 -
456 - fn decode(
457 - &self,
458 - token_ids: &[u32],
459 - _skip_special_tokens: bool,
460 - ) -> vllm_text::tokenizer::Result<String> {
461 - Ok(
462 - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>())
463 - .into_owned(),
464 - )
465 - }
466 -
467 - fn token_to_id(&self, token: &str) -> Option<u32> {
468 - match token {
469 - "<image>" => Some(999),
470 - "<|image_pad|>" => Some(151655),
471 - "<think>" => Some(0xF001),
472 - "</think>" => Some(0xF002),
473 - "<|START_THINKING|>" => Some(0xF003),
474 - "<|END_THINKING|>" => Some(0xF004),
475 - "◁think▷" => Some(0xF005),
476 - "◁/think▷" => Some(0xF006),
477 - _ => None,
478 - }
479 - }
480 -
481 - fn id_to_token(&self, id: u32) -> Option<String> {
482 - match id {
483 - FAKE_BOS_TOKEN_ID => Some("<bos>".to_string()),
484 - 999 => Some("<image>".to_string()),
485 - 151655 => Some("<|image_pad|>".to_string()),
486 - 0xF001 => Some("<think>".to_string()),
487 - 0xF002 => Some("</think>".to_string()),
488 - 0xF003 => Some("<|START_THINKING|>".to_string()),
489 - 0xF004 => Some("<|END_THINKING|>".to_string()),
490 - 0xF005 => Some("◁think▷".to_string()),
491 - 0xF006 => Some("◁/think▷".to_string()),
492 - id if id < 128 => char::from_u32(id).map(|ch| ch.to_string()),
493 - _ => None,
494 - }
495 - }
421 +const FAKE_BOS_TOKEN_ID: u32 = 256;
422 +const UNKNOWN_DECODE_TOKEN_ID: u32 = 10_000;
423 +
424 +fn fake_chat_tokenizer() -> TestTokenizer {
425 + TestTokenizer::new()
426 + .with_bos_token("<bos>", FAKE_BOS_TOKEN_ID)
427 + .with_regular_token("<image>", 999)
428 + .with_regular_token("<|image_pad|>", 151655)
429 + .with_regular_token("<think>", 0xF001)
430 + .with_regular_token("</think>", 0xF002)
431 + .with_regular_token("<|START_THINKING|>", 0xF003)
432 + .with_regular_token("<|END_THINKING|>", 0xF004)
433 + .with_regular_token("◁think▷", 0xF005)
434 + .with_regular_token("◁/think▷", 0xF006)
435 }
436
437 impl FakeChatBackend {
@@ -530,7 +469,7 @@ impl fmt::Debug for FakeChatBackend {
469
470 impl TextBackend for FakeChatBackend {
471 fn tokenizer(&self) -> DynTokenizer {
533 - Arc::new(FakeChatTokenizer)
472 + Arc::new(fake_chat_tokenizer())
473 }
474
475 fn model_id(&self) -> &str {
@@ -630,7 +569,7 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
569 Some("qwen2_vl".to_string()),
570 Some(&config_path),
571 None,
633 - Arc::new(FakeChatTokenizer),
572 + Arc::new(fake_chat_tokenizer()),
573 )
574 .expect("load multimodal info")
575 .expect("qwen multimodal info is registered");
@@ -638,70 +577,6 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
577 info
578 }
579
641 -#[derive(Clone, Debug)]
642 -struct FailingDecodeChatBackend;
643 -
644 -#[derive(Debug)]
645 -struct FailingDecodeTokenizer;
646 -
647 -impl Tokenizer for FailingDecodeTokenizer {
648 - fn encode(
649 - &self,
650 - text: &str,
651 - add_special_tokens: bool,
652 - ) -> vllm_text::tokenizer::Result<Vec<u32>> {
653 - FakeChatTokenizer.encode(text, add_special_tokens)
654 - }
655 -
656 - fn decode(
657 - &self,
658 - token_ids: &[u32],
659 - skip_special_tokens: bool,
660 - ) -> vllm_text::tokenizer::Result<String> {
661 - if token_ids.contains(&(b'i' as u32)) {
662 - return Err(vllm_text::tokenizer::TokenizerError(
663 - "forced decode failure for streaming test".to_string(),
664 - ));
665 - }
666 -
667 - FakeChatTokenizer.decode(token_ids, skip_special_tokens)
668 - }
669 -
670 - fn token_to_id(&self, token: &str) -> Option<u32> {
671 - FakeChatTokenizer.token_to_id(token)
672 - }
673 -}
674 -
675 -impl TextBackend for FailingDecodeChatBackend {
676 - fn tokenizer(&self) -> DynTokenizer {
677 - Arc::new(FailingDecodeTokenizer)
678 - }
679 -
680 - fn model_id(&self) -> &str {
681 - "test-model"
682 - }
683 -}
684 -
685 -impl ChatBackend for FailingDecodeChatBackend {
686 - fn chat_renderer(&self) -> DynChatRenderer {
687 - Arc::new(self.clone())
688 - }
689 -
690 - fn new_chat_output_processor(
691 - &self,
692 - _request: &mut ChatRequest,
693 - _options: NewChatOutputProcessorOptions<'_>,
694 - ) -> vllm_chat::Result<DynChatOutputProcessor> {
695 - Ok(Box::new(DefaultChatOutputProcessor::plain_text_only()))
696 - }
697 -}
698 -
699 -impl ChatRenderer for FailingDecodeChatBackend {
700 - fn render(&self, request: &ChatRequest) -> vllm_chat::Result<vllm_chat::RenderedPrompt> {
701 - FakeChatBackend::new().render(request)
702 - }
703 -}
704 -
580 async fn test_models_with_engine_outputs_and_backend_inner(
581 engine_id: impl Into<EngineId>,
582 output_specs: Vec<(Vec<u32>, Option<EngineCoreFinishReason>)>,
@@ -2755,8 +2630,8 @@ async fn load_endpoint_resets_when_stream_response_is_dropped() {
2630 #[serial]
2631 async fn stream_error_is_returned_as_openai_error_sse() {
2632 let (app, engine_task) = test_app_with_backend_and_stream_output_specs(
2758 - Arc::new(FailingDecodeChatBackend),
2759 - default_stream_output_specs(),
2633 + Arc::new(FakeChatBackend::new()),
2634 + vec![(vec![UNKNOWN_DECODE_TOKEN_ID], None)],
2635 )
2636 .await;
2637 let response = app
@@ -2789,7 +2664,9 @@ async fn stream_error_is_returned_as_openai_error_sse() {
2664 assert!(text.contains("\"role\":\"assistant\""), "{text}");
2665 assert!(text.contains("\"type\":\"server_error\""), "{text}");
2666 assert!(
2792 - text.contains("forced decode failure for streaming test"),
2667 + text.contains(&format!(
2668 + "test tokenizer cannot decode unknown token id {UNKNOWN_DECODE_TOKEN_ID}"
2669 + )),
2670 "{text}"
2671 );
2672 assert!(!text.contains("\"usage\":"), "{text}");
rust/src/text/Cargo.toml
+1
@@ -31,6 +31,7 @@ serial_test.workspace = true
31 tempfile.workspace = true
32 tokio.workspace = true
33 vllm-llm = { workspace = true, features = ["test-util"] }
34 +vllm-tokenizer = { workspace = true, features = ["test-utils"] }
35
36 [lints]
37 workspace = true
rust/src/text/src/lower.rs
+4 -57
@@ -275,6 +275,7 @@ mod tests {
275 use std::collections::{BTreeSet, HashMap};
276
277 use serial_test::file_serial;
278 + use vllm_tokenizer::test_utils::TestTokenizer;
279
280 use super::*;
281 use crate::backend::hf::HfTextBackend;
@@ -282,60 +283,8 @@ mod tests {
283 use crate::error::{LogprobsError, TokenIdsError};
284 use crate::request::{Prompt, TextRequest};
285
285 - /// Stub tokenizer that returns empty token IDs — sufficient for tests that
286 - /// don't exercise bad-words tokenization.
287 - struct StubTokenizer;
288 -
289 - impl Tokenizer for StubTokenizer {
290 - fn encode(
291 - &self,
292 - _text: &str,
293 - _add_special_tokens: bool,
294 - ) -> vllm_tokenizer::Result<Vec<u32>> {
295 - Ok(vec![])
296 - }
297 -
298 - fn decode(
299 - &self,
300 - _token_ids: &[u32],
301 - _skip_special_tokens: bool,
302 - ) -> vllm_tokenizer::Result<String> {
303 - Ok(String::new())
304 - }
305 -
306 - fn token_to_id(&self, _token: &str) -> Option<u32> {
307 - None
308 - }
309 - }
310 -
311 - fn stub_tokenizer() -> StubTokenizer {
312 - StubTokenizer
313 - }
314 -
315 - struct FixedTokenizer {
316 - token_ids: Vec<u32>,
317 - }
318 -
319 - impl Tokenizer for FixedTokenizer {
320 - fn encode(
321 - &self,
322 - _text: &str,
323 - _add_special_tokens: bool,
324 - ) -> vllm_tokenizer::Result<Vec<u32>> {
325 - Ok(self.token_ids.clone())
326 - }
327 -
328 - fn decode(
329 - &self,
330 - _token_ids: &[u32],
331 - _skip_special_tokens: bool,
332 - ) -> vllm_tokenizer::Result<String> {
333 - Ok(String::new())
334 - }
335 -
336 - fn token_to_id(&self, _token: &str) -> Option<u32> {
337 - None
338 - }
286 + fn stub_tokenizer() -> TestTokenizer {
287 + TestTokenizer::new()
288 }
289
290 fn sample_request() -> TextRequest {
@@ -952,9 +901,7 @@ mod tests {
901
902 #[test]
903 fn lower_sampling_params_rejects_out_of_vocab_bad_words() {
955 - let tokenizer = FixedTokenizer {
956 - token_ids: vec![1999, 2000],
957 - };
904 + let tokenizer = TestTokenizer::new().with_regular_token("blocked", 2000);
905 let error = lower_sampling_params(
906 SamplingParams {
907 bad_words: Some(vec!["blocked".to_string()]),
rust/src/text/src/output/decoded.rs
+3 -29
@@ -323,37 +323,11 @@ mod tests {
323 use futures::{Stream, stream};
324 use vllm_engine_core_client::AbortCause;
325 use vllm_llm::GenerateOutput;
326 - use vllm_tokenizer::Tokenizer;
326 + use vllm_tokenizer::test_utils::TestTokenizer;
327
328 use super::*;
329 use crate::output::TextOutputStreamExt as _;
330
331 - /// Backend that treats each token ID as a raw byte, producing lossy UTF-8.
332 - struct ByteTokenizer;
333 -
334 - impl Tokenizer for ByteTokenizer {
335 - fn encode(
336 - &self,
337 - _text: &str,
338 - _add_special_tokens: bool,
339 - ) -> vllm_tokenizer::Result<Vec<u32>> {
340 - unreachable!()
341 - }
342 -
343 - fn decode(
344 - &self,
345 - token_ids: &[u32],
346 - _skip_special_tokens: bool,
347 - ) -> vllm_tokenizer::Result<String> {
348 - let bytes = token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>();
349 - Ok(String::from_utf8_lossy(&bytes).into_owned())
350 - }
351 -
352 - fn token_to_id(&self, _token: &str) -> Option<u32> {
353 - unreachable!()
354 - }
355 - }
356 -
331 /// Helper: run `decoded_text_event_stream` to completion and return the
332 /// collected output.
333 async fn run_to_completion(
@@ -366,7 +340,7 @@ mod tests {
340 token_ids,
341 Some(FinishReason::Length),
342 ))]);
369 - let tokenizer: DynTokenizer = Arc::new(ByteTokenizer);
343 + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new());
344 decoded_text_event_stream("test".into(), tokenizer, raw_stream, decode_options, false)
345 .collect_output()
346 .await
@@ -419,7 +393,7 @@ mod tests {
393 ))),
394 dropped_cause: Arc::clone(&dropped_cause),
395 };
422 - let tokenizer: DynTokenizer = Arc::new(ByteTokenizer);
396 + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new());
397
398 let output = decoded_text_event_stream(
399 "test".into(),
rust/src/text/src/output/logprobs.rs
+3 -30
@@ -129,40 +129,13 @@ fn decode_position_logprobs<T: Tokenizer + ?Sized>(
129 #[cfg(test)]
130 mod tests {
131 use vllm_llm::{Logprobs, PositionLogprobs, TokenLogprob};
132 + use vllm_tokenizer::test_utils::TestTokenizer;
133
134 use super::*;
135
135 - #[derive(Debug)]
136 - struct ByteTokenizer;
137 -
138 - impl vllm_tokenizer::Tokenizer for ByteTokenizer {
139 - fn encode(
140 - &self,
141 - _text: &str,
142 - _add_special_tokens: bool,
143 - ) -> vllm_tokenizer::Result<Vec<u32>> {
144 - unreachable!()
145 - }
146 -
147 - fn decode(
148 - &self,
149 - token_ids: &[u32],
150 - _skip_special_tokens: bool,
151 - ) -> vllm_tokenizer::Result<String> {
152 - Ok(String::from_utf8_lossy(
153 - &token_ids.iter().map(|token_id| *token_id as u8).collect::<Vec<_>>(),
154 - )
155 - .into_owned())
156 - }
157 -
158 - fn token_to_id(&self, _token: &str) -> Option<u32> {
159 - unreachable!()
160 - }
161 - }
162 -
136 #[test]
137 fn decode_logprobs_decodes_every_candidate_token() {
165 - let tokenizer = ByteTokenizer;
138 + let tokenizer = TestTokenizer::new();
139 let logprobs = Logprobs {
140 positions: vec![PositionLogprobs {
141 entries: vec![
@@ -205,7 +178,7 @@ mod tests {
178
179 #[test]
180 fn decode_prompt_logprobs_separates_first_prompt_token() {
208 - let tokenizer = ByteTokenizer;
181 + let tokenizer = TestTokenizer::new();
182 let logprobs = Logprobs {
183 positions: vec![PositionLogprobs {
184 entries: vec![TokenLogprob {
rust/src/tokenizer/Cargo.toml
+3
@@ -4,6 +4,9 @@ version.workspace = true
4 edition.workspace = true
5 license.workspace = true
6
7 +[features]
8 +test-utils = []
9 +
10 [dependencies]
11 base64.workspace = true
12 fastokens.workspace = true
rust/src/tokenizer/src/incremental.rs
+12
@@ -170,6 +170,10 @@ mod tests {
170 fn token_to_id(&self, _token: &str) -> Option<u32> {
171 unreachable!()
172 }
173 +
174 + fn id_to_token(&self, _id: u32) -> Option<String> {
175 + unreachable!()
176 + }
177 }
178
179 #[test]
@@ -248,6 +252,10 @@ mod tests {
252 fn token_to_id(&self, _token: &str) -> Option<u32> {
253 unreachable!()
254 }
255 +
256 + fn id_to_token(&self, _id: u32) -> Option<String> {
257 + unreachable!()
258 + }
259 }
260
261 #[test]
@@ -320,6 +328,10 @@ mod tests {
328 fn token_to_id(&self, _token: &str) -> Option<u32> {
329 unreachable!()
330 }
331 +
332 + fn id_to_token(&self, _id: u32) -> Option<String> {
333 + unreachable!()
334 + }
335 }
336
337 /// Without the char-boundary fix, this panics slicing mid-emoji.
rust/src/tokenizer/src/lib.rs
+3 -5
@@ -8,6 +8,8 @@ mod error;
8 mod hf;
9 mod incremental;
10 mod tekken;
11 +#[cfg(any(test, feature = "test-utils"))]
12 +pub mod test_utils;
13 mod tiktoken;
14
15 pub use error::{Result, TokenizerError};
@@ -28,11 +30,7 @@ pub trait Tokenizer: Send + Sync {
30 fn token_to_id(&self, token: &str) -> Option<u32>;
31
32 /// Convert one token ID into the tokenizer's raw token string.
31 - fn id_to_token(&self, _id: u32) -> Option<String> {
32 - // TODO: remove default impl and require this to be implemented by all
33 - // tokenizers
34 - None
35 - }
33 + fn id_to_token(&self, id: u32) -> Option<String>;
34
35 /// Return the vocabulary size. Backends that cannot report it fall back to
36 /// `usize::MAX`, an effectively unbounded value used only by test stubs.
rust/src/tokenizer/src/test_utils.rs new
+434
@@ -0,0 +1,434 @@
1 +use std::collections::BTreeMap;
2 +
3 +use crate::{Result, Tokenizer, TokenizerError};
4 +
5 +const FIRST_CONFIGURED_TOKEN_ID: u32 = 256;
6 +
7 +/// Whether a configured test token should be treated as special.
8 +///
9 +/// Special tokens are skipped by [`Tokenizer::decode`] when
10 +/// `skip_special_tokens` is set. Regular configured tokens are always emitted.
11 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 +pub enum TestTokenKind {
13 + /// Token is skipped when `skip_special_tokens = true`.
14 + Special,
15 + /// Token is emitted regardless of `skip_special_tokens`.
16 + Regular,
17 +}
18 +
19 +impl TestTokenKind {
20 + fn is_special(self) -> bool {
21 + matches!(self, Self::Special)
22 + }
23 +}
24 +
25 +/// Decode behavior for token ids that are neither configured tokens nor byte ids.
26 +///
27 +/// The default is [`UnknownDecode::Error`] so tests notice missing tokenizer
28 +/// fixtures instead of silently accepting impossible ids. Individual tests can
29 +/// opt into empty or replacement output when they are explicitly modeling a
30 +/// lenient detokenization path.
31 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 +pub enum UnknownDecode {
33 + /// Return a tokenizer error on the first unknown id.
34 + Error,
35 + /// Drop unknown ids from decoded output.
36 + Empty,
37 + /// Emit U+FFFD for each unknown id.
38 + Replacement,
39 +}
40 +
41 +#[derive(Debug, Clone)]
42 +struct TestToken {
43 + text: String,
44 + kind: TestTokenKind,
45 +}
46 +
47 +/// Configurable tokenizer for Rust frontend tests.
48 +///
49 +/// `TestTokenizer` is intentionally small, but its methods obey the same basic
50 +/// contract as production tokenizers:
51 +///
52 +/// - ordinary text encodes as UTF-8 byte ids;
53 +/// - configured token ids start at 256, leaving `0..=255` for byte fallback;
54 +/// - configured token ids and token text are unique;
55 +/// - configured tokens are matched before ordinary bytes, using longest-prefix matching so
56 +/// multi-character markers such as `<think>` work naturally;
57 +/// - `token_to_id` and `id_to_token` are consistent for configured tokens;
58 +/// - `decode` is strict by default for ids outside the byte range and the configured token table;
59 +/// - `vocab_size` is an exclusive upper bound covering byte ids and configured token ids unless a
60 +/// test sets it explicitly.
61 +///
62 +/// Prefer this helper over ad-hoc fake tokenizers for tests that rely on
63 +/// tokenizer semantics. Keep dedicated tiny fakes for error injection or for
64 +/// tests that deliberately need a degenerate tokenizer.
65 +#[derive(Debug, Clone)]
66 +pub struct TestTokenizer {
67 + token_to_id: BTreeMap<String, u32>,
68 + id_to_token: BTreeMap<u32, TestToken>,
69 + unknown_decode: UnknownDecode,
70 + vocab_size: Option<usize>,
71 + bos_token_id: Option<u32>,
72 +}
73 +
74 +impl Default for TestTokenizer {
75 + fn default() -> Self {
76 + Self::new()
77 + }
78 +}
79 +
80 +impl TestTokenizer {
81 + /// Create a byte-level test tokenizer with strict unknown-id decode.
82 + pub fn new() -> Self {
83 + Self {
84 + token_to_id: BTreeMap::new(),
85 + id_to_token: BTreeMap::new(),
86 + unknown_decode: UnknownDecode::Error,
87 + vocab_size: None,
88 + bos_token_id: None,
89 + }
90 + }
91 +
92 + /// Add a configured token and return the updated tokenizer.
93 + ///
94 + /// Configured tokens must use ids outside the byte range and may be marked
95 + /// special or regular.
96 + pub fn with_token(mut self, token: impl Into<String>, id: u32, kind: TestTokenKind) -> Self {
97 + self.insert_token(token, id, kind);
98 + self
99 + }
100 +
101 + /// Add a special configured token and return the updated tokenizer.
102 + pub fn with_special_token(self, token: impl Into<String>, id: u32) -> Self {
103 + self.with_token(token, id, TestTokenKind::Special)
104 + }
105 +
106 + /// Add a regular configured token and return the updated tokenizer.
107 + pub fn with_regular_token(self, token: impl Into<String>, id: u32) -> Self {
108 + self.with_token(token, id, TestTokenKind::Regular)
109 + }
110 +
111 + /// Add a special BOS token inserted by `encode(..., true)`.
112 + ///
113 + /// This also registers the token in the normal token/id maps so
114 + /// `token_to_id`, `id_to_token`, `decode`, and `is_special_id` stay
115 + /// consistent for the inserted id.
116 + pub fn with_bos_token(mut self, token: impl Into<String>, id: u32) -> Self {
117 + self.insert_token(token, id, TestTokenKind::Special);
118 + self.bos_token_id = Some(id);
119 + self
120 + }
121 +
122 + /// Set decode behavior for unknown non-byte ids.
123 + pub fn with_unknown_decode(mut self, behavior: UnknownDecode) -> Self {
124 + self.unknown_decode = behavior;
125 + self
126 + }
127 +
128 + /// Set an explicit vocabulary size.
129 + ///
130 + /// Use this when a test needs a model-like vocabulary bound that differs
131 + /// from the highest configured token id plus one.
132 + pub fn with_vocab_size(mut self, vocab_size: usize) -> Self {
133 + self.vocab_size = Some(vocab_size);
134 + self
135 + }
136 +
137 + fn insert_token(&mut self, token: impl Into<String>, id: u32, kind: TestTokenKind) {
138 + let token = token.into();
139 + assert!(
140 + !token.is_empty(),
141 + "configured test token text must be non-empty"
142 + );
143 + assert!(
144 + id >= FIRST_CONFIGURED_TOKEN_ID,
145 + "configured test token id {id} overlaps byte fallback range 0..=255"
146 + );
147 + assert!(
148 + token.len() > 1,
149 + "configured test token text {token:?} overlaps byte fallback token text"
150 + );
151 + if self.token_to_id.insert(token.clone(), id).is_some() {
152 + panic!("configured test token text {token:?} was registered more than once");
153 + }
154 + if self.id_to_token.insert(id, TestToken { text: token, kind }).is_some() {
155 + panic!("configured test token id {id} was registered more than once");
156 + }
157 + }
158 +
159 + fn byte_to_token(id: u32) -> Option<String> {
160 + u8::try_from(id).ok().map(|byte| String::from_utf8_lossy(&[byte]).into_owned())
161 + }
162 +
163 + fn flush_bytes(bytes: &mut Vec<u8>, output: &mut String) {
164 + if !bytes.is_empty() {
165 + output.push_str(&String::from_utf8_lossy(bytes));
166 + bytes.clear();
167 + }
168 + }
169 +
170 + fn configured_token_prefix(&self, text: &str) -> Option<(&str, u32)> {
171 + self.token_to_id
172 + .iter()
173 + .filter_map(|(token, &id)| text.starts_with(token).then_some((token.as_str(), id)))
174 + .max_by_key(|(token, _)| token.len())
175 + }
176 +
177 + fn inferred_vocab_size(&self) -> usize {
178 + let max_configured =
179 + self.id_to_token.last_key_value().map(|(&id, _)| id as usize + 1).unwrap_or(0);
180 + 256.max(max_configured)
181 + }
182 +}
183 +
184 +impl Tokenizer for TestTokenizer {
185 + fn encode(&self, text: &str, add_special_tokens: bool) -> Result<Vec<u32>> {
186 + let mut ids = Vec::new();
187 + if add_special_tokens && let Some(bos_token_id) = self.bos_token_id {
188 + ids.push(bos_token_id);
189 + }
190 +
191 + let mut rest = text;
192 + while !rest.is_empty() {
193 + if let Some((token, id)) = self.configured_token_prefix(rest) {
194 + ids.push(id);
195 + rest = &rest[token.len()..];
196 + continue;
197 + }
198 +
199 + let ch = rest.chars().next().expect("rest is not empty");
200 + let mut buf = [0_u8; 4];
201 + ids.extend(ch.encode_utf8(&mut buf).bytes().map(u32::from));
202 + rest = &rest[ch.len_utf8()..];
203 + }
204 +
205 + Ok(ids)
206 + }
207 +
208 + fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
209 + let mut output = String::new();
210 + let mut pending_bytes = Vec::new();
211 + for &id in token_ids {
212 + if let Some(token) = self.id_to_token.get(&id) {
213 + Self::flush_bytes(&mut pending_bytes, &mut output);
214 + if !(skip_special_tokens && token.kind.is_special()) {
215 + output.push_str(&token.text);
216 + }
217 + } else if let Ok(byte) = u8::try_from(id) {
218 + pending_bytes.push(byte);
219 + } else {
220 + Self::flush_bytes(&mut pending_bytes, &mut output);
221 + match self.unknown_decode {
222 + UnknownDecode::Error => {
223 + return Err(TokenizerError(format!(
224 + "test tokenizer cannot decode unknown token id {id}"
225 + )));
226 + }
227 + UnknownDecode::Empty => {}
228 + UnknownDecode::Replacement => output.push('\u{FFFD}'),
229 + }
230 + }
231 + }
232 + Self::flush_bytes(&mut pending_bytes, &mut output);
233 + Ok(output)
234 + }
235 +
236 + fn token_to_id(&self, token: &str) -> Option<u32> {
237 + self.token_to_id.get(token).copied().or_else(|| {
238 + let bytes = token.as_bytes();
239 + (bytes.len() == 1).then(|| u32::from(bytes[0]))
240 + })
241 + }
242 +
243 + fn id_to_token(&self, id: u32) -> Option<String> {
244 + self.id_to_token
245 + .get(&id)
246 + .map(|token| token.text.clone())
247 + .or_else(|| Self::byte_to_token(id))
248 + }
249 +
250 + fn vocab_size(&self) -> usize {
251 + self.vocab_size.unwrap_or_else(|| self.inferred_vocab_size())
252 + }
253 +
254 + fn is_special_id(&self, token_id: u32) -> bool {
255 + self.id_to_token.get(&token_id).is_some_and(|token| token.kind.is_special())
256 + }
257 +}
258 +
259 +#[cfg(test)]
260 +mod tests {
261 + use super::*;
262 +
263 + #[test]
264 + fn byte_text_roundtrips_and_reports_byte_ids() {
265 + let tokenizer = TestTokenizer::new();
266 +
267 + let ids = tokenizer.encode("hi", false).unwrap();
268 + assert_eq!(ids, vec![b'h' as u32, b'i' as u32]);
269 + assert_eq!(tokenizer.decode(&ids, false).unwrap(), "hi");
270 + assert_eq!(tokenizer.token_to_id("h"), Some(b'h' as u32));
271 + assert_eq!(tokenizer.id_to_token(b'h' as u32).as_deref(), Some("h"));
272 + assert_eq!(tokenizer.vocab_size(), 256);
273 + }
274 +
275 + #[test]
276 + fn configured_tokens_use_longest_prefix_matching() {
277 + let tokenizer = TestTokenizer::new()
278 + .with_regular_token("<image>", 999)
279 + .with_regular_token("<image></image>", 1000);
280 +
281 + assert_eq!(
282 + tokenizer.encode("a<image></image>b", false).unwrap(),
283 + vec![b'a' as u32, 1000, b'b' as u32,]
284 + );
285 + assert_eq!(
286 + tokenizer.decode(&[b'a' as u32, 1000, b'b' as u32], false).unwrap(),
287 + "a<image></image>b"
288 + );
289 + assert_eq!(tokenizer.token_to_id("<image>"), Some(999));
290 + assert_eq!(
291 + tokenizer.id_to_token(1000).as_deref(),
292 + Some("<image></image>")
293 + );
294 + assert_eq!(tokenizer.vocab_size(), 1001);
295 + }
296 +
297 + #[test]
298 + fn non_ascii_text_roundtrips_through_buffered_byte_decode() {
299 + let tokenizer = TestTokenizer::new();
300 + let text = "你好, café, 🚀";
301 +
302 + let ids = tokenizer.encode(text, false).unwrap();
303 + assert_eq!(
304 + ids,
305 + text.as_bytes().iter().copied().map(u32::from).collect::<Vec<_>>()
306 + );
307 + assert_eq!(tokenizer.decode(&ids, false).unwrap(), text);
308 + }
309 +
310 + #[test]
311 + fn buffered_byte_decode_flushes_around_configured_tokens() {
312 + let tokenizer = TestTokenizer::new()
313 + .with_regular_token("<image>", 999)
314 + .with_special_token("<skip>", 1000);
315 +
316 + assert_eq!(
317 + tokenizer.encode("你<image>好<skip>🚀", false).unwrap(),
318 + vec![228, 189, 160, 999, 229, 165, 189, 1000, 240, 159, 154, 128]
319 + );
320 + assert_eq!(
321 + tokenizer
322 + .decode(
323 + &[228, 189, 160, 999, 229, 165, 189, 1000, 240, 159, 154, 128],
324 + false
325 + )
326 + .unwrap(),
327 + "你<image>好<skip>🚀"
328 + );
329 + assert_eq!(
330 + tokenizer
331 + .decode(
332 + &[228, 189, 160, 999, 229, 165, 189, 1000, 240, 159, 154, 128],
333 + true
334 + )
335 + .unwrap(),
336 + "你<image>好🚀"
337 + );
338 + }
339 +
340 + #[test]
341 + fn invalid_utf8_bytes_decode_lossily_as_a_sequence() {
342 + let tokenizer = TestTokenizer::new();
343 +
344 + assert_eq!(tokenizer.decode(&[0xE4, 0xBD], false).unwrap(), "\u{FFFD}");
345 + assert_eq!(
346 + tokenizer.decode(&[0xFF, b'a' as u32], false).unwrap(),
347 + "\u{FFFD}a"
348 + );
349 + }
350 +
351 + #[test]
352 + fn special_tokens_respect_skip_special_tokens() {
353 + let tokenizer = TestTokenizer::new()
354 + .with_bos_token("<bos>", 256)
355 + .with_special_token("<think>", 0xF001)
356 + .with_regular_token("</think>", 0xF002);
357 +
358 + assert_eq!(
359 + tokenizer.encode("<think>x</think>", true).unwrap(),
360 + vec![256, 0xF001, b'x' as u32, 0xF002,]
361 + );
362 + assert_eq!(
363 + tokenizer.decode(&[256, 0xF001, b'x' as u32, 0xF002], false).unwrap(),
364 + "<bos><think>x</think>"
365 + );
366 + assert_eq!(
367 + tokenizer.decode(&[256, 0xF001, b'x' as u32, 0xF002], true).unwrap(),
368 + "x</think>"
369 + );
370 + assert!(tokenizer.is_special_id(0xF001));
371 + assert!(!tokenizer.is_special_id(0xF002));
372 + }
373 +
374 + #[test]
375 + #[should_panic(expected = "configured test token id 255 overlaps byte fallback range 0..=255")]
376 + fn configured_token_id_must_stay_outside_byte_range() {
377 + let _ = TestTokenizer::new().with_regular_token("<token>", 255);
378 + }
379 +
380 + #[test]
381 + #[should_panic(expected = "configured test token text \"a\" overlaps byte fallback token text")]
382 + fn configured_token_text_must_not_shadow_byte_tokens() {
383 + let _ = TestTokenizer::new().with_regular_token("a", 256);
384 + }
385 +
386 + #[test]
387 + #[should_panic(
388 + expected = "configured test token text \"<token>\" was registered more than once"
389 + )]
390 + fn configured_token_text_must_be_unique() {
391 + let _ = TestTokenizer::new()
392 + .with_regular_token("<token>", 256)
393 + .with_regular_token("<token>", 257);
394 + }
395 +
396 + #[test]
397 + #[should_panic(expected = "configured test token id 256 was registered more than once")]
398 + fn configured_token_id_must_be_unique() {
399 + let _ = TestTokenizer::new()
400 + .with_regular_token("<token-a>", 256)
401 + .with_regular_token("<token-b>", 256);
402 + }
403 +
404 + #[test]
405 + fn unknown_decode_is_strict_by_default_and_configurable() {
406 + let strict = TestTokenizer::new();
407 + assert!(strict.decode(&[300], false).is_err());
408 + assert_eq!(
409 + TestTokenizer::new()
410 + .with_unknown_decode(UnknownDecode::Empty)
411 + .decode(&[b'a' as u32, 300, b'b' as u32], false)
412 + .unwrap(),
413 + "ab"
414 + );
415 + assert_eq!(
416 + TestTokenizer::new()
417 + .with_unknown_decode(UnknownDecode::Replacement)
418 + .decode(&[300], false)
419 + .unwrap(),
420 + "\u{FFFD}"
421 + );
422 + assert_eq!(strict.id_to_token(300), None);
423 + }
424 +
425 + #[test]
426 + fn explicit_vocab_size_overrides_inferred_bound() {
427 + let tokenizer = TestTokenizer::new()
428 + .with_regular_token("<high>", 10_000)
429 + .with_vocab_size(20_000);
430 +
431 + assert_eq!(tokenizer.vocab_size(), 20_000);
432 + assert_eq!(tokenizer.id_to_token(10_000).as_deref(), Some("<high>"));
433 + }
434 +}