1use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use unicode_normalization::UnicodeNormalization;
16
17const DEFAULT_SPLIT: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
20
21pub struct Tokenizer {
23 vocab: HashMap<String, u32>,
25 id_to_token: Vec<String>,
27 ranks: HashMap<(String, String), u32>,
29 added: Vec<(String, u32)>,
31 added_ids: HashSet<u32>,
33 special_ids: HashSet<u32>,
35 split_res: Vec<fancy_regex::Regex>,
39 sp_prepend: bool,
42 sp_prepend_first: bool,
48 metaspace: bool,
51 nfc: bool,
54 byte_to_char: [char; 256],
56 char_to_byte: HashMap<char, u8>,
58 pub bos_token_id: Option<u32>,
60 pub eos_token_id: Option<u32>,
61 pub pad_token_id: Option<u32>,
62 pub im_start_id: Option<u32>,
64 pub im_end_id: Option<u32>,
65 pub chat_template: Option<String>,
68 pub extra_eos: HashSet<u32>,
70 pub add_bos: bool,
72}
73
74impl std::fmt::Debug for Tokenizer {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("Tokenizer")
77 .field("vocab", &self.vocab.len())
78 .field("merges", &self.ranks.len())
79 .field("added", &self.added.len())
80 .finish()
81 }
82}
83
84fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
87 let mut b2c = ['\0'; 256];
88 let mut c2b = HashMap::with_capacity(256);
89 let mut n = 0u32;
90 for b in 0..=255u16 {
91 let printable =
92 (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
93 let c = if printable {
94 char::from_u32(b as u32).unwrap()
95 } else {
96 let c = char::from_u32(256 + n).unwrap();
97 n += 1;
98 c
99 };
100 b2c[b as usize] = c;
101 c2b.insert(c, b as u8);
102 }
103 (b2c, c2b)
104}
105
106#[derive(Deserialize)]
108struct HfTokenizerJson {
109 model: HfModel,
110 #[serde(default)]
111 added_tokens: Vec<HfAddedToken>,
112 #[serde(default)]
113 pre_tokenizer: Option<serde_json::Value>,
114 #[serde(default)]
115 normalizer: Option<serde_json::Value>,
116 #[serde(default)]
117 post_processor: Option<serde_json::Value>,
118}
119
120#[derive(Deserialize)]
121struct HfModel {
122 vocab: HashMap<String, u32>,
123 #[serde(default)]
124 merges: Vec<HfMerge>,
125 #[serde(default)]
126 byte_fallback: bool,
127}
128
129#[derive(Deserialize)]
132#[serde(untagged)]
133enum HfMerge {
134 Pair([String; 2]),
135 Text(String),
136}
137
138#[derive(Deserialize)]
139struct HfAddedToken {
140 id: u32,
141 content: String,
142 special: bool,
143}
144
145fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
154 if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
155 if let Some(r) = pt
156 .get("pattern")
157 .and_then(|p| p.get("Regex"))
158 .and_then(|r| r.as_str())
159 {
160 out.push(r.to_string());
161 }
162 return;
163 }
164 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
165 for p in list {
166 collect_split_patterns(p, out);
167 }
168 }
169}
170
171fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
174 if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
175 return pt
176 .get("prepend_scheme")
177 .and_then(|p| p.as_str())
178 .map(String::from);
179 }
180 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
181 return list.iter().find_map(find_prepend_scheme);
182 }
183 None
184}
185
186pub(crate) fn strip_generation_tags(tpl: &str) -> std::borrow::Cow<'_, str> {
202 if !tpl.contains("generation") {
203 return std::borrow::Cow::Borrowed(tpl);
204 }
205 let mut out = String::with_capacity(tpl.len());
206 let mut rest = tpl;
207 let mut touched = false;
208 while let Some(open) = rest.find("{%") {
209 let Some(close_rel) = rest[open..].find("%}") else {
210 break;
211 };
212 let close = open + close_rel + 2;
213 let tag = &rest[open..close];
214 let inner = tag[2..tag.len() - 2].trim();
215 let lead = inner.starts_with('-');
216 let trail = inner.ends_with('-');
217 let name = inner.trim_matches('-').trim();
218 out.push_str(&rest[..open]);
219 if name == "generation" || name == "endgeneration" {
220 out.push_str(if lead { "{%-" } else { "{%" });
221 out.push_str(" set _generation_span = true ");
222 out.push_str(if trail { "-%}" } else { "%}" });
223 touched = true;
224 } else {
225 out.push_str(tag);
226 }
227 rest = &rest[close..];
228 }
229 if !touched {
230 return std::borrow::Cow::Borrowed(tpl);
231 }
232 out.push_str(rest);
233 std::borrow::Cow::Owned(out)
234}
235
236impl Tokenizer {
237 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
239 let data = std::fs::read_to_string(path.as_ref())
240 .map_err(|e| TokenizerError::Io(e.to_string()))?;
241 Self::from_json(&data)
242 }
243
244 pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
246 let s = std::str::from_utf8(bytes)
247 .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
248 Self::from_json(s)
249 }
250
251 pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
253 let hf: HfTokenizerJson =
254 serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
255
256 let mut vocab = hf.model.vocab;
257 let mut ranks = HashMap::new();
258 for (rank, m) in hf.model.merges.into_iter().enumerate() {
259 let (a, b) = match m {
260 HfMerge::Pair([a, b]) => (a, b),
261 HfMerge::Text(s) => {
262 let mut it = s.splitn(2, ' ');
263 match (it.next(), it.next()) {
264 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
265 _ => continue,
266 }
267 }
268 };
269 ranks.insert((a, b), rank as u32);
270 }
271
272 let mut saw_gemma_bos = false;
277 let add_bos_detected = hf
278 .post_processor
279 .as_ref()
280 .map(|p| {
281 let pp = p.to_string();
282 pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
283 })
284 .unwrap_or(false);
285 let nfc = hf
286 .normalizer
287 .as_ref()
288 .map(|n| n.to_string().contains("NFC"))
289 .unwrap_or(false);
290 let metaspace = hf.model.byte_fallback
291 || hf
292 .normalizer
293 .as_ref()
294 .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
295 .unwrap_or(false);
296 let sp_prepend = hf
297 .normalizer
298 .as_ref()
299 .map(|n| n.to_string().contains("Prepend"))
300 .unwrap_or(false);
301 let (sp_prepend, sp_prepend_first) = if sp_prepend {
305 (true, false)
306 } else {
307 match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
308 Some(s) if s == "always" => (true, false),
309 Some(s) if s == "first" => (false, true),
310 _ => (false, false),
311 }
312 };
313 let split_res = if metaspace {
314 Vec::new()
315 } else {
316 let mut pats = Vec::new();
317 if let Some(pt) = hf.pre_tokenizer.as_ref() {
318 collect_split_patterns(pt, &mut pats);
319 }
320 if pats.is_empty() {
321 pats.push(DEFAULT_SPLIT.to_string());
322 }
323 pats.iter()
324 .map(|p| {
325 fancy_regex::Regex::new(p)
326 .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
327 })
328 .collect::<Result<Vec<_>, _>>()?
329 };
330
331 let mut bos_token_id = None;
333 let mut eos_token_id = None;
334 let mut pad_token_id = None;
335 let mut im_start_id = None;
336 let mut im_end_id = None;
337 let mut special_ids = HashSet::new();
338 let mut added_ids = HashSet::new();
339 let mut added = Vec::new();
340
341 for at in &hf.added_tokens {
342 vocab.insert(at.content.clone(), at.id);
343 added.push((at.content.clone(), at.id));
344 added_ids.insert(at.id);
345 if at.special {
346 special_ids.insert(at.id);
347 }
348 match at.content.as_str() {
349 "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
350 "<|im_start|>" => im_start_id = Some(at.id),
351 "<|im_end|>" => im_end_id = Some(at.id),
352 "<s>" | "[BOS]" => bos_token_id = Some(at.id),
353 "<bos>" => {
357 bos_token_id = Some(at.id);
358 saw_gemma_bos = true;
359 }
360 "<pad>" => pad_token_id = Some(at.id),
361 _ => {}
362 }
363 }
364
365 const DSV41_SPECIALS: &[&str] = &[
370 "<|begin▁of▁sentence|>",
371 "<|end▁of▁sentence|>",
372 "<|User|>",
373 "<|Assistant|>",
374 "<|System|>",
375 "<|latest_reminder|>",
376 "<|deepseek_image|>",
377 "<|action|>",
378 "<|query|>",
379 "<|authority|>",
380 "<|domain|>",
381 "<|title|>",
382 "<|read_url|>",
383 "<think>",
384 "</think>",
385 "|DSML|",
386 ];
387 for token in DSV41_SPECIALS {
388 if let Some(&id) = vocab.get(*token) {
389 if !added.iter().any(|(content, _)| content.as_str() == *token) {
390 added.push(((*token).to_string(), id));
391 }
392 added_ids.insert(id);
393 match *token {
394 "<|begin▁of▁sentence|>" => bos_token_id = Some(id),
395 "<|end▁of▁sentence|>" => eos_token_id = Some(id),
396 _ => {}
397 }
398 }
399 }
400 added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
401
402 let gemma_family = saw_gemma_bos
409 || vocab.contains_key("<start_of_turn>")
410 || added.iter().any(|(c, _)| c == "<start_of_turn>");
411
412 if let Some(pp) = hf.post_processor.as_ref() {
416 let pp = pp.to_string();
417 for name in ["<bos>", "<s>"] {
418 if pp.contains(&format!("\"{name}\"")) {
419 if let Some(&id) = vocab.get(name) {
420 bos_token_id = Some(id);
421 }
422 break;
423 }
424 }
425 }
426
427 let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
429 let mut id_to_token = vec![String::new(); max_id + 1];
430 for (token, &id) in &vocab {
431 if (id as usize) < id_to_token.len() {
432 id_to_token[id as usize] = token.clone();
433 }
434 }
435
436 let (byte_to_char, char_to_byte) = bytes_to_unicode();
437
438 tracing::info!(
439 "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
440 vocab.len(),
441 ranks.len(),
442 added.len(),
443 eos_token_id
444 );
445
446 Ok(Self {
447 vocab,
448 id_to_token,
449 ranks,
450 added,
451 added_ids,
452 special_ids,
453 split_res,
454 metaspace,
455 sp_prepend,
456 sp_prepend_first,
457 nfc,
458 byte_to_char,
459 char_to_byte,
460 bos_token_id,
461 eos_token_id,
462 pad_token_id,
463 im_start_id,
464 im_end_id,
465 chat_template: None,
466 extra_eos: HashSet::new(),
467 add_bos: add_bos_detected || gemma_family,
468 })
469 }
470
471 pub fn byte_level() -> Self {
473 let mut vocab = HashMap::new();
474 let mut id_to_token = Vec::with_capacity(256);
475 for i in 0..256u32 {
476 let tok = format!("<0x{:02X}>", i);
477 vocab.insert(tok.clone(), i);
478 id_to_token.push(tok);
479 }
480 let (byte_to_char, char_to_byte) = bytes_to_unicode();
481 Self {
482 vocab,
483 id_to_token,
484 ranks: HashMap::new(),
485 added: Vec::new(),
486 added_ids: HashSet::new(),
487 special_ids: HashSet::new(),
488 split_res: Vec::new(),
489 metaspace: false,
490 sp_prepend: false,
491 sp_prepend_first: false,
492 nfc: false,
493 byte_to_char,
494 char_to_byte,
495 bos_token_id: None,
496 eos_token_id: None,
497 pad_token_id: None,
498 im_start_id: None,
499 im_end_id: None,
500 chat_template: None,
501 extra_eos: HashSet::new(),
502 add_bos: false,
503 }
504 }
505
506 pub fn encode(&self, text: &str) -> Vec<u32> {
508 let mut ids = Vec::new();
509 let mut rest = text;
511 let mut head = true;
516 'outer: while !rest.is_empty() {
517 let mut best: Option<(usize, usize, u32)> = None; for (content, id) in &self.added {
519 if let Some(pos) = rest.find(content.as_str()) {
520 let better = match best {
521 None => true,
522 Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
523 };
524 if better {
525 best = Some((pos, content.len(), *id));
526 }
527 if pos == 0 {
528 break; }
530 }
531 }
532 match best {
533 Some((pos, len, id)) => {
534 self.encode_segment_at(&rest[..pos], head, &mut ids);
535 ids.push(id);
536 rest = &rest[pos + len..];
537 head = false;
538 }
539 None => {
540 self.encode_segment_at(rest, head, &mut ids);
541 break 'outer;
542 }
543 }
544 }
545 ids
546 }
547
548 fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
552 if segment.is_empty() {
553 return;
554 }
555 let norm: String = if self.nfc {
556 segment.nfc().collect()
557 } else {
558 segment.to_string()
559 };
560 if self.metaspace {
561 let sp = if self.sp_prepend {
565 format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
568 } else {
569 let replaced = norm.replace(' ', "\u{2581}");
573 if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
574 format!("\u{2581}{replaced}")
575 } else {
576 replaced
577 }
578 };
579 self.bpe_piece_sp(&sp, out);
580 return;
581 }
582 if !self.split_res.is_empty() {
583 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
586 for re in &self.split_res {
587 let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
588 for (ps, pe) in pieces {
589 let seg = &norm[ps..pe];
590 let mut last = 0usize;
591 for m in re.find_iter(seg) {
592 let m = match m {
593 Ok(m) => m,
594 Err(e) => {
595 tracing::error!("pre-tokenizer regex failed: {e}");
596 break;
597 }
598 };
599 if m.start() > last {
600 next.push((ps + last, ps + m.start()));
601 }
602 if m.end() > m.start() {
603 next.push((ps + m.start(), ps + m.end()));
604 }
605 last = m.end();
606 }
607 if last < seg.len() {
608 next.push((ps + last, pe));
609 }
610 }
611 pieces = next;
612 }
613 for (ps, pe) in pieces {
614 self.bpe_piece(&norm[ps..pe], out);
615 }
616 } else {
617 {
618 for b in norm.bytes() {
620 let tok = format!("<0x{:02X}>", b);
621 if let Some(&id) = self.vocab.get(&tok) {
622 out.push(id);
623 }
624 }
625 }
626 }
627 }
628
629 fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
632 if piece.is_empty() {
633 return;
634 }
635 let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
636 loop {
637 let mut best: Option<(u32, usize)> = None;
638 for i in 0..sym.len().saturating_sub(1) {
639 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
640 if best.map(|(br, _)| r < br).unwrap_or(true) {
641 best = Some((r, i));
642 }
643 }
644 }
645 let Some((_, i)) = best else { break };
646 let merged = format!("{}{}", sym[i], sym[i + 1]);
647 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
648 let mut j = 0;
649 while j + 1 < sym.len() {
650 if sym[j] == left && sym[j + 1] == right {
651 sym[j] = merged.clone();
652 sym.remove(j + 1);
653 }
654 j += 1;
655 }
656 }
657 for t in &sym {
658 if let Some(&id) = self.vocab.get(t) {
659 out.push(id);
660 } else {
661 let mut ok = true;
662 for byte in t.bytes() {
663 let tok = format!("<0x{:02X}>", byte);
664 match self.vocab.get(&tok) {
665 Some(&id) => out.push(id),
666 None => {
667 ok = false;
668 break;
669 }
670 }
671 }
672 if !ok {
673 tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
674 }
675 }
676 }
677 }
678
679 fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
681 if piece.is_empty() {
682 return;
683 }
684 let mapped: Vec<String> = piece
685 .bytes()
686 .map(|b| self.byte_to_char[b as usize].to_string())
687 .collect();
688 let mut sym = mapped;
689
690 loop {
692 let mut best: Option<(u32, usize)> = None;
693 for i in 0..sym.len().saturating_sub(1) {
694 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
695 if best.map(|(br, _)| r < br).unwrap_or(true) {
696 best = Some((r, i));
697 }
698 }
699 }
700 let Some((_, i)) = best else { break };
701 let merged = format!("{}{}", sym[i], sym[i + 1]);
702 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
704 let mut j = 0;
705 while j + 1 < sym.len() {
706 if sym[j] == left && sym[j + 1] == right {
707 sym[j] = merged.clone();
708 sym.remove(j + 1);
709 }
710 j += 1;
711 }
712 }
713
714 for s in &sym {
715 if let Some(&id) = self.vocab.get(s) {
716 out.push(id);
717 } else {
718 let mut ok = true;
720 for ch in s.chars() {
721 let Some(&b) = self.char_to_byte.get(&ch) else {
722 ok = false;
723 break;
724 };
725 let tok = format!("<0x{:02X}>", b);
726 if let Some(&id) = self.vocab.get(&tok) {
727 out.push(id);
728 } else {
729 ok = false;
730 break;
731 }
732 }
733 if !ok {
734 tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
735 }
736 }
737 }
738 }
739
740 pub fn decode(&self, ids: &[u32]) -> String {
743 let mut bytes: Vec<u8> = Vec::new();
744 for &id in ids {
745 if self.special_ids.contains(&id) {
746 continue;
747 }
748 let idx = id as usize;
749 if idx >= self.id_to_token.len() {
750 continue;
751 }
752 let tok = &self.id_to_token[idx];
753 if self.added_ids.contains(&id) {
754 if self.metaspace && tok.contains('\u{2581}') {
757 bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
758 } else {
759 bytes.extend_from_slice(tok.as_bytes());
760 }
761 continue;
762 }
763 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
765 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
766 bytes.push(b);
767 continue;
768 }
769 }
770 if self.metaspace {
771 for ch in tok.chars() {
773 if ch == '\u{2581}' {
774 bytes.push(b' ');
775 } else {
776 let mut buf = [0u8; 4];
777 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
778 }
779 }
780 continue;
781 }
782 for ch in tok.chars() {
783 match self.char_to_byte.get(&ch) {
784 Some(&b) => bytes.push(b),
785 None => {
788 let mut buf = [0u8; 4];
789 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
790 }
791 }
792 }
793 }
794 let text = String::from_utf8_lossy(&bytes).into_owned();
795 if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
796 if let Some(stripped) = text.strip_prefix(' ') {
798 return stripped.to_string();
799 }
800 }
801 text
802 }
803
804 pub fn decode_token(&self, id: u32) -> String {
807 if self.special_ids.contains(&id) {
808 return String::new();
809 }
810 let idx = id as usize;
811 if idx >= self.id_to_token.len() {
812 return String::new();
813 }
814 let tok = &self.id_to_token[idx];
815 if self.added_ids.contains(&id) {
816 if self.metaspace && tok.contains('\u{2581}') {
817 return tok.replace('\u{2581}', " ");
818 }
819 return tok.clone();
820 }
821 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
822 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
823 return String::from_utf8_lossy(&[b]).into_owned();
824 }
825 }
826 if self.metaspace {
827 return tok.replace('\u{2581}', " ");
828 }
829 let mut bytes = Vec::new();
830 for ch in tok.chars() {
831 match self.char_to_byte.get(&ch) {
832 Some(&b) => bytes.push(b),
833 None => {
834 let mut buf = [0u8; 4];
835 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
836 }
837 }
838 }
839 String::from_utf8_lossy(&bytes).into_owned()
840 }
841
842 pub fn decode_token_for_hash(&self, id: u32) -> String {
845 let idx = id as usize;
846 if idx >= self.id_to_token.len() {
847 return String::new();
848 }
849 if self.special_ids.contains(&id) {
850 return self.id_to_token[idx].clone();
851 }
852 self.decode_token(id)
853 }
854
855 pub fn decode_for_protocol(&self, ids: &[u32]) -> String {
858 let mut out = String::new();
859 for &id in ids {
860 let idx = id as usize;
861 if self.special_ids.contains(&id) {
862 if let Some(token) = self.id_to_token.get(idx) {
863 out.push_str(token);
864 }
865 } else {
866 out.push_str(&self.decode_token(id));
867 }
868 }
869 out
870 }
871
872 pub fn raw_token_for_hash(&self, id: u32) -> String {
874 self.id_to_token
875 .get(id as usize)
876 .cloned()
877 .unwrap_or_default()
878 }
879
880 pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
884 self.apply_chat_template_opts(messages, None)
885 }
886
887 pub fn apply_chat_template_json(
904 &self,
905 messages: &[serde_json::Value],
906 tools: Option<&[serde_json::Value]>,
907 enable_thinking: Option<bool>,
908 ) -> Vec<u32> {
909 if let Some(tpl) = &self.chat_template {
910 match self.render_template_json(tpl, messages, tools, enable_thinking) {
911 Ok(text) => return self.with_bos(self.encode(&text)),
912 Err(e) => {
913 tracing::error!("chat template render failed ({e}); ChatML fallback");
914 }
915 }
916 }
917 let pairs: Vec<(String, String)> = messages
918 .iter()
919 .map(|m| {
920 (
921 m.get("role")
922 .and_then(|v| v.as_str())
923 .unwrap_or("user")
924 .to_string(),
925 m.get("content")
926 .and_then(|v| v.as_str())
927 .unwrap_or("")
928 .to_string(),
929 )
930 })
931 .collect();
932 self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
933 }
934
935 pub fn render_chat_json(
937 &self,
938 messages: &[serde_json::Value],
939 tools: Option<&[serde_json::Value]>,
940 enable_thinking: Option<bool>,
941 ) -> Option<String> {
942 let tpl = self.chat_template.as_ref()?;
943 match self.render_template_json(tpl, messages, tools, enable_thinking) {
944 Ok(t) => Some(t),
945 Err(e) => {
946 tracing::error!("chat template render (json): {e:#}");
947 eprintln!("chat template render (json): {e:#}");
948 None
949 }
950 }
951 }
952
953 fn render_template_json(
954 &self,
955 tpl: &str,
956 messages: &[serde_json::Value],
957 tools: Option<&[serde_json::Value]>,
958 enable_thinking: Option<bool>,
959 ) -> Result<String, minijinja::Error> {
960 let mut env = minijinja::Environment::new();
961 env.set_trim_blocks(true);
962 env.set_lstrip_blocks(true);
963 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
964 env.add_function("visible_text", |v: minijinja::Value| -> String {
970 if let Some(s) = v.as_str() {
971 return s.to_string();
972 }
973 if let Ok(iter) = v.try_iter() {
974 let mut out = Vec::new();
975 for item in iter {
976 if let Some(s) = item.as_str() {
977 out.push(s.to_string());
978 } else if let Ok(t) = item.get_attr("text") {
979 if let Some(s) = t.as_str() {
980 out.push(s.to_string());
981 }
982 }
983 }
984 return out.join("\n");
985 }
986 String::new()
987 });
988 let tpl_src = strip_generation_tags(tpl);
989 env.add_template("chat", &tpl_src)?;
990 let msgs: Vec<minijinja::Value> = messages
991 .iter()
992 .map(minijinja::Value::from_serialize)
993 .collect();
994 let tools_v: Option<Vec<minijinja::Value>> =
995 tools.map(|ts| ts.iter().map(minijinja::Value::from_serialize).collect());
996 let tpl = env.get_template("chat")?;
997 let rendered = match (tools_v, enable_thinking) {
1004 (Some(ts), Some(v)) => tpl.render(minijinja::context! {
1005 messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
1006 tool_call_format => "json",
1007 })?,
1008 (Some(ts), None) => tpl.render(minijinja::context! {
1009 messages => msgs, tools => ts, add_generation_prompt => true,
1010 tool_call_format => "json",
1011 })?,
1012 (None, Some(v)) => tpl.render(minijinja::context! {
1013 messages => msgs, add_generation_prompt => true, enable_thinking => v,
1014 tool_call_format => "json",
1015 })?,
1016 (None, None) => tpl.render(minijinja::context! {
1017 messages => msgs, add_generation_prompt => true,
1018 tool_call_format => "json",
1019 })?,
1020 };
1021 Ok(rendered)
1022 }
1023
1024 pub fn apply_chat_template_opts(
1025 &self,
1026 messages: &[(String, String)],
1027 enable_thinking: Option<bool>,
1028 ) -> Vec<u32> {
1029 if let Some(tpl) = &self.chat_template {
1030 match self.render_template(tpl, messages, enable_thinking) {
1031 Ok(text) => return self.with_bos(self.encode(&text)),
1032 Err(e) => {
1033 tracing::error!("chat template render failed ({e}); ChatML fallback");
1034 }
1035 }
1036 }
1037 self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
1038 }
1039
1040 pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
1042 if self.add_bos {
1043 if let Some(b) = self.bos_token_id {
1044 if ids.first() != Some(&b) {
1045 ids.insert(0, b);
1046 }
1047 }
1048 }
1049 ids
1050 }
1051
1052 pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
1054 self.render_chat_opts(messages, None)
1055 }
1056
1057 pub fn render_chat_opts(
1059 &self,
1060 messages: &[(String, String)],
1061 enable_thinking: Option<bool>,
1062 ) -> Option<String> {
1063 let tpl = self.chat_template.as_ref()?;
1064 match self.render_template(tpl, messages, enable_thinking) {
1065 Ok(t) => Some(t),
1066 Err(e) => {
1067 tracing::error!("chat template render: {e:#}");
1068 None
1069 }
1070 }
1071 }
1072
1073 fn render_template(
1074 &self,
1075 tpl: &str,
1076 messages: &[(String, String)],
1077 enable_thinking: Option<bool>,
1078 ) -> Result<String, minijinja::Error> {
1079 let mut env = minijinja::Environment::new();
1080 env.set_trim_blocks(true);
1081 env.set_lstrip_blocks(true);
1082 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
1084 let tpl_src = strip_generation_tags(tpl);
1085 env.add_template("chat", &tpl_src)?;
1086 let msgs: Vec<minijinja::Value> = messages
1087 .iter()
1088 .map(|(role, content)| {
1089 minijinja::context! { role => role, content => content }
1090 })
1091 .collect();
1092 let rendered = match enable_thinking {
1095 Some(v) => env.get_template("chat")?.render(minijinja::context! {
1096 messages => msgs,
1097 add_generation_prompt => true,
1098 enable_thinking => v,
1099 })?,
1100 None => env.get_template("chat")?.render(minijinja::context! {
1101 messages => msgs,
1102 add_generation_prompt => true,
1103 })?,
1104 };
1105 if enable_thinking == Some(false) && !rendered.contains("</think>") {
1109 if let Some(pos) = rendered.rfind("assistant") {
1110 let mut insert_at = pos + "assistant".len();
1111 if let Some(idx) = rendered[insert_at..].find('\n') {
1112 insert_at += idx + 1;
1113 }
1114 let mut out = String::with_capacity(rendered.len() + 24);
1115 out.push_str(&rendered[..insert_at]);
1116 if !out.ends_with('\n') {
1117 out.push('\n');
1118 }
1119 out.push_str("<think>\n\n</think>\n\n");
1120 out.push_str(&rendered[insert_at..]);
1121 return Ok(out);
1122 }
1123 }
1124 Ok(rendered)
1125 }
1126
1127 fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
1129 self.chatml_fallback_opts(messages, None)
1130 }
1131
1132 fn chatml_fallback_opts(
1134 &self,
1135 messages: &[(String, String)],
1136 enable_thinking: Option<bool>,
1137 ) -> Vec<u32> {
1138 let mut tokens = Vec::new();
1139
1140 for (role, content) in messages {
1141 if let Some(start_id) = self.im_start_id {
1143 tokens.push(start_id);
1144 }
1145 tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1146 if let Some(end_id) = self.im_end_id {
1147 tokens.push(end_id);
1148 }
1149 tokens.extend(self.encode("\n"));
1150 }
1151
1152 if let Some(start_id) = self.im_start_id {
1154 tokens.push(start_id);
1155 }
1156 tokens.extend(self.encode("assistant\n"));
1157 if enable_thinking == Some(false) {
1158 tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1159 }
1160
1161 tokens
1162 }
1163
1164 pub fn vocab_size(&self) -> usize {
1166 self.id_to_token.len()
1167 }
1168
1169 pub fn token_to_id(&self, token: &str) -> Option<u32> {
1173 self.vocab.get(token).copied()
1174 }
1175
1176 pub fn convert_tokens_to_ids(&self, token: &str) -> Option<u32> {
1179 self.token_to_id(token)
1180 }
1181
1182 pub fn is_eos(&self, id: u32) -> bool {
1184 self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1185 }
1186}
1187
1188#[derive(Debug, thiserror::Error)]
1189pub enum TokenizerError {
1190 #[error("IO error: {0}")]
1191 Io(String),
1192 #[error("Parse error: {0}")]
1193 Parse(String),
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199
1200 #[test]
1201 fn byte_unicode_bijection() {
1202 let (b2c, c2b) = bytes_to_unicode();
1203 for b in 0..=255u8 {
1204 assert_eq!(c2b[&b2c[b as usize]], b);
1205 }
1206 assert_eq!(b2c[b' ' as usize], 'Ġ');
1208 assert_eq!(b2c[b'\n' as usize], 'Ċ');
1209 }
1210
1211 #[test]
1212 fn byte_level_roundtrip_utf8() {
1213 let tok = Tokenizer::byte_level();
1214 let text = "hello 🌍 hi\n";
1215 let ids = tok.encode(text);
1216 assert_eq!(ids.len(), text.len()); assert_eq!(tok.decode(&ids), text);
1218 }
1219
1220 fn mini_json() -> String {
1223 let vocab: Vec<(&str, u32)> = vec![
1225 ("h", 0),
1226 ("e", 1),
1227 ("l", 2),
1228 ("o", 3),
1229 ("Ġ", 4),
1230 ("w", 5),
1231 ("r", 6),
1232 ("d", 7),
1233 ("he", 8),
1234 ("Ġw", 9),
1235 ];
1236 let vocab_json: String = vocab
1237 .iter()
1238 .map(|(t, i)| format!("\"{t}\": {i}"))
1239 .collect::<Vec<_>>()
1240 .join(", ");
1241 format!(
1242 r#"{{
1243 "model": {{
1244 "type": "BPE",
1245 "vocab": {{ {vocab_json} }},
1246 "merges": [["h", "e"], ["Ġ", "w"]]
1247 }},
1248 "added_tokens": [
1249 {{"id": 10, "content": "<|eot|>", "special": true}}
1250 ]
1251 }}"#
1252 )
1253 }
1254
1255 #[test]
1258 fn real_tokenizer_parity_when_available() {
1259 let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1260 return;
1261 };
1262 let t = Tokenizer::from_file(&path).expect("load");
1263 for (text, want) in [
1264 (
1265 "The capital of France is",
1266 vec![671u32, 6102, 294, 8760, 344],
1267 ),
1268 ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1269 ] {
1270 let got = t.encode(text);
1271 assert_eq!(got, want, "«{text}»");
1272 }
1273 }
1274
1275 #[test]
1280 fn granite_42_chat_template_when_available() {
1281 let Ok(path) = std::env::var("CMF_GRANITE_CHAT_TEMPLATE") else {
1282 return;
1283 };
1284 let mut tok = Tokenizer::byte_level();
1285 tok.chat_template = Some(std::fs::read_to_string(path).expect("read Granite template"));
1286 let messages = vec![("user".to_string(), "Hello".to_string())];
1287
1288 let thinking = tok
1289 .render_chat_opts(&messages, Some(true))
1290 .expect("render Granite thinking prompt");
1291 assert_eq!(
1292 thinking,
1293 "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1294 );
1295
1296 let direct = tok
1297 .render_chat_opts(&messages, Some(false))
1298 .expect("render Granite direct prompt");
1299 assert_eq!(
1300 direct,
1301 "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think></think>"
1302 );
1303 }
1304
1305 #[test]
1311 fn every_split_in_a_sequence_is_applied() {
1312 let pt = serde_json::json!({
1313 "type": "Sequence",
1314 "pretokenizers": [
1315 {"type": "Split", "behavior": "Isolated",
1316 "pattern": {"Regex": r"\p{N}{1,3}"}},
1317 {"type": "Split", "behavior": "Isolated",
1318 "pattern": {"Regex": r" ?[\p{L}]+"}},
1319 {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1320 ]
1321 });
1322 let mut pats = Vec::new();
1323 collect_split_patterns(&pt, &mut pats);
1324 assert_eq!(
1325 pats.len(),
1326 2,
1327 "both Split stages must be collected: {pats:?}"
1328 );
1329 assert!(pats[0].contains("p{N}"), "digit rule first");
1330 assert!(pats[1].contains("p{L}"), "word rule second");
1331
1332 let re: Vec<fancy_regex::Regex> = pats
1336 .iter()
1337 .map(|p| fancy_regex::Regex::new(p).unwrap())
1338 .collect();
1339 let norm = "ab cd12";
1340 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1341 for r in &re {
1342 let mut next = Vec::new();
1343 for (ps, pe) in pieces {
1344 let seg = &norm[ps..pe];
1345 let mut last = 0;
1346 for m in r.find_iter(seg).flatten() {
1347 if m.start() > last {
1348 next.push((ps + last, ps + m.start()));
1349 }
1350 if m.end() > m.start() {
1351 next.push((ps + m.start(), ps + m.end()));
1352 }
1353 last = m.end();
1354 }
1355 if last < seg.len() {
1356 next.push((ps + last, pe));
1357 }
1358 }
1359 pieces = next;
1360 }
1361 let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1362 assert_eq!(
1363 got,
1364 vec!["ab", " cd", "12"],
1365 "staged split produced {got:?}"
1366 );
1367 }
1368
1369 #[test]
1370 fn full_pipeline_merges_and_added_tokens() {
1371 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1372 let ids = tok.encode("hello world");
1374 assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1375 assert_eq!(tok.decode(&ids), "hello world");
1376 let ids2 = tok.encode("he<|eot|>he");
1378 assert_eq!(ids2, vec![8, 10, 8]);
1379 assert_eq!(tok.decode(&ids2), "hehe");
1380 }
1381
1382 #[test]
1383 fn non_ascii_is_never_silently_dropped() {
1384 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1385 let ids = tok.encode("hello");
1388 assert!(!ids.is_empty());
1389 }
1390}
1391
1392#[cfg(test)]
1393mod generation_tag_tests {
1394 use super::strip_generation_tags;
1395
1396 #[test]
1401 fn a_generation_block_becomes_a_no_op_keeping_its_whitespace_control() {
1402 let tpl = "a{%- generation -%}b{%- endgeneration -%}c";
1403 let out = strip_generation_tags(tpl);
1404 assert!(!out.contains("{%- generation"));
1405 assert!(!out.contains("endgeneration"));
1406 assert_eq!(out.matches("{%-").count(), 2);
1408 assert_eq!(out.matches("-%}").count(), 2);
1409 assert!(out.starts_with('a') && out.ends_with('c'));
1410 }
1411
1412 #[test]
1415 fn each_side_keeps_its_own_dash() {
1416 let out = strip_generation_tags("{% generation %}x{%- endgeneration %}");
1417 assert!(out.starts_with("{% set"), "no dash added on the left");
1418 assert!(out.contains("{%- set"), "the right tag keeps its dash");
1419 assert!(!out.contains("-%}"), "no trailing dash invented");
1420 }
1421
1422 #[test]
1425 fn everything_else_is_left_alone() {
1426 let plain = "{%- if x -%}{{ y }}{%- endif -%}";
1427 assert_eq!(strip_generation_tags(plain), plain);
1428 let prose = "{{ 'the generation of tokens' }}";
1430 assert_eq!(strip_generation_tags(prose), prose);
1431 }
1432}