Skip to main content

ferrum_server/
chat_template.rs

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/// Model-provided chat template, usually from GGUF `tokenizer.chat_template`
11/// or HuggingFace `tokenizer_config.json`.
12#[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/// Common prompt-message shape used by both CLI `run` and OpenAI `serve`.
54#[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/// Tool-call shape exposed to model chat templates.
100///
101/// OpenAI's wire format serializes `function.arguments` as a JSON string, but
102/// HuggingFace chat templates generally expect a parsed mapping so they can
103/// apply `tojson`, `items`, and similar template operations. Keep that internal
104/// shape separate from the API response type.
105#[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
203/// Render common chat messages into the prompt string the model was trained
204/// on. Prefer a model-provided chat template when available; otherwise use
205/// a centralized legacy fallback for model families ferrum already supports.
206pub 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
342/// Render OpenAI-style chat messages into the prompt string the model was
343/// trained on.
344///
345/// Detects model family from the request's `model` field:
346///   - qwen (Qwen2 / Qwen2.5 / Qwen3): ChatML with `<|im_start|>` / `<|im_end|>`
347///   - llama 3: `<|start_header_id|>...<|end_header_id|>` + `<|eot_id|>`
348///   - fallback: TinyLlama-style `<|system|>` / `<|user|>` / `<|assistant|>`
349///     with `</s>` separators
350///
351/// All templates end with the assistant header so the first generated token
352/// becomes the reply content (no extra role prefix).
353pub 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        // The engine encodes prompts with `add_special=true`, so do not
405        // include `<|begin_of_text|>` here. Including it manually creates a
406        // double-BOS prompt for Llama-3 tokenizers and degrades instruction
407        // following.
408        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        // TinyLlama / generic chat format. Promote the first system message
425        // to the top; subsequent ones (rare) are emitted inline.
426        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}