use anyhow::Result;
fn gguf_bool(gguf: &mlx_native::gguf::GgufFile, key: &str) -> bool {
matches!(
gguf.metadata(key),
Some(mlx_native::gguf::MetadataValue::Bool(true))
)
}
fn resolve_token_id(
gguf: &mlx_native::gguf::GgufFile,
tokenizer: &tokenizers::Tokenizer,
metadata_key: &str,
) -> Option<u32> {
llama_cpp_special_token_id(gguf, metadata_key)
.and_then(|id| tokenizer.id_to_token(id).map(|_| id))
}
fn llama_cpp_special_token_id(
gguf: &mlx_native::gguf::GgufFile,
metadata_key: &str,
) -> Option<u32> {
if let Some(id) = gguf.metadata_u32(metadata_key) {
return Some(id);
}
let tokenizer_model = gguf.metadata_string("tokenizer.ggml.model")?;
llama_cpp_special_token_id_for_model(tokenizer_model, metadata_key)
}
fn llama_cpp_special_token_id_for_model(tokenizer_model: &str, metadata_key: &str) -> Option<u32> {
match (tokenizer_model, metadata_key) {
("gpt2", "tokenizer.ggml.bos_token_id") | ("gpt2", "tokenizer.ggml.eos_token_id") => {
Some(11)
}
_ => None,
}
}
pub fn tokenize_with_bos_eos_from_gguf(
gguf: &mlx_native::gguf::GgufFile,
tokenizer: &tokenizers::Tokenizer,
prompt_text: &str,
) -> Result<Vec<u32>> {
let encoding = tokenizer
.encode(prompt_text, false)
.map_err(|e| anyhow::anyhow!("Tokenization failed: {e}"))?;
let mut prompt_tokens: Vec<u32> = encoding.get_ids().to_vec();
if gguf_bool(gguf, "tokenizer.ggml.add_bos_token") {
if let Some(bos) = resolve_token_id(gguf, tokenizer, "tokenizer.ggml.bos_token_id") {
if prompt_tokens.first() != Some(&bos) {
prompt_tokens.insert(0, bos);
}
}
}
if gguf_bool(gguf, "tokenizer.ggml.add_eos_token") {
if let Some(eos) = resolve_token_id(gguf, tokenizer, "tokenizer.ggml.eos_token_id") {
if prompt_tokens.last() != Some(&eos) {
prompt_tokens.push(eos);
}
}
}
Ok(prompt_tokens)
}
pub fn resolve_bos_token_id(
gguf: &mlx_native::gguf::GgufFile,
tokenizer: &tokenizers::Tokenizer,
) -> Option<u32> {
if !gguf_bool(gguf, "tokenizer.ggml.add_bos_token") {
return None;
}
resolve_token_id(gguf, tokenizer, "tokenizer.ggml.bos_token_id")
}
pub fn fix_tokenizer_json_bos(
path: &std::path::Path,
bos_token_text: &str,
bos_token_id: u32,
) -> Result<bool> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("fix_tokenizer_json_bos: read {}: {e}", path.display()))?;
let mut tk: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!("fix_tokenizer_json_bos: parse JSON {}: {e}", path.display())
})?;
let pp = tk.get_mut("post_processor").ok_or_else(|| {
anyhow::anyhow!(
"fix_tokenizer_json_bos: tokenizer.json has no post_processor field at {}",
path.display()
)
})?;
let pp_type = pp.get("type").and_then(|v| v.as_str()).unwrap_or("");
if pp_type != "TemplateProcessing" {
anyhow::bail!(
"fix_tokenizer_json_bos: refusing to mutate post_processor of type {:?} \
(only TemplateProcessing is supported); file: {}",
pp_type,
path.display()
);
}
let single = pp
.get_mut("single")
.and_then(|v| v.as_array_mut())
.ok_or_else(|| {
anyhow::anyhow!(
"fix_tokenizer_json_bos: post_processor.single is not an array at {}",
path.display()
)
})?;
let already_starts_with_bos = single
.first()
.and_then(|v| v.get("SpecialToken"))
.and_then(|st| st.get("id"))
.and_then(|id| id.as_str())
== Some(bos_token_text);
if already_starts_with_bos {
return Ok(false);
}
let bos_entry = serde_json::json!({
"SpecialToken": {
"id": bos_token_text,
"type_id": 0,
}
});
single.insert(0, bos_entry);
let special_tokens = pp
.get_mut("special_tokens")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| {
anyhow::anyhow!(
"fix_tokenizer_json_bos: post_processor.special_tokens is not an object at {}",
path.display()
)
})?;
special_tokens.insert(
bos_token_text.to_string(),
serde_json::json!({
"id": bos_token_text,
"ids": [bos_token_id],
"tokens": [bos_token_text],
}),
);
let patched = serde_json::to_string_pretty(&tk)
.map_err(|e| anyhow::anyhow!("fix_tokenizer_json_bos: serialize: {e}"))?;
std::fs::write(path, patched)
.map_err(|e| anyhow::anyhow!("fix_tokenizer_json_bos: write {}: {e}", path.display()))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn synth_legacy_tokenizer_json() -> NamedTempFile {
let body = serde_json::json!({
"version": "1.0",
"truncation": null,
"padding": null,
"added_tokens": [
{"id": 0, "content": "<pad>", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true},
{"id": 1, "content": "<eos>", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true},
{"id": 2, "content": "<bos>", "single_word": false, "lstrip": false, "rstrip": false, "normalized": false, "special": true},
],
"normalizer": null,
"pre_tokenizer": {"type": "Whitespace"},
"post_processor": {
"type": "TemplateProcessing",
"single": [
{"Sequence": {"id": "A", "type_id": 0}}
],
"pair": [
{"Sequence": {"id": "A", "type_id": 0}},
{"Sequence": {"id": "B", "type_id": 1}}
],
"special_tokens": {}
},
"decoder": null,
"model": {
"type": "WordLevel",
"vocab": {
"<pad>": 0,
"<eos>": 1,
"<bos>": 2,
"hello": 10,
"world": 11
},
"unk_token": "<pad>"
}
});
let mut tmp = NamedTempFile::new().expect("tempfile");
tmp.write_all(serde_json::to_string(&body).unwrap().as_bytes())
.expect("write");
tmp
}
#[test]
fn raw_encode_misses_bos_when_post_processor_lacks_it_2026_05_23() {
let tmp = synth_legacy_tokenizer_json();
let tk = tokenizers::Tokenizer::from_file(tmp.path()).expect("load");
let enc_true = tk.encode("hello world", true).expect("encode true");
let enc_false = tk.encode("hello world", false).expect("encode false");
assert_eq!(enc_true.get_ids(), enc_false.get_ids());
assert_eq!(enc_true.get_ids(), &[10, 11]);
assert_ne!(enc_true.get_ids().first(), Some(&2u32));
}
#[test]
fn fix_tokenizer_json_bos_makes_encode_prepend_bos_2026_05_23() {
let tmp = synth_legacy_tokenizer_json();
let patched =
fix_tokenizer_json_bos(tmp.path(), "<bos>", 2).expect("fix_tokenizer_json_bos");
assert!(patched, "first patch should mutate the file");
let tk = tokenizers::Tokenizer::from_file(tmp.path()).expect("load patched");
let enc = tk.encode("hello world", true).expect("encode");
assert_eq!(enc.get_ids(), &[2, 10, 11], "BOS=2 should now be prepended");
let patched2 = fix_tokenizer_json_bos(tmp.path(), "<bos>", 2)
.expect("fix_tokenizer_json_bos idempotent");
assert!(!patched2, "second patch should be no-op (already fixed)");
}
}