1use crate::openai::{
2 ChatFunction, ChatMessage, ChatTool, FunctionCallChoice, MessageRole, ToolChoice,
3};
4use minijinja::Environment;
5use serde::ser::SerializeStruct;
6use serde::Serialize;
7use serde_json::Value;
8use tracing::warn;
9
10#[derive(Clone, Debug)]
13pub struct ModelChatTemplate {
14 pub template: String,
15 pub source: String,
16 pub bos_token: Option<String>,
17 pub eos_token: Option<String>,
18}
19
20impl ModelChatTemplate {
21 pub fn new(template: impl Into<String>, source: impl Into<String>) -> Self {
22 Self {
23 template: template.into(),
24 source: source.into(),
25 bos_token: None,
26 eos_token: None,
27 }
28 }
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct ChatTemplateOptions {
33 pub enable_thinking: Option<bool>,
34}
35
36impl ChatTemplateOptions {
37 pub fn default_for_template(model_template: Option<&ModelChatTemplate>) -> Self {
38 if model_template_supports_enable_thinking(model_template) {
39 return Self {
40 enable_thinking: Some(false),
41 };
42 }
43 Self::default()
44 }
45}
46
47fn model_template_supports_enable_thinking(model_template: Option<&ModelChatTemplate>) -> bool {
48 model_template
49 .map(|template| template.template.contains("enable_thinking"))
50 .unwrap_or(false)
51}
52
53#[derive(Clone, Debug)]
55pub struct PromptMessage {
56 pub role: String,
57 pub content: String,
58 pub reasoning_content: Option<String>,
59 pub name: Option<String>,
60 pub tool_calls: Option<Vec<PromptToolCall>>,
61 pub tool_call_id: Option<String>,
62 pub function_call: Option<crate::openai::ChatFunctionCall>,
63}
64
65impl Serialize for PromptMessage {
66 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
67 where
68 S: serde::Serializer,
69 {
70 let mut len = 2;
71 len += usize::from(self.reasoning_content.is_some());
72 len += usize::from(self.name.is_some());
73 len += usize::from(self.tool_calls.is_some());
74 len += usize::from(self.tool_call_id.is_some());
75 len += usize::from(self.function_call.is_some());
76 let mut state = serializer.serialize_struct("PromptMessage", len)?;
77 state.serialize_field("role", &self.role)?;
78 let content = template_content_value(&self.content);
79 state.serialize_field("content", &content)?;
80 if let Some(reasoning_content) = &self.reasoning_content {
81 state.serialize_field("reasoning_content", reasoning_content)?;
82 }
83 if let Some(name) = &self.name {
84 state.serialize_field("name", name)?;
85 }
86 if let Some(tool_calls) = &self.tool_calls {
87 state.serialize_field("tool_calls", tool_calls)?;
88 }
89 if let Some(tool_call_id) = &self.tool_call_id {
90 state.serialize_field("tool_call_id", tool_call_id)?;
91 }
92 if let Some(function_call) = &self.function_call {
93 state.serialize_field("function_call", function_call)?;
94 }
95 state.end()
96 }
97}
98
99#[derive(Clone, Debug, Serialize)]
106pub struct PromptToolCall {
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub index: Option<u32>,
109 pub id: String,
110 #[serde(rename = "type")]
111 pub tool_type: String,
112 pub function: PromptFunctionCall,
113}
114
115#[derive(Clone, Debug, Serialize)]
116pub struct PromptFunctionCall {
117 pub name: String,
118 pub arguments: Value,
119}
120
121impl From<&crate::openai::ChatToolCall> for PromptToolCall {
122 fn from(call: &crate::openai::ChatToolCall) -> Self {
123 Self {
124 index: call.index,
125 id: call.id.clone(),
126 tool_type: call.tool_type.clone(),
127 function: PromptFunctionCall {
128 name: call.function.name.clone(),
129 arguments: parse_template_arguments(&call.function.arguments),
130 },
131 }
132 }
133}
134
135fn parse_template_arguments(arguments: &str) -> Value {
136 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
137}
138
139fn template_content_value(content: &str) -> Value {
140 Value::String(content.to_string())
141}
142
143impl PromptMessage {
144 pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
145 let role = role.into();
146 let content = content.into();
147 if role == "assistant" {
148 let (reasoning_content, content) = split_reasoning_content(content);
149 return Self {
150 role,
151 content,
152 reasoning_content,
153 name: None,
154 tool_calls: None,
155 tool_call_id: None,
156 function_call: None,
157 };
158 }
159 Self {
160 role,
161 content,
162 reasoning_content: None,
163 name: None,
164 tool_calls: None,
165 tool_call_id: None,
166 function_call: None,
167 }
168 }
169
170 fn from_chat_message(message: &ChatMessage) -> Self {
171 let mut prompt = Self::new(template_role(message), message.content.clone());
172 if matches!(message.role, MessageRole::Assistant) && message.reasoning.is_some() {
173 prompt.reasoning_content = message.reasoning.clone();
174 }
175 prompt.name = message.name.clone();
176 prompt.tool_calls = message
177 .tool_calls
178 .as_ref()
179 .map(|calls| calls.iter().map(PromptToolCall::from).collect());
180 prompt.tool_call_id = message.tool_call_id.clone();
181 prompt.function_call = message.function_call.clone();
182 prompt
183 }
184}
185
186fn split_reasoning_content(content: String) -> (Option<String>, String) {
187 let Some(end_idx) = content.find("</think>") else {
188 return (None, content);
189 };
190 let before_end = &content[..end_idx];
191 let after_end = content[end_idx + "</think>".len()..]
192 .trim_start_matches('\n')
193 .to_string();
194 let reasoning = before_end
195 .rsplit_once("<think>")
196 .map(|(_, reasoning)| reasoning)
197 .unwrap_or(before_end)
198 .trim_matches('\n')
199 .to_string();
200 (Some(reasoning), after_end)
201}
202
203pub fn render_prompt_messages(
207 messages: &[PromptMessage],
208 model_id: &str,
209 model_template: Option<&ModelChatTemplate>,
210) -> String {
211 render_prompt_messages_with_options(
212 messages,
213 model_id,
214 model_template,
215 &ChatTemplateOptions::default(),
216 )
217}
218
219pub fn render_prompt_messages_with_options(
220 messages: &[PromptMessage],
221 model_id: &str,
222 model_template: Option<&ModelChatTemplate>,
223 options: &ChatTemplateOptions,
224) -> String {
225 if let Some(model_template) = model_template {
226 match render_model_template(messages, model_template, options, None, None, None, None) {
227 Ok(prompt) if !prompt.trim().is_empty() => return prompt,
228 Ok(_) => warn!(
229 "model chat template rendered an empty prompt; falling back to legacy renderer"
230 ),
231 Err(e) => warn!(
232 "failed to render model chat template from {}: {}; falling back to legacy renderer",
233 model_template.source, e
234 ),
235 }
236 }
237 render_fallback_prompt(messages, model_id, None)
238}
239
240#[derive(Serialize)]
241struct ModelTemplateContext<'a> {
242 messages: &'a [PromptMessage],
243 add_generation_prompt: bool,
244 bos_token: &'a str,
245 eos_token: &'a str,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 enable_thinking: Option<bool>,
248 #[serde(skip_serializing_if = "Option::is_none")]
249 tools: Option<&'a [ChatTool]>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 tool_choice: Option<&'a ToolChoice>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 functions: Option<&'a [ChatFunction]>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 function_call: Option<&'a FunctionCallChoice>,
256}
257
258fn render_model_template(
259 messages: &[PromptMessage],
260 model_template: &ModelChatTemplate,
261 options: &ChatTemplateOptions,
262 tools: Option<&[ChatTool]>,
263 tool_choice: Option<&ToolChoice>,
264 functions: Option<&[ChatFunction]>,
265 function_call: Option<&FunctionCallChoice>,
266) -> std::result::Result<String, minijinja::Error> {
267 let mut env = Environment::new();
268 env.add_filter("trim_newlines", |s: String| {
269 s.trim_matches('\n').to_string()
270 });
271 env.add_filter("trim_start_newlines", |s: String| {
272 s.trim_start_matches('\n').to_string()
273 });
274 env.add_filter("trim_end_newlines", |s: String| {
275 s.trim_end_matches('\n').to_string()
276 });
277 env.add_filter("starts_with", |s: String, prefix: String| {
278 s.starts_with(&prefix)
279 });
280 env.add_filter("ends_with", |s: String, suffix: String| {
281 s.ends_with(&suffix)
282 });
283 env.add_filter("after_think_end", |s: String| {
284 s.split("</think>")
285 .last()
286 .unwrap_or("")
287 .trim_start_matches('\n')
288 .to_string()
289 });
290 env.add_filter("reasoning_from_think", |s: String| {
291 s.split("</think>")
292 .next()
293 .unwrap_or("")
294 .trim_end_matches('\n')
295 .rsplit("<think>")
296 .next()
297 .unwrap_or("")
298 .trim_start_matches('\n')
299 .to_string()
300 });
301 let template = normalize_hf_chat_template(&model_template.template);
302 env.add_template("chat", &template)?;
303 let tmpl = env.get_template("chat")?;
304 tmpl.render(ModelTemplateContext {
305 messages,
306 add_generation_prompt: true,
307 bos_token: model_template.bos_token.as_deref().unwrap_or(""),
308 eos_token: model_template.eos_token.as_deref().unwrap_or(""),
309 enable_thinking: options.enable_thinking,
310 tools,
311 tool_choice,
312 functions,
313 function_call,
314 })
315}
316
317fn normalize_hf_chat_template(template: &str) -> String {
318 template
319 .replace(
320 "message.content.split('</think>')[-1].lstrip('\\n')",
321 "message.content|after_think_end",
322 )
323 .replace(
324 "message.content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n')",
325 "message.content|reasoning_from_think",
326 )
327 .replace(
328 "content.split('</think>')[-1].lstrip('\\n')",
329 "content|after_think_end",
330 )
331 .replace(
332 "content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n')",
333 "content|reasoning_from_think",
334 )
335 .replace(".startswith(", "|starts_with(")
336 .replace(".endswith(", "|ends_with(")
337 .replace(".strip('\\n')", "|trim_newlines")
338 .replace(".lstrip('\\n')", "|trim_start_newlines")
339 .replace(".rstrip('\\n')", "|trim_end_newlines")
340}
341
342pub fn render_chat_prompt(messages: &[ChatMessage], model_id: &str) -> String {
354 render_chat_prompt_with_model_template(messages, model_id, None)
355}
356
357pub fn render_chat_prompt_with_model_template(
358 messages: &[ChatMessage],
359 model_id: &str,
360 model_template: Option<&ModelChatTemplate>,
361) -> String {
362 render_chat_prompt_with_model_template_options(
363 messages,
364 model_id,
365 model_template,
366 &ChatTemplateOptions::default(),
367 )
368}
369
370pub fn render_chat_prompt_with_model_template_options(
371 messages: &[ChatMessage],
372 model_id: &str,
373 model_template: Option<&ModelChatTemplate>,
374 options: &ChatTemplateOptions,
375) -> String {
376 let prompt_messages = messages
377 .iter()
378 .map(PromptMessage::from_chat_message)
379 .collect::<Vec<_>>();
380 render_prompt_messages_with_options(&prompt_messages, model_id, model_template, options)
381}
382
383fn render_fallback_prompt(
384 messages: &[PromptMessage],
385 model_id: &str,
386 tool_spec: Option<String>,
387) -> String {
388 let model_lower = model_id.to_lowercase();
389
390 if model_lower.contains("qwen") {
391 let mut prompt = String::new();
392 if let Some(tool_spec) = tool_spec {
393 prompt.push_str(&format!("<|im_start|>system\n{}<|im_end|>\n", tool_spec));
394 }
395 for msg in messages {
396 prompt.push_str(&format!(
397 "<|im_start|>{}\n{}<|im_end|>\n",
398 msg.role, msg.content
399 ));
400 }
401 prompt.push_str("<|im_start|>assistant\n");
402 prompt
403 } else if model_lower.contains("llama") && model_lower.contains("3") {
404 let mut prompt = String::new();
409 if let Some(tool_spec) = tool_spec {
410 prompt.push_str(&format!(
411 "<|start_header_id|>system<|end_header_id|>\n\n{}<|eot_id|>",
412 tool_spec
413 ));
414 }
415 for msg in messages {
416 prompt.push_str(&format!(
417 "<|start_header_id|>{}<|end_header_id|>\n\n{}<|eot_id|>",
418 msg.role, msg.content
419 ));
420 }
421 prompt.push_str("<|start_header_id|>assistant<|end_header_id|>\n\n");
422 prompt
423 } else {
424 let has_system = messages.iter().any(|m| m.role == "system");
427 let mut prompt = String::new();
428 if let Some(tool_spec) = tool_spec {
429 prompt.push_str(&format!("<|system|>\n{}</s>\n", tool_spec));
430 } else if !has_system {
431 prompt.push_str("<|system|>\nYou are a helpful assistant.</s>\n");
432 }
433 for msg in messages {
434 prompt.push_str(&format!("<|{}|>\n{}</s>\n", msg.role, msg.content));
435 }
436 prompt.push_str("<|assistant|>\n");
437 prompt
438 }
439}
440
441pub fn render_chat_prompt_with_tools(
442 messages: &[ChatMessage],
443 model_id: &str,
444 tools: &[ChatTool],
445 tool_choice: Option<&ToolChoice>,
446 functions: &[ChatFunction],
447 function_call: Option<&FunctionCallChoice>,
448) -> String {
449 let prompt_messages = messages
450 .iter()
451 .map(|msg| PromptMessage::new(template_role(msg), template_content(msg)))
452 .collect::<Vec<_>>();
453 render_fallback_prompt(
454 &prompt_messages,
455 model_id,
456 render_tool_spec(tools, tool_choice, functions, function_call),
457 )
458}
459
460pub fn render_chat_prompt_with_tools_and_model_template(
461 messages: &[ChatMessage],
462 model_id: &str,
463 model_template: Option<&ModelChatTemplate>,
464 options: &ChatTemplateOptions,
465 tools: &[ChatTool],
466 tool_choice: Option<&ToolChoice>,
467 functions: &[ChatFunction],
468 function_call: Option<&FunctionCallChoice>,
469) -> String {
470 if let Some(model_template) = model_template {
471 let prompt_messages = messages
472 .iter()
473 .map(PromptMessage::from_chat_message)
474 .collect::<Vec<_>>();
475 match render_model_template(
476 &prompt_messages,
477 model_template,
478 options,
479 (!tools.is_empty()).then_some(tools),
480 tool_choice,
481 (!functions.is_empty()).then_some(functions),
482 function_call,
483 ) {
484 Ok(prompt) if !prompt.trim().is_empty() => return prompt,
485 Ok(_) => warn!(
486 "model chat template rendered an empty tool prompt; falling back to legacy renderer"
487 ),
488 Err(e) => warn!(
489 "failed to render tool prompt with model chat template from {}: {}; falling back to legacy renderer",
490 model_template.source, e
491 ),
492 }
493 }
494
495 render_chat_prompt_with_tools(
496 messages,
497 model_id,
498 tools,
499 tool_choice,
500 functions,
501 function_call,
502 )
503}
504
505fn template_role(msg: &ChatMessage) -> &'static str {
506 match msg.role {
507 MessageRole::System => "system",
508 MessageRole::User => "user",
509 MessageRole::Assistant => "assistant",
510 MessageRole::Function => "function",
511 MessageRole::Tool => "tool",
512 }
513}
514
515fn template_content(msg: &ChatMessage) -> String {
516 let mut parts = Vec::new();
517 if !msg.content.is_empty() {
518 parts.push(msg.content.clone());
519 }
520 if let Some(tool_calls) = msg.tool_calls.as_deref().filter(|calls| !calls.is_empty()) {
521 parts.push(json_line(serde_json::json!({ "tool_calls": tool_calls })));
522 }
523 if let Some(function_call) = msg.function_call.as_ref() {
524 parts.push(json_line(
525 serde_json::json!({ "function_call": function_call }),
526 ));
527 }
528 parts.join("\n")
529}
530
531fn render_tool_spec(
532 tools: &[ChatTool],
533 tool_choice: Option<&ToolChoice>,
534 functions: &[ChatFunction],
535 function_call: Option<&FunctionCallChoice>,
536) -> Option<String> {
537 if tools.is_empty() && functions.is_empty() {
538 return None;
539 }
540
541 let mut spec = serde_json::Map::new();
542 spec.insert(
543 "instruction".to_string(),
544 serde_json::Value::String(
545 "When a tool is needed, respond with JSON matching the provided tool/function schema; otherwise answer normally."
546 .to_string(),
547 ),
548 );
549 if !tools.is_empty() {
550 spec.insert("tools".to_string(), serde_json::json!(tools));
551 }
552 if let Some(choice) = tool_choice {
553 spec.insert("tool_choice".to_string(), serde_json::json!(choice));
554 }
555 if !functions.is_empty() {
556 spec.insert("functions".to_string(), serde_json::json!(functions));
557 }
558 if let Some(choice) = function_call {
559 spec.insert("function_call".to_string(), serde_json::json!(choice));
560 }
561 Some(json_line(serde_json::Value::Object(spec)))
562}
563
564fn json_line(value: serde_json::Value) -> String {
565 serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 fn msg(role: MessageRole, content: &str) -> ChatMessage {
573 ChatMessage {
574 role,
575 content: content.to_string(),
576 reasoning: None,
577 name: None,
578 tool_calls: None,
579 tool_call_id: None,
580 function_call: None,
581 }
582 }
583
584 fn tool(name: &str) -> ChatTool {
585 ChatTool {
586 tool_type: "function".to_string(),
587 function: ChatFunction {
588 name: name.to_string(),
589 description: Some("Get weather".to_string()),
590 parameters: Some(serde_json::json!({
591 "type": "object",
592 "properties": {"city": {"type": "string"}},
593 "required": ["city"]
594 })),
595 strict: None,
596 },
597 }
598 }
599
600 #[test]
601 fn qwen3_renders_chatml_without_forced_think_marker() {
602 let out = render_chat_prompt(
603 &[
604 msg(MessageRole::System, "You are helpful."),
605 msg(MessageRole::User, "Hi"),
606 ],
607 "qwen3:0.6b",
608 );
609 assert!(out.contains("<|im_start|>system\nYou are helpful.<|im_end|>"));
610 assert!(out.contains("<|im_start|>user\nHi<|im_end|>"));
611 assert!(out.ends_with("<|im_start|>assistant\n"));
612 assert!(!out.contains("<think>"));
613 }
614
615 #[test]
616 fn qwen2_renders_chatml_without_think() {
617 let out = render_chat_prompt(&[msg(MessageRole::User, "Hi")], "Qwen/Qwen2.5-7B-Instruct");
618 assert!(out.ends_with("<|im_start|>assistant\n"));
619 assert!(!out.contains("<think>"));
620 }
621
622 #[test]
623 fn model_template_is_preferred_over_family_fallback() {
624 let template = ModelChatTemplate::new(
625 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
626 "test-template",
627 );
628 let out = render_chat_prompt_with_model_template(
629 &[msg(MessageRole::User, "Hi")],
630 "qwen3",
631 Some(&template),
632 );
633 assert_eq!(out, "[user]Hi[assistant]");
634 }
635
636 #[test]
637 fn model_template_is_used_for_tool_requests() {
638 let template = ModelChatTemplate::new(
639 "{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% if message.tool_calls %}{% for tool_call in message.tool_calls %}<tool_call>{{ tool_call.function.name }}:{{ tool_call.function.arguments }}</tool_call>{% endfor %}{% endif %}{% if message.tool_call_id %}<tool_response id=\"{{ message.tool_call_id }}\">{{ message.content }}</tool_response>{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
640 "tool-template",
641 );
642 let mut assistant = msg(MessageRole::Assistant, "");
643 assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
644 index: None,
645 id: "call_1".to_string(),
646 tool_type: "function".to_string(),
647 function: crate::openai::ChatFunctionCall {
648 name: "weather".to_string(),
649 arguments: "{\"city\":\"Paris\"}".to_string(),
650 },
651 }]);
652 let mut tool_result = msg(MessageRole::Tool, "sunny");
653 tool_result.tool_call_id = Some("call_1".to_string());
654
655 let out = render_chat_prompt_with_tools_and_model_template(
656 &[
657 msg(MessageRole::User, "Use weather."),
658 assistant,
659 tool_result,
660 ],
661 "served-hash-id",
662 Some(&template),
663 &ChatTemplateOptions::default(),
664 &[tool("weather")],
665 Some(&ToolChoice::Mode("auto".to_string())),
666 &[],
667 None,
668 );
669
670 assert!(out.contains("<tools>weather</tools>"));
671 assert!(out.contains("<tool_call>weather:"), "{out}");
672 assert!(out.contains("\"city\""), "{out}");
673 assert!(out.contains("Paris"), "{out}");
674 assert!(out.contains("<tool_response id=\"call_1\">sunny</tool_response>"));
675 assert!(out.ends_with("[assistant]"));
676 assert!(
677 !out.contains("<|assistant|>"),
678 "tool requests with model templates must not use generic fallback: {out}"
679 );
680 }
681
682 #[test]
683 fn model_template_tools_supports_qwen3_template_primitives() {
684 let template = ModelChatTemplate::new(
685 "{% if tools %}<tools>{% for tool in tools %}{{ tool | tojson }}{% endfor %}</tools>{% endif %}{% for message in messages[::-1] %}[{{ message.role }}]{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
686 "qwen3-tool-primitives",
687 );
688 let out = render_chat_prompt_with_tools_and_model_template(
689 &[
690 msg(MessageRole::User, "Use weather."),
691 msg(MessageRole::Assistant, "ok"),
692 ],
693 "served-hash-id",
694 Some(&template),
695 &ChatTemplateOptions::default(),
696 &[tool("weather")],
697 Some(&ToolChoice::Mode("auto".to_string())),
698 &[],
699 None,
700 );
701
702 assert!(out.contains("\"name\":\"weather\""), "{out}");
703 assert!(out.contains("[assistant][user][assistant]"), "{out}");
704 }
705
706 #[test]
707 fn model_template_tool_arguments_are_parsed_for_hf_templates() {
708 let template = ModelChatTemplate::new(
709 "{% for message in messages %}{% if message.tool_calls %}{% set tool_call = message.tool_calls[0].function %}{{ tool_call.arguments | tojson }}{% for name, value in tool_call.arguments | items %}[{{ name }}={{ value }}]{% endfor %}{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
710 "llama-tool-primitives",
711 );
712 let mut assistant = msg(MessageRole::Assistant, "");
713 assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
714 index: None,
715 id: "call_1".to_string(),
716 tool_type: "function".to_string(),
717 function: crate::openai::ChatFunctionCall {
718 name: "weather".to_string(),
719 arguments: "{\"city\":\"Paris\",\"unit\":\"celsius\"}".to_string(),
720 },
721 }]);
722
723 let out = render_chat_prompt_with_tools_and_model_template(
724 &[msg(MessageRole::User, "Use weather."), assistant],
725 "served-hash-id",
726 Some(&template),
727 &ChatTemplateOptions::default(),
728 &[tool("weather")],
729 Some(&ToolChoice::Mode("auto".to_string())),
730 &[],
731 None,
732 );
733
734 assert!(out.contains("\"city\""), "{out}");
735 assert!(out.contains("\"Paris\""), "{out}");
736 assert!(out.contains("[city=Paris]"), "{out}");
737 assert!(out.contains("[unit=celsius]"), "{out}");
738 assert!(out.ends_with("[assistant]"));
739 }
740
741 #[test]
742 fn model_template_tool_result_content_stays_string_for_hf_templates() {
743 let template = ModelChatTemplate::new(
744 "{% for message in messages %}{% if message.role == 'tool' %}{% if message.content is string %}<tool_response>{{ message.content }}</tool_response>{% else %}not-string{% endif %}{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
745 "qwen-tool-result-primitives",
746 );
747 let mut tool_result = msg(
748 MessageRole::Tool,
749 "{\"city\":\"北京\",\"temp\":22,\"desc\":\"晴\"}",
750 );
751 tool_result.tool_call_id = Some("call_1".to_string());
752
753 let out = render_chat_prompt_with_tools_and_model_template(
754 &[msg(MessageRole::User, "Use weather."), tool_result],
755 "served-hash-id",
756 Some(&template),
757 &ChatTemplateOptions::default(),
758 &[tool("weather")],
759 Some(&ToolChoice::Mode("auto".to_string())),
760 &[],
761 None,
762 );
763
764 assert!(out.contains("\"temp\""), "{out}");
765 assert!(out.contains("22"), "{out}");
766 assert!(out.contains("\"desc\":\"晴\""), "{out}");
767 assert!(out.contains("<tool_response>"), "{out}");
768 assert!(!out.contains("not-string"), "{out}");
769 assert!(out.ends_with("[assistant]"));
770 }
771
772 #[test]
773 fn qwen_style_model_template_does_not_force_empty_think() {
774 let template = ModelChatTemplate::new(
775 "{%- for message in messages %}{{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>\\n' }}{%- endfor %}{%- if add_generation_prompt %}{{- '<|im_start|>assistant\\n' }}{%- endif %}",
776 "qwen-template",
777 );
778 let out = render_chat_prompt_with_model_template(
779 &[msg(MessageRole::User, "Hi")],
780 "qwen3",
781 Some(&template),
782 );
783 assert_eq!(
784 out,
785 "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
786 );
787 assert!(!out.contains("<think>"));
788 }
789
790 #[test]
791 fn enable_thinking_false_is_model_template_controlled() {
792 let template = ModelChatTemplate::new(
793 "{%- for message in messages %}{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' }}{%- endfor %}{%- if add_generation_prompt %}{{- '<|im_start|>assistant\n' }}{%- if enable_thinking is defined and enable_thinking is false %}{{- '<think>\n\n</think>\n\n' }}{%- endif %}{%- endif %}",
794 "thinking-template",
795 );
796 let options = ChatTemplateOptions::default_for_template(Some(&template));
797 assert_eq!(options.enable_thinking, Some(false));
798 let out = render_chat_prompt_with_model_template_options(
799 &[msg(MessageRole::User, "Hi")],
800 "served-model-alias",
801 Some(&template),
802 &options,
803 );
804 assert!(out.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
805 }
806
807 #[test]
808 fn explicit_enable_thinking_overrides_template_default() {
809 let template = ModelChatTemplate::new(
810 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
811 "thinking-template",
812 );
813 let out = render_chat_prompt_with_model_template_options(
814 &[msg(MessageRole::User, "Hi")],
815 "Qwen/Qwen3-0.6B",
816 Some(&template),
817 &ChatTemplateOptions {
818 enable_thinking: Some(true),
819 },
820 );
821 assert_eq!(out, "<assistant>");
822 }
823
824 #[test]
825 fn template_without_enable_thinking_does_not_get_thinking_default() {
826 let template = ModelChatTemplate::new(
827 "{% if add_generation_prompt %}<assistant>{% endif %}",
828 "plain-template",
829 );
830 let options = ChatTemplateOptions::default_for_template(Some(&template));
831 assert_eq!(options.enable_thinking, None);
832 let out = render_chat_prompt_with_model_template_options(
833 &[msg(MessageRole::User, "Hi")],
834 "Qwen/Qwen3-0.6B",
835 Some(&template),
836 &options,
837 );
838 assert_eq!(out, "<assistant>");
839 }
840
841 #[test]
842 fn assistant_think_history_exposes_reasoning_content_to_model_template() {
843 let template = ModelChatTemplate::new(
844 "{% for message in messages %}{% if message.reasoning_content is defined and message.reasoning_content is not none %}<r>{{ message.reasoning_content|trim_newlines }}</r>{{ message.content|trim_start_newlines }}{% else %}{{ message.content }}{% endif %}{% endfor %}{% if add_generation_prompt %}<assistant>{% endif %}",
845 "reasoning-template",
846 );
847 let out = render_prompt_messages(
848 &[
849 PromptMessage::new("assistant", "<think>\nreason\n</think>\n\nanswer"),
850 PromptMessage::new("user", "next"),
851 ],
852 "qwen3",
853 Some(&template),
854 );
855 assert_eq!(out, "<r>reason</r>answernext<assistant>");
856 }
857
858 #[test]
859 fn hf_python_split_expressions_are_normalized_for_minijinja() {
860 let template = ModelChatTemplate::new(
861 "{% for message in messages %}{% set content = message.content.split('</think>')[-1].lstrip('\\n') %}{% set reasoning_content = message.content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}<r>{{ reasoning_content.strip('\\n') }}</r>{{ content.lstrip('\\n') }}{% endfor %}",
862 "split-template",
863 );
864 let out = render_prompt_messages(
865 &[PromptMessage {
866 role: "assistant".to_string(),
867 content: "<think>\nreason\n</think>\n\nanswer".to_string(),
868 reasoning_content: None,
869 name: None,
870 tool_calls: None,
871 tool_call_id: None,
872 function_call: None,
873 }],
874 "qwen3",
875 Some(&template),
876 );
877 assert_eq!(out, "<r>reason</r>answer");
878 }
879
880 #[test]
881 fn qwen3_content_variable_split_expressions_are_normalized_for_minijinja() {
882 let template = ModelChatTemplate::new(
883 "{% for message in messages %}{% set content = message.content %}{% if '</think>' in content %}{% set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}{% set content = content.split('</think>')[-1].lstrip('\\n') %}{% endif %}<r>{{ reasoning_content.strip('\\n') }}</r>{{ content.lstrip('\\n') }}{% endfor %}",
884 "qwen3-content-split-template",
885 );
886 let out = render_prompt_messages(
887 &[PromptMessage {
888 role: "assistant".to_string(),
889 content: "<think>\nreason\n</think>\n\nanswer".to_string(),
890 reasoning_content: None,
891 name: None,
892 tool_calls: None,
893 tool_call_id: None,
894 function_call: None,
895 }],
896 "qwen3",
897 Some(&template),
898 );
899 assert_eq!(out, "<r>reason</r>answer");
900 }
901
902 #[test]
903 fn qwen3_python_startswith_endswith_are_normalized_for_minijinja() {
904 let template = ModelChatTemplate::new(
905 "{% for message in messages %}{% if message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}plain{% else %}tool{% endif %}{% endfor %}",
906 "qwen3-startswith-template",
907 );
908 let out = render_prompt_messages(
909 &[
910 PromptMessage::new("user", "hello"),
911 PromptMessage::new("user", "<tool_response>ok</tool_response>"),
912 ],
913 "qwen3",
914 Some(&template),
915 );
916 assert_eq!(out, "plaintool");
917 }
918
919 #[test]
920 fn multi_turn_preserves_order() {
921 let out = render_chat_prompt(
922 &[
923 msg(MessageRole::User, "A"),
924 msg(MessageRole::Assistant, "B"),
925 msg(MessageRole::User, "C"),
926 ],
927 "qwen3",
928 );
929 let a_idx = out.find("A").unwrap();
930 let b_idx = out.find("B").unwrap();
931 let c_idx = out.find("C").unwrap();
932 assert!(a_idx < b_idx && b_idx < c_idx);
933 }
934
935 #[test]
936 fn llama3_renders_header_format() {
937 let out = render_chat_prompt(
938 &[
939 msg(MessageRole::System, "sys"),
940 msg(MessageRole::User, "hi"),
941 ],
942 "meta-llama/Llama-3.2-1B-Instruct",
943 );
944 assert!(!out.starts_with("<|begin_of_text|>"));
945 assert!(out.contains("<|start_header_id|>system<|end_header_id|>\n\nsys<|eot_id|>"));
946 assert!(out.contains("<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>"));
947 assert!(out.ends_with("<|start_header_id|>assistant<|end_header_id|>\n\n"));
948 }
949
950 #[test]
951 fn unknown_model_uses_tinyllama_fallback() {
952 let out = render_chat_prompt(&[msg(MessageRole::User, "hi")], "mystery-model");
953 assert!(out.contains("<|system|>"));
954 assert!(out.contains("<|user|>\nhi</s>"));
955 assert!(out.ends_with("<|assistant|>\n"));
956 }
957
958 #[test]
959 fn fallback_preserves_legacy_function_and_tool_roles() {
960 let out = render_chat_prompt(
961 &[
962 msg(MessageRole::Function, "{\"city\":\"Paris\"}"),
963 msg(MessageRole::Tool, "sunny"),
964 ],
965 "mystery-model",
966 );
967 assert!(out.contains("<|function|>\n{\"city\":\"Paris\"}</s>"));
968 assert!(out.contains("<|tool|>\nsunny</s>"));
969 }
970
971 #[test]
972 fn qwen_renders_tool_definitions_and_assistant_tool_call_history() {
973 let mut assistant = msg(MessageRole::Assistant, "");
974 assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
975 index: None,
976 id: "call_1".to_string(),
977 tool_type: "function".to_string(),
978 function: crate::openai::ChatFunctionCall {
979 name: "weather".to_string(),
980 arguments: "{\"city\":\"Paris\"}".to_string(),
981 },
982 }]);
983
984 let out = render_chat_prompt_with_tools(
985 &[
986 msg(MessageRole::User, "Use weather."),
987 assistant,
988 msg(MessageRole::Tool, "sunny"),
989 ],
990 "qwen3",
991 &[tool("weather")],
992 Some(&ToolChoice::Mode("auto".to_string())),
993 &[],
994 None,
995 );
996
997 assert!(out.contains("\"tools\":[{"));
998 assert!(out.contains("\"type\":\"function\""));
999 assert!(out.contains("\"tool_choice\":\"auto\""));
1000 assert!(out.contains("<|im_start|>assistant\n{"));
1001 assert!(out.contains("\"tool_calls\":[{"));
1002 assert!(out.contains("\"id\":\"call_1\""));
1003 assert!(out.contains("\"name\":\"weather\""));
1004 assert!(out.contains("<|im_start|>tool\nsunny<|im_end|>"));
1005 }
1006}