#[derive(Debug, Clone, PartialEq)]
pub enum Val {
Null,
Bool(bool),
Num(String),
Str(String),
Arr(Vec<Val>),
Obj(Vec<(String, Val)>),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ToolCall {
pub name: String,
pub params: Vec<(String, String)>,
pub args: Vec<(String, Val)>,
pub id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Turn {
pub role: String,
pub content: String,
pub tool_calls: Vec<ToolCall>,
pub reasoning: Option<String>,
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
pub tool_responses: Vec<(String, Val)>,
}
#[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(),
..Default::default()
})
.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> {
apply_chat_template_tools_ex(
template,
turns,
add_generation_prompt,
tools_json,
&[],
think,
reasoning_effort,
)
}
#[allow(clippy::too_many_arguments)]
pub fn apply_chat_template_tools_ex(
template: Option<&str>,
turns: &[Turn],
add_generation_prompt: bool,
tools_json: &[String],
tools_struct: &[Val],
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(template_has_tools_branch);
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("<|turn>") && t.contains("<|tool>")) {
let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
return Ok(apply_gemma4_tools_template(
turns,
add_generation_prompt,
tools_struct,
think == ThinkMode::Think,
closed_tail,
));
}
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
}
pub fn template_has_tools_branch(t: &str) -> bool {
if t.contains("hy_User") {
return false;
}
t.contains("<tools>") || (t.contains("<|turn>") && t.contains("<|tool>"))
}
fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
let mut v: Vec<&(String, Val)> = pairs.iter().collect();
v.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
v
}
fn format_argument(v: &Val, escape_keys: bool) -> String {
match v {
Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
Val::Obj(pairs) => {
let mut out = String::from("{");
for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
if i > 0 {
out.push(',');
}
if escape_keys {
out.push_str(&format!("<|\"|>{k}<|\"|>"));
} else {
out.push_str(k);
}
out.push(':');
out.push_str(&format_argument(val, escape_keys));
}
out.push('}');
out
}
Val::Arr(items) => {
let mut out = String::from("[");
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format_argument(item, escape_keys));
}
out.push(']');
out
}
Val::Null => "None".to_string(),
Val::Num(s) => s.clone(),
}
}
fn strip_thinking(text: &str) -> String {
let mut result = String::new();
for part in text.split("<channel|>") {
match part.find("<|channel>") {
Some(o) => result.push_str(&part[..o]),
None => result.push_str(part),
}
}
result.trim().to_string()
}
fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
match v {
Val::Obj(p) => Some(p),
_ => None,
}
}
fn as_str(v: &Val) -> Option<&str> {
match v {
Val::Str(s) => Some(s),
_ => None,
}
}
fn truthy(v: &Val) -> bool {
match v {
Val::Null => false,
Val::Bool(b) => *b,
Val::Str(s) => !s.is_empty(),
Val::Num(s) => s != "0" && s != "0.0",
Val::Arr(a) => !a.is_empty(),
Val::Obj(o) => !o.is_empty(),
}
}
fn comma(out: &mut String, add: &mut bool) {
if *add {
out.push(',');
} else {
*add = true;
}
}
fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
let mut found_first = false;
for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
if filter_keys && STANDARD.contains(&key.as_str()) {
continue;
}
if found_first {
out.push(',');
}
found_first = true;
out.push_str(key);
out.push_str(":{");
let vobj = as_obj(value);
let mut add = false;
if let Some(d) = vobj
.and_then(|o| val_get(o, "description"))
.filter(|d| truthy(d))
{
out.push_str("description:<|\"|>");
out.push_str(as_str(d).unwrap_or(""));
out.push_str("<|\"|>");
add = true;
}
let ty_up = vobj
.and_then(|o| val_get(o, "type"))
.and_then(as_str)
.map(|s| s.to_uppercase());
match ty_up.as_deref() {
Some("STRING") => {
if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
comma(out, &mut add);
out.push_str("enum:");
out.push_str(&format_argument(en, true));
}
}
Some("ARRAY") => {
if let Some(items) = vobj
.and_then(|o| val_get(o, "items"))
.filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
{
comma(out, &mut add);
out.push_str("items:{");
format_items(out, as_obj(items).unwrap());
out.push('}');
}
}
_ => {}
}
if vobj
.and_then(|o| val_get(o, "nullable"))
.is_some_and(truthy)
{
comma(out, &mut add);
out.push_str("nullable:true");
}
if ty_up.as_deref() == Some("OBJECT") {
if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
comma(out, &mut add);
out.push_str("properties:{");
format_parameters(out, sub, false);
out.push('}');
} else if let Some(o) = vobj {
comma(out, &mut add);
out.push_str("properties:{");
format_parameters(out, o, true);
out.push('}');
}
if let Some(req) = vobj
.and_then(|o| val_get(o, "required"))
.filter(|r| truthy(r))
{
comma(out, &mut add);
out.push_str("required:[");
push_str_list(out, req);
out.push(']');
}
}
comma(out, &mut add);
out.push_str("type:<|\"|>");
out.push_str(ty_up.as_deref().unwrap_or(""));
out.push_str("<|\"|>}");
}
}
fn format_items(out: &mut String, items: &[(String, Val)]) {
let mut found_first = false;
for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
if matches!(v, Val::Null) {
continue;
}
if found_first {
out.push(',');
}
found_first = true;
match k.as_str() {
"properties" => {
out.push_str("properties:{");
if let Some(o) = as_obj(v) {
format_parameters(out, o, false);
}
out.push('}');
}
"required" => {
out.push_str("required:[");
push_str_list(out, v);
out.push(']');
}
"type" => {
out.push_str("type:");
match v {
Val::Str(s) => {
out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
}
Val::Arr(a) => {
let upper: Vec<Val> = a
.iter()
.map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
.collect();
out.push_str(&format_argument(&Val::Arr(upper), true));
}
other => out.push_str(&format_argument(other, true)),
}
}
_ => {
out.push_str(k);
out.push(':');
out.push_str(&format_argument(v, true));
}
}
}
}
fn push_str_list(out: &mut String, v: &Val) {
if let Val::Arr(items) = v {
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str("<|\"|>");
out.push_str(as_str(item).unwrap_or(""));
out.push_str("<|\"|>");
}
}
}
fn format_function_declaration(func: &[(String, Val)]) -> String {
let mut out = String::new();
out.push_str("declaration:");
out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
out.push_str("{description:<|\"|>");
out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
out.push_str("<|\"|>");
if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
let pobj = as_obj(params);
out.push_str(",parameters:{");
if let Some(props) = pobj
.and_then(|o| val_get(o, "properties"))
.filter(|p| truthy(p))
.and_then(as_obj)
{
out.push_str("properties:{");
format_parameters(&mut out, props, false);
out.push_str("},");
}
if let Some(req) = pobj
.and_then(|o| val_get(o, "required"))
.filter(|r| truthy(r))
{
out.push_str("required:[");
push_str_list(&mut out, req);
out.push_str("],");
}
if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
out.push_str("type:<|\"|>");
out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
out.push_str("<|\"|>}");
}
}
if let Some(resp) = val_get(func, "response").and_then(as_obj) {
out.push_str(",response:{");
if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
out.push_str("description:<|\"|>");
out.push_str(as_str(d).unwrap_or(""));
out.push_str("<|\"|>,");
}
if val_get(resp, "type")
.and_then(as_str)
.map(|s| s.to_uppercase())
== Some("OBJECT".into())
{
out.push_str("type:<|\"|>OBJECT<|\"|>}");
}
}
out.push('}');
out
}
fn format_tool_response_block(name: &str, response: &Val) -> String {
let mut out = String::from("<|tool_response>");
match response {
Val::Obj(pairs) => {
out.push_str("response:");
out.push_str(name);
out.push('{');
for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(k);
out.push(':');
out.push_str(&format_argument(v, false));
}
out.push('}');
}
other => {
out.push_str("response:");
out.push_str(name);
out.push_str("{value:");
out.push_str(&format_argument(other, false));
out.push('}');
}
}
out.push_str("<tool_response|>");
out
}
fn apply_gemma4_tools_template(
turns: &[Turn],
add_generation_prompt: bool,
tools: &[Val],
thinking: bool,
closed_tail: bool,
) -> String {
let mut out = String::new();
let mut prev: Option<&str> = None;
let mut msgs = turns;
let is_sys = |r: &str| r == "system" || r == "developer";
let leading_system = msgs.first().filter(|t| is_sys(&t.role));
if thinking || !tools.is_empty() || leading_system.is_some() {
out.push_str("<|turn>system\n");
if thinking {
out.push_str("<|think|>\n");
prev = Some("think");
}
if let Some(sys) = leading_system {
out.push_str(sys.content.trim());
msgs = &msgs[1..];
}
for tool in tools {
out.push_str("<|tool>");
if let Some(func) = as_obj(tool) {
out.push_str(format_function_declaration(func).trim());
}
out.push_str("<tool|>");
}
if !tools.is_empty() {
prev = Some("tool");
}
out.push_str("<turn|>\n");
}
let last_user_idx: isize = msgs
.iter()
.enumerate()
.rev()
.find(|(_, t)| t.role == "user")
.map(|(i, _)| i as isize)
.unwrap_or(-1);
for (i, m) in msgs.iter().enumerate() {
if m.role == "tool" {
continue; }
prev = None;
let role = if m.role == "assistant" {
"model"
} else {
m.role.as_str()
};
let prev_nt_role = (0..i)
.rev()
.map(|j| &msgs[j])
.find(|t| t.role != "tool")
.map(|t| t.role.as_str());
let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
if !continue_same_model_turn {
out.push_str("<|turn>");
out.push_str(role);
out.push('\n');
}
if let Some(rt) = m.reasoning.as_deref() {
if !rt.is_empty() && (i as isize) > last_user_idx && !m.tool_calls.is_empty() {
out.push_str("<|channel>thought\n");
out.push_str(rt);
out.push_str("\n<channel|>");
}
}
if !m.tool_calls.is_empty() {
for tc in &m.tool_calls {
out.push_str("<|tool_call>call:");
out.push_str(&tc.name);
out.push('{');
for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
if j > 0 {
out.push(',');
}
out.push_str(k);
out.push(':');
out.push_str(&format_argument(v, false));
}
out.push_str("}<tool_call|>");
}
prev = Some("tool_call");
}
let mut tr_flag = false;
if !m.tool_responses.is_empty() {
for (name, resp) in &m.tool_responses {
out.push_str(&format_tool_response_block(name, resp));
tr_flag = true;
prev = Some("tool_response");
}
} else if !m.tool_calls.is_empty() {
for k in (i + 1)..msgs.len() {
let follow = &msgs[k];
if follow.role != "tool" {
break;
}
let mut name = follow
.tool_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
if let Some(fid) = follow.tool_call_id.as_deref() {
for tc in &m.tool_calls {
if tc.id.as_deref() == Some(fid) {
name = tc.name.clone();
}
}
}
out.push_str(&format_tool_response_block(
&name,
&Val::Str(follow.content.clone()),
));
tr_flag = true;
prev = Some("tool_response");
}
}
let captured = if role == "model" {
strip_thinking(&m.content)
} else {
m.content.trim().to_string()
};
out.push_str(&captured);
let has_content = !captured.trim().is_empty();
if prev == Some("tool_call") && !tr_flag {
out.push_str("<|tool_response>"); } else if !(tr_flag && !has_content) {
out.push_str("<turn|>\n");
}
}
if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
out.push_str("<|turn>model\n");
if closed_tail && !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(),
..Default::default()
})
.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(),
..Default::default()
},
Turn {
role: "user".into(),
content: "Weather in Paris?".into(),
tool_calls: Vec::new(),
..Default::default()
},
Turn {
role: "assistant".into(),
content: "".into(),
tool_calls: vec![ToolCall {
name: "get_weather".into(),
params: vec![("city".into(), "Paris".into())],
..Default::default()
}],
..Default::default()
},
Turn {
role: "tool".into(),
content: "{\"temp_c\": 21}".into(),
tool_calls: Vec::new(),
..Default::default()
},
];
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(),
..Default::default()
},
Turn {
role: "assistant".into(),
content: "checking".into(),
tool_calls: vec![
ToolCall {
name: "a".into(),
params: vec![("x".into(), "1".into())],
..Default::default()
},
ToolCall {
name: "b".into(),
params: Vec::new(),
..Default::default()
},
],
..Default::default()
},
Turn {
role: "tool".into(),
content: "r1".into(),
tool_calls: Vec::new(),
..Default::default()
},
Turn {
role: "tool".into(),
content: "r2".into(),
tool_calls: Vec::new(),
..Default::default()
},
];
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(),
..Default::default()
}];
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(),
..Default::default()
}];
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(),
..Default::default()
}];
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"
);
}
const GEMMA_TOOLUSE_QAT_TMPL: &str =
"... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
#[test]
fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
let batteries: &[&[(&str, &str)]] = &[
&[("user", "Hi")],
&[("system", "Be terse."), ("user", "Weather?")],
&[
("system", "rules"),
("user", "task"),
("assistant", "work"),
("user", "more"),
],
&[("user", " padded "), ("assistant", "reply\nwith lines")],
];
for msgs in batteries {
let turns: Vec<Turn> = msgs
.iter()
.map(|(r, c)| Turn {
role: r.to_string(),
content: c.to_string(),
..Default::default()
})
.collect();
for (mode, thinking) in [
(ThinkMode::Default, false),
(ThinkMode::NoThink, false),
(ThinkMode::Think, true),
] {
let legacy = apply_gemma4_template(msgs, true, thinking);
let arm = apply_chat_template_tools(
Some(GEMMA_TOOLUSE_QAT_TMPL),
&turns,
true,
&[],
mode,
None,
)
.unwrap();
assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
}
}
}
#[test]
fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
let turns = vec![turn("user", "Weather?")];
let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
let err = apply_chat_template_tools(
Some("... <|turn> ..."),
&turns,
true,
&tools,
ThinkMode::Default,
None,
);
assert!(err.is_err());
}
#[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(),
..Default::default()
}
}
#[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())],
..Default::default()
},
ToolCall {
name: "b".into(),
params: Vec::new(),
..Default::default()
},
],
..Default::default()
},
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"
);
}
}