use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::backends::gguf::types::MetaValue;
use crate::quantize::ggml_quants::ArchName;
const TOKEN_TYPE_NORMAL: i32 = 1;
const TOKEN_TYPE_UNKNOWN: i32 = 2;
const TOKEN_TYPE_CONTROL: i32 = 3;
const TOKEN_TYPE_USER_DEFINED: i32 = 4;
const TOKEN_TYPE_UNUSED: i32 = 5;
const TOKEN_TYPE_BYTE: i32 = 6;
#[derive(Debug)]
pub enum TokenizerError {
TokenizerJsonMissing { dir: String },
TokenizerJsonMalformed { dir: String, source: String },
TokenizerJsonMissingModel { dir: String },
ConfigMissingVocabSize { dir: String },
SpecialTokenUnresolvable {
which: &'static str,
token: String,
merged_vocab_size: usize,
},
AddedTokenIdOutOfRange { id: u32, vocab_size: usize },
}
impl std::fmt::Display for TokenizerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenizerError::TokenizerJsonMissing { dir } => write!(
f,
"tokenizer: no tokenizer.json in {dir} (convert-v2 requires one — \
producing a GGUF without tokenizer metadata yields llama.cpp's \
`key not found in model: tokenizer.ggml.model` rejection)"
),
TokenizerError::TokenizerJsonMalformed { dir, source } => write!(
f,
"tokenizer: tokenizer.json in {dir} is unreadable / malformed: {source}"
),
TokenizerError::TokenizerJsonMissingModel { dir } => write!(
f,
"tokenizer: tokenizer.json in {dir} is missing the `model` section \
(or `model.vocab` is empty / non-object)"
),
TokenizerError::ConfigMissingVocabSize { dir } => write!(
f,
"tokenizer: config.json in {dir} has no `vocab_size` (and no \
`text_config.vocab_size`). Refusing to fall back to \
max-observed-id+1 — that is the silent-corruption path that \
produced the 2026-04-30 DWQ48/46 broken GGUFs."
),
TokenizerError::SpecialTokenUnresolvable {
which,
token,
merged_vocab_size,
} => write!(
f,
"tokenizer: {which} = {token:?} declared in tokenizer_config.json \
but not present in the merged vocab of {merged_vocab_size} ids — \
same silent-corruption signature as the 2026-04-30 DWQ48/46 \
regression. Refusing to emit."
),
TokenizerError::AddedTokenIdOutOfRange { id, vocab_size } => write!(
f,
"tokenizer: added_token id {id} is >= resolved vocab_size {vocab_size}; \
emitting would gap-fill with `[PAD]` and silently drop the special \
token. Either config.json[vocab_size] is too small or \
tokenizer.json[added_tokens] has an out-of-range entry."
),
}
}
}
impl std::error::Error for TokenizerError {}
pub fn build_tokenizer_metadata(
model_dir: &Path,
arch: ArchName,
) -> Result<Vec<(String, MetaValue)>, TokenizerError> {
let tokenizer_path = model_dir.join("tokenizer.json");
if !tokenizer_path.exists() {
return Err(TokenizerError::TokenizerJsonMissing {
dir: model_dir.display().to_string(),
});
}
let tokenizer_json: serde_json::Value = match std::fs::read_to_string(&tokenizer_path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(v) => v,
Err(e) => {
return Err(TokenizerError::TokenizerJsonMalformed {
dir: model_dir.display().to_string(),
source: e.to_string(),
});
}
},
Err(e) => {
return Err(TokenizerError::TokenizerJsonMalformed {
dir: model_dir.display().to_string(),
source: e.to_string(),
});
}
};
let model_section =
tokenizer_json
.get("model")
.ok_or_else(|| TokenizerError::TokenizerJsonMissingModel {
dir: model_dir.display().to_string(),
})?;
let model_type = model_section
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut id_to_token: HashMap<u32, String>;
let base_vocab_size: usize;
let mut token_scores: Vec<f32> = Vec::new(); if model_type == "Unigram" {
let vocab_arr = model_section
.get("vocab")
.and_then(|v| v.as_array())
.ok_or_else(|| TokenizerError::TokenizerJsonMissingModel {
dir: model_dir.display().to_string(),
})?;
base_vocab_size = vocab_arr.len();
id_to_token = HashMap::with_capacity(base_vocab_size + 64);
token_scores = Vec::with_capacity(base_vocab_size);
for (idx, entry) in vocab_arr.iter().enumerate() {
let arr =
entry
.as_array()
.ok_or_else(|| TokenizerError::TokenizerJsonMissingModel {
dir: model_dir.display().to_string(),
})?;
let token = arr.first().and_then(|v| v.as_str()).ok_or_else(|| {
TokenizerError::TokenizerJsonMissingModel {
dir: model_dir.display().to_string(),
}
})?;
let score = arr.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
id_to_token.insert(idx as u32, token.to_string());
token_scores.push(score);
}
} else {
let vocab_obj = model_section
.get("vocab")
.and_then(|v| v.as_object())
.ok_or_else(|| TokenizerError::TokenizerJsonMissingModel {
dir: model_dir.display().to_string(),
})?;
base_vocab_size = vocab_obj.len();
id_to_token = HashMap::with_capacity(base_vocab_size + 64);
for (tok, id_val) in vocab_obj.iter() {
if let Some(id) = id_val.as_u64() {
id_to_token.insert(id as u32, tok.clone());
}
}
}
let tokenizer_config: Option<serde_json::Value> =
std::fs::read_to_string(model_dir.join("tokenizer_config.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok());
let added_tokens_arr = tokenizer_json
.get("added_tokens")
.and_then(|v| v.as_array());
let mut added_ids: HashSet<u32> = HashSet::new();
let mut added_special_flag: HashSet<u32> = HashSet::new();
if let Some(added) = added_tokens_arr {
for entry in added {
let id_opt = entry.get("id").and_then(|v| v.as_u64()).map(|x| x as u32);
let content_opt = entry.get("content").and_then(|v| v.as_str());
let is_special = entry
.get("special")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let (Some(id), Some(content)) = (id_opt, content_opt) {
id_to_token.insert(id, content.to_string());
added_ids.insert(id);
if is_special {
added_special_flag.insert(id);
}
}
}
}
if let Some(cfg) = tokenizer_config.as_ref() {
if let Some(decoder) = cfg.get("added_tokens_decoder").and_then(|v| v.as_object()) {
for (id_str, entry) in decoder {
let Ok(id) = id_str.parse::<u32>() else {
continue;
};
let Some(content) = entry.get("content").and_then(|v| v.as_str()) else {
continue;
};
let is_special = entry
.get("special")
.and_then(|v| v.as_bool())
.unwrap_or(false);
id_to_token.entry(id).or_insert_with(|| content.to_string());
added_ids.insert(id);
if is_special {
added_special_flag.insert(id);
}
}
}
}
let max_observed_id = id_to_token.keys().max().copied().unwrap_or(0) as usize;
let target_vocab_size = match read_full_vocab_size_from_config(model_dir) {
Some(v) => {
let v = v as usize;
if v < max_observed_id + 1 {
return Err(TokenizerError::ConfigMissingVocabSize {
dir: model_dir.display().to_string(),
});
}
v
}
None => {
return Err(TokenizerError::ConfigMissingVocabSize {
dir: model_dir.display().to_string(),
});
}
};
let unigram_realigned = model_type == "Unigram";
let mut tokens: Vec<String> = (0..target_vocab_size)
.map(|i| {
if unigram_realigned && i >= 4 {
format!("[PAD{}]", i - 1)
} else {
format!("[PAD{i}]")
}
})
.collect();
let mut filled_ids: HashSet<u32> = HashSet::new();
for (id, token) in &id_to_token {
if (*id as usize) < target_vocab_size {
tokens[*id as usize] = token.clone();
filled_ids.insert(*id);
}
}
if let Some(&out_of_range) = added_ids
.iter()
.find(|id| (**id as usize) >= target_vocab_size)
{
return Err(TokenizerError::AddedTokenIdOutOfRange {
id: out_of_range,
vocab_size: target_vocab_size,
});
}
let mut vocab_entries: Vec<(String, u32)> =
id_to_token.iter().map(|(id, t)| (t.clone(), *id)).collect();
vocab_entries.sort_by_key(|(_, id)| *id);
let visible_tokens: HashSet<&str> = [
"<|channel>",
"<channel|>",
"<|tool_call>",
"<tool_call|>",
"<|tool_response>",
"<tool_response|>",
"<|\"|>",
]
.iter()
.copied()
.collect();
let bos_id = resolve_special_token_id("bos_token", &tokenizer_config, &vocab_entries);
let eos_id = resolve_special_token_id("eos_token", &tokenizer_config, &vocab_entries);
let unk_id = resolve_special_token_id("unk_token", &tokenizer_config, &vocab_entries);
let pad_id = resolve_special_token_id("pad_token", &tokenizer_config, &vocab_entries);
let sep_id = resolve_special_token_id("sep_token", &tokenizer_config, &vocab_entries);
let mask_id = resolve_special_token_id("mask_token", &tokenizer_config, &vocab_entries);
let cls_id = resolve_special_token_id("cls_token", &tokenizer_config, &vocab_entries);
let scores: Vec<f32> = if model_type == "Unigram" {
let mut s = vec![-10000.0_f32; target_vocab_size];
for (i, score) in token_scores.iter().enumerate() {
if i < target_vocab_size {
s[i] = *score;
}
}
s
} else {
vec![-1000.0_f32; target_vocab_size]
};
let token_types: Vec<i32> = tokens
.iter()
.enumerate()
.map(|(id, token)| {
let id_u32 = id as u32;
if !filled_ids.contains(&id_u32) {
return TOKEN_TYPE_UNUSED;
}
if model_type == "Unigram" && unk_id == Some(id_u32) {
return TOKEN_TYPE_UNKNOWN;
}
if arch == ArchName::Gemma4 && visible_tokens.contains(token.as_str()) {
return TOKEN_TYPE_USER_DEFINED;
}
if is_byte_token(token) {
return TOKEN_TYPE_BYTE;
}
if added_ids.contains(&id_u32) {
if added_special_flag.contains(&id_u32) || does_token_look_special(token) {
return TOKEN_TYPE_CONTROL;
}
return TOKEN_TYPE_USER_DEFINED;
}
TOKEN_TYPE_NORMAL
})
.collect();
if let Some(cfg) = tokenizer_config.as_ref() {
if let Some(eos_str) = extract_special_token_string(cfg, "eos_token") {
if eos_id.is_none() {
return Err(TokenizerError::SpecialTokenUnresolvable {
which: "eos_token",
token: eos_str,
merged_vocab_size: target_vocab_size,
});
}
}
}
let tokenizer_model_name = determine_tokenizer_model_name(model_section, arch);
let pre_tokenizer = determine_pre_tokenizer_type(arch);
let merges = extract_merges(model_section);
let mut kv: Vec<(String, MetaValue)> = Vec::with_capacity(20);
if model_type == "Unigram" {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push(("tokenizer.ggml.scores".into(), MetaValue::ArrayF32(scores)));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
kv.push((
"tokenizer.ggml.add_space_prefix".into(),
MetaValue::Bool(true),
));
let type_vocab_size = read_type_vocab_size_from_config(model_dir).unwrap_or(1);
kv.push((
"tokenizer.ggml.token_type_count".into(),
MetaValue::U32(type_vocab_size),
));
kv.push((
"tokenizer.ggml.remove_extra_whitespaces".into(),
MetaValue::Bool(true),
));
if let Some(cm_bytes) = extract_precompiled_charsmap(&tokenizer_json) {
kv.push((
"tokenizer.ggml.precompiled_charsmap".into(),
MetaValue::ArrayU8(cm_bytes),
));
}
if let Some(id) = bos_id {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = unk_id {
kv.push(("tokenizer.ggml.unknown_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = sep_id {
kv.push((
"tokenizer.ggml.seperator_token_id".into(),
MetaValue::U32(id),
));
}
if let Some(id) = pad_id {
kv.push(("tokenizer.ggml.padding_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = mask_id {
kv.push(("tokenizer.ggml.mask_token_id".into(), MetaValue::U32(id)));
}
kv.push(("tokenizer.ggml.add_bos_token".into(), MetaValue::Bool(true)));
kv.push(("tokenizer.ggml.add_eos_token".into(), MetaValue::Bool(true)));
kv.push(("tokenizer.ggml.add_sep_token".into(), MetaValue::Bool(true)));
} else if arch == ArchName::MiniMaxM2 {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
if !merges.is_empty() {
kv.push((
"tokenizer.ggml.merges".into(),
MetaValue::ArrayString(merges),
));
}
if let Some(id) = bos_id {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = unk_id {
kv.push(("tokenizer.ggml.unknown_token_id".into(), MetaValue::U32(id)));
}
} else if matches!(
arch,
ArchName::Qwen3VlText | ArchName::Qwen35Moe | ArchName::Qwen35MoeFull
) {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
if !merges.is_empty() {
kv.push((
"tokenizer.ggml.merges".into(),
MetaValue::ArrayString(merges),
));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = pad_id {
kv.push(("tokenizer.ggml.padding_token_id".into(), MetaValue::U32(id)));
}
let bos_from_config = read_bos_token_id_from_config(model_dir);
if let Some(id) = bos_from_config.or(bos_id) {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(cfg) = tokenizer_config.as_ref() {
if let Some(v) = cfg.get("add_bos_token").and_then(|v| v.as_bool()) {
kv.push(("tokenizer.ggml.add_bos_token".into(), MetaValue::Bool(v)));
}
}
} else if arch == ArchName::Bert {
let phantom_tokens: Vec<String> = tokens
.iter()
.zip(token_types.iter())
.map(|(tok, ty)| {
if *ty == TOKEN_TYPE_CONTROL {
tok.clone()
} else if let Some(stripped) = tok.strip_prefix("##") {
stripped.to_string()
} else {
format!("\u{2581}{tok}")
}
})
.collect();
let type_vocab_size = read_type_vocab_size_from_config(model_dir).unwrap_or(1);
kv.push((
"tokenizer.ggml.token_type_count".into(),
MetaValue::U32(type_vocab_size),
));
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(phantom_tokens),
));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
let bos_resolved = bos_id.or(cls_id);
let eos_resolved = eos_id.or(sep_id);
if let Some(id) = bos_resolved {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_resolved {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = unk_id {
kv.push(("tokenizer.ggml.unknown_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = sep_id {
kv.push((
"tokenizer.ggml.seperator_token_id".into(),
MetaValue::U32(id),
));
}
if let Some(id) = pad_id {
kv.push(("tokenizer.ggml.padding_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = mask_id {
kv.push(("tokenizer.ggml.mask_token_id".into(), MetaValue::U32(id)));
}
kv.push(("tokenizer.ggml.add_bos_token".into(), MetaValue::Bool(true)));
kv.push(("tokenizer.ggml.add_eos_token".into(), MetaValue::Bool(true)));
kv.push((
"tokenizer.ggml.add_sep_token".into(),
MetaValue::Bool(false),
));
} else if arch == ArchName::Deepseek4 {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
if !merges.is_empty() {
kv.push((
"tokenizer.ggml.merges".into(),
MetaValue::ArrayString(merges),
));
}
if let Some(id) = bos_id {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = pad_id {
kv.push(("tokenizer.ggml.padding_token_id".into(), MetaValue::U32(id)));
}
let add_bos = tokenizer_config
.as_ref()
.and_then(|v| v.get("add_bos_token"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let add_eos = tokenizer_config
.as_ref()
.and_then(|v| v.get("add_eos_token"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
kv.push((
"tokenizer.ggml.add_bos_token".into(),
MetaValue::Bool(add_bos),
));
kv.push((
"tokenizer.ggml.add_eos_token".into(),
MetaValue::Bool(add_eos),
));
} else if arch == ArchName::Llama3 {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
if !merges.is_empty() {
kv.push((
"tokenizer.ggml.merges".into(),
MetaValue::ArrayString(merges),
));
}
if let Some(id) = bos_id {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
kv.push(("tokenizer.ggml.add_bos_token".into(), MetaValue::Bool(true)));
kv.push((
"tokenizer.ggml.add_sep_token".into(),
MetaValue::Bool(false),
));
} else if arch == ArchName::Gemma4 {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push(("tokenizer.ggml.scores".into(), MetaValue::ArrayF32(scores)));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
if !merges.is_empty() {
kv.push((
"tokenizer.ggml.merges".into(),
MetaValue::ArrayString(merges),
));
}
if let Some(id) = bos_id {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = unk_id {
kv.push(("tokenizer.ggml.unknown_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = pad_id {
kv.push(("tokenizer.ggml.padding_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = mask_id {
kv.push(("tokenizer.ggml.mask_token_id".into(), MetaValue::U32(id)));
}
let gemma_chat = resolve_gemma_chat_template(model_dir, &tokenizer_config, arch);
if let Some(tmpl) = &gemma_chat {
kv.push((
"tokenizer.chat_template".into(),
MetaValue::String(tmpl.clone()),
));
}
kv.push((
"tokenizer.ggml.add_space_prefix".into(),
MetaValue::Bool(false),
));
kv.push(("tokenizer.ggml.add_bos_token".into(), MetaValue::Bool(true)));
} else {
kv.push((
"tokenizer.ggml.model".into(),
MetaValue::String(tokenizer_model_name),
));
kv.push((
"tokenizer.ggml.tokens".into(),
MetaValue::ArrayString(tokens),
));
kv.push(("tokenizer.ggml.scores".into(), MetaValue::ArrayF32(scores)));
kv.push((
"tokenizer.ggml.token_type".into(),
MetaValue::ArrayI32(token_types),
));
if !merges.is_empty() {
kv.push((
"tokenizer.ggml.merges".into(),
MetaValue::ArrayString(merges),
));
}
if let Some(id) = bos_id {
kv.push(("tokenizer.ggml.bos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = eos_id {
kv.push(("tokenizer.ggml.eos_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = unk_id {
kv.push(("tokenizer.ggml.unknown_token_id".into(), MetaValue::U32(id)));
}
if let Some(id) = pad_id {
kv.push(("tokenizer.ggml.padding_token_id".into(), MetaValue::U32(id)));
}
kv.push(("tokenizer.ggml.add_bos_token".into(), MetaValue::Bool(true)));
kv.push((
"tokenizer.ggml.add_space_prefix".into(),
MetaValue::Bool(false),
));
kv.push((
"tokenizer.ggml.pre".into(),
MetaValue::String(pre_tokenizer),
));
}
if arch != ArchName::Gemma4 {
let template = resolve_gemma_chat_template(model_dir, &tokenizer_config, arch);
if let Some(tmpl) = template {
kv.push(("tokenizer.chat_template".into(), MetaValue::String(tmpl)));
}
}
Ok(kv)
}
fn resolve_gemma_chat_template(
model_dir: &Path,
tokenizer_config: &Option<serde_json::Value>,
arch: ArchName,
) -> Option<String> {
let chat_template_path = model_dir.join("chat_template.jinja");
if chat_template_path.exists() {
return std::fs::read_to_string(&chat_template_path).ok();
}
if let Some(s) = tokenizer_config
.as_ref()
.and_then(|c| c.get("chat_template"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
{
return Some(s);
}
crate::core::chat_templates::arch_default_chat_template(arch.name()).map(|s| s.to_string())
}
fn read_bos_token_id_from_config(dir: &Path) -> Option<u32> {
let path = dir.join("config.json");
let s = std::fs::read_to_string(&path).ok()?;
let v: serde_json::Value = serde_json::from_str(&s).ok()?;
v.get("bos_token_id")
.or_else(|| v.get("text_config").and_then(|tc| tc.get("bos_token_id")))
.and_then(|x| x.as_u64())
.map(|n| n as u32)
}
fn read_type_vocab_size_from_config(dir: &Path) -> Option<u32> {
let path = dir.join("config.json");
let s = std::fs::read_to_string(&path).ok()?;
let v: serde_json::Value = serde_json::from_str(&s).ok()?;
v.get("type_vocab_size")
.or_else(|| {
v.get("text_config")
.and_then(|tc| tc.get("type_vocab_size"))
})
.and_then(|x| x.as_u64())
.map(|n| n as u32)
}
fn extract_precompiled_charsmap(tokenizer_json: &serde_json::Value) -> Option<Vec<u8>> {
use base64::Engine as _;
let b64 = tokenizer_json
.get("normalizer")?
.get("precompiled_charsmap")?
.as_str()?;
base64::engine::general_purpose::STANDARD.decode(b64).ok()
}
fn read_full_vocab_size_from_config(dir: &Path) -> Option<u64> {
let path = dir.join("config.json");
let s = std::fs::read_to_string(&path).ok()?;
let v: serde_json::Value = serde_json::from_str(&s).ok()?;
v.get("text_config")
.and_then(|tc| tc.get("vocab_size"))
.and_then(|x| x.as_u64())
.or_else(|| v.get("vocab_size").and_then(|x| x.as_u64()))
}
fn is_byte_token(token: &str) -> bool {
let b = token.as_bytes();
b.len() == 6
&& b[0] == b'<'
&& b[1] == b'0'
&& b[2] == b'x'
&& b[3].is_ascii_hexdigit()
&& b[4].is_ascii_hexdigit()
&& b[5] == b'>'
}
fn does_token_look_special(token: &str) -> bool {
matches!(token, "<pad>" | "<mask>" | "<2mass>" | "[@BOS@]")
|| (token.starts_with("<|") && token.ends_with("|>"))
|| (token.starts_with("<\u{ff5c}") && token.ends_with("\u{ff5c}>"))
|| (token.starts_with("<unused") && token.ends_with('>'))
}
fn resolve_special_token_id(
key: &str,
config: &Option<serde_json::Value>,
vocab_entries: &[(String, u32)],
) -> Option<u32> {
let cfg = config.as_ref()?;
let v = cfg.get(key)?;
let token_str = v.as_str().map(|s| s.to_string()).or_else(|| {
v.get("content")
.and_then(|c| c.as_str())
.map(|s| s.to_string())
})?;
vocab_entries
.iter()
.find(|(t, _)| t == &token_str)
.map(|(_, id)| *id)
}
fn extract_special_token_string(config: &serde_json::Value, key: &str) -> Option<String> {
let v = config.get(key)?;
v.as_str().map(|s| s.to_string()).or_else(|| {
v.get("content")
.and_then(|c| c.as_str())
.map(|s| s.to_string())
})
}
fn determine_tokenizer_model_name(model_section: &serde_json::Value, arch: ArchName) -> String {
if arch == ArchName::Gemma4 {
return "gemma4".into();
}
if arch == ArchName::Bert {
return "bert".into();
}
let model_type = model_section
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
if arch == ArchName::NomicBert {
return if model_type == "Unigram" {
"t5".into()
} else {
"bert".into()
};
}
let byte_fallback = model_section
.get("byte_fallback")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if model_type == "BPE" {
if byte_fallback {
"llama".into()
} else {
"gpt2".into()
}
} else {
"llama".into()
}
}
fn determine_pre_tokenizer_type(arch: ArchName) -> String {
match arch {
ArchName::Qwen35Moe | ArchName::Qwen35MoeFull => "qwen35".into(),
ArchName::Qwen3VlText => "qwen2".into(),
ArchName::Gemma4 | ArchName::Gemma4Mmproj | ArchName::Gemma4VisionMmproj => "gemma4".into(),
ArchName::Llama3 => "llama-bpe".into(),
ArchName::MiniMaxM2 => "minimax-m2".into(),
ArchName::Deepseek4 => "deepseek-v3".into(),
ArchName::Bert => "jina-v2-en".into(),
ArchName::NomicBert => "default".into(),
ArchName::Falcon => "default".into(),
}
}
fn extract_merges(model_section: &serde_json::Value) -> Vec<String> {
let merges_val = match model_section.get("merges") {
Some(v) => v,
None => return Vec::new(),
};
let merges_arr = match merges_val.as_array() {
Some(a) if !a.is_empty() => a,
_ => return Vec::new(),
};
if merges_arr[0].is_string() {
merges_arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
} else if merges_arr[0].is_array() {
let space_replacement = '\u{0120}'; merges_arr
.iter()
.filter_map(|pair| {
let arr = pair.as_array()?;
if arr.len() != 2 {
return None;
}
let left = arr[0].as_str()?;
let right = arr[1].as_str()?;
let left_e: String = left
.chars()
.map(|c| if c == ' ' { space_replacement } else { c })
.collect();
let right_e: String = right
.chars()
.map(|c| if c == ' ' { space_replacement } else { c })
.collect();
Some(format!("{left_e} {right_e}"))
})
.collect()
} else {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_tiny_gemma4_tokenizer(dir: &Path) -> (u32, u32, usize) {
let mut vocab = serde_json::Map::new();
for (i, ch) in ('a'..='l').enumerate() {
vocab.insert(ch.to_string(), serde_json::json!(i as u64));
}
let tokenizer_json = serde_json::json!({
"model": {
"type": "BPE",
"byte_fallback": true,
"vocab": vocab,
"merges": [
["a", "b"],
["c", "d"]
]
},
"added_tokens": [
{"id": 12, "content": "<bos>", "special": true},
{"id": 13, "content": "<eos>", "special": true},
{"id": 14, "content": "<pad>", "special": true},
{"id": 15, "content": "<unk>", "special": true},
]
});
fs::write(
dir.join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer_json).unwrap(),
)
.unwrap();
let tokenizer_config = serde_json::json!({
"bos_token": "<bos>",
"eos_token": {"content": "<eos>"},
"pad_token": "<pad>",
"unk_token": "<unk>",
"add_bos_token": true,
"add_eos_token": false,
});
fs::write(
dir.join("tokenizer_config.json"),
serde_json::to_string_pretty(&tokenizer_config).unwrap(),
)
.unwrap();
let config = serde_json::json!({
"model_type": "gemma4",
"architectures": ["Gemma4ForConditionalGeneration"],
"text_config": {
"vocab_size": 16
}
});
fs::write(
dir.join("config.json"),
serde_json::to_string_pretty(&config).unwrap(),
)
.unwrap();
(12, 13, 16)
}
#[test]
fn gemma4_tokenizer_emits_canonical_kv() {
let tmp = tempfile::tempdir().unwrap();
let (bos_id, eos_id, vocab_size) = write_tiny_gemma4_tokenizer(tmp.path());
let kv = build_tokenizer_metadata(tmp.path(), ArchName::Gemma4)
.expect("must succeed for a well-formed Gemma 4 fixture");
let keys: HashSet<&str> = kv.iter().map(|(k, _)| k.as_str()).collect();
for required in [
"tokenizer.ggml.model",
"tokenizer.ggml.tokens",
"tokenizer.ggml.scores",
"tokenizer.ggml.token_type",
"tokenizer.ggml.merges",
"tokenizer.ggml.bos_token_id",
"tokenizer.ggml.eos_token_id",
"tokenizer.ggml.unknown_token_id",
"tokenizer.ggml.padding_token_id",
"tokenizer.ggml.add_bos_token",
"tokenizer.ggml.add_space_prefix",
] {
assert!(keys.contains(required), "missing key {required}");
}
assert!(
!keys.contains("tokenizer.ggml.pre"),
"Gemma 4 canonical does NOT emit tokenizer.ggml.pre"
);
let model_name = lookup_str(&kv, "tokenizer.ggml.model");
assert_eq!(model_name, "gemma4");
let bos = lookup_u32(&kv, "tokenizer.ggml.bos_token_id");
assert_eq!(bos, bos_id);
let eos = lookup_u32(&kv, "tokenizer.ggml.eos_token_id");
assert_eq!(eos, eos_id);
let add_bos = lookup_bool(&kv, "tokenizer.ggml.add_bos_token");
assert!(add_bos);
let add_space = lookup_bool(&kv, "tokenizer.ggml.add_space_prefix");
assert!(!add_space);
let tokens = match kv.iter().find(|(k, _)| k == "tokenizer.ggml.tokens") {
Some((_, MetaValue::ArrayString(v))) => v.clone(),
other => panic!("tokens not ArrayString: {other:?}"),
};
assert_eq!(tokens.len(), vocab_size);
assert_eq!(tokens[0], "a");
assert_eq!(tokens[11], "l");
assert_eq!(tokens[12], "<bos>");
assert_eq!(tokens[13], "<eos>");
let scores = match kv.iter().find(|(k, _)| k == "tokenizer.ggml.scores") {
Some((_, MetaValue::ArrayF32(v))) => v.clone(),
other => panic!("scores not ArrayF32: {other:?}"),
};
assert_eq!(scores.len(), vocab_size);
for s in scores {
assert_eq!(s, -1000.0);
}
let toktypes = match kv.iter().find(|(k, _)| k == "tokenizer.ggml.token_type") {
Some((_, MetaValue::ArrayI32(v))) => v.clone(),
other => panic!("token_type not ArrayI32: {other:?}"),
};
assert_eq!(toktypes.len(), vocab_size);
for v in &toktypes[..12] {
assert_eq!(*v, TOKEN_TYPE_NORMAL);
}
for v in &toktypes[12..16] {
assert_eq!(*v, TOKEN_TYPE_CONTROL);
}
let merges = match kv.iter().find(|(k, _)| k == "tokenizer.ggml.merges") {
Some((_, MetaValue::ArrayString(v))) => v.clone(),
other => panic!("merges not ArrayString: {other:?}"),
};
assert_eq!(merges, vec!["a b".to_string(), "c d".to_string()]);
}
#[test]
fn llama3_tokenizer_emits_llama_model_and_llama_bpe_pre() {
let tmp = tempfile::tempdir().unwrap();
let tokenizer_json = serde_json::json!({
"model": {
"type": "BPE",
"byte_fallback": true,
"vocab": {"a": 0, "b": 1, "c": 2, "d": 3},
},
"added_tokens": [
{"id": 4, "content": "<|begin_of_text|>", "special": true},
{"id": 5, "content": "<|end_of_text|>", "special": true},
]
});
fs::write(
tmp.path().join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer_json).unwrap(),
)
.unwrap();
let cfg = serde_json::json!({
"vocab_size": 6,
"bos_token": "<|begin_of_text|>",
"eos_token": "<|end_of_text|>"
});
fs::write(
tmp.path().join("tokenizer_config.json"),
serde_json::to_string_pretty(&serde_json::json!({
"bos_token": "<|begin_of_text|>",
"eos_token": "<|end_of_text|>"
}))
.unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("config.json"),
serde_json::to_string_pretty(&cfg).unwrap(),
)
.unwrap();
let kv = build_tokenizer_metadata(tmp.path(), ArchName::Llama3).unwrap();
assert_eq!(lookup_str(&kv, "tokenizer.ggml.model"), "llama");
assert_eq!(lookup_str(&kv, "tokenizer.ggml.pre"), "llama-bpe");
assert_eq!(lookup_u32(&kv, "tokenizer.ggml.bos_token_id"), 4);
assert_eq!(lookup_u32(&kv, "tokenizer.ggml.eos_token_id"), 5);
}
#[test]
fn missing_tokenizer_json_errors() {
let tmp = tempfile::tempdir().unwrap();
let err = build_tokenizer_metadata(tmp.path(), ArchName::Gemma4)
.expect_err("must error on missing tokenizer.json");
assert!(matches!(err, TokenizerError::TokenizerJsonMissing { .. }));
}
#[test]
fn missing_vocab_size_in_config_errors() {
let tmp = tempfile::tempdir().unwrap();
let tokenizer_json = serde_json::json!({
"model": {
"type": "BPE",
"byte_fallback": true,
"vocab": {"a": 0, "b": 1}
}
});
fs::write(
tmp.path().join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer_json).unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("config.json"),
serde_json::to_string_pretty(&serde_json::json!({"model_type": "llama"})).unwrap(),
)
.unwrap();
let err = build_tokenizer_metadata(tmp.path(), ArchName::Llama3)
.expect_err("must error when vocab_size is missing");
assert!(matches!(err, TokenizerError::ConfigMissingVocabSize { .. }));
}
#[test]
fn eos_token_unresolvable_errors() {
let tmp = tempfile::tempdir().unwrap();
let tokenizer_json = serde_json::json!({
"model": {
"type": "BPE",
"byte_fallback": true,
"vocab": {"a": 0, "b": 1}
}
});
fs::write(
tmp.path().join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer_json).unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("config.json"),
serde_json::to_string_pretty(&serde_json::json!({"vocab_size": 2})).unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("tokenizer_config.json"),
serde_json::to_string_pretty(&serde_json::json!({
"eos_token": "<|eom|>"
}))
.unwrap(),
)
.unwrap();
let err = build_tokenizer_metadata(tmp.path(), ArchName::Llama3)
.expect_err("must error when eos is unresolvable");
assert!(matches!(
err,
TokenizerError::SpecialTokenUnresolvable { .. }
));
}
#[test]
fn qwen35moe_dispatch_emits_qwen35_pre() {
let tmp = tempfile::tempdir().unwrap();
let tokenizer_json = serde_json::json!({
"model": {
"type": "BPE",
"byte_fallback": false,
"vocab": {"a": 0, "b": 1}
}
});
fs::write(
tmp.path().join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer_json).unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("config.json"),
serde_json::to_string_pretty(&serde_json::json!({"vocab_size": 2})).unwrap(),
)
.unwrap();
let kv = build_tokenizer_metadata(tmp.path(), ArchName::Qwen35Moe).unwrap();
assert_eq!(lookup_str(&kv, "tokenizer.ggml.model"), "gpt2");
assert_eq!(lookup_str(&kv, "tokenizer.ggml.pre"), "qwen35");
}
#[test]
fn deepseek4_emits_0731_tokenizer_contract() {
let tmp = tempfile::tempdir().unwrap();
let tokenizer = serde_json::json!({
"model": {
"type": "BPE",
"byte_fallback": false,
"vocab": {"x": 2, "y": 3},
"merges": ["x y"]
},
"added_tokens": [
{"id": 0, "content": "<|begin▁of▁sentence|>", "special": true},
{"id": 1, "content": "<|end▁of▁sentence|>", "special": true},
{"id": 4, "content": "<|User|>", "special": false},
{"id": 5, "content": "<|Assistant|>", "special": false},
{"id": 6, "content": "<think>", "special": false},
{"id": 7, "content": "</think>", "special": false},
{"id": 8, "content": "|DSML|", "special": false}
]
});
fs::write(
tmp.path().join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer).unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("config.json"),
serde_json::to_string_pretty(&serde_json::json!({"vocab_size": 9})).unwrap(),
)
.unwrap();
fs::write(
tmp.path().join("tokenizer_config.json"),
serde_json::to_string_pretty(&serde_json::json!({
"bos_token": "<|begin▁of▁sentence|>",
"eos_token": "<|end▁of▁sentence|>",
"pad_token": "<|end▁of▁sentence|>",
"add_bos_token": false,
"add_eos_token": false
}))
.unwrap(),
)
.unwrap();
let kv = build_tokenizer_metadata(tmp.path(), ArchName::Deepseek4).unwrap();
assert_eq!(lookup_str(&kv, "tokenizer.ggml.model"), "gpt2");
assert_eq!(lookup_str(&kv, "tokenizer.ggml.pre"), "deepseek-v3");
assert_eq!(lookup_u32(&kv, "tokenizer.ggml.bos_token_id"), 0);
assert_eq!(lookup_u32(&kv, "tokenizer.ggml.eos_token_id"), 1);
assert_eq!(lookup_u32(&kv, "tokenizer.ggml.padding_token_id"), 1);
assert!(!lookup_bool(&kv, "tokenizer.ggml.add_bos_token"));
assert!(!lookup_bool(&kv, "tokenizer.ggml.add_eos_token"));
assert_eq!(
lookup_str(&kv, "tokenizer.chat_template"),
crate::core::chat_templates::DEEPSEEK_V4_FLASH_0731
);
let keys: Vec<&str> = kv.iter().map(|(key, _)| key.as_str()).collect();
assert!(!keys.contains(&"tokenizer.ggml.scores"));
assert!(!keys.contains(&"tokenizer.ggml.add_space_prefix"));
}
#[test]
fn does_token_look_special_pinned_pattern() {
for s in &[
"<|im_end|>",
"<|tool_call|>",
"<pad>",
"<mask>",
"<unused0>",
] {
assert!(does_token_look_special(s), "{s} should look special");
}
for s in &["abc", "<unk>", "<>", "<", ">", "<eos>", "<think>"] {
assert!(!does_token_look_special(s), "{s} should NOT look special");
}
}
#[test]
fn is_byte_token_pinned_pattern() {
for s in &["<0x00>", "<0xff>", "<0xAB>", "<0x1f>"] {
assert!(is_byte_token(s), "{s} should be a byte token");
}
for s in &["<0x0>", "<0xfff>", "<0xZZ>", "abc"] {
assert!(!is_byte_token(s), "{s} should NOT be a byte token");
}
}
fn lookup_str(kv: &[(String, MetaValue)], key: &str) -> String {
match kv.iter().find(|(k, _)| k == key) {
Some((_, MetaValue::String(s))) => s.clone(),
other => panic!("expected String at {key}, got {other:?}"),
}
}
fn lookup_u32(kv: &[(String, MetaValue)], key: &str) -> u32 {
match kv.iter().find(|(k, _)| k == key) {
Some((_, MetaValue::U32(v))) => *v,
other => panic!("expected U32 at {key}, got {other:?}"),
}
}
fn lookup_bool(kv: &[(String, MetaValue)], key: &str) -> bool {
match kv.iter().find(|(k, _)| k == key) {
Some((_, MetaValue::Bool(v))) => *v,
other => panic!("expected Bool at {key}, got {other:?}"),
}
}
}