1pub mod chat;
11mod json;
12mod unicode;
13mod unicode_data;
14
15pub use chat::apply_chat_template_str;
16
17use memra_gguf::{GgufFile, MetaValue};
18use std::cmp::Ordering;
19use std::collections::{BinaryHeap, HashMap};
20
21const TT_UNKNOWN: i64 = 2;
23const TT_CONTROL: i64 = 3;
24const TT_USER_DEFINED: i64 = 4;
25const TT_BYTE: i64 = 6;
26const QWEN35_PRETOKENIZE_REGEX: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum TokAttr {
30 Normal,
31 Unknown,
32 Control,
33 UserDefined,
34 Byte,
35 Other,
36}
37
38impl TokAttr {
39 fn from_toktype(t: i64) -> Self {
40 match t {
41 TT_UNKNOWN => TokAttr::Unknown,
42 TT_CONTROL => TokAttr::Control,
43 TT_USER_DEFINED => TokAttr::UserDefined,
44 TT_BYTE => TokAttr::Byte,
45 1 => TokAttr::Normal,
46 _ => TokAttr::Other,
47 }
48 }
49 fn is_special(self) -> bool {
52 matches!(
53 self,
54 TokAttr::Control | TokAttr::UserDefined | TokAttr::Unknown
55 )
56 }
57}
58
59pub struct Tokenizer {
60 id_to_token: Vec<String>,
62 token_to_id: HashMap<String, u32>,
64 attrs: Vec<TokAttr>,
66 bpe_ranks: HashMap<(String, String), i32>,
68 special_tokens: Vec<u32>,
70 eos_id: u32,
71 bos_id: Option<u32>,
72 add_bos: bool,
73 pre: String,
74 chat_template: Option<String>,
75 spm_style: bool,
77}
78
79#[derive(Clone, Eq, PartialEq)]
84struct Bigram {
85 left: i32,
86 right: i32,
87 rank: i32,
88 text: String,
89}
90
91impl Ord for Bigram {
92 fn cmp(&self, other: &Self) -> Ordering {
93 match other.rank.cmp(&self.rank) {
96 Ordering::Equal => other.left.cmp(&self.left),
97 o => o,
98 }
99 }
100}
101impl PartialOrd for Bigram {
102 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
103 Some(self.cmp(other))
104 }
105}
106
107struct Symbol {
109 text: String,
110 prev: i32,
111 next: i32,
112 n: usize, }
114
115impl Tokenizer {
116 pub fn from_gguf(g: &GgufFile) -> Result<Self, String> {
118 let model = g
119 .metadata
120 .get("tokenizer.ggml.model")
121 .and_then(|v| v.as_str())
122 .ok_or("missing tokenizer.ggml.model")?;
123 if model != "gpt2" && model != "gemma4" {
124 return Err(format!(
125 "unsupported tokenizer model '{model}' (only gpt2/gemma4)"
126 ));
127 }
128 let spm_style = model == "gemma4";
132 let pre = g
133 .metadata
134 .get("tokenizer.ggml.pre")
135 .and_then(|v| v.as_str())
136 .unwrap_or(if spm_style { "gemma4" } else { "default" })
137 .to_string();
138
139 let tokens = match g.metadata.get("tokenizer.ggml.tokens") {
141 Some(MetaValue::Array(a)) => a,
142 _ => return Err("missing tokenizer.ggml.tokens array".into()),
143 };
144 let n = tokens.len();
145 let mut id_to_token = Vec::with_capacity(n);
146 let mut token_to_id = HashMap::with_capacity(n);
147 for (i, t) in tokens.iter().enumerate() {
148 let s = t.as_str().ok_or("non-string in tokens[]")?.to_string();
149 token_to_id.entry(s.clone()).or_insert(i as u32);
151 id_to_token.push(s);
152 }
153
154 let mut attrs = vec![TokAttr::Normal; n];
156 if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.token_type") {
157 for (i, v) in a.iter().enumerate().take(n) {
158 if let Some(t) = v.as_u64() {
159 attrs[i] = TokAttr::from_toktype(t as i64);
160 } else if let MetaValue::I32(t) = v {
161 attrs[i] = TokAttr::from_toktype(*t as i64);
162 }
163 }
164 }
165
166 let mut bpe_ranks = HashMap::new();
168 if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.merges") {
169 for (i, v) in a.iter().enumerate() {
170 let word = v.as_str().ok_or("non-string in merges[]")?;
171 let bytes = word.as_bytes();
175 if let Some(pos) = bytes.iter().skip(1).position(|&b| b == b' ').map(|p| p + 1) {
176 let first = word[..pos].to_string();
177 let second = word[pos + 1..].to_string();
178 bpe_ranks.insert((first, second), i as i32);
179 }
180 }
181 } else {
182 return Err("missing tokenizer.ggml.merges array".into());
183 }
184
185 let mut special_tokens: Vec<u32> = (0..n as u32)
187 .filter(|&id| attrs[id as usize].is_special())
188 .collect();
189 special_tokens.sort_by(|&a, &b| {
190 id_to_token[b as usize]
191 .len()
192 .cmp(&id_to_token[a as usize].len())
193 });
194
195 let eos_id = g
196 .metadata
197 .get("tokenizer.ggml.eos_token_id")
198 .and_then(|v| v.as_u64())
199 .map(|v| v as u32)
200 .ok_or("missing tokenizer.ggml.eos_token_id")?;
201 let bos_id = g
202 .metadata
203 .get("tokenizer.ggml.bos_token_id")
204 .and_then(|v| v.as_u64())
205 .map(|v| v as u32);
206 let add_bos = g
207 .metadata
208 .get("tokenizer.ggml.add_bos_token")
209 .and_then(|v| match v {
210 MetaValue::Bool(b) => Some(*b),
211 _ => v.as_u64().map(|x| x != 0),
212 })
213 .unwrap_or(false);
214 let add_bos = add_bos || spm_style;
215
216 let chat_template = g
217 .metadata
218 .get("tokenizer.chat_template")
219 .and_then(|v| v.as_str())
220 .map(|s| s.to_string());
221
222 Ok(Tokenizer {
223 id_to_token,
224 token_to_id,
225 attrs,
226 bpe_ranks,
227 special_tokens,
228 eos_id,
229 bos_id,
230 add_bos,
231 pre,
232 chat_template,
233 spm_style,
234 })
235 }
236
237 pub fn from_hf_dir(dir: &std::path::Path) -> Result<Self, String> {
254 let tj_path = dir.join("tokenizer.json");
255 let text = std::fs::read_to_string(&tj_path)
256 .map_err(|e| format!("read {}: {e}", tj_path.display()))?;
257 let tj = json::parse(&text).map_err(|e| format!("{}: {e}", tj_path.display()))?;
258
259 let model = tj.get("model").ok_or("tokenizer.json: missing model")?;
260 if let Some(t) = model.get("type").and_then(|v| v.as_str()) {
261 if t != "BPE" {
262 return Err(format!(
263 "unsupported tokenizer.json model type '{t}' (only BPE)"
264 ));
265 }
266 }
267 let pre_tok = tj
269 .get("pre_tokenizer")
270 .ok_or("tokenizer.json: missing pre_tokenizer")?;
271 if !pre_tokenizer_is_byte_level(pre_tok) {
272 return Err(
273 "tokenizer.json: pre_tokenizer is not ByteLevel — only byte-level \
274 BPE is supported"
275 .into(),
276 );
277 }
278
279 let vocab = model
281 .get("vocab")
282 .and_then(|v| v.as_obj())
283 .ok_or("tokenizer.json: missing model.vocab")?;
284 let empty: Vec<json::Value> = Vec::new();
285 let added = tj
286 .get("added_tokens")
287 .and_then(|v| v.as_arr())
288 .unwrap_or(&empty);
289 let mut max_id = 0u32;
290 for v in vocab.values() {
291 let id =
292 v.as_u64()
293 .ok_or("tokenizer.json: non-integer id in model.vocab")? as u32;
294 max_id = max_id.max(id);
295 }
296 for a in added {
297 if let Some(id) = a.get("id").and_then(|v| v.as_u64()) {
298 max_id = max_id.max(id as u32);
299 }
300 }
301 let n = max_id as usize + 1;
302 let mut id_to_token = vec![String::new(); n];
303 let mut token_to_id: HashMap<String, u32> = HashMap::with_capacity(n);
304 let mut attrs = vec![TokAttr::Normal; n];
305 for (tok, v) in vocab {
306 let id = v.as_u64().unwrap() as u32;
307 id_to_token[id as usize] = tok.clone();
308 token_to_id.entry(tok.clone()).or_insert(id);
309 }
310 for a in added {
313 let id =
314 a.get("id")
315 .and_then(|v| v.as_u64())
316 .ok_or("tokenizer.json: added_tokens entry missing id")? as u32;
317 let content = a
318 .get("content")
319 .and_then(|v| v.as_str())
320 .ok_or("tokenizer.json: added_tokens entry missing content")?;
321 if id_to_token[id as usize].is_empty() {
322 id_to_token[id as usize] = content.to_string();
323 }
324 token_to_id.entry(content.to_string()).or_insert(id);
325 if a.get("special").and_then(|v| v.as_bool()).unwrap_or(false) {
326 attrs[id as usize] = TokAttr::Control;
327 } else {
328 attrs[id as usize] = TokAttr::UserDefined;
334 }
335 }
336
337 let merges = model
339 .get("merges")
340 .and_then(|v| v.as_arr())
341 .ok_or("tokenizer.json: missing model.merges")?;
342 let mut bpe_ranks = HashMap::with_capacity(merges.len());
343 for (i, m) in merges.iter().enumerate() {
344 let (first, second) = match m {
345 json::Value::Str(s) => {
346 let bytes = s.as_bytes();
349 let pos = bytes
350 .iter()
351 .skip(1)
352 .position(|&b| b == b' ')
353 .map(|p| p + 1)
354 .ok_or_else(|| format!("tokenizer.json: merges[{i}] has no space"))?;
355 (s[..pos].to_string(), s[pos + 1..].to_string())
356 }
357 json::Value::Arr(a) if a.len() == 2 => {
358 let f = a[0]
359 .as_str()
360 .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
361 let s2 = a[1]
362 .as_str()
363 .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
364 (f.to_string(), s2.to_string())
365 }
366 _ => {
367 return Err(format!(
368 "tokenizer.json: merges[{i}] is neither \"a b\" string nor [a, b] pair"
369 ));
370 }
371 };
372 bpe_ranks.insert((first, second), i as i32);
373 }
374
375 let mut special_tokens: Vec<u32> = (0..n as u32)
377 .filter(|&id| attrs[id as usize].is_special())
378 .collect();
379 special_tokens.sort_by(|&a, &b| {
380 id_to_token[b as usize]
381 .len()
382 .cmp(&id_to_token[a as usize].len())
383 });
384
385 let tc = std::fs::read_to_string(dir.join("tokenizer_config.json"))
387 .ok()
388 .and_then(|t| json::parse(&t).ok());
389 let gc = std::fs::read_to_string(dir.join("generation_config.json"))
390 .ok()
391 .and_then(|t| json::parse(&t).ok());
392
393 let tok_content = |v: &json::Value| -> Option<String> {
395 v.as_str().map(|s| s.to_string()).or_else(|| {
396 v.get("content")
397 .and_then(|c| c.as_str())
398 .map(|s| s.to_string())
399 })
400 };
401 let eos_from_cfg = tc
402 .as_ref()
403 .and_then(|c| c.get("eos_token"))
404 .and_then(&tok_content)
405 .and_then(|s| token_to_id.get(&s).copied());
406 let eos_from_gen = gc
408 .as_ref()
409 .and_then(|c| c.get("eos_token_id"))
410 .and_then(|v| match v {
411 json::Value::Num(_) => v.as_u64(),
412 json::Value::Arr(a) => a.first().and_then(|x| x.as_u64()),
413 _ => None,
414 })
415 .map(|v| v as u32);
416 let eos_id = eos_from_cfg.or(eos_from_gen).ok_or(
417 "no eos token: need tokenizer_config.json eos_token or \
418 generation_config.json eos_token_id",
419 )?;
420 let bos_id = tc
421 .as_ref()
422 .and_then(|c| c.get("bos_token"))
423 .and_then(&tok_content)
424 .and_then(|s| token_to_id.get(&s).copied());
425 let add_bos = tc
426 .as_ref()
427 .and_then(|c| c.get("add_bos_token"))
428 .and_then(|v| v.as_bool())
429 .unwrap_or(false);
430
431 let chat_template = tc
433 .as_ref()
434 .and_then(|c| c.get("chat_template"))
435 .and_then(|v| v.as_str())
436 .map(|s| s.to_string())
437 .or_else(|| std::fs::read_to_string(dir.join("chat_template.jinja")).ok());
438 let pre = tc
439 .as_ref()
440 .and_then(|c| c.get("pretokenize_regex"))
441 .and_then(|v| v.as_str())
442 .filter(|regex| *regex == QWEN35_PRETOKENIZE_REGEX)
443 .map_or("default", |_| "qwen35");
444
445 Ok(Tokenizer {
446 id_to_token,
447 token_to_id,
448 attrs,
449 bpe_ranks,
450 special_tokens,
451 eos_id,
452 bos_id,
453 add_bos,
454 pre: pre.to_string(),
455 chat_template,
456 spm_style: false,
457 })
458 }
459
460 pub fn eos_id(&self) -> u32 {
461 self.eos_id
462 }
463 pub fn id_of(&self, piece: &str) -> Option<u32> {
465 self.token_to_id.get(piece).copied()
466 }
467 pub fn eog_ids(&self) -> Vec<u32> {
470 let mut ids = vec![self.eos_id];
471 for t in ["<|im_end|>", "<turn|>", "<end_of_turn>"] {
472 if let Some(&id) = self.token_to_id.get(t) {
473 if !ids.contains(&id) {
474 ids.push(id);
475 }
476 }
477 }
478 ids
479 }
480 pub fn bos_id(&self) -> Option<u32> {
481 self.bos_id
482 }
483 pub fn vocab_size(&self) -> usize {
484 self.id_to_token.len()
485 }
486 pub fn pre(&self) -> &str {
487 &self.pre
488 }
489 pub fn chat_template(&self) -> Option<&str> {
490 self.chat_template.as_deref()
491 }
492
493 #[inline]
494 fn text_to_token(&self, s: &str) -> Option<u32> {
495 self.token_to_id.get(s).copied()
496 }
497
498 fn find_bpe_rank(&self, left: &str, right: &str) -> i32 {
499 self.bpe_ranks
500 .get(&(left.to_string(), right.to_string()))
501 .copied()
502 .unwrap_or(-1)
503 }
504
505 pub fn encode(&self, text: &str, add_special: bool) -> Vec<u32> {
511 self.encode_special(text, add_special, true)
512 }
513
514 pub fn encode_special(&self, text: &str, add_special: bool, parse_special: bool) -> Vec<u32> {
515 let mut output: Vec<u32> = Vec::new();
516 if add_special && self.add_bos {
517 if let Some(b) = self.bos_id {
518 output.push(b);
519 }
520 }
521 if text.is_empty() {
522 return output;
523 }
524
525 for frag in self.st_partition(text, parse_special) {
527 match frag {
528 Fragment::Token(id) => output.push(id),
529 Fragment::Text(span) => self.bpe_tokenize(&span, &mut output),
530 }
531 }
532 output
533 }
534
535 fn st_partition(&self, text: &str, parse_special: bool) -> Vec<Fragment> {
537 let mut frags = vec![Fragment::Text(text.to_string())];
538 for &sid in &self.special_tokens {
539 let attr = self.attrs[sid as usize];
540 if !parse_special && matches!(attr, TokAttr::Control | TokAttr::Unknown) {
542 continue;
543 }
544 let needle = &self.id_to_token[sid as usize];
545 if needle.is_empty() {
546 continue;
547 }
548 let mut next: Vec<Fragment> = Vec::with_capacity(frags.len());
549 for f in frags.drain(..) {
550 match f {
551 Fragment::Token(id) => next.push(Fragment::Token(id)),
552 Fragment::Text(s) => {
553 let mut rest: &str = &s;
554 let mut acc = String::new();
555 while let Some(m) = rest.find(needle.as_str()) {
556 acc.push_str(&rest[..m]);
557 if !acc.is_empty() {
558 next.push(Fragment::Text(std::mem::take(&mut acc)));
559 }
560 next.push(Fragment::Token(sid));
561 rest = &rest[m + needle.len()..];
562 }
563 acc.push_str(rest);
564 if !acc.is_empty() {
565 next.push(Fragment::Text(acc));
566 }
567 }
568 }
569 }
570 frags = next;
571 }
572 frags
573 }
574
575 fn warn_unsupported_pre(pre: &str) {
579 use std::sync::OnceLock;
580 static WARNED: OnceLock<()> = OnceLock::new();
581 let pre = pre.to_string();
582 WARNED.get_or_init(move || {
583 eprintln!(
584 "memra-tokenizer: WARNING unsupported tokenizer.ggml.pre '{pre}' — falling back \
585 to the qwen35 pre-tokenizer split. Token ids will NOT be exact for this model."
586 );
587 });
588 }
589
590 fn bpe_tokenize(&self, text: &str, output: &mut Vec<u32>) {
592 if self.spm_style {
593 let escaped: String = text
596 .chars()
597 .map(|c| if c == ' ' { '\u{2581}' } else { c })
598 .collect();
599 let mut words: Vec<String> = Vec::new();
600 let mut cur = String::new();
601 let mut cur_nl: Option<bool> = None;
602 for c in escaped.chars() {
603 let nl = c == '\n';
604 if cur_nl != Some(nl) && !cur.is_empty() {
605 words.push(std::mem::take(&mut cur));
606 }
607 cur_nl = Some(nl);
608 cur.push(c);
609 }
610 if !cur.is_empty() {
611 words.push(cur);
612 }
613 for word in &words {
614 if word.chars().all(|c| c == '\n') {
616 if let Some(tok) = self.text_to_token(word) {
617 output.push(tok);
618 continue;
619 }
620 }
621 self.bpe_merge_word(word, output);
622 }
623 return;
624 }
625 let words: Vec<String> = match self.pre.as_str() {
627 "qwen35" => unicode::split_qwen35(text),
628 "deepseek-v3" => unicode::split_deepseek_v3(text),
632 "qwen2" => unicode::split_qwen35(text),
635 other => {
638 Self::warn_unsupported_pre(other);
639 unicode::split_qwen35(text)
640 }
641 };
642
643 for word in &words {
644 let word = unicode::byte_encode(word);
645 self.bpe_merge_word(&word, output);
646 }
647 }
648
649 fn bpe_merge_word(&self, word: &str, output: &mut Vec<u32>) {
652 {
653 let word = word.to_string();
654
655 let chars: Vec<char> = word.chars().collect();
657 let mut symbols: Vec<Symbol> = Vec::with_capacity(chars.len());
658 for (i, &c) in chars.iter().enumerate() {
659 symbols.push(Symbol {
660 text: c.to_string(),
661 prev: i as i32 - 1,
662 next: if i + 1 == chars.len() {
663 -1
664 } else {
665 i as i32 + 1
666 },
667 n: 1,
668 });
669 }
670
671 let mut queue: BinaryHeap<Bigram> = BinaryHeap::new();
673 for i in 1..symbols.len() {
674 self.add_bigram(&symbols, i as i32 - 1, i as i32, &mut queue);
675 }
676
677 while let Some(bigram) = queue.pop() {
679 let li = bigram.left as usize;
680 let ri = bigram.right as usize;
681 if symbols[li].n == 0 || symbols[ri].n == 0 {
682 continue;
683 }
684 let combined = format!("{}{}", symbols[li].text, symbols[ri].text);
685 if combined != bigram.text {
686 continue; }
688 symbols[li].text = combined;
690 symbols[li].n += symbols[ri].n;
691 symbols[ri].n = 0;
692 let r_next = symbols[ri].next;
693 symbols[li].next = r_next;
694 if r_next >= 0 {
695 symbols[r_next as usize].prev = bigram.left;
696 }
697 let l_prev = symbols[li].prev;
698 let l_next = symbols[li].next;
699 self.add_bigram(&symbols, l_prev, bigram.left, &mut queue);
700 self.add_bigram(&symbols, bigram.left, l_next, &mut queue);
701 }
702
703 for sym in &symbols {
705 if sym.n == 0 {
706 continue;
707 }
708 match self.text_to_token(&sym.text) {
709 Some(tok) => output.push(tok),
710 None => {
711 for b in sym.text.bytes() {
713 let bs = if self.spm_style {
714 format!("<0x{b:02X}>") } else {
716 (b as char).to_string()
717 };
718 if let Some(t) = self.text_to_token(&bs) {
719 output.push(t);
720 }
721 }
722 }
723 }
724 }
725 }
726 }
727
728 fn add_bigram(
729 &self,
730 symbols: &[Symbol],
731 left: i32,
732 right: i32,
733 queue: &mut BinaryHeap<Bigram>,
734 ) {
735 if left == -1 || right == -1 {
736 return;
737 }
738 let lt = &symbols[left as usize].text;
739 let rt = &symbols[right as usize].text;
740 let rank = self.find_bpe_rank(lt, rt);
741 if rank < 0 {
742 return;
743 }
744 queue.push(Bigram {
745 left,
746 right,
747 rank,
748 text: format!("{lt}{rt}"),
749 });
750 }
751
752 pub fn decode(&self, ids: &[u32]) -> String {
755 self.decode_special(ids, true)
756 }
757
758 pub fn token_is_control(&self, id: u32) -> bool {
763 match self.attrs.get(id as usize) {
764 Some(TokAttr::Control) | Some(TokAttr::Unknown) => true,
765 _ => false,
766 }
767 }
768
769 pub fn decode_special(&self, ids: &[u32], special: bool) -> String {
770 String::from_utf8_lossy(&self.decode_bytes_special(ids, special)).into_owned()
771 }
772
773 pub fn decode_bytes_special(&self, ids: &[u32], special: bool) -> Vec<u8> {
776 let mut bytes: Vec<u8> = Vec::new();
777 for &id in ids {
778 let i = id as usize;
779 if i >= self.id_to_token.len() {
780 continue;
781 }
782 let attr = self.attrs[i];
783 let piece = &self.id_to_token[i];
784 match attr {
785 TokAttr::Normal | TokAttr::Byte => {
786 if self.spm_style {
787 if matches!(attr, TokAttr::Byte)
789 || (piece.len() == 6
790 && piece.starts_with("<0x")
791 && piece.ends_with('>'))
792 {
793 if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
794 bytes.push(b);
795 continue;
796 }
797 }
798 for c in piece.chars() {
799 if c == '\u{2581}' {
800 bytes.push(b' ');
801 } else {
802 let mut buf = [0u8; 4];
803 bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
804 }
805 }
806 } else {
807 self.piece_to_bytes(piece, &mut bytes);
809 }
810 }
811 TokAttr::UserDefined => {
812 bytes.extend_from_slice(piece.as_bytes());
814 }
815 TokAttr::Control | TokAttr::Unknown => {
816 if special {
817 bytes.extend_from_slice(piece.as_bytes());
818 }
819 }
821 TokAttr::Other => {}
822 }
823 }
824 bytes
825 }
826
827 fn piece_to_bytes(&self, piece: &str, out: &mut Vec<u8>) {
828 for c in piece.chars() {
829 match unicode::unicode_to_byte(c) {
830 Some(b) => out.push(b),
831 None => {
832 let mut buf = [0u8; 4];
834 out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
835 }
836 }
837 }
838 }
839
840 pub fn apply_chat_template(
843 &self,
844 messages: &[(&str, &str)],
845 add_generation_prompt: bool,
846 ) -> String {
847 chat::apply_chat_template_str(
848 self.chat_template.as_deref(),
849 messages,
850 add_generation_prompt,
851 )
852 }
853
854 pub fn apply_chat_template_tools(
858 &self,
859 turns: &[chat::Turn],
860 add_generation_prompt: bool,
861 tools_json: &[String],
862 think: chat::ThinkMode,
863 reasoning_effort: Option<&str>,
864 ) -> Result<String, String> {
865 chat::apply_chat_template_tools(
866 self.chat_template.as_deref(),
867 turns,
868 add_generation_prompt,
869 tools_json,
870 think,
871 reasoning_effort,
872 )
873 }
874}
875
876enum Fragment {
877 Text(String),
878 Token(u32),
879}
880
881fn pre_tokenizer_is_byte_level(pt: &json::Value) -> bool {
884 match pt.get("type").and_then(|v| v.as_str()) {
885 Some("ByteLevel") => true,
886 Some("Sequence") => pt
887 .get("pretokenizers")
888 .and_then(|v| v.as_arr())
889 .map(|arr| arr.iter().any(pre_tokenizer_is_byte_level))
890 .unwrap_or(false),
891 _ => false,
892 }
893}
894
895#[cfg(test)]
896mod hf_tests {
897 use super::*;
898
899 const TOKENIZER_JSON: &str = r#"{
903 "version": "1.0",
904 "added_tokens": [
905 {"id": 15, "content": "<|end|>", "special": true},
906 {"id": 16, "content": "<think>", "special": false}
907 ],
908 "pre_tokenizer": {
909 "type": "Sequence",
910 "pretokenizers": [
911 {"type": "Split", "pattern": {"Regex": ""}, "behavior": "Isolated"},
912 {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false}
913 ]
914 },
915 "model": {
916 "type": "BPE",
917 "vocab": {
918 "h": 0, "e": 1, "l": 2, "o": 3, "Ġ": 4, "w": 5, "r": 6, "d": 7,
919 "he": 8, "ll": 9, "hell": 10, "hello": 11, "Ġw": 12, "or": 13, "!": 14
920 },
921 "merges": [
922 "h e",
923 ["l", "l"],
924 "he ll",
925 ["hell", "o"],
926 ["Ġ", "w"],
927 "o r"
928 ]
929 }
930 }"#;
931
932 fn write_fixture(
933 name: &str,
934 tokenizer_config: Option<&str>,
935 generation_config: Option<&str>,
936 jinja: Option<&str>,
937 ) -> std::path::PathBuf {
938 let dir = std::env::temp_dir().join(format!("memra-tok-hf-{name}-{}", std::process::id()));
939 let _ = std::fs::remove_dir_all(&dir);
940 std::fs::create_dir_all(&dir).unwrap();
941 std::fs::write(dir.join("tokenizer.json"), TOKENIZER_JSON).unwrap();
942 if let Some(tc) = tokenizer_config {
943 std::fs::write(dir.join("tokenizer_config.json"), tc).unwrap();
944 }
945 if let Some(gc) = generation_config {
946 std::fs::write(dir.join("generation_config.json"), gc).unwrap();
947 }
948 if let Some(j) = jinja {
949 std::fs::write(dir.join("chat_template.jinja"), j).unwrap();
950 }
951 dir
952 }
953
954 #[test]
955 fn hf_dir_encode_decode_roundtrip_and_specials() {
956 let tc = r#"{
958 "eos_token": {"content": "<|end|>", "lstrip": false},
959 "add_bos_token": false,
960 "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
961 "chat_template": "{{ messages }}<|end|>"
962 }"#;
963 let dir = write_fixture("full", Some(tc), None, None);
964 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
965
966 assert_eq!(tok.eos_id(), 15);
967 assert_eq!(tok.bos_id(), None);
968 assert_eq!(tok.pre(), "qwen35");
969 assert_eq!(tok.vocab_size(), 17); assert_eq!(tok.chat_template(), Some("{{ messages }}<|end|>"));
971
972 let ids = tok.encode("hello world", true);
976 assert_eq!(ids, vec![11, 12, 13, 2, 7]);
977 assert_eq!(tok.decode(&ids), "hello world");
978
979 let ids = tok.encode("hello<|end|> world", true);
981 assert_eq!(ids, vec![11, 15, 12, 13, 2, 7]);
982 assert_eq!(tok.decode_special(&ids, true), "hello<|end|> world");
984 assert_eq!(tok.decode_special(&ids, false), "hello world");
985
986 assert_eq!(tok.decode(&[16]), "<think>");
988 let _ = std::fs::remove_dir_all(&dir);
989 }
990
991 #[test]
992 fn hf_dir_generation_config_eos_fallback_and_jinja() {
993 let gc = r#"{"eos_token_id": [15, 14]}"#;
996 let dir = write_fixture("genconf", None, Some(gc), Some("JINJA {{ messages }}"));
997 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
998 assert_eq!(tok.eos_id(), 15);
999 assert!(!tok.encode("hello", true).is_empty());
1000 assert_eq!(tok.chat_template(), Some("JINJA {{ messages }}"));
1001 let _ = std::fs::remove_dir_all(&dir);
1002 }
1003
1004 #[test]
1005 fn hf_dir_rejects_non_byte_level() {
1006 let dir = std::env::temp_dir().join(format!("memra-tok-hf-nonbl-{}", std::process::id()));
1007 let _ = std::fs::remove_dir_all(&dir);
1008 std::fs::create_dir_all(&dir).unwrap();
1009 let bad = TOKENIZER_JSON.replace("\"ByteLevel\"", "\"Metaspace\"");
1010 std::fs::write(dir.join("tokenizer.json"), bad).unwrap();
1011 assert!(Tokenizer::from_hf_dir(&dir).is_err());
1012 let _ = std::fs::remove_dir_all(&dir);
1013 }
1014}