#[derive(Debug, Clone, PartialEq)]
pub struct ToolCall {
pub name: String,
pub params: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Turn {
pub role: String,
pub content: String,
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThinkMode {
Default,
NoThink,
Think,
}
pub fn apply_chat_template_str(
template: Option<&str>,
messages: &[(&str, &str)],
add_generation_prompt: bool,
) -> String {
if template.is_some_and(|t| t.contains("hy_User")) {
return apply_hy3_template(messages, add_generation_prompt, "no_think");
}
if template.is_some_and(|t| t.contains("render_message_content")) {
let turns: Vec<Turn> = messages.iter().map(|(r, c)| Turn {
role: r.to_string(), content: c.to_string(), tool_calls: Vec::new(),
}).collect();
return apply_step35_template(&turns, add_generation_prompt, &[], None);
}
if template.is_some_and(|t| t.contains("<|turn>")) {
return apply_gemma4_template(messages, add_generation_prompt, false);
}
let qwen_think = template
.map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
.unwrap_or(false);
let mut out = String::new();
for (i, (role, content)) in messages.iter().enumerate() {
let content = content.trim();
match *role {
"system" => {
let _ = i;
out.push_str("<|im_start|>system\n");
out.push_str(content);
out.push_str("<|im_end|>\n");
}
"user" => {
out.push_str("<|im_start|>user\n");
out.push_str(content);
out.push_str("<|im_end|>\n");
}
"assistant" => {
out.push_str("<|im_start|>assistant\n");
out.push_str(content);
out.push_str("<|im_end|>\n");
}
other => {
out.push_str("<|im_start|>");
out.push_str(other);
out.push('\n');
out.push_str(content);
out.push_str("<|im_end|>\n");
}
}
}
if add_generation_prompt {
out.push_str("<|im_start|>assistant\n");
if qwen_think {
out.push_str("<think>\n");
}
}
out
}
const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
your function call in natural language BEFORE the function call, but NOT after\n- If there is \
no function call available, answer the question like normal with your current knowledge and do \
not tell the user about function calls\n</IMPORTANT>";
pub fn apply_chat_template_tools(
template: Option<&str>,
turns: &[Turn],
add_generation_prompt: bool,
tools_json: &[String],
think: ThinkMode,
reasoning_effort: Option<&str>,
) -> Result<String, String> {
let has_tool_features = !tools_json.is_empty()
|| turns.iter().any(|t| t.role == "tool" || !t.tool_calls.is_empty());
let tools_branch = template.is_some_and(|t| t.contains("<tools>"));
if has_tool_features && !tools_branch {
return Err("model chat template has no tools branch".into());
}
if template.is_some_and(|t| t.contains("render_message_content")) {
return Ok(apply_step35_template(turns, add_generation_prompt, tools_json,
reasoning_effort));
}
if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
if has_tool_features {
return Err("tools are not supported on this model's chat-template dialect".into());
}
let messages: Vec<(&str, &str)> =
turns.iter().map(|t| (t.role.as_str(), t.content.as_str())).collect();
if template.is_some_and(|t| t.contains("hy_User")) {
let effort = match (think, reasoning_effort) {
(ThinkMode::Think, Some("high")) => "high",
(ThinkMode::Think, _) => "low",
_ => "no_think",
};
return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
}
return Ok(apply_gemma4_template(&messages, add_generation_prompt,
think == ThinkMode::Think));
}
let qwen_think = template
.map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
.unwrap_or(false);
let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
let mut out = String::new();
let mut skip_leading_system = false;
if !tools_json.is_empty() {
out.push_str("<|im_start|>system\n");
out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
for tool in tools_json {
out.push('\n');
out.push_str(tool);
}
out.push_str("\n</tools>");
out.push_str(QWEN_TOOLS_INSTRUCTION);
if let Some(first) = turns.first() {
if first.role == "system" {
skip_leading_system = true;
let content = first.content.trim();
if !content.is_empty() {
out.push_str("\n\n");
out.push_str(content);
}
}
}
out.push_str("<|im_end|>\n");
}
for (i, turn) in turns.iter().enumerate() {
if i == 0 && skip_leading_system {
continue;
}
let content = turn.content.trim();
match turn.role.as_str() {
"system" => {
out.push_str("<|im_start|>system\n");
out.push_str(content);
out.push_str("<|im_end|>\n");
}
"user" => {
out.push_str("<|im_start|>user\n");
out.push_str(content);
out.push_str("<|im_end|>\n");
}
"assistant" => {
out.push_str("<|im_start|>assistant\n");
out.push_str(content);
for (k, call) in turn.tool_calls.iter().enumerate() {
if k == 0 {
if !content.is_empty() {
out.push_str("\n\n");
}
} else {
out.push('\n');
}
out.push_str("<tool_call>\n<function=");
out.push_str(&call.name);
out.push_str(">\n");
for (key, value) in &call.params {
out.push_str("<parameter=");
out.push_str(key);
out.push_str(">\n");
out.push_str(value);
out.push_str("\n</parameter>\n");
}
out.push_str("</function>\n</tool_call>");
}
out.push_str("<|im_end|>\n");
}
"tool" => {
if i == 0 || turns[i - 1].role != "tool" {
out.push_str("<|im_start|>user");
}
out.push_str("\n<tool_response>\n");
out.push_str(content);
out.push_str("\n</tool_response>");
if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
out.push_str("<|im_end|>\n");
}
}
other => {
out.push_str("<|im_start|>");
out.push_str(other);
out.push('\n');
out.push_str(content);
out.push_str("<|im_end|>\n");
}
}
}
if add_generation_prompt {
out.push_str("<|im_start|>assistant\n");
if qwen_think {
if think == ThinkMode::NoThink && think_switch {
out.push_str("<think>\n\n</think>\n\n");
} else {
out.push_str("<think>\n");
}
}
}
Ok(out)
}
const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
fn apply_step35_template(turns: &[Turn], add_generation_prompt: bool, tools_json: &[String],
reasoning_effort: Option<&str>) -> String {
let mut out = String::new();
let leading_system = turns.first().filter(|t| t.role == "system");
if !tools_json.is_empty() {
out.push_str("<|im_start|>system\n");
if let Some(effort) = reasoning_effort {
out.push_str("Reasoning: ");
out.push_str(effort);
out.push_str("\n\n");
}
if let Some(sys) = leading_system {
out.push_str(&sys.content);
out.push_str("\n\n");
}
out.push_str("# Tools\n\nYou have access to the following functions in JSONSchema \
format:\n\n<tools>");
for tool in tools_json {
out.push('\n');
out.push_str(tool);
}
out.push_str("\n</tools>");
out.push_str(STEP35_TOOLS_INSTRUCTION);
out.push_str("<|im_end|>\n");
} else if let Some(sys) = leading_system {
out.push_str("<|im_start|>system\n");
if let Some(effort) = reasoning_effort {
out.push_str("Reasoning: ");
out.push_str(effort);
out.push_str("\n\n");
}
out.push_str(&sys.content);
out.push_str("<|im_end|>\n");
} else if let Some(effort) = reasoning_effort {
out.push_str("<|im_start|>system\nReasoning: ");
out.push_str(effort);
out.push_str("\n\n<|im_end|>\n");
}
let last_query_index = turns.iter().enumerate().rev()
.find(|(_, t)| t.role == "user"
&& !(t.content.starts_with("<tool_response>")
&& t.content.ends_with("</tool_response>")))
.map(|(i, _)| i)
.unwrap_or(turns.len().saturating_sub(1));
for (i, turn) in turns.iter().enumerate() {
let content = &turn.content; match turn.role.as_str() {
"system" if i == 0 => {}
"system" | "user" => {
out.push_str("<|im_start|>");
out.push_str(&turn.role);
out.push('\n');
out.push_str(content);
out.push_str("<|im_end|>\n");
}
"assistant" => {
let (reasoning, body): (String, &str) = match content.find("</think>") {
Some(first) => {
let pre = content[..first].trim_end_matches('\n');
let pre = match pre.rfind("<think>") {
Some(o) => &pre[o + "<think>".len()..],
None => pre,
};
let last = content.rfind("</think>").unwrap();
(pre.trim_start_matches('\n').to_string(),
content[last + "</think>".len()..].trim_start_matches('\n'))
}
None => (String::new(), content.as_str()),
};
out.push_str("<|im_start|>assistant\n");
if i > last_query_index {
out.push_str("<think>\n");
out.push_str(&reasoning);
out.push_str("\n</think>\n");
}
out.push_str(body);
for call in &turn.tool_calls {
out.push_str("<tool_call>\n<function=");
out.push_str(&call.name);
out.push_str(">\n");
for (key, value) in &call.params {
out.push_str("<parameter=");
out.push_str(key);
out.push_str(">\n");
out.push_str(value);
out.push_str("\n</parameter>\n");
}
out.push_str("</function>\n</tool_call>");
}
out.push_str("<|im_end|>\n");
}
"tool" => {
if i == 0 || turns[i - 1].role != "tool" {
out.push_str("<|im_start|>tool_response\n");
}
out.push_str("<tool_response>");
out.push_str(content);
out.push_str("</tool_response>");
if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
out.push_str("<|im_end|>\n");
}
}
other => {
out.push_str("<|im_start|>");
out.push_str(other);
out.push('\n');
out.push_str(content);
out.push_str("<|im_end|>\n");
}
}
}
if add_generation_prompt {
out.push_str("<|im_start|>assistant\n<think>\n");
}
out
}
fn apply_hy3_template(messages: &[(&str, &str)], add_generation_prompt: bool,
effort: &str) -> String {
const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
const THINK_BEGIN: &str = "<think:opensource>";
const THINK_END: &str = "</think:opensource>";
debug_assert!(matches!(effort, "no_think" | "low" | "high"),
"hy3 reasoning_effort must be no_think|low|high, got {effort:?}");
let mut out = String::from(BOS);
for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
let _ = role;
out.push_str(content);
}
out.push_str(REASONING);
out.push_str("reasoning_effort:");
out.push_str(effort);
let mut last_is_assistant = false;
let n = messages.len();
for (i, (role, content)) in messages.iter().enumerate() {
last_is_assistant = false;
match *role {
"user" => { out.push_str(USER); out.push_str(content); }
"assistant" => {
out.push_str(ASSISTANT);
out.push_str(THINK_BEGIN);
out.push_str(THINK_END);
out.push_str(content);
if i + 1 < n { out.push_str(EOS); } last_is_assistant = true;
}
_ => {} }
}
if add_generation_prompt && !last_is_assistant {
out.push_str(ASSISTANT);
out.push_str(THINK_BEGIN);
if effort == "no_think" {
out.push_str(THINK_END); }
}
out
}
fn apply_gemma4_template(messages: &[(&str, &str)], add_generation_prompt: bool,
thinking: bool) -> String {
let mut out = String::new();
let mut msgs = messages;
let leading_system = msgs.first().filter(|(r, _)| *r == "system");
if thinking || leading_system.is_some() {
out.push_str("<|turn>system\n");
if thinking {
out.push_str("<|think|>\n");
}
if let Some((_, content)) = leading_system {
out.push_str(content.trim());
msgs = &msgs[1..];
}
out.push_str("<turn|>\n");
}
for (role, content) in msgs {
let role = if *role == "assistant" { "model" } else { role };
out.push_str("<|turn>");
out.push_str(role);
out.push('\n');
out.push_str(content.trim());
out.push_str("<turn|>\n");
}
if add_generation_prompt {
out.push_str("<|turn>model\n");
if !thinking {
out.push_str("<|channel>thought\n<channel|>");
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_chatml() {
let s = apply_chat_template_str(
None,
&[("user", "Hello")],
true,
);
assert_eq!(s, "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n");
}
const QWEN_TOOLS_TMPL: &str =
"... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
#[test]
fn tools_renderer_matches_legacy_when_plain() {
let batteries: &[&[(&str, &str)]] = &[
&[("user", "Hello")],
&[("system", "You are helpful."), ("user", "Hi")],
&[("system", "rules"), ("user", "task"), ("assistant", "work"), ("user", "more")],
&[("user", " padded "), ("assistant", "reply\nwith lines")],
];
for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
for msgs in batteries {
let legacy = apply_chat_template_str(tmpl, msgs, true);
let turns: Vec<Turn> = msgs.iter().map(|(r, c)| Turn {
role: r.to_string(), content: c.to_string(), tool_calls: Vec::new(),
}).collect();
let ext = apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
.unwrap();
assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
}
}
}
#[test]
fn tools_header_and_tool_response_render_per_template_law() {
let tools = vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
let turns = vec![
Turn { role: "system".into(), content: "Be terse.".into(), tool_calls: Vec::new() },
Turn { role: "user".into(), content: "Weather in Paris?".into(), tool_calls: Vec::new() },
Turn { role: "assistant".into(), content: "".into(), tool_calls: vec![ToolCall {
name: "get_weather".into(),
params: vec![("city".into(), "Paris".into())],
}] },
Turn { role: "tool".into(), content: "{\"temp_c\": 21}".into(), tool_calls: Vec::new() },
];
let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &tools,
ThinkMode::Default, None).unwrap();
let expected = concat!(
"<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
"<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
"\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
"\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
"value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
"second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
"\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
"<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
"- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
"function call in natural language BEFORE the function call, but NOT after\n- If there is ",
"no function call available, answer the question like normal with your current knowledge ",
"and do not tell the user about function calls\n</IMPORTANT>",
"\n\nBe terse.<|im_end|>\n",
"<|im_start|>user\nWeather in Paris?<|im_end|>\n",
"<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
"</parameter>\n</function>\n</tool_call><|im_end|>\n",
"<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
"<|im_start|>assistant\n<think>\n",
);
assert_eq!(s, expected);
}
#[test]
fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
let turns = vec![
Turn { role: "user".into(), content: "both".into(), tool_calls: Vec::new() },
Turn { role: "assistant".into(), content: "checking".into(), tool_calls: vec![
ToolCall { name: "a".into(), params: vec![("x".into(), "1".into())] },
ToolCall { name: "b".into(), params: Vec::new() },
] },
Turn { role: "tool".into(), content: "r1".into(), tool_calls: Vec::new() },
Turn { role: "tool".into(), content: "r2".into(), tool_calls: Vec::new() },
];
let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, false, &[],
ThinkMode::Default, None).unwrap();
assert_eq!(s, concat!(
"<|im_start|>user\nboth<|im_end|>\n",
"<|im_start|>assistant\nchecking\n\n",
"<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
"<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
"<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
"\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
));
}
#[test]
fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &[],
ThinkMode::NoThink, None).unwrap();
assert!(s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"), "{s:?}");
let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
let s = apply_chat_template_tools(Some(tmpl_no_switch), &turns, true, &[],
ThinkMode::NoThink, None).unwrap();
assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
let s = apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
}
#[test]
fn tools_on_templates_without_tools_branch_error() {
let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
let tools = vec!["{}".to_string()];
for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
let err = apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
assert!(err.is_err(), "template={tmpl:?}");
}
let tool_turns = vec![Turn { role: "tool".into(), content: "r".into(), tool_calls: Vec::new() }];
assert!(apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None).is_err());
}
fn one_user() -> Vec<Turn> {
vec![turn("user", "Hi")]
}
#[test]
fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
let g = |think: ThinkMode| {
apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think,
None).unwrap()
};
let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
assert_eq!(g(ThinkMode::Default), closed);
assert_eq!(g(ThinkMode::NoThink), closed);
assert_eq!(apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
closed, "legacy renderer = the default arm");
assert_eq!(g(ThinkMode::Think),
"<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n");
let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
let s = apply_chat_template_tools(Some("... <|turn> ..."), &turns, true, &[],
ThinkMode::Think, None).unwrap();
assert_eq!(s, "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
<|turn>user\nHi<turn|>\n<|turn>model\n");
}
#[test]
fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
const HY_TMPL: Option<&str> = Some("... hy_User ...");
let h = |think: ThinkMode, effort: Option<&str>| {
apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
};
let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
<\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
<\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
<\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
<think:opensource></think:opensource>";
assert_eq!(h(ThinkMode::Default, None), closed);
assert_eq!(h(ThinkMode::NoThink, Some("low")), closed,
"NoThink wins over a level: thinking off IS no_think");
assert_eq!(apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true), closed,
"legacy renderer = the default arm");
let low = h(ThinkMode::Think, Some("low"));
assert!(low.contains("reasoning_effort:low"), "{low:?}");
assert!(low.ends_with("<think:opensource>"), "{low:?}");
let high = h(ThinkMode::Think, Some("high"));
assert!(high.contains("reasoning_effort:high"), "{high:?}");
assert!(high.ends_with("<think:opensource>"), "{high:?}");
assert_eq!(h(ThinkMode::Think, Some("medium")), low);
assert_eq!(h(ThinkMode::Think, None), low);
let turns = vec![turn("user", "q"), turn("assistant", "a"), turn("user", "more")];
let s = apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think,
Some("low")).unwrap();
assert_eq!(s, "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
<\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
<\u{ff5c}hy_User:opensource\u{ff5c}>q\
<\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
<think:opensource></think:opensource>a\
<\u{ff5c}hy_eos:opensource\u{ff5c}>\
<\u{ff5c}hy_User:opensource\u{ff5c}>more\
<\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>");
}
#[test]
fn qwen_think_mode_covers_all_three_directions() {
let q = |think: ThinkMode| {
apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think,
None).unwrap()
};
assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
assert!(q(ThinkMode::NoThink)
.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
}
const STEP35_TMPL: &str =
"{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
}
fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
apply_chat_template_tools(Some(STEP35_TMPL), &turns, genp, tools, ThinkMode::Default, None)
.unwrap()
}
fn turn(role: &str, content: &str) -> Turn {
Turn { role: role.into(), content: content.into(), tool_calls: Vec::new() }
}
#[test]
fn step35_plain_paths_match_the_shipped_jinja() {
assert_eq!(s35(&[("user", "Hello")], true),
"<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n");
assert_eq!(s35(&[("user", "Hello")], false), "<|im_start|>user\nHello<|im_end|>\n");
assert_eq!(s35(&[("system", "You are helpful."), ("user", "Hi")], true),
"<|im_start|>system\nYou are helpful.<|im_end|>\n\
<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n");
assert_eq!(
s35(&[("system", "rules"), ("user", "task"), ("assistant", "work"),
("user", "more")], true),
"<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
<|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
<|im_start|>assistant\n<think>\n");
assert_eq!(s35(&[("user", " padded ")], true),
"<|im_start|>user\n padded <|im_end|>\n<|im_start|>assistant\n<think>\n");
}
#[test]
fn step35_dispatch_beats_the_qwen_marker_arm() {
let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
let step = s35(&[("user", " pad ")], true);
assert_eq!(qwen, "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n");
assert_eq!(step, "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n");
assert_ne!(qwen, step);
}
#[test]
fn step35_reasoning_effort_renders_in_the_system_turn() {
assert_eq!(
apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
"<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n");
assert_eq!(
apply_step35_template(&[turn("system", "Be terse."), turn("user", "Hi")], true, &[],
Some("low")),
"<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n");
let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
let s = apply_step35_template(&[turn("system", "Be terse."), turn("user", "q")], true,
&tools, Some("medium"));
assert!(s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
"{s:?}");
}
#[test]
fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
let turns = vec![turn("user", "Hi")];
let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[],
ThinkMode::Default, Some("high")).unwrap();
assert!(s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"), "{s:?}");
let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[],
ThinkMode::Default, None).unwrap();
assert!(!s.contains("Reasoning:"), "{s:?}");
for tmpl in [None, Some(QWEN_TOOLS_TMPL), Some("... hy_User ..."), Some("... <|turn> ...")] {
let with = apply_chat_template_tools(tmpl, &turns, true, &[],
ThinkMode::Default, Some("high")).unwrap();
let without = apply_chat_template_tools(tmpl, &turns, true, &[],
ThinkMode::Default, None).unwrap();
assert_eq!(with, without, "template={tmpl:?}");
}
}
#[test]
fn step35_tools_header_is_not_the_qwen_header() {
let tools = vec![
r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
];
let s = s35_turns(vec![turn("system", "Be terse."), turn("user", "Weather in Paris?")],
true, &tools);
assert_eq!(s, concat!(
"<|im_start|>system\nBe terse.\n\n# Tools\n\n",
"You have access to the following functions in JSONSchema format:\n\n<tools>\n",
"{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
"{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
"\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
"\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
"value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
"second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
"\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
"<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
"</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
"<|im_end|>\n",
"<|im_start|>user\nWeather in Paris?<|im_end|>\n",
"<|im_start|>assistant\n<think>\n",
));
assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
}
#[test]
fn step35_tool_results_take_their_own_role_and_group() {
let tools = vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
let turns = vec![
turn("user", "both"),
Turn { role: "assistant".into(), content: "checking".into(), tool_calls: vec![
ToolCall { name: "a".into(), params: vec![("x".into(), "1".into())] },
ToolCall { name: "b".into(), params: Vec::new() },
] },
turn("tool", "r1"),
turn("tool", "r2"),
];
let s = s35_turns(turns, true, &tools);
let body = s.split("<|im_end|>\n").skip(1).collect::<Vec<_>>().join("<|im_end|>\n");
assert_eq!(body, concat!(
"<|im_start|>user\nboth<|im_end|>\n",
"<|im_start|>assistant\n<think>\n\n</think>\nchecking",
"<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
"<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
"<|im_start|>tool_response\n<tool_response>r1</tool_response>",
"<tool_response>r2</tool_response><|im_end|>\n",
"<|im_start|>assistant\n<think>\n",
));
}
#[test]
fn step35_assistant_think_split_and_the_reasoning_boundary() {
assert_eq!(
s35(&[("user", "q"), ("assistant", "<think>\nreasoned\n</think>\nanswer")], false),
"<|im_start|>user\nq<|im_end|>\n\
<|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n");
assert_eq!(
s35(&[("user", "q"), ("assistant", "plain")], false),
"<|im_start|>user\nq<|im_end|>\n\
<|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n");
assert_eq!(
s35(&[("user", "real question"), ("assistant", "thinking about it"),
("user", "<tool_response>r</tool_response>")], true),
"<|im_start|>user\nreal question<|im_end|>\n\
<|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
<|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
<|im_start|>assistant\n<think>\n");
}
#[test]
fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
let turns = vec![turn("user", "hi")];
for mode in [ThinkMode::Default, ThinkMode::NoThink] {
let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None).unwrap();
assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "mode={mode:?} {s:?}");
}
}
#[test]
fn step35_plain_path_is_identical_through_both_renderers() {
let batteries: &[&[(&str, &str)]] = &[
&[("user", "Hello")],
&[("system", "You are helpful."), ("user", "Hi")],
&[("system", "rules"), ("user", "task"), ("assistant", "work"), ("user", "more")],
&[("user", " padded "), ("assistant", "reply\nwith lines")],
];
for msgs in batteries {
let legacy = s35(msgs, true);
let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
assert_eq!(legacy, ext, "msgs={msgs:?}");
}
}
#[test]
fn qwen_think_tail() {
let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
let s = apply_chat_template_str(
Some(tmpl),
&[("system", "You are helpful."), ("user", "Hi")],
true,
);
assert_eq!(
s,
"<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
);
}
}