#[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,
}
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);
}
if template.is_some_and(|t| t.contains("<|turn>")) {
return apply_gemma4_template(messages, add_generation_prompt);
}
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,
) -> 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("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();
return Ok(apply_chat_template_str(template, &messages, add_generation_prompt));
}
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)
}
fn apply_hy3_template(messages: &[(&str, &str)], add_generation_prompt: bool) -> 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>";
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:no_think");
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);
out.push_str(THINK_END);
}
out
}
fn apply_gemma4_template(messages: &[(&str, &str)], add_generation_prompt: bool) -> String {
let mut out = String::new();
for (role, content) in messages {
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<|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)
.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).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).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).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).unwrap();
assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
let s = apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink).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);
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).is_err());
}
#[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"
);
}
}