1#[derive(Debug, Clone, PartialEq)]
22pub struct ToolCall {
23 pub name: String,
24 pub params: Vec<(String, String)>,
25}
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct Turn {
30 pub role: String,
31 pub content: String,
32 pub tool_calls: Vec<ToolCall>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ThinkMode {
42 Default,
43 NoThink,
44}
45
46pub fn apply_chat_template_str(
52 template: Option<&str>,
53 messages: &[(&str, &str)],
54 add_generation_prompt: bool,
55) -> String {
56 if template.is_some_and(|t| t.contains("hy_User")) {
59 return apply_hy3_template(messages, add_generation_prompt);
60 }
61 if template.is_some_and(|t| t.contains("<|turn>")) {
66 return apply_gemma4_template(messages, add_generation_prompt);
67 }
68 let qwen_think = template
70 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
71 .unwrap_or(false);
72
73 let mut out = String::new();
74 for (i, (role, content)) in messages.iter().enumerate() {
75 let content = content.trim();
76 match *role {
77 "system" => {
78 let _ = i;
81 out.push_str("<|im_start|>system\n");
82 out.push_str(content);
83 out.push_str("<|im_end|>\n");
84 }
85 "user" => {
86 out.push_str("<|im_start|>user\n");
87 out.push_str(content);
88 out.push_str("<|im_end|>\n");
89 }
90 "assistant" => {
91 out.push_str("<|im_start|>assistant\n");
92 out.push_str(content);
93 out.push_str("<|im_end|>\n");
94 }
95 other => {
96 out.push_str("<|im_start|>");
98 out.push_str(other);
99 out.push('\n');
100 out.push_str(content);
101 out.push_str("<|im_end|>\n");
102 }
103 }
104 }
105
106 if add_generation_prompt {
107 out.push_str("<|im_start|>assistant\n");
108 if qwen_think {
109 out.push_str("<think>\n");
110 }
111 }
112
113 out
114}
115
116const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
120following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
121<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
122This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
123</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
124format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
125XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
126your function call in natural language BEFORE the function call, but NOT after\n- If there is \
127no function call available, answer the question like normal with your current knowledge and do \
128not tell the user about function calls\n</IMPORTANT>";
129
130pub fn apply_chat_template_tools(
153 template: Option<&str>,
154 turns: &[Turn],
155 add_generation_prompt: bool,
156 tools_json: &[String],
157 think: ThinkMode,
158) -> Result<String, String> {
159 let has_tool_features = !tools_json.is_empty()
160 || turns.iter().any(|t| t.role == "tool" || !t.tool_calls.is_empty());
161 let tools_branch = template.is_some_and(|t| t.contains("<tools>"));
162 if has_tool_features && !tools_branch {
163 return Err("model chat template has no tools branch".into());
164 }
165 if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
166 if has_tool_features {
170 return Err("tools are not supported on this model's chat-template dialect".into());
171 }
172 let messages: Vec<(&str, &str)> =
173 turns.iter().map(|t| (t.role.as_str(), t.content.as_str())).collect();
174 return Ok(apply_chat_template_str(template, &messages, add_generation_prompt));
175 }
176 let qwen_think = template
177 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
178 .unwrap_or(false);
179 let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
180
181 let mut out = String::new();
182 let mut skip_leading_system = false;
185 if !tools_json.is_empty() {
186 out.push_str("<|im_start|>system\n");
187 out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
188 for tool in tools_json {
189 out.push('\n');
190 out.push_str(tool);
191 }
192 out.push_str("\n</tools>");
193 out.push_str(QWEN_TOOLS_INSTRUCTION);
194 if let Some(first) = turns.first() {
195 if first.role == "system" {
196 skip_leading_system = true;
197 let content = first.content.trim();
198 if !content.is_empty() {
199 out.push_str("\n\n");
200 out.push_str(content);
201 }
202 }
203 }
204 out.push_str("<|im_end|>\n");
205 }
206
207 for (i, turn) in turns.iter().enumerate() {
208 if i == 0 && skip_leading_system {
209 continue;
210 }
211 let content = turn.content.trim();
212 match turn.role.as_str() {
213 "system" => {
214 out.push_str("<|im_start|>system\n");
215 out.push_str(content);
216 out.push_str("<|im_end|>\n");
217 }
218 "user" => {
219 out.push_str("<|im_start|>user\n");
220 out.push_str(content);
221 out.push_str("<|im_end|>\n");
222 }
223 "assistant" => {
224 out.push_str("<|im_start|>assistant\n");
225 out.push_str(content);
226 for (k, call) in turn.tool_calls.iter().enumerate() {
227 if k == 0 {
228 if !content.is_empty() {
229 out.push_str("\n\n");
230 }
231 } else {
232 out.push('\n');
233 }
234 out.push_str("<tool_call>\n<function=");
235 out.push_str(&call.name);
236 out.push_str(">\n");
237 for (key, value) in &call.params {
238 out.push_str("<parameter=");
239 out.push_str(key);
240 out.push_str(">\n");
241 out.push_str(value);
242 out.push_str("\n</parameter>\n");
243 }
244 out.push_str("</function>\n</tool_call>");
245 }
246 out.push_str("<|im_end|>\n");
247 }
248 "tool" => {
249 if i == 0 || turns[i - 1].role != "tool" {
250 out.push_str("<|im_start|>user");
251 }
252 out.push_str("\n<tool_response>\n");
253 out.push_str(content);
254 out.push_str("\n</tool_response>");
255 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
256 out.push_str("<|im_end|>\n");
257 }
258 }
259 other => {
260 out.push_str("<|im_start|>");
262 out.push_str(other);
263 out.push('\n');
264 out.push_str(content);
265 out.push_str("<|im_end|>\n");
266 }
267 }
268 }
269
270 if add_generation_prompt {
271 out.push_str("<|im_start|>assistant\n");
272 if qwen_think {
273 if think == ThinkMode::NoThink && think_switch {
274 out.push_str("<think>\n\n</think>\n\n");
275 } else {
276 out.push_str("<think>\n");
277 }
278 }
279 }
280 Ok(out)
281}
282
283fn apply_hy3_template(messages: &[(&str, &str)], add_generation_prompt: bool) -> String {
293 const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
294 const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
295 const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
296 const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
297 const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
298 const THINK_BEGIN: &str = "<think:opensource>";
299 const THINK_END: &str = "</think:opensource>";
300
301 let mut out = String::from(BOS);
302 for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
303 let _ = role;
304 out.push_str(content);
305 }
306 out.push_str(REASONING);
307 out.push_str("reasoning_effort:no_think");
308
309 let mut last_is_assistant = false;
310 let n = messages.len();
311 for (i, (role, content)) in messages.iter().enumerate() {
312 last_is_assistant = false;
313 match *role {
314 "user" => { out.push_str(USER); out.push_str(content); }
315 "assistant" => {
316 out.push_str(ASSISTANT);
317 out.push_str(THINK_BEGIN);
318 out.push_str(THINK_END);
319 out.push_str(content);
320 if i + 1 < n { out.push_str(EOS); } last_is_assistant = true;
322 }
323 _ => {} }
325 }
326 if add_generation_prompt && !last_is_assistant {
327 out.push_str(ASSISTANT);
328 out.push_str(THINK_BEGIN);
329 out.push_str(THINK_END);
330 }
331 out
332}
333
334
335fn apply_gemma4_template(messages: &[(&str, &str)], add_generation_prompt: bool) -> String {
339 let mut out = String::new();
340 for (role, content) in messages {
341 let role = if *role == "assistant" { "model" } else { role };
342 out.push_str("<|turn>");
343 out.push_str(role);
344 out.push('\n');
345 out.push_str(content.trim());
346 out.push_str("<turn|>\n");
347 }
348 if add_generation_prompt {
349 out.push_str("<|turn>model\n<|channel>thought\n<channel|>");
350 }
351 out
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn plain_chatml() {
360 let s = apply_chat_template_str(
361 None,
362 &[("user", "Hello")],
363 true,
364 );
365 assert_eq!(s, "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n");
366 }
367
368 const QWEN_TOOLS_TMPL: &str =
371 "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
372
373 #[test]
377 fn tools_renderer_matches_legacy_when_plain() {
378 let batteries: &[&[(&str, &str)]] = &[
379 &[("user", "Hello")],
380 &[("system", "You are helpful."), ("user", "Hi")],
381 &[("system", "rules"), ("user", "task"), ("assistant", "work"), ("user", "more")],
382 &[("user", " padded "), ("assistant", "reply\nwith lines")],
383 ];
384 for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
385 for msgs in batteries {
386 let legacy = apply_chat_template_str(tmpl, msgs, true);
387 let turns: Vec<Turn> = msgs.iter().map(|(r, c)| Turn {
388 role: r.to_string(), content: c.to_string(), tool_calls: Vec::new(),
389 }).collect();
390 let ext = apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default)
391 .unwrap();
392 assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
393 }
394 }
395 }
396
397 #[test]
398 fn tools_header_and_tool_response_render_per_template_law() {
399 let tools = vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
400 let turns = vec![
401 Turn { role: "system".into(), content: "Be terse.".into(), tool_calls: Vec::new() },
402 Turn { role: "user".into(), content: "Weather in Paris?".into(), tool_calls: Vec::new() },
403 Turn { role: "assistant".into(), content: "".into(), tool_calls: vec![ToolCall {
404 name: "get_weather".into(),
405 params: vec![("city".into(), "Paris".into())],
406 }] },
407 Turn { role: "tool".into(), content: "{\"temp_c\": 21}".into(), tool_calls: Vec::new() },
408 ];
409 let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &tools,
410 ThinkMode::Default).unwrap();
411 let expected = concat!(
412 "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
413 "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
414 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
415 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
416 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
417 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
418 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
419 "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
420 "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
421 "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
422 "no function call available, answer the question like normal with your current knowledge ",
423 "and do not tell the user about function calls\n</IMPORTANT>",
424 "\n\nBe terse.<|im_end|>\n",
425 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
426 "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
427 "</parameter>\n</function>\n</tool_call><|im_end|>\n",
428 "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
429 "<|im_start|>assistant\n<think>\n",
430 );
431 assert_eq!(s, expected);
432 }
433
434 #[test]
435 fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
436 let turns = vec![
437 Turn { role: "user".into(), content: "both".into(), tool_calls: Vec::new() },
438 Turn { role: "assistant".into(), content: "checking".into(), tool_calls: vec![
439 ToolCall { name: "a".into(), params: vec![("x".into(), "1".into())] },
440 ToolCall { name: "b".into(), params: Vec::new() },
441 ] },
442 Turn { role: "tool".into(), content: "r1".into(), tool_calls: Vec::new() },
443 Turn { role: "tool".into(), content: "r2".into(), tool_calls: Vec::new() },
444 ];
445 let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, false, &[],
446 ThinkMode::Default).unwrap();
447 assert_eq!(s, concat!(
448 "<|im_start|>user\nboth<|im_end|>\n",
449 "<|im_start|>assistant\nchecking\n\n",
450 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
451 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
452 "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
453 "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
454 ));
455 }
456
457 #[test]
458 fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
459 let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
460 let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &[],
462 ThinkMode::NoThink).unwrap();
463 assert!(s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"), "{s:?}");
464 let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
466 let s = apply_chat_template_tools(Some(tmpl_no_switch), &turns, true, &[],
467 ThinkMode::NoThink).unwrap();
468 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
469 let s = apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink).unwrap();
471 assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
472 }
473
474 #[test]
475 fn tools_on_templates_without_tools_branch_error() {
476 let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
477 let tools = vec!["{}".to_string()];
478 for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
479 let err = apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default);
480 assert!(err.is_err(), "template={tmpl:?}");
481 }
482 let tool_turns = vec![Turn { role: "tool".into(), content: "r".into(), tool_calls: Vec::new() }];
484 assert!(apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default).is_err());
485 }
486
487 #[test]
488 fn qwen_think_tail() {
489 let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
491 let s = apply_chat_template_str(
492 Some(tmpl),
493 &[("system", "You are helpful."), ("user", "Hi")],
494 true,
495 );
496 assert_eq!(
497 s,
498 "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
499 );
500 }
501}