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 added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
365
366 let gemma_family = saw_gemma_bos
373 || vocab.contains_key("<start_of_turn>")
374 || added.iter().any(|(c, _)| c == "<start_of_turn>");
375
376 if let Some(pp) = hf.post_processor.as_ref() {
380 let pp = pp.to_string();
381 for name in ["<bos>", "<s>"] {
382 if pp.contains(&format!("\"{name}\"")) {
383 if let Some(&id) = vocab.get(name) {
384 bos_token_id = Some(id);
385 }
386 break;
387 }
388 }
389 }
390
391 let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
393 let mut id_to_token = vec![String::new(); max_id + 1];
394 for (token, &id) in &vocab {
395 if (id as usize) < id_to_token.len() {
396 id_to_token[id as usize] = token.clone();
397 }
398 }
399
400 let (byte_to_char, char_to_byte) = bytes_to_unicode();
401
402 tracing::info!(
403 "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
404 vocab.len(),
405 ranks.len(),
406 added.len(),
407 eos_token_id
408 );
409
410 Ok(Self {
411 vocab,
412 id_to_token,
413 ranks,
414 added,
415 added_ids,
416 special_ids,
417 split_res,
418 metaspace,
419 sp_prepend,
420 sp_prepend_first,
421 nfc,
422 byte_to_char,
423 char_to_byte,
424 bos_token_id,
425 eos_token_id,
426 pad_token_id,
427 im_start_id,
428 im_end_id,
429 chat_template: None,
430 extra_eos: HashSet::new(),
431 add_bos: add_bos_detected || gemma_family,
432 })
433 }
434
435 pub fn byte_level() -> Self {
437 let mut vocab = HashMap::new();
438 let mut id_to_token = Vec::with_capacity(256);
439 for i in 0..256u32 {
440 let tok = format!("<0x{:02X}>", i);
441 vocab.insert(tok.clone(), i);
442 id_to_token.push(tok);
443 }
444 let (byte_to_char, char_to_byte) = bytes_to_unicode();
445 Self {
446 vocab,
447 id_to_token,
448 ranks: HashMap::new(),
449 added: Vec::new(),
450 added_ids: HashSet::new(),
451 special_ids: HashSet::new(),
452 split_res: Vec::new(),
453 metaspace: false,
454 sp_prepend: false,
455 sp_prepend_first: false,
456 nfc: false,
457 byte_to_char,
458 char_to_byte,
459 bos_token_id: None,
460 eos_token_id: None,
461 pad_token_id: None,
462 im_start_id: None,
463 im_end_id: None,
464 chat_template: None,
465 extra_eos: HashSet::new(),
466 add_bos: false,
467 }
468 }
469
470 pub fn encode(&self, text: &str) -> Vec<u32> {
472 let mut ids = Vec::new();
473 let mut rest = text;
475 let mut head = true;
480 'outer: while !rest.is_empty() {
481 let mut best: Option<(usize, usize, u32)> = None; for (content, id) in &self.added {
483 if let Some(pos) = rest.find(content.as_str()) {
484 let better = match best {
485 None => true,
486 Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
487 };
488 if better {
489 best = Some((pos, content.len(), *id));
490 }
491 if pos == 0 {
492 break; }
494 }
495 }
496 match best {
497 Some((pos, len, id)) => {
498 self.encode_segment_at(&rest[..pos], head, &mut ids);
499 ids.push(id);
500 rest = &rest[pos + len..];
501 head = false;
502 }
503 None => {
504 self.encode_segment_at(rest, head, &mut ids);
505 break 'outer;
506 }
507 }
508 }
509 ids
510 }
511
512 fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
516 if segment.is_empty() {
517 return;
518 }
519 let norm: String = if self.nfc {
520 segment.nfc().collect()
521 } else {
522 segment.to_string()
523 };
524 if self.metaspace {
525 let sp = if self.sp_prepend {
529 format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
532 } else {
533 let replaced = norm.replace(' ', "\u{2581}");
537 if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
538 format!("\u{2581}{replaced}")
539 } else {
540 replaced
541 }
542 };
543 self.bpe_piece_sp(&sp, out);
544 return;
545 }
546 if !self.split_res.is_empty() {
547 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
550 for re in &self.split_res {
551 let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
552 for (ps, pe) in pieces {
553 let seg = &norm[ps..pe];
554 let mut last = 0usize;
555 for m in re.find_iter(seg) {
556 let m = match m {
557 Ok(m) => m,
558 Err(e) => {
559 tracing::error!("pre-tokenizer regex failed: {e}");
560 break;
561 }
562 };
563 if m.start() > last {
564 next.push((ps + last, ps + m.start()));
565 }
566 if m.end() > m.start() {
567 next.push((ps + m.start(), ps + m.end()));
568 }
569 last = m.end();
570 }
571 if last < seg.len() {
572 next.push((ps + last, pe));
573 }
574 }
575 pieces = next;
576 }
577 for (ps, pe) in pieces {
578 self.bpe_piece(&norm[ps..pe], out);
579 }
580 } else {
581 {
582 for b in norm.bytes() {
584 let tok = format!("<0x{:02X}>", b);
585 if let Some(&id) = self.vocab.get(&tok) {
586 out.push(id);
587 }
588 }
589 }
590 }
591 }
592
593 fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
596 if piece.is_empty() {
597 return;
598 }
599 let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
600 loop {
601 let mut best: Option<(u32, usize)> = None;
602 for i in 0..sym.len().saturating_sub(1) {
603 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
604 if best.map(|(br, _)| r < br).unwrap_or(true) {
605 best = Some((r, i));
606 }
607 }
608 }
609 let Some((_, i)) = best else { break };
610 let merged = format!("{}{}", sym[i], sym[i + 1]);
611 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
612 let mut j = 0;
613 while j + 1 < sym.len() {
614 if sym[j] == left && sym[j + 1] == right {
615 sym[j] = merged.clone();
616 sym.remove(j + 1);
617 }
618 j += 1;
619 }
620 }
621 for t in &sym {
622 if let Some(&id) = self.vocab.get(t) {
623 out.push(id);
624 } else {
625 let mut ok = true;
626 for byte in t.bytes() {
627 let tok = format!("<0x{:02X}>", byte);
628 match self.vocab.get(&tok) {
629 Some(&id) => out.push(id),
630 None => {
631 ok = false;
632 break;
633 }
634 }
635 }
636 if !ok {
637 tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
638 }
639 }
640 }
641 }
642
643 fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
645 if piece.is_empty() {
646 return;
647 }
648 let mapped: Vec<String> = piece
649 .bytes()
650 .map(|b| self.byte_to_char[b as usize].to_string())
651 .collect();
652 let mut sym = mapped;
653
654 loop {
656 let mut best: Option<(u32, usize)> = None;
657 for i in 0..sym.len().saturating_sub(1) {
658 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
659 if best.map(|(br, _)| r < br).unwrap_or(true) {
660 best = Some((r, i));
661 }
662 }
663 }
664 let Some((_, i)) = best else { break };
665 let merged = format!("{}{}", sym[i], sym[i + 1]);
666 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
668 let mut j = 0;
669 while j + 1 < sym.len() {
670 if sym[j] == left && sym[j + 1] == right {
671 sym[j] = merged.clone();
672 sym.remove(j + 1);
673 }
674 j += 1;
675 }
676 }
677
678 for s in &sym {
679 if let Some(&id) = self.vocab.get(s) {
680 out.push(id);
681 } else {
682 let mut ok = true;
684 for ch in s.chars() {
685 let Some(&b) = self.char_to_byte.get(&ch) else {
686 ok = false;
687 break;
688 };
689 let tok = format!("<0x{:02X}>", b);
690 if let Some(&id) = self.vocab.get(&tok) {
691 out.push(id);
692 } else {
693 ok = false;
694 break;
695 }
696 }
697 if !ok {
698 tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
699 }
700 }
701 }
702 }
703
704 pub fn decode(&self, ids: &[u32]) -> String {
707 let mut bytes: Vec<u8> = Vec::new();
708 for &id in ids {
709 if self.special_ids.contains(&id) {
710 continue;
711 }
712 let idx = id as usize;
713 if idx >= self.id_to_token.len() {
714 continue;
715 }
716 let tok = &self.id_to_token[idx];
717 if self.added_ids.contains(&id) {
718 if self.metaspace && tok.contains('\u{2581}') {
721 bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
722 } else {
723 bytes.extend_from_slice(tok.as_bytes());
724 }
725 continue;
726 }
727 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
729 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
730 bytes.push(b);
731 continue;
732 }
733 }
734 if self.metaspace {
735 for ch in tok.chars() {
737 if ch == '\u{2581}' {
738 bytes.push(b' ');
739 } else {
740 let mut buf = [0u8; 4];
741 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
742 }
743 }
744 continue;
745 }
746 for ch in tok.chars() {
747 match self.char_to_byte.get(&ch) {
748 Some(&b) => bytes.push(b),
749 None => {
752 let mut buf = [0u8; 4];
753 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
754 }
755 }
756 }
757 }
758 let text = String::from_utf8_lossy(&bytes).into_owned();
759 if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
760 if let Some(stripped) = text.strip_prefix(' ') {
762 return stripped.to_string();
763 }
764 }
765 text
766 }
767
768 pub fn decode_token(&self, id: u32) -> String {
771 if self.special_ids.contains(&id) {
772 return String::new();
773 }
774 let idx = id as usize;
775 if idx >= self.id_to_token.len() {
776 return String::new();
777 }
778 let tok = &self.id_to_token[idx];
779 if self.added_ids.contains(&id) {
780 if self.metaspace && tok.contains('\u{2581}') {
781 return tok.replace('\u{2581}', " ");
782 }
783 return tok.clone();
784 }
785 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
786 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
787 return String::from_utf8_lossy(&[b]).into_owned();
788 }
789 }
790 if self.metaspace {
791 return tok.replace('\u{2581}', " ");
792 }
793 let mut bytes = Vec::new();
794 for ch in tok.chars() {
795 match self.char_to_byte.get(&ch) {
796 Some(&b) => bytes.push(b),
797 None => {
798 let mut buf = [0u8; 4];
799 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
800 }
801 }
802 }
803 String::from_utf8_lossy(&bytes).into_owned()
804 }
805
806 pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
810 self.apply_chat_template_opts(messages, None)
811 }
812
813 pub fn apply_chat_template_json(
830 &self,
831 messages: &[serde_json::Value],
832 tools: Option<&[serde_json::Value]>,
833 enable_thinking: Option<bool>,
834 ) -> Vec<u32> {
835 if let Some(tpl) = &self.chat_template {
836 match self.render_template_json(tpl, messages, tools, enable_thinking) {
837 Ok(text) => return self.with_bos(self.encode(&text)),
838 Err(e) => {
839 tracing::error!("chat template render failed ({e}); ChatML fallback");
840 }
841 }
842 }
843 let pairs: Vec<(String, String)> = messages
844 .iter()
845 .map(|m| {
846 (
847 m.get("role")
848 .and_then(|v| v.as_str())
849 .unwrap_or("user")
850 .to_string(),
851 m.get("content")
852 .and_then(|v| v.as_str())
853 .unwrap_or("")
854 .to_string(),
855 )
856 })
857 .collect();
858 self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
859 }
860
861 pub fn render_chat_json(
863 &self,
864 messages: &[serde_json::Value],
865 tools: Option<&[serde_json::Value]>,
866 enable_thinking: Option<bool>,
867 ) -> Option<String> {
868 let tpl = self.chat_template.as_ref()?;
869 match self.render_template_json(tpl, messages, tools, enable_thinking) {
870 Ok(t) => Some(t),
871 Err(e) => {
872 tracing::error!("chat template render (json): {e:#}");
873 eprintln!("chat template render (json): {e:#}");
874 None
875 }
876 }
877 }
878
879 fn render_template_json(
880 &self,
881 tpl: &str,
882 messages: &[serde_json::Value],
883 tools: Option<&[serde_json::Value]>,
884 enable_thinking: Option<bool>,
885 ) -> Result<String, minijinja::Error> {
886 let mut env = minijinja::Environment::new();
887 env.set_trim_blocks(true);
888 env.set_lstrip_blocks(true);
889 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
890 env.add_function("visible_text", |v: minijinja::Value| -> String {
896 if let Some(s) = v.as_str() {
897 return s.to_string();
898 }
899 if let Ok(iter) = v.try_iter() {
900 let mut out = Vec::new();
901 for item in iter {
902 if let Some(s) = item.as_str() {
903 out.push(s.to_string());
904 } else if let Ok(t) = item.get_attr("text") {
905 if let Some(s) = t.as_str() {
906 out.push(s.to_string());
907 }
908 }
909 }
910 return out.join("\n");
911 }
912 String::new()
913 });
914 let tpl_src = strip_generation_tags(tpl);
915 env.add_template("chat", &tpl_src)?;
916 let msgs: Vec<minijinja::Value> = messages
917 .iter()
918 .map(minijinja::Value::from_serialize)
919 .collect();
920 let tools_v: Option<Vec<minijinja::Value>> =
921 tools.map(|ts| ts.iter().map(minijinja::Value::from_serialize).collect());
922 let tpl = env.get_template("chat")?;
923 let rendered = match (tools_v, enable_thinking) {
930 (Some(ts), Some(v)) => tpl.render(minijinja::context! {
931 messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
932 tool_call_format => "json",
933 })?,
934 (Some(ts), None) => tpl.render(minijinja::context! {
935 messages => msgs, tools => ts, add_generation_prompt => true,
936 tool_call_format => "json",
937 })?,
938 (None, Some(v)) => tpl.render(minijinja::context! {
939 messages => msgs, add_generation_prompt => true, enable_thinking => v,
940 tool_call_format => "json",
941 })?,
942 (None, None) => tpl.render(minijinja::context! {
943 messages => msgs, add_generation_prompt => true,
944 tool_call_format => "json",
945 })?,
946 };
947 Ok(rendered)
948 }
949
950 pub fn apply_chat_template_opts(
951 &self,
952 messages: &[(String, String)],
953 enable_thinking: Option<bool>,
954 ) -> Vec<u32> {
955 if let Some(tpl) = &self.chat_template {
956 match self.render_template(tpl, messages, enable_thinking) {
957 Ok(text) => return self.with_bos(self.encode(&text)),
958 Err(e) => {
959 tracing::error!("chat template render failed ({e}); ChatML fallback");
960 }
961 }
962 }
963 self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
964 }
965
966 pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
968 if self.add_bos {
969 if let Some(b) = self.bos_token_id {
970 if ids.first() != Some(&b) {
971 ids.insert(0, b);
972 }
973 }
974 }
975 ids
976 }
977
978 pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
980 self.render_chat_opts(messages, None)
981 }
982
983 pub fn render_chat_opts(
985 &self,
986 messages: &[(String, String)],
987 enable_thinking: Option<bool>,
988 ) -> Option<String> {
989 let tpl = self.chat_template.as_ref()?;
990 match self.render_template(tpl, messages, enable_thinking) {
991 Ok(t) => Some(t),
992 Err(e) => {
993 tracing::error!("chat template render: {e:#}");
994 None
995 }
996 }
997 }
998
999 fn render_template(
1000 &self,
1001 tpl: &str,
1002 messages: &[(String, String)],
1003 enable_thinking: Option<bool>,
1004 ) -> Result<String, minijinja::Error> {
1005 let mut env = minijinja::Environment::new();
1006 env.set_trim_blocks(true);
1007 env.set_lstrip_blocks(true);
1008 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
1010 let tpl_src = strip_generation_tags(tpl);
1011 env.add_template("chat", &tpl_src)?;
1012 let msgs: Vec<minijinja::Value> = messages
1013 .iter()
1014 .map(|(role, content)| {
1015 minijinja::context! { role => role, content => content }
1016 })
1017 .collect();
1018 let rendered = match enable_thinking {
1021 Some(v) => env.get_template("chat")?.render(minijinja::context! {
1022 messages => msgs,
1023 add_generation_prompt => true,
1024 enable_thinking => v,
1025 })?,
1026 None => env.get_template("chat")?.render(minijinja::context! {
1027 messages => msgs,
1028 add_generation_prompt => true,
1029 })?,
1030 };
1031 if enable_thinking == Some(false) && !rendered.contains("</think>") {
1035 if let Some(pos) = rendered.rfind("assistant") {
1036 let mut insert_at = pos + "assistant".len();
1037 if let Some(idx) = rendered[insert_at..].find('\n') {
1038 insert_at += idx + 1;
1039 }
1040 let mut out = String::with_capacity(rendered.len() + 24);
1041 out.push_str(&rendered[..insert_at]);
1042 if !out.ends_with('\n') {
1043 out.push('\n');
1044 }
1045 out.push_str("<think>\n\n</think>\n\n");
1046 out.push_str(&rendered[insert_at..]);
1047 return Ok(out);
1048 }
1049 }
1050 Ok(rendered)
1051 }
1052
1053 fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
1055 self.chatml_fallback_opts(messages, None)
1056 }
1057
1058 fn chatml_fallback_opts(
1060 &self,
1061 messages: &[(String, String)],
1062 enable_thinking: Option<bool>,
1063 ) -> Vec<u32> {
1064 let mut tokens = Vec::new();
1065
1066 for (role, content) in messages {
1067 if let Some(start_id) = self.im_start_id {
1069 tokens.push(start_id);
1070 }
1071 tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1072 if let Some(end_id) = self.im_end_id {
1073 tokens.push(end_id);
1074 }
1075 tokens.extend(self.encode("\n"));
1076 }
1077
1078 if let Some(start_id) = self.im_start_id {
1080 tokens.push(start_id);
1081 }
1082 tokens.extend(self.encode("assistant\n"));
1083 if enable_thinking == Some(false) {
1084 tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1085 }
1086
1087 tokens
1088 }
1089
1090 pub fn vocab_size(&self) -> usize {
1092 self.id_to_token.len()
1093 }
1094
1095 pub fn is_eos(&self, id: u32) -> bool {
1097 self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1098 }
1099}
1100
1101#[derive(Debug, thiserror::Error)]
1102pub enum TokenizerError {
1103 #[error("IO error: {0}")]
1104 Io(String),
1105 #[error("Parse error: {0}")]
1106 Parse(String),
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112
1113 #[test]
1114 fn byte_unicode_bijection() {
1115 let (b2c, c2b) = bytes_to_unicode();
1116 for b in 0..=255u8 {
1117 assert_eq!(c2b[&b2c[b as usize]], b);
1118 }
1119 assert_eq!(b2c[b' ' as usize], 'Ġ');
1121 assert_eq!(b2c[b'\n' as usize], 'Ċ');
1122 }
1123
1124 #[test]
1125 fn byte_level_roundtrip_utf8() {
1126 let tok = Tokenizer::byte_level();
1127 let text = "hello 🌍 hi\n";
1128 let ids = tok.encode(text);
1129 assert_eq!(ids.len(), text.len()); assert_eq!(tok.decode(&ids), text);
1131 }
1132
1133 fn mini_json() -> String {
1136 let vocab: Vec<(&str, u32)> = vec![
1138 ("h", 0),
1139 ("e", 1),
1140 ("l", 2),
1141 ("o", 3),
1142 ("Ġ", 4),
1143 ("w", 5),
1144 ("r", 6),
1145 ("d", 7),
1146 ("he", 8),
1147 ("Ġw", 9),
1148 ];
1149 let vocab_json: String = vocab
1150 .iter()
1151 .map(|(t, i)| format!("\"{t}\": {i}"))
1152 .collect::<Vec<_>>()
1153 .join(", ");
1154 format!(
1155 r#"{{
1156 "model": {{
1157 "type": "BPE",
1158 "vocab": {{ {vocab_json} }},
1159 "merges": [["h", "e"], ["Ġ", "w"]]
1160 }},
1161 "added_tokens": [
1162 {{"id": 10, "content": "<|eot|>", "special": true}}
1163 ]
1164 }}"#
1165 )
1166 }
1167
1168 #[test]
1171 fn real_tokenizer_parity_when_available() {
1172 let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1173 return;
1174 };
1175 let t = Tokenizer::from_file(&path).expect("load");
1176 for (text, want) in [
1177 (
1178 "The capital of France is",
1179 vec![671u32, 6102, 294, 8760, 344],
1180 ),
1181 ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1182 ] {
1183 let got = t.encode(text);
1184 assert_eq!(got, want, "«{text}»");
1185 }
1186 }
1187
1188 #[test]
1194 fn every_split_in_a_sequence_is_applied() {
1195 let pt = serde_json::json!({
1196 "type": "Sequence",
1197 "pretokenizers": [
1198 {"type": "Split", "behavior": "Isolated",
1199 "pattern": {"Regex": r"\p{N}{1,3}"}},
1200 {"type": "Split", "behavior": "Isolated",
1201 "pattern": {"Regex": r" ?[\p{L}]+"}},
1202 {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1203 ]
1204 });
1205 let mut pats = Vec::new();
1206 collect_split_patterns(&pt, &mut pats);
1207 assert_eq!(
1208 pats.len(),
1209 2,
1210 "both Split stages must be collected: {pats:?}"
1211 );
1212 assert!(pats[0].contains("p{N}"), "digit rule first");
1213 assert!(pats[1].contains("p{L}"), "word rule second");
1214
1215 let re: Vec<fancy_regex::Regex> = pats
1219 .iter()
1220 .map(|p| fancy_regex::Regex::new(p).unwrap())
1221 .collect();
1222 let norm = "ab cd12";
1223 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1224 for r in &re {
1225 let mut next = Vec::new();
1226 for (ps, pe) in pieces {
1227 let seg = &norm[ps..pe];
1228 let mut last = 0;
1229 for m in r.find_iter(seg).flatten() {
1230 if m.start() > last {
1231 next.push((ps + last, ps + m.start()));
1232 }
1233 if m.end() > m.start() {
1234 next.push((ps + m.start(), ps + m.end()));
1235 }
1236 last = m.end();
1237 }
1238 if last < seg.len() {
1239 next.push((ps + last, pe));
1240 }
1241 }
1242 pieces = next;
1243 }
1244 let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1245 assert_eq!(
1246 got,
1247 vec!["ab", " cd", "12"],
1248 "staged split produced {got:?}"
1249 );
1250 }
1251
1252 #[test]
1253 fn full_pipeline_merges_and_added_tokens() {
1254 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1255 let ids = tok.encode("hello world");
1257 assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1258 assert_eq!(tok.decode(&ids), "hello world");
1259 let ids2 = tok.encode("he<|eot|>he");
1261 assert_eq!(ids2, vec![8, 10, 8]);
1262 assert_eq!(tok.decode(&ids2), "hehe");
1263 }
1264
1265 #[test]
1266 fn non_ascii_is_never_silently_dropped() {
1267 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1268 let ids = tok.encode("hello");
1271 assert!(!ids.is_empty());
1272 }
1273}
1274
1275#[cfg(test)]
1276mod generation_tag_tests {
1277 use super::strip_generation_tags;
1278
1279 #[test]
1284 fn a_generation_block_becomes_a_no_op_keeping_its_whitespace_control() {
1285 let tpl = "a{%- generation -%}b{%- endgeneration -%}c";
1286 let out = strip_generation_tags(tpl);
1287 assert!(!out.contains("{%- generation"));
1288 assert!(!out.contains("endgeneration"));
1289 assert_eq!(out.matches("{%-").count(), 2);
1291 assert_eq!(out.matches("-%}").count(), 2);
1292 assert!(out.starts_with('a') && out.ends_with('c'));
1293 }
1294
1295 #[test]
1298 fn each_side_keeps_its_own_dash() {
1299 let out = strip_generation_tags("{% generation %}x{%- endgeneration %}");
1300 assert!(out.starts_with("{% set"), "no dash added on the left");
1301 assert!(out.contains("{%- set"), "the right tag keeps its dash");
1302 assert!(!out.contains("-%}"), "no trailing dash invented");
1303 }
1304
1305 #[test]
1308 fn everything_else_is_left_alone() {
1309 let plain = "{%- if x -%}{{ y }}{%- endif -%}";
1310 assert_eq!(strip_generation_tags(plain), plain);
1311 let prose = "{{ 'the generation of tokens' }}";
1313 assert_eq!(strip_generation_tags(prose), prose);
1314 }
1315}