use ferrox_models::chat_template::TemplateError;
use serde_json::{json, Value};
const MAX_MARKER: usize = 64;
const ANSWER_A: &str = "ferroxprobeanswerone";
const ANSWER_B: &str = "ferroxprobeanswerthealternateone";
const USER_ONE: &str = "ferroxprobequestionone";
const USER_TWO: &str = "ferroxprobequestiontwo";
pub(crate) fn probe_end_of_turn<R>(mut render: R, bos: Option<&str>) -> Option<String>
where
R: FnMut(&[Value]) -> Result<String, TemplateError>,
{
let first = derive(&mut render, ANSWER_A, bos)?;
let second = derive(&mut render, ANSWER_B, bos)?;
if first != second {
return None;
}
if first.is_empty() || first.len() > MAX_MARKER {
return None;
}
if [ANSWER_A, ANSWER_B, USER_ONE, USER_TWO]
.iter()
.any(|p| first.contains(p))
{
return None;
}
Some(first)
}
fn derive<R>(render: &mut R, answer: &str, bos: Option<&str>) -> Option<String>
where
R: FnMut(&[Value]) -> Result<String, TemplateError>,
{
let user = |text: &str| json!({"role": "user", "content": text});
let assistant = |text: &str| json!({"role": "assistant", "content": text});
let closed = render(&[user(USER_ONE), assistant(answer), user(USER_TWO)]).ok()?;
let start = closed.find(answer)? + answer.len();
let rest = &closed[start..];
let gap = &rest[..rest.find(USER_TWO)?];
let one = render(&[user(USER_ONE)]).ok()?;
let at = one.find(USER_ONE)?;
let opener = &one[..at];
let user_terminator = one[at + USER_ONE.len()..].trim();
if !user_terminator.is_empty() && gap.starts_with(user_terminator) {
return Some(user_terminator.to_string());
}
let opener = bos.and_then(|b| opener.strip_prefix(b)).unwrap_or(opener);
let terminator = gap.strip_suffix(opener)?.trim();
if !terminator.is_empty() {
return Some(terminator.to_string());
}
let boundary = gap.trim_end();
(!boundary.is_empty() && boundary.len() <= MAX_MARKER).then(|| boundary.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn shaped(
bos: &'static str,
prefix: &'static str,
infix: &'static str,
suffix: &'static str,
) -> impl FnMut(&[Value]) -> Result<String, TemplateError> {
move |messages: &[Value]| {
let mut out = String::from(bos);
for m in messages {
out.push_str(prefix);
out.push_str(m["role"].as_str().unwrap());
out.push_str(infix);
out.push_str(m["content"].as_str().unwrap());
out.push_str(suffix);
}
Ok(out)
}
}
#[test]
fn chatml_terminator_is_derived_not_listed() {
let r = shaped("", "<|im_start|>", "\n", "<|im_end|>\n");
assert_eq!(
probe_end_of_turn(r, Some("<bos>")).as_deref(),
Some("<|im_end|>")
);
}
#[test]
fn a_leading_bos_does_not_confuse_the_opener() {
let r = shaped("<bos>", "<start_of_turn>", "\n", "<end_of_turn>\n");
assert_eq!(
probe_end_of_turn(r, Some("<bos>")).as_deref(),
Some("<end_of_turn>")
);
}
#[test]
fn gemma4_turn_marker_needs_no_arm_of_its_own() {
let r = shaped("<bos>", "<|turn>", "\n", "<turn|>\n");
assert_eq!(
probe_end_of_turn(r, Some("<bos>")).as_deref(),
Some("<turn|>")
);
}
#[test]
fn an_unknown_family_is_derived_like_any_other() {
let r = shaped("", "[[", "]]", "[[/turn]]\n");
assert_eq!(
probe_end_of_turn(r, Some("<bos>")).as_deref(),
Some("[[/turn]]")
);
}
#[test]
fn a_family_whose_two_turns_end_differently_is_not_truncated() {
let render = |messages: &[Value]| {
let mut out = String::from("<s>");
for m in messages {
let content = m["content"].as_str().unwrap();
match m["role"].as_str().unwrap() {
"user" => out.push_str(&format!("[INST] {content} [/INST]")),
_ => out.push_str(&format!("{content}</s>")),
}
}
Ok(out)
};
assert_eq!(
probe_end_of_turn(render, Some("<s>")).as_deref(),
Some("</s>")
);
}
#[test]
fn a_zephyr_shaped_template_keeps_its_whole_terminator() {
let render = |messages: &[Value]| {
let mut out = String::new();
for m in messages {
out.push_str(&format!(
"<|{}|>\n{}</s>\n",
m["role"].as_str().unwrap(),
m["content"].as_str().unwrap()
));
}
Ok(out)
};
assert_eq!(probe_end_of_turn(render, None).as_deref(), Some("</s>"));
}
#[test]
fn a_template_with_no_terminator_falls_back_to_the_next_turn() {
let render = |messages: &[Value]| {
let lines: Vec<String> = messages
.iter()
.map(|m| {
format!(
"{}: {}",
m["role"].as_str().unwrap(),
m["content"].as_str().unwrap()
)
})
.collect();
Ok(lines.join("\n"))
};
assert_eq!(probe_end_of_turn(render, None).as_deref(), Some("\nuser:"));
}
#[test]
fn a_prefill_template_still_reports_its_terminator() {
let render = |messages: &[Value]| {
let mut out = String::new();
for (i, m) in messages.iter().enumerate() {
let last = i + 1 == messages.len();
let role = m["role"].as_str().unwrap();
out.push_str("<|im_start|>");
out.push_str(role);
out.push('\n');
out.push_str(m["content"].as_str().unwrap());
if !(last && role == "assistant") {
out.push_str("<|im_end|>\n");
}
}
Ok(out)
};
assert_eq!(
probe_end_of_turn(render, None).as_deref(),
Some("<|im_end|>")
);
}
#[test]
fn a_content_dependent_gap_is_refused() {
let render = |messages: &[Value]| {
let mut out = String::new();
for m in messages {
out.push_str(m["role"].as_str().unwrap());
out.push(':');
let content = m["content"].as_str().unwrap();
out.push_str(content);
out.push_str(&format!("<end len={}>\n", content.len()));
}
Ok(out)
};
assert_eq!(probe_end_of_turn(render, None), None);
}
#[test]
fn a_template_that_cannot_render_the_probe_reports_nothing() {
let render = |_: &[Value]| Err(TemplateError::Render("probe".into()));
assert_eq!(probe_end_of_turn(render, None), None);
}
#[test]
fn an_overlong_gap_is_not_a_delimiter() {
let filler: &'static str = "x".repeat(MAX_MARKER + 1).leak();
let r = shaped("", "<|s|>", "\n", filler);
assert_eq!(probe_end_of_turn(r, Some("<bos>")), None);
}
}
#[cfg(test)]
mod real_checkpoints {
use super::probe_end_of_turn;
use crate::chat_template::{turn_render, PromptTemplate};
use ferrox_models::chat_template::ChatTemplate as JinjaTemplate;
fn open(file: &str) -> ferrox_gguf::ShardedGguf {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../models/");
ferrox_gguf::ShardedGguf::open(format!("{dir}{file}")).expect("open")
}
fn derived(file: &str) -> Option<String> {
let f = open(file);
let template = JinjaTemplate::from_gguf_metadata(
f.metadata_str("tokenizer.chat_template"),
None,
false,
false,
);
let bos = f.token_text("tokenizer.ggml.bos_token_id");
let eos = f.token_text("tokenizer.ggml.eos_token_id");
probe_end_of_turn(
turn_render(&template, bos.as_deref(), eos.as_deref()),
bos.as_deref(),
)
}
fn stop_string(file: &str) -> Option<String> {
let f = open(file);
PromptTemplate::from_source(
f.metadata_str("tokenizer.chat_template"),
f.token_text("tokenizer.ggml.bos_token_id"),
f.token_text("tokenizer.ggml.eos_token_id"),
)
.end_of_turn()
.map(str::to_string)
}
#[test]
#[ignore = "needs models/"]
fn every_family_reports_the_terminator_its_template_prints() {
for (file, want) in [
("Yi-1.5-6B-Chat-Q4_K_M.gguf", "<|im_end|>"),
("Qwen2.5-1.5B-Instruct-Q4_K_M.gguf", "<|im_end|>"),
("Qwen1.5-MoE-A2.7B-Chat-Q4_K_M.gguf", "<|im_end|>"),
("gemma-2-2b-it-Q4_K_M.gguf", "<end_of_turn>"),
("gemma-4-E2B-it-Q4_K_M.gguf", "<turn|>"),
("Llama-3.2-1B-Instruct-Q4_K_M.gguf", "<|eot_id|>"),
("Mistral-7B-Instruct-v0.2-Q4_K_M.gguf", "</s>"),
("tinyllama-1.1b-chat-v1.0.Q8_0.gguf", "</s>"),
("Phi-4-mini-instruct-Q4_K_M.gguf", "<|end|>"),
(
"DeepSeek-R1-Distill-Qwen-1.5B-Q4_K_M.gguf",
"<\u{ff5c}end\u{2581}of\u{2581}sentence\u{ff5c}>",
),
] {
assert_eq!(derived(file).as_deref(), Some(want), "{file}");
}
}
#[test]
#[ignore = "needs models/olmoe-1b-7b-0924-q4_0.gguf"]
fn a_checkpoint_with_no_template_stops_at_the_next_turn_label() {
assert_eq!(
derived("olmoe-1b-7b-0924-q4_0.gguf").as_deref(),
Some("\nuser:")
);
}
#[test]
#[ignore = "needs models/"]
fn a_terminator_that_is_not_the_eos_reaches_the_stop_set() {
assert_eq!(
stop_string("Yi-1.5-6B-Chat-Q4_K_M.gguf").as_deref(),
Some("<|im_end|>")
);
assert_eq!(
stop_string("gemma-2-2b-it-Q4_K_M.gguf").as_deref(),
Some("<end_of_turn>")
);
}
#[test]
#[ignore = "needs models/"]
fn a_terminator_that_is_the_eos_adds_no_stop_string() {
for file in [
"gemma-4-E2B-it-Q4_K_M.gguf",
"Llama-3.2-1B-Instruct-Q4_K_M.gguf",
"Mistral-7B-Instruct-v0.2-Q4_K_M.gguf",
"Qwen2.5-1.5B-Instruct-Q4_K_M.gguf",
"tinyllama-1.1b-chat-v1.0.Q8_0.gguf",
] {
assert_eq!(stop_string(file), None, "{file}");
}
}
}