[Rust Frontend] Add ordinary-text tokenizer encoding (#49992)

Co-authored-by: OpenAI Codex <noreply@openai.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jul 28, 2026 at 15:46 UTC 5ed3faa43ddf075f24482396b634edc33e047a40
10 files changed +448 -2
rust/src/chat/src/renderer/inkling/tests.rs
+4
@@ -31,6 +31,10 @@ impl Tokenizer for FixtureTokenizer {
31 Ok(text.bytes().map(u32::from).collect())
32 }
33
34 + fn encode_ordinary(&self, text: &str) -> vllm_tokenizer::Result<Vec<u32>> {
35 + self.encode(text, false)
36 + }
37 +
38 fn decode(
39 &self,
40 token_ids: &[u32],
rust/src/parser/benches/utils/adapter.rs
+4
@@ -19,6 +19,10 @@ impl Tokenizer for BenchTokenizer {
19 Ok(text.chars().map(|_| u32::MAX).collect())
20 }
21
22 + fn encode_ordinary(&self, text: &str) -> vllm_tokenizer::Result<Vec<u32>> {
23 + self.encode(text, false)
24 + }
25 +
26 fn decode(
27 &self,
28 token_ids: &[u32],
rust/src/parser/src/unified/inkling.rs
+8
@@ -414,6 +414,10 @@ mod tests {
414 Ok(text.chars().map(u32::from).collect())
415 }
416
417 + fn encode_ordinary(&self, text: &str) -> vllm_tokenizer::Result<Vec<u32>> {
418 + self.encode(text, false)
419 + }
420 +
421 fn decode(
422 &self,
423 token_ids: &[u32],
@@ -733,6 +737,10 @@ mod tests {
737 Ok(vec![])
738 }
739
740 + fn encode_ordinary(&self, text: &str) -> vllm_tokenizer::Result<Vec<u32>> {
741 + self.encode(text, false)
742 + }
743 +
744 fn decode(
745 &self,
746 _token_ids: &[u32],
rust/src/text/src/backend/hf/mod.rs
+4
@@ -177,6 +177,10 @@ mod tests {
177 Ok(vec![])
178 }
179
180 + fn encode_ordinary(&self, text: &str) -> vllm_tokenizer::Result<Vec<u32>> {
181 + self.encode(text, false)
182 + }
183 +
184 fn decode(
185 &self,
186 _token_ids: &[u32],
rust/src/tokenizer/src/hf.rs
+288 -2
@@ -1,13 +1,20 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +use std::borrow::Cow;
5 use std::path::Path;
5 -use std::sync::Arc;
6 +use std::sync::{Arc, LazyLock};
7
8 use fastokens::Tokenizer as FastokensTokenizer;
9 use fastokens::decoders::Decoder as FastokensDecoder;
10 +use fastokens::pre_tokenized::{
11 + PreTokenizedString as FastokensPreTokenizedString, Split as FastokensSplit,
12 +};
13 +use fastokens::{PreTokenizer as FastokensPreTokenizer, Split as FastokensSplitPreTokenizer};
14 use thiserror_ext::AsReport as _;
10 -use tokenizers::Tokenizer as HfTokenizer;
15 +use tokenizers::{
16 + AddedVocabulary, Model as _, OffsetType, PreTokenizer as _, Tokenizer as HfTokenizer,
17 +};
18 use tracing::{info, warn};
19
20 use crate::byte_level_decode::decode_byte_level;
@@ -16,6 +23,8 @@ use crate::{Result, Tokenizer};
23
24 mod added_tokens;
25
26 +static EMPTY_HF_ADDED_VOCABULARY: LazyLock<AddedVocabulary> = LazyLock::new(AddedVocabulary::new);
27 +
28 enum Backend {
29 Hf(Box<HfTokenizer>),
30 Fastokens(Box<FastokensTokenizer>),
@@ -53,6 +62,85 @@ fn decode_fastokens_byte_level(
62 Ok(decode_byte_level(tokens))
63 }
64
65 +fn encode_hf_ordinary(tokenizer: &HfTokenizer, text: &str) -> tokenizers::Result<Vec<u32>> {
66 + let mut pretokenized =
67 + EMPTY_HF_ADDED_VOCABULARY.extract_and_normalize(tokenizer.get_normalizer(), text);
68 +
69 + if let Some(pre_tokenizer) = tokenizer.get_pre_tokenizer() {
70 + pre_tokenizer.pre_tokenize(&mut pretokenized)?;
71 + }
72 + pretokenized.tokenize(|normalized| tokenizer.get_model().tokenize(normalized.get()))?;
73 + let encoding = pretokenized.into_encoding(None, 0, OffsetType::Byte)?;
74 + let encoding = tokenizer.post_process(encoding, None, false)?;
75 + Ok(encoding.get_ids().to_vec())
76 +}
77 +
78 +fn fastokens_fused_split(tokenizer: &FastokensTokenizer) -> Option<&FastokensSplitPreTokenizer> {
79 + // Keep this predicate aligned with fastokens::Tokenizer::detect_fused_byte_level.
80 + let FastokensPreTokenizer::Sequence(steps) = tokenizer.pre_tokenizer()? else {
81 + return None;
82 + };
83 + let [
84 + FastokensPreTokenizer::Split(split),
85 + FastokensPreTokenizer::ByteLevel(byte_level),
86 + ] = steps.as_slice()
87 + else {
88 + return None;
89 + };
90 + byte_level.is_bulk_only().then_some(split)
91 +}
92 +
93 +fn fastokens_pre_tokenized_ordinary(
94 + tokenizer: &FastokensTokenizer,
95 + text: &str,
96 +) -> FastokensPreTokenizedString {
97 + // This is fastokens::Tokenizer::build_pre_tokenized with added_tokens = None.
98 + let normalized = tokenizer
99 + .normalizer()
100 + .map_or(Cow::Borrowed(text), |normalizer| normalizer.normalize(text));
101 + match normalized {
102 + Cow::Borrowed(_) => FastokensPreTokenizedString::from_text(text),
103 + Cow::Owned(text) => {
104 + let len = text.len();
105 + FastokensPreTokenizedString::new(
106 + text,
107 + vec![FastokensSplit {
108 + range: 0..len,
109 + token_id: None,
110 + }],
111 + )
112 + }
113 + }
114 +}
115 +
116 +fn encode_fastokens_ordinary(
117 + tokenizer: &FastokensTokenizer,
118 + text: &str,
119 +) -> std::result::Result<Vec<u32>, fastokens::Error> {
120 + if text.is_empty() {
121 + return Ok(Vec::new());
122 + }
123 +
124 + let mut pretokenized = fastokens_pre_tokenized_ordinary(tokenizer, text);
125 + let ids = if let Some(split) = fastokens_fused_split(tokenizer) {
126 + split.pre_tokenize(&mut pretokenized)?;
127 + pretokenized
128 + .tokenize_batched(|buffer, splits, output| {
129 + tokenizer.model().tokenize_batch_fused(buffer, splits, output)
130 + })
131 + .map_err(fastokens::Error::Model)?
132 + } else {
133 + if let Some(pre_tokenizer) = tokenizer.pre_tokenizer() {
134 + pre_tokenizer.pre_tokenize(&mut pretokenized)?;
135 + }
136 + pretokenized
137 + .tokenize(|text, output| tokenizer.model().tokenize_into(text, output))
138 + .map_err(fastokens::Error::Model)?
139 + };
140 +
141 + Ok(tokenizer.post_process(ids, false))
142 +}
143 +
144 /// Tokenizer from `tokenizer.json` in HuggingFace format.
145 ///
146 /// This tries to load with `fastokens` first for better performance, then falls
@@ -156,6 +244,17 @@ impl Tokenizer for HuggingFaceTokenizer {
244 }
245 }
246
247 + fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>> {
248 + match &self.backend {
249 + Backend::Hf(tokenizer) => encode_hf_ordinary(tokenizer, text)
250 + .map_err(|error| tokenizer_error!("encoding failed: {}", error.as_report())),
251 + Backend::Fastokens(tokenizer) | Backend::FastokensByteLevel(tokenizer) => {
252 + encode_fastokens_ordinary(tokenizer, text)
253 + .map_err(|error| tokenizer_error!("encoding failed: {}", error.as_report()))
254 + }
255 + }
256 + }
257 +
258 fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
259 match &self.backend {
260 Backend::Hf(t) => t
@@ -200,12 +299,19 @@ impl Tokenizer for HuggingFaceTokenizer {
299
300 #[cfg(test)]
301 mod tests {
302 + use std::path::{Path, PathBuf};
303 +
304 + use serde_json::{Value, json};
305 use tempfile::tempdir;
306 use tokenizers::models::bpe::BPE;
307 + use tokenizers::pre_tokenizers::byte_level::ByteLevel;
308 use tokenizers::{AddedToken, Tokenizer as HfTokenizer};
309
310 use super::{HuggingFaceTokenizer, Tokenizer};
311
312 + const REGULAR_TOKEN: &str = "<|regular|>";
313 + const SPECIAL_TOKEN: &str = "<|special|>";
314 +
315 fn tiny_bpe_tokenizer() -> HfTokenizer {
316 let vocab = [
317 ("<unk>".to_string(), 0),
@@ -232,6 +338,186 @@ mod tests {
338 HfTokenizer::new(model)
339 }
340
341 + fn ordinary_test_tokenizer_json(fused: bool, with_added_tokens: bool) -> Value {
342 + let mut alphabet: Vec<char> = ByteLevel::alphabet().into_iter().collect();
343 + alphabet.sort_unstable();
344 + let vocab = alphabet
345 + .into_iter()
346 + .enumerate()
347 + .map(|(id, token)| (token.to_string(), json!(id)))
348 + .collect::<serde_json::Map<_, _>>();
349 +
350 + let pre_tokenizer = if fused {
351 + json!({
352 + "type": "Sequence",
353 + "pretokenizers": [
354 + {
355 + "type": "Split",
356 + "pattern": {"Regex": "\\S+|\\s+"},
357 + "behavior": "Isolated",
358 + "invert": false
359 + },
360 + {
361 + "type": "ByteLevel",
362 + "add_prefix_space": false,
363 + "trim_offsets": true,
364 + "use_regex": false
365 + }
366 + ]
367 + })
368 + } else {
369 + json!({
370 + "type": "ByteLevel",
371 + "add_prefix_space": false,
372 + "trim_offsets": true,
373 + "use_regex": true
374 + })
375 + };
376 + let added_tokens = with_added_tokens.then(|| {
377 + json!([
378 + {
379 + "id": 256,
380 + "content": REGULAR_TOKEN,
381 + "single_word": false,
382 + "lstrip": false,
383 + "rstrip": false,
384 + "normalized": true,
385 + "special": false
386 + },
387 + {
388 + "id": 257,
389 + "content": SPECIAL_TOKEN,
390 + "single_word": false,
391 + "lstrip": false,
392 + "rstrip": false,
393 + "normalized": false,
394 + "special": true
395 + }
396 + ])
397 + });
398 +
399 + json!({
400 + "version": "1.0",
401 + "truncation": {
402 + "direction": "Right",
403 + "max_length": 24,
404 + "strategy": "LongestFirst",
405 + "stride": 0
406 + },
407 + "padding": null,
408 + "added_tokens": added_tokens.unwrap_or_else(|| json!([])),
409 + "normalizer": {"type": "NFC"},
410 + "pre_tokenizer": pre_tokenizer,
411 + "post_processor": {
412 + "type": "ByteLevel",
413 + "add_prefix_space": false,
414 + "trim_offsets": true,
415 + "use_regex": true
416 + },
417 + "decoder": {
418 + "type": "ByteLevel",
419 + "add_prefix_space": false,
420 + "trim_offsets": true,
421 + "use_regex": true
422 + },
423 + "model": {
424 + "type": "BPE",
425 + "dropout": null,
426 + "unk_token": null,
427 + "continuing_subword_prefix": null,
428 + "end_of_word_suffix": null,
429 + "fuse_unk": false,
430 + "byte_fallback": false,
431 + "ignore_merges": false,
432 + "vocab": vocab,
433 + "merges": []
434 + }
435 + })
436 + }
437 +
438 + fn write_tokenizer_json(dir: &Path, name: &str, value: &Value) -> PathBuf {
439 + let path = dir.join(name);
440 + std::fs::write(
441 + &path,
442 + serde_json::to_vec(value).expect("serialize tokenizer"),
443 + )
444 + .expect("write tokenizer");
445 + path
446 + }
447 +
448 + fn assert_ordinary_matches_added_empty(
449 + constructor: fn(&Path) -> crate::Result<HuggingFaceTokenizer>,
450 + fused: bool,
451 + ) {
452 + let dir = tempdir().expect("create temp dir");
453 + let added_path = write_tokenizer_json(
454 + dir.path(),
455 + "with-added.json",
456 + &ordinary_test_tokenizer_json(fused, true),
457 + );
458 + let empty_path = write_tokenizer_json(
459 + dir.path(),
460 + "added-empty.json",
461 + &ordinary_test_tokenizer_json(fused, false),
462 + );
463 + let tokenizer = constructor(&added_path).expect("load tokenizer with added tokens");
464 + let added_empty = constructor(&empty_path).expect("load tokenizer with empty added tokens");
465 +
466 + if let super::Backend::Fastokens(inner) | super::Backend::FastokensByteLevel(inner) =
467 + &tokenizer.backend
468 + {
469 + assert_eq!(super::fastokens_fused_split(inner).is_some(), fused);
470 + }
471 +
472 + assert_eq!(
473 + tokenizer.encode(REGULAR_TOKEN, false).unwrap(),
474 + vec![tokenizer.token_to_id(REGULAR_TOKEN).unwrap()]
475 + );
476 + assert_eq!(
477 + tokenizer.encode(SPECIAL_TOKEN, false).unwrap(),
478 + vec![tokenizer.token_to_id(SPECIAL_TOKEN).unwrap()]
479 + );
480 +
481 + for text in [
482 + "",
483 + "hello",
484 + "Cafe\u{301}",
485 + REGULAR_TOKEN,
486 + SPECIAL_TOKEN,
487 + "hello <|regular|> Cafe\u{301} <|special|> tail",
488 + ] {
489 + assert_eq!(
490 + tokenizer.encode_ordinary(text).unwrap(),
491 + added_empty.encode(text, false).unwrap(),
492 + "fused={fused}, text={text:?}",
493 + );
494 + }
495 + if matches!(&tokenizer.backend, super::Backend::Hf(_)) {
496 + assert_eq!(
497 + tokenizer
498 + .encode_ordinary("hello <|regular|> Cafe\u{301} <|special|> tail")
499 + .unwrap()
500 + .len(),
501 + 24,
502 + "HF post-processing must retain configured truncation",
503 + );
504 + }
505 + }
506 +
507 + #[test]
508 + fn hf_ordinary_matches_original_encode_with_added_empty() {
509 + for fused in [false, true] {
510 + assert_ordinary_matches_added_empty(HuggingFaceTokenizer::new_hf, fused);
511 + }
512 + }
513 +
514 + #[test]
515 + fn fastokens_ordinary_matches_original_encode_with_added_empty() {
516 + for fused in [false, true] {
517 + assert_ordinary_matches_added_empty(HuggingFaceTokenizer::new_fastokens, fused);
518 + }
519 + }
520 +
521 #[test]
522 fn hf_constructor_resolves_added_token_ids() {
523 let mut tokenizer = tiny_bpe_tokenizer();
rust/src/tokenizer/src/incremental.rs
+12
@@ -199,6 +199,10 @@ mod tests {
199 unreachable!()
200 }
201
202 + fn encode_ordinary(&self, _text: &str) -> Result<Vec<u32>> {
203 + unreachable!()
204 + }
205 +
206 fn decode(&self, token_ids: &[u32], _skip_special_tokens: bool) -> Result<String> {
207 let bytes = token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>();
208 Ok(String::from_utf8_lossy(&bytes).into_owned())
@@ -273,6 +277,10 @@ mod tests {
277 unreachable!()
278 }
279
280 + fn encode_ordinary(&self, _text: &str) -> Result<Vec<u32>> {
281 + unreachable!()
282 + }
283 +
284 fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
285 let mut text = String::new();
286 for &token_id in token_ids {
@@ -410,6 +418,10 @@ mod tests {
418 unreachable!()
419 }
420
421 + fn encode_ordinary(&self, _text: &str) -> Result<Vec<u32>> {
422 + unreachable!()
423 + }
424 +
425 fn decode(&self, token_ids: &[u32], _skip_special_tokens: bool) -> Result<String> {
426 match token_ids {
427 [1] => Ok("abc".into()),
rust/src/tokenizer/src/lib.rs
+4
@@ -25,6 +25,10 @@ pub trait Tokenizer: Send + Sync {
25 /// Encode one prompt string into token IDs.
26 fn encode(&self, text: &str, add_special_tokens: bool) -> Result<Vec<u32>>;
27
28 + /// Equivalent to `encode(text, false)`, except that every added,
29 + /// special, and control-token matcher is bypassed.
30 + fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>>;
31 +
32 /// Decode one token sequence into text.
33 fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String>;
34
rust/src/tokenizer/src/tekken.rs
+54
@@ -35,6 +35,12 @@ impl Tokenizer for TekkenTokenizer {
35 .map_err(|error| tokenizer_error!("encoding failed: {error}"))
36 }
37
38 + fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>> {
39 + self.inner
40 + .encode(text, false, false)
41 + .map_err(|error| tokenizer_error!("encoding failed: {error}"))
42 + }
43 +
44 fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
45 let policy = if skip_special_tokens {
46 tekken::SpecialTokenPolicy::Ignore
@@ -67,3 +73,51 @@ impl Tokenizer for TekkenTokenizer {
73 self.inner.is_special_token(token_id)
74 }
75 }
76 +
77 +#[cfg(test)]
78 +mod tests {
79 + use base64::Engine as _;
80 + use tekken::config::TokenizerVersion;
81 + use tekken::{SpecialTokenInfo, TokenInfo};
82 +
83 + use super::*;
84 +
85 + fn test_tokenizer() -> TekkenTokenizer {
86 + let vocab = (0_u8..=255)
87 + .map(|byte| TokenInfo {
88 + rank: byte as usize,
89 + token_bytes: base64::engine::general_purpose::STANDARD.encode([byte]),
90 + token_str: None,
91 + })
92 + .collect();
93 + let special_tokens = vec![SpecialTokenInfo {
94 + rank: 0,
95 + token_str: "<control>".to_string(),
96 + is_control: true,
97 + }];
98 + let inner = Tekkenizer::new(
99 + vocab,
100 + &special_tokens,
101 + r"(?s).",
102 + 257,
103 + 1,
104 + TokenizerVersion::V3,
105 + None,
106 + )
107 + .expect("build Tekken tokenizer");
108 + TekkenTokenizer { inner }
109 + }
110 +
111 + #[test]
112 + fn ordinary_matches_tekkens_empty_special_encoding() {
113 + let tokenizer = test_tokenizer();
114 + let text = "user <control> text";
115 + let control_id = tokenizer.token_to_id("<control>").unwrap();
116 + let ordinary_ids = tokenizer.encode_ordinary(text).unwrap();
117 +
118 + assert_eq!(control_id, 0);
119 + assert_eq!(ordinary_ids, tokenizer.encode(text, false).unwrap());
120 + assert!(!ordinary_ids.contains(&control_id));
121 + assert_eq!(tokenizer.decode(&ordinary_ids, false).unwrap(), text);
122 + }
123 +}
rust/src/tokenizer/src/test_utils.rs
+30
@@ -208,6 +208,10 @@ impl Tokenizer for TestTokenizer {
208 Ok(ids)
209 }
210
211 + fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>> {
212 + Ok(text.as_bytes().iter().copied().map(u32::from).collect())
213 + }
214 +
215 fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
216 let mut output = String::new();
217 let mut pending_bytes = Vec::new();
@@ -374,6 +378,32 @@ mod tests {
378 assert!(!tokenizer.is_special_id(0xF002));
379 }
380
381 + #[test]
382 + fn ordinary_encoding_bypasses_all_configured_tokens() {
383 + let tokenizer = TestTokenizer::new()
384 + .with_bos_token("<bos>", 256)
385 + .with_special_token("<control>", 257)
386 + .with_regular_token("<visible>", 258);
387 + let ordinary_text = "user <control> and <visible>";
388 +
389 + assert_eq!(tokenizer.encode("<control>", false).unwrap(), vec![257]);
390 + assert_eq!(tokenizer.encode("<visible>", false).unwrap(), vec![258]);
391 + assert_eq!(
392 + tokenizer.encode_ordinary(ordinary_text).unwrap(),
393 + ordinary_text.as_bytes().iter().copied().map(u32::from).collect::<Vec<_>>()
394 + );
395 +
396 + let mut segmented = tokenizer.encode("<control>", false).unwrap();
397 + segmented.extend(tokenizer.encode_ordinary(ordinary_text).unwrap());
398 + segmented.extend(tokenizer.encode("<visible>", false).unwrap());
399 + assert_eq!(segmented.first(), Some(&257));
400 + assert_eq!(segmented.last(), Some(&258));
401 + assert_eq!(
402 + tokenizer.decode(&segmented, false).unwrap(),
403 + format!("<control>{ordinary_text}<visible>")
404 + );
405 + }
406 +
407 #[test]
408 #[should_panic(expected = "configured test token id 255 overlaps byte fallback range 0..=255")]
409 fn configured_token_id_must_stay_outside_byte_range() {
rust/src/tokenizer/src/tiktoken.rs
+40
@@ -462,6 +462,13 @@ impl Tokenizer for TiktokenTokenizer {
462 })
463 }
464
465 + fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>> {
466 + Ok(match &self.backend {
467 + Backend::Riptoken(backend) => backend.inner.encode_ordinary(text),
468 + Backend::TiktokenRs(backend) => backend.inner.encode_ordinary(text),
469 + })
470 + }
471 +
472 fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
473 // Filter passes:
474 //
@@ -752,6 +759,39 @@ mod tests {
759 }
760 }
761
762 + #[test]
763 + fn tiktoken_ordinary_bypasses_every_registered_added_token() {
764 + let dir = tempfile::tempdir().expect("create temp dir");
765 + let bpe_path = write_synthetic_bpe_file(dir.path());
766 + fs::write(
767 + dir.path().join("tokenizer_config.json"),
768 + r#"{
769 + "added_tokens_decoder": {
770 + "257": { "content": "<|im_end|>", "special": true },
771 + "258": { "content": "<|tool_call_begin|>", "special": false }
772 + }
773 + }"#,
774 + )
775 + .expect("write tokenizer_config.json");
776 + fs::write(dir.path().join("config.json"), r#"{"vocab_size": 260}"#)
777 + .expect("write config.json");
778 +
779 + let input = "<|im_end|><|tool_call_begin|><|reserved_token_259|>";
780 + let expected: Vec<u32> = input.as_bytes().iter().copied().map(u32::from).collect();
781 + for backend in explicit_backends(&bpe_path) {
782 + assert_eq!(backend.encode("<|im_end|>", false).unwrap(), vec![257]);
783 + assert_eq!(
784 + backend.encode("<|tool_call_begin|>", false).unwrap(),
785 + vec![258]
786 + );
787 + assert_eq!(
788 + backend.encode("<|reserved_token_259|>", false).unwrap(),
789 + vec![259]
790 + );
791 + assert_eq!(backend.encode_ordinary(input).unwrap(), expected);
792 + }
793 + }
794 +
795 /// `vocab_size` may live under `text_config` for composite (e.g.
796 /// multimodal) configs.
797 #[test]