Skip to main content

ferrum_server/
chat_template.rs

1use crate::openai::{
2    ChatFunction, ChatMessage, ChatTool, FunctionCallChoice, MessageRole, ToolChoice,
3};
4use ferrum_types::{
5    has_unclosed_thinking_block, ApiToolCallProtocol, FerrumError, THINK_END_TAG, THINK_START_TAG,
6};
7use minijinja::Environment;
8use serde::ser::SerializeStruct;
9use serde::Serialize;
10use serde_json::Value;
11
12/// Model-provided chat template, usually from GGUF `tokenizer.chat_template`
13/// or HuggingFace `tokenizer_config.json`.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum ModelReasoningProtocol {
16    None,
17    PromptOpened,
18    ModelGenerated,
19}
20
21#[derive(Clone, Debug)]
22pub struct ModelChatTemplate {
23    pub template: String,
24    pub source: String,
25    pub bos_token: Option<String>,
26    pub eos_token: Option<String>,
27    pub tool_call_protocol: ApiToolCallProtocol,
28    pub reasoning_protocol: ModelReasoningProtocol,
29    pub reasoning_default_enabled: bool,
30}
31
32impl ModelChatTemplate {
33    pub fn new(template: impl Into<String>, source: impl Into<String>) -> Self {
34        let template = template.into();
35        let mut model_template = Self {
36            tool_call_protocol: tool_call_protocol_for_template(&template),
37            template,
38            source: source.into(),
39            bos_token: None,
40            eos_token: None,
41            reasoning_protocol: ModelReasoningProtocol::None,
42            reasoning_default_enabled: false,
43        };
44        let (reasoning_protocol, reasoning_default_enabled) =
45            detect_model_reasoning_protocol(&model_template);
46        model_template.reasoning_protocol = reasoning_protocol;
47        model_template.reasoning_default_enabled = reasoning_default_enabled;
48        model_template
49    }
50
51    pub fn reasoning_enabled(&self, requested: Option<bool>) -> bool {
52        self.reasoning_protocol != ModelReasoningProtocol::None
53            && requested.unwrap_or(self.reasoning_default_enabled)
54    }
55}
56
57fn tool_call_protocol_for_template(template: &str) -> ApiToolCallProtocol {
58    if template.contains("<tool_call>")
59        && template.contains("<function=")
60        && template.contains("<parameter=")
61    {
62        ApiToolCallProtocol::FunctionParameterXml
63    } else {
64        ApiToolCallProtocol::Json
65    }
66}
67
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct ChatTemplateOptions {
70    pub enable_thinking: Option<bool>,
71    /// Clock seen by the template's `strftime_now` (Mistral-Small-3.2 and
72    /// Llama-3.x inject "today's date" into the system prompt). `None` =
73    /// local wall clock; golden tests pin the timestamp recorded at
74    /// fixture-generation time so byte comparison survives the date
75    /// changing.
76    pub now_override: Option<chrono::NaiveDateTime>,
77}
78
79impl ChatTemplateOptions {
80    pub fn default_for_template(_model_template: Option<&ModelChatTemplate>) -> Self {
81        // Omission is a real third state: the model-owned template decides its
82        // default. Only explicit product controls may force true or false.
83        Self::default()
84    }
85}
86
87/// Common prompt-message shape used by both CLI `run` and OpenAI `serve`.
88#[derive(Clone, Debug)]
89pub struct PromptMessage {
90    pub role: String,
91    pub content: String,
92    pub reasoning_content: Option<String>,
93    pub name: Option<String>,
94    pub tool_calls: Option<Vec<PromptToolCall>>,
95    pub tool_call_id: Option<String>,
96    pub function_call: Option<crate::openai::ChatFunctionCall>,
97}
98
99impl Serialize for PromptMessage {
100    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
101    where
102        S: serde::Serializer,
103    {
104        let mut len = 2;
105        len += usize::from(self.reasoning_content.is_some());
106        len += usize::from(self.name.is_some());
107        len += usize::from(self.tool_calls.is_some());
108        len += usize::from(self.tool_call_id.is_some());
109        len += usize::from(self.function_call.is_some());
110        let mut state = serializer.serialize_struct("PromptMessage", len)?;
111        state.serialize_field("role", &self.role)?;
112        let content = template_content_value(&self.content);
113        state.serialize_field("content", &content)?;
114        if let Some(reasoning_content) = &self.reasoning_content {
115            state.serialize_field("reasoning_content", reasoning_content)?;
116        }
117        if let Some(name) = &self.name {
118            state.serialize_field("name", name)?;
119        }
120        if let Some(tool_calls) = &self.tool_calls {
121            state.serialize_field("tool_calls", tool_calls)?;
122        }
123        if let Some(tool_call_id) = &self.tool_call_id {
124            state.serialize_field("tool_call_id", tool_call_id)?;
125        }
126        if let Some(function_call) = &self.function_call {
127            state.serialize_field("function_call", function_call)?;
128        }
129        state.end()
130    }
131}
132
133/// Tool-call shape exposed to model chat templates.
134///
135/// OpenAI's wire format serializes `function.arguments` as a JSON string, but
136/// HuggingFace chat templates generally expect a parsed mapping so they can
137/// apply `tojson`, `items`, and similar template operations. Keep that internal
138/// shape separate from the API response type.
139#[derive(Clone, Debug, Serialize)]
140pub struct PromptToolCall {
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub index: Option<u32>,
143    pub id: String,
144    #[serde(rename = "type")]
145    pub tool_type: String,
146    pub function: PromptFunctionCall,
147}
148
149#[derive(Clone, Debug, Serialize)]
150pub struct PromptFunctionCall {
151    pub name: String,
152    pub arguments: Value,
153}
154
155impl From<&crate::openai::ChatToolCall> for PromptToolCall {
156    fn from(call: &crate::openai::ChatToolCall) -> Self {
157        Self {
158            index: call.index,
159            id: call.id.clone(),
160            tool_type: call.tool_type.clone(),
161            function: PromptFunctionCall {
162                name: call.function.name.clone(),
163                arguments: parse_template_arguments(&call.function.arguments),
164            },
165        }
166    }
167}
168
169fn parse_template_arguments(arguments: &str) -> Value {
170    serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
171}
172
173fn template_content_value(content: &str) -> Value {
174    Value::String(content.to_string())
175}
176
177impl PromptMessage {
178    /// Content is passed to the chat template verbatim — including any
179    /// `<think>...</think>` blocks in assistant history. Whether reasoning
180    /// is kept or stripped from history is a per-template policy (DeepSeek
181    /// strips it, Qwen3-Coder keeps it); pre-splitting here diverged from
182    /// what `transformers.apply_chat_template` feeds the same template.
183    /// `reasoning_content` is only set when the client supplies reasoning
184    /// as a separate field.
185    pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
186        Self {
187            role: role.into(),
188            content: content.into(),
189            reasoning_content: None,
190            name: None,
191            tool_calls: None,
192            tool_call_id: None,
193            function_call: None,
194        }
195    }
196
197    fn from_chat_message(message: &ChatMessage) -> Self {
198        let mut prompt = Self::new(template_role(message), message.content.clone());
199        if matches!(message.role, MessageRole::Assistant) && message.reasoning.is_some() {
200            prompt.reasoning_content = message.reasoning.clone();
201        }
202        prompt.name = message.name.clone();
203        prompt.tool_calls = message
204            .tool_calls
205            .as_ref()
206            .map(|calls| calls.iter().map(PromptToolCall::from).collect());
207        prompt.tool_call_id = message.tool_call_id.clone();
208        prompt.function_call = message.function_call.clone();
209        prompt
210    }
211}
212
213/// Render common chat messages into the prompt string the model was trained
214/// on. Prefer a model-provided chat template when available; otherwise use
215/// a centralized legacy fallback for model families ferrum already supports.
216///
217/// A model-provided template that fails to render (or renders empty) is a
218/// hard error: silently falling back to a generic prompt format feeds the
219/// model a prompt it was not trained on and degrades output quality without
220/// any visible failure.
221pub fn render_prompt_messages(
222    messages: &[PromptMessage],
223    model_id: &str,
224    model_template: Option<&ModelChatTemplate>,
225) -> ferrum_types::Result<String> {
226    render_prompt_messages_with_options(
227        messages,
228        model_id,
229        model_template,
230        &ChatTemplateOptions::default(),
231    )
232}
233
234pub fn render_prompt_messages_with_options(
235    messages: &[PromptMessage],
236    model_id: &str,
237    model_template: Option<&ModelChatTemplate>,
238    options: &ChatTemplateOptions,
239) -> ferrum_types::Result<String> {
240    if let Some(model_template) = model_template {
241        return match render_model_template(
242            messages,
243            model_template,
244            options,
245            None,
246            None,
247            None,
248            None,
249        ) {
250            Ok(prompt) if !prompt.trim().is_empty() => Ok(prompt),
251            Ok(_) => Err(chat_template_render_error(
252                model_template,
253                "template rendered an empty prompt",
254            )),
255            Err(e) => Err(chat_template_render_error(model_template, e)),
256        };
257    }
258    Ok(render_fallback_prompt(messages, model_id, None))
259}
260
261fn chat_template_render_error(
262    template: &ModelChatTemplate,
263    reason: impl std::fmt::Display,
264) -> FerrumError {
265    FerrumError::model(format!(
266        "chat template from {} failed to render: {reason}. Refusing to fall back \
267         to a generic prompt format because that silently degrades output quality; \
268         fix the model's chat template or serve the model without one.",
269        template.source
270    ))
271}
272
273#[derive(Serialize)]
274struct ModelTemplateContext<'a> {
275    messages: &'a [PromptMessage],
276    add_generation_prompt: bool,
277    bos_token: &'a str,
278    eos_token: &'a str,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    enable_thinking: Option<bool>,
281    /// Pre-converted to `serde_json::Value`: minijinja serializes Rust
282    /// *structs* with alphabetically sorted fields, but a JSON map (with
283    /// serde_json `preserve_order`) keeps the OpenAI canonical key order
284    /// that transformers' `tojson` renders.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    tools: Option<serde_json::Value>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    tool_choice: Option<&'a ToolChoice>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    functions: Option<serde_json::Value>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    function_call: Option<&'a FunctionCallChoice>,
293}
294
295fn render_model_template(
296    messages: &[PromptMessage],
297    model_template: &ModelChatTemplate,
298    options: &ChatTemplateOptions,
299    tools: Option<&[ChatTool]>,
300    tool_choice: Option<&ToolChoice>,
301    functions: Option<&[ChatFunction]>,
302    function_call: Option<&FunctionCallChoice>,
303) -> std::result::Result<String, minijinja::Error> {
304    let mut env = Environment::new();
305    // HF chat templates are written for Jinja2 and freely use Python string
306    // methods (`.split()`, `.strip()`, `.startswith()`, ...). pycompat
307    // resolves those at runtime; `normalize_hf_chat_template` below remains
308    // for the exact spellings it already rewrote before pycompat landed.
309    env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
310    // transformers compiles chat templates with
311    // `ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)`
312    // and a plain-`json.dumps` `tojson` filter; match both so rendering is
313    // byte-identical (verified by tests/chat_template_golden.rs).
314    env.set_trim_blocks(true);
315    env.set_lstrip_blocks(true);
316    env.add_filter("tojson", python_style_tojson);
317    env.add_filter("trim_newlines", |s: String| {
318        s.trim_matches('\n').to_string()
319    });
320    env.add_filter("trim_start_newlines", |s: String| {
321        s.trim_start_matches('\n').to_string()
322    });
323    env.add_filter("trim_end_newlines", |s: String| {
324        s.trim_end_matches('\n').to_string()
325    });
326    env.add_filter("starts_with", |s: String, prefix: String| {
327        s.starts_with(&prefix)
328    });
329    env.add_filter("ends_with", |s: String, suffix: String| {
330        s.ends_with(&suffix)
331    });
332    env.add_filter("after_think_end", |s: String| {
333        s.split("</think>")
334            .last()
335            .unwrap_or("")
336            .trim_start_matches('\n')
337            .to_string()
338    });
339    env.add_filter("reasoning_from_think", |s: String| {
340        s.split("</think>")
341            .next()
342            .unwrap_or("")
343            .trim_end_matches('\n')
344            .rsplit("<think>")
345            .next()
346            .unwrap_or("")
347            .trim_start_matches('\n')
348            .to_string()
349    });
350    // transformers exposes `strftime_now(format)` = `datetime.now().strftime`
351    // to templates (Mistral-Small-3.2 / Llama-3.x date their system prompts
352    // with it). chrono's strftime covers the specifiers real templates use
353    // (%d %m %Y %b %H %M %S); an unsupported one is a render error, not a
354    // panic.
355    let now = options
356        .now_override
357        .unwrap_or_else(|| chrono::Local::now().naive_local());
358    env.add_function(
359        "strftime_now",
360        move |fmt: String| -> std::result::Result<String, minijinja::Error> {
361            use std::fmt::Write as _;
362            let mut out = String::new();
363            write!(out, "{}", now.format(&fmt)).map_err(|_| {
364                minijinja::Error::new(
365                    minijinja::ErrorKind::InvalidOperation,
366                    format!("strftime_now: unsupported format string {fmt:?}"),
367                )
368            })?;
369            Ok(out)
370        },
371    );
372    let template = normalize_hf_chat_template(&model_template.template);
373    env.add_template("chat", &template)?;
374    let tmpl = env.get_template("chat")?;
375    tmpl.render(ModelTemplateContext {
376        messages,
377        add_generation_prompt: true,
378        bos_token: model_template.bos_token.as_deref().unwrap_or(""),
379        eos_token: model_template.eos_token.as_deref().unwrap_or(""),
380        enable_thinking: options.enable_thinking,
381        tools: tools.and_then(|t| serde_json::to_value(t).ok()),
382        tool_choice,
383        functions: functions.and_then(|f| serde_json::to_value(f).ok()),
384        function_call,
385    })
386}
387
388fn detect_model_reasoning_protocol(
389    model_template: &ModelChatTemplate,
390) -> (ModelReasoningProtocol, bool) {
391    let messages = [PromptMessage {
392        role: "user".to_string(),
393        content: "reasoning protocol probe".to_string(),
394        reasoning_content: None,
395        name: None,
396        tool_calls: None,
397        tool_call_id: None,
398        function_call: None,
399    }];
400    let now =
401        chrono::NaiveDate::from_ymd_opt(2000, 1, 1).and_then(|date| date.and_hms_opt(0, 0, 0));
402    let render = |enable_thinking| {
403        render_model_template(
404            &messages,
405            model_template,
406            &ChatTemplateOptions {
407                enable_thinking,
408                now_override: now,
409            },
410            None,
411            None,
412            None,
413            None,
414        )
415        .ok()
416    };
417    let Some(enabled) = render(Some(true)) else {
418        return (ModelReasoningProtocol::None, false);
419    };
420    let default = render(None);
421    if has_unclosed_thinking_block(&enabled) {
422        let default_enabled = default.as_deref().is_some_and(has_unclosed_thinking_block);
423        return (ModelReasoningProtocol::PromptOpened, default_enabled);
424    }
425    let Some(disabled) = render(Some(false)) else {
426        return (ModelReasoningProtocol::None, false);
427    };
428    let completed_blocks = |prompt: &str| {
429        prompt
430            .matches(THINK_START_TAG)
431            .count()
432            .min(prompt.matches(THINK_END_TAG).count())
433    };
434    if completed_blocks(&disabled) > completed_blocks(&enabled) {
435        return (
436            ModelReasoningProtocol::ModelGenerated,
437            default.as_deref() == Some(enabled.as_str()),
438        );
439    }
440    (ModelReasoningProtocol::None, false)
441}
442
443/// `tojson` matching Python's `json.dumps(..., ensure_ascii=False)` as used
444/// by transformers' chat-template environment: `", "` / `": "` separators and
445/// insertion key order (hence the minijinja `preserve_order` feature).
446/// minijinja's builtin emits compact separators, which breaks byte equality
447/// with transformers-rendered tool definitions.
448fn python_style_tojson(
449    value: minijinja::value::Value,
450    kwargs: minijinja::value::Kwargs,
451) -> Result<String, minijinja::Error> {
452    // transformers' `tojson` forwards kwargs to `json.dumps`; real templates
453    // use `indent=N` (Llama-3.x tool specs). Python's indented output equals
454    // serde_json's pretty formatter with an N-space indent. Anything else
455    // (sort_keys, separators) is unimplemented — erroring beats silently
456    // rendering a different prompt.
457    let indent = kwargs.get::<Option<usize>>("indent")?;
458    kwargs.assert_all_used()?;
459    if let Some(indent) = indent {
460        let indent_bytes = vec![b' '; indent];
461        let mut out = Vec::new();
462        let mut ser = serde_json::Serializer::with_formatter(
463            &mut out,
464            serde_json::ser::PrettyFormatter::with_indent(&indent_bytes),
465        );
466        serde::Serialize::serialize(&value, &mut ser).map_err(|e| {
467            minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string())
468        })?;
469        return String::from_utf8(out).map_err(|e| {
470            minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string())
471        });
472    }
473    struct PyFormatter;
474    impl serde_json::ser::Formatter for PyFormatter {
475        fn begin_object_key<W: ?Sized + std::io::Write>(
476            &mut self,
477            writer: &mut W,
478            first: bool,
479        ) -> std::io::Result<()> {
480            if !first {
481                writer.write_all(b", ")?;
482            }
483            Ok(())
484        }
485        fn begin_object_value<W: ?Sized + std::io::Write>(
486            &mut self,
487            writer: &mut W,
488        ) -> std::io::Result<()> {
489            writer.write_all(b": ")
490        }
491        fn begin_array_value<W: ?Sized + std::io::Write>(
492            &mut self,
493            writer: &mut W,
494            first: bool,
495        ) -> std::io::Result<()> {
496            if !first {
497                writer.write_all(b", ")?;
498            }
499            Ok(())
500        }
501    }
502
503    let mut out = Vec::new();
504    let mut ser = serde_json::Serializer::with_formatter(&mut out, PyFormatter);
505    serde::Serialize::serialize(&value, &mut ser).map_err(|e| {
506        minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string())
507    })?;
508    String::from_utf8(out)
509        .map_err(|e| minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string()))
510}
511
512fn normalize_hf_chat_template(template: &str) -> String {
513    template
514        .replace(
515            "message.content.split('</think>')[-1].lstrip('\\n')",
516            "message.content|after_think_end",
517        )
518        .replace(
519            "message.content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n')",
520            "message.content|reasoning_from_think",
521        )
522        .replace(
523            "content.split('</think>')[-1].lstrip('\\n')",
524            "content|after_think_end",
525        )
526        .replace(
527            "content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n')",
528            "content|reasoning_from_think",
529        )
530        .replace(".startswith(", "|starts_with(")
531        .replace(".endswith(", "|ends_with(")
532        .replace(".strip('\\n')", "|trim_newlines")
533        .replace(".lstrip('\\n')", "|trim_start_newlines")
534        .replace(".rstrip('\\n')", "|trim_end_newlines")
535}
536
537/// Render OpenAI-style chat messages into the prompt string the model was
538/// trained on.
539///
540/// Detects model family from the request's `model` field:
541///   - qwen (Qwen2 / Qwen2.5 / Qwen3): ChatML with `<|im_start|>` / `<|im_end|>`
542///   - llama 3: `<|start_header_id|>...<|end_header_id|>` + `<|eot_id|>`
543///   - fallback: TinyLlama-style `<|system|>` / `<|user|>` / `<|assistant|>`
544///     with `</s>` separators
545///
546/// All templates end with the assistant header so the first generated token
547/// becomes the reply content (no extra role prefix).
548pub fn render_chat_prompt(messages: &[ChatMessage], model_id: &str) -> String {
549    let prompt_messages = messages
550        .iter()
551        .map(PromptMessage::from_chat_message)
552        .collect::<Vec<_>>();
553    render_fallback_prompt(&prompt_messages, model_id, None)
554}
555
556pub fn render_chat_prompt_with_model_template(
557    messages: &[ChatMessage],
558    model_id: &str,
559    model_template: Option<&ModelChatTemplate>,
560) -> ferrum_types::Result<String> {
561    render_chat_prompt_with_model_template_options(
562        messages,
563        model_id,
564        model_template,
565        &ChatTemplateOptions::default(),
566    )
567}
568
569pub fn render_chat_prompt_with_model_template_options(
570    messages: &[ChatMessage],
571    model_id: &str,
572    model_template: Option<&ModelChatTemplate>,
573    options: &ChatTemplateOptions,
574) -> ferrum_types::Result<String> {
575    let prompt_messages = messages
576        .iter()
577        .map(PromptMessage::from_chat_message)
578        .collect::<Vec<_>>();
579    render_prompt_messages_with_options(&prompt_messages, model_id, model_template, options)
580}
581
582fn render_fallback_prompt(
583    messages: &[PromptMessage],
584    model_id: &str,
585    tool_spec: Option<String>,
586) -> String {
587    let model_lower = model_id.to_lowercase();
588
589    if model_lower.contains("qwen") {
590        let mut prompt = String::new();
591        if let Some(tool_spec) = tool_spec {
592            prompt.push_str(&format!("<|im_start|>system\n{}<|im_end|>\n", tool_spec));
593        }
594        for msg in messages {
595            prompt.push_str(&format!(
596                "<|im_start|>{}\n{}<|im_end|>\n",
597                msg.role, msg.content
598            ));
599        }
600        prompt.push_str("<|im_start|>assistant\n");
601        prompt
602    } else if model_lower.contains("llama") && model_lower.contains("3") {
603        // The engine encodes prompts with `add_special=true`, so do not
604        // include `<|begin_of_text|>` here. Including it manually creates a
605        // double-BOS prompt for Llama-3 tokenizers and degrades instruction
606        // following.
607        let mut prompt = String::new();
608        if let Some(tool_spec) = tool_spec {
609            prompt.push_str(&format!(
610                "<|start_header_id|>system<|end_header_id|>\n\n{}<|eot_id|>",
611                tool_spec
612            ));
613        }
614        for msg in messages {
615            prompt.push_str(&format!(
616                "<|start_header_id|>{}<|end_header_id|>\n\n{}<|eot_id|>",
617                msg.role, msg.content
618            ));
619        }
620        prompt.push_str("<|start_header_id|>assistant<|end_header_id|>\n\n");
621        prompt
622    } else {
623        // TinyLlama / generic chat format. Promote the first system message
624        // to the top; subsequent ones (rare) are emitted inline.
625        let has_system = messages.iter().any(|m| m.role == "system");
626        let mut prompt = String::new();
627        if let Some(tool_spec) = tool_spec {
628            prompt.push_str(&format!("<|system|>\n{}</s>\n", tool_spec));
629        } else if !has_system {
630            prompt.push_str("<|system|>\nYou are a helpful assistant.</s>\n");
631        }
632        for msg in messages {
633            prompt.push_str(&format!("<|{}|>\n{}</s>\n", msg.role, msg.content));
634        }
635        prompt.push_str("<|assistant|>\n");
636        prompt
637    }
638}
639
640pub fn render_chat_prompt_with_tools(
641    messages: &[ChatMessage],
642    model_id: &str,
643    tools: &[ChatTool],
644    tool_choice: Option<&ToolChoice>,
645    functions: &[ChatFunction],
646    function_call: Option<&FunctionCallChoice>,
647) -> String {
648    let prompt_messages = messages
649        .iter()
650        .map(|msg| PromptMessage::new(template_role(msg), template_content(msg)))
651        .collect::<Vec<_>>();
652    render_fallback_prompt(
653        &prompt_messages,
654        model_id,
655        render_tool_spec(tools, tool_choice, functions, function_call),
656    )
657}
658
659pub fn render_chat_prompt_with_tools_and_model_template(
660    messages: &[ChatMessage],
661    model_id: &str,
662    model_template: Option<&ModelChatTemplate>,
663    options: &ChatTemplateOptions,
664    tools: &[ChatTool],
665    tool_choice: Option<&ToolChoice>,
666    functions: &[ChatFunction],
667    function_call: Option<&FunctionCallChoice>,
668) -> ferrum_types::Result<String> {
669    if let Some(model_template) = model_template {
670        if model_template_supports_tools(model_template) {
671            let prompt_messages = messages
672                .iter()
673                .map(PromptMessage::from_chat_message)
674                .collect::<Vec<_>>();
675            return match render_model_template(
676                &prompt_messages,
677                model_template,
678                options,
679                (!tools.is_empty()).then_some(tools),
680                tool_choice,
681                (!functions.is_empty()).then_some(functions),
682                function_call,
683            ) {
684                Ok(prompt) if !prompt.trim().is_empty() => Ok(prompt),
685                Ok(_) => Err(chat_template_render_error(
686                    model_template,
687                    "template rendered an empty prompt",
688                )),
689                Err(e) => Err(chat_template_render_error(model_template, e)),
690            };
691        }
692
693        // The model ships a chat template with no `tools` support (e.g. the
694        // DeepSeek-R1 distills). Inject the generic tool spec as a leading
695        // system message and render it *through the model's own template*,
696        // so tool definitions still reach the model in its native prompt
697        // format instead of being silently dropped.
698        let mut prompt_messages = Vec::with_capacity(messages.len() + 1);
699        if let Some(spec) = render_tool_spec(tools, tool_choice, functions, function_call) {
700            prompt_messages.push(PromptMessage::new("system", spec));
701        }
702        prompt_messages.extend(messages.iter().map(PromptMessage::from_chat_message));
703        return match render_model_template(
704            &prompt_messages,
705            model_template,
706            options,
707            None,
708            None,
709            None,
710            None,
711        ) {
712            Ok(prompt) if !prompt.trim().is_empty() => Ok(prompt),
713            Ok(_) => Err(chat_template_render_error(
714                model_template,
715                "template rendered an empty prompt",
716            )),
717            Err(e) => Err(chat_template_render_error(model_template, e)),
718        };
719    }
720
721    Ok(render_chat_prompt_with_tools(
722        messages,
723        model_id,
724        tools,
725        tool_choice,
726        functions,
727        function_call,
728    ))
729}
730
731/// Whether a chat template references the `tools` variable as a standalone
732/// identifier (substring matching alone would not distinguish a template
733/// that only handles `message.tool_calls` history from one that renders
734/// tool definitions).
735fn model_template_supports_tools(template: &ModelChatTemplate) -> bool {
736    let src = template.template.as_bytes();
737    let needle = b"tools";
738    let mut start = 0;
739    while let Some(pos) = template.template[start..].find("tools") {
740        let abs = start + pos;
741        let before_ok = abs == 0 || {
742            let c = src[abs - 1];
743            !(c.is_ascii_alphanumeric() || c == b'_')
744        };
745        let after = abs + needle.len();
746        let after_ok = after >= src.len() || {
747            let c = src[after];
748            !(c.is_ascii_alphanumeric() || c == b'_')
749        };
750        if before_ok && after_ok {
751            return true;
752        }
753        start = after;
754    }
755    false
756}
757
758fn template_role(msg: &ChatMessage) -> &'static str {
759    match msg.role {
760        MessageRole::System => "system",
761        MessageRole::User => "user",
762        MessageRole::Assistant => "assistant",
763        MessageRole::Function => "function",
764        MessageRole::Tool => "tool",
765    }
766}
767
768fn template_content(msg: &ChatMessage) -> String {
769    let mut parts = Vec::new();
770    if !msg.content.is_empty() {
771        parts.push(msg.content.clone());
772    }
773    if let Some(tool_calls) = msg.tool_calls.as_deref().filter(|calls| !calls.is_empty()) {
774        parts.push(json_line(serde_json::json!({ "tool_calls": tool_calls })));
775    }
776    if let Some(function_call) = msg.function_call.as_ref() {
777        parts.push(json_line(
778            serde_json::json!({ "function_call": function_call }),
779        ));
780    }
781    parts.join("\n")
782}
783
784fn render_tool_spec(
785    tools: &[ChatTool],
786    tool_choice: Option<&ToolChoice>,
787    functions: &[ChatFunction],
788    function_call: Option<&FunctionCallChoice>,
789) -> Option<String> {
790    if tools.is_empty() && functions.is_empty() {
791        return None;
792    }
793
794    let mut spec = serde_json::Map::new();
795    spec.insert(
796        "instruction".to_string(),
797        serde_json::Value::String(
798            "When a tool is needed, respond with JSON matching the provided tool/function schema; otherwise answer normally."
799                .to_string(),
800        ),
801    );
802    if !tools.is_empty() {
803        spec.insert("tools".to_string(), serde_json::json!(tools));
804    }
805    if let Some(choice) = tool_choice {
806        spec.insert("tool_choice".to_string(), serde_json::json!(choice));
807    }
808    if !functions.is_empty() {
809        spec.insert("functions".to_string(), serde_json::json!(functions));
810    }
811    if let Some(choice) = function_call {
812        spec.insert("function_call".to_string(), serde_json::json!(choice));
813    }
814    Some(json_line(serde_json::Value::Object(spec)))
815}
816
817fn json_line(value: serde_json::Value) -> String {
818    serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    fn msg(role: MessageRole, content: &str) -> ChatMessage {
826        ChatMessage {
827            role,
828            content: content.to_string(),
829            reasoning: None,
830            name: None,
831            tool_calls: None,
832            tool_call_id: None,
833            function_call: None,
834        }
835    }
836
837    fn tool(name: &str) -> ChatTool {
838        ChatTool {
839            tool_type: "function".to_string(),
840            function: ChatFunction {
841                name: name.to_string(),
842                description: Some("Get weather".to_string()),
843                parameters: Some(serde_json::json!({
844                    "type": "object",
845                    "properties": {"city": {"type": "string"}},
846                    "required": ["city"]
847                })),
848                strict: None,
849            },
850        }
851    }
852
853    #[test]
854    fn qwen3_renders_chatml_without_forced_think_marker() {
855        let out = render_chat_prompt(
856            &[
857                msg(MessageRole::System, "You are helpful."),
858                msg(MessageRole::User, "Hi"),
859            ],
860            "qwen3:0.6b",
861        );
862        assert!(out.contains("<|im_start|>system\nYou are helpful.<|im_end|>"));
863        assert!(out.contains("<|im_start|>user\nHi<|im_end|>"));
864        assert!(out.ends_with("<|im_start|>assistant\n"));
865        assert!(!out.contains("<think>"));
866    }
867
868    #[test]
869    fn qwen2_renders_chatml_without_think() {
870        let out = render_chat_prompt(&[msg(MessageRole::User, "Hi")], "Qwen/Qwen2.5-7B-Instruct");
871        assert!(out.ends_with("<|im_start|>assistant\n"));
872        assert!(!out.contains("<think>"));
873    }
874
875    #[test]
876    fn model_template_is_preferred_over_family_fallback() {
877        let template = ModelChatTemplate::new(
878            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
879            "test-template",
880        );
881        let out = render_chat_prompt_with_model_template(
882            &[msg(MessageRole::User, "Hi")],
883            "qwen3",
884            Some(&template),
885        )
886        .unwrap();
887        assert_eq!(out, "[user]Hi[assistant]");
888    }
889
890    #[test]
891    fn model_template_is_used_for_tool_requests() {
892        let template = ModelChatTemplate::new(
893            "{% 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 %}",
894            "tool-template",
895        );
896        let mut assistant = msg(MessageRole::Assistant, "");
897        assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
898            index: None,
899            id: "call_1".to_string(),
900            tool_type: "function".to_string(),
901            function: crate::openai::ChatFunctionCall {
902                name: "weather".to_string(),
903                arguments: "{\"city\":\"Paris\"}".to_string(),
904            },
905        }]);
906        let mut tool_result = msg(MessageRole::Tool, "sunny");
907        tool_result.tool_call_id = Some("call_1".to_string());
908
909        let out = render_chat_prompt_with_tools_and_model_template(
910            &[
911                msg(MessageRole::User, "Use weather."),
912                assistant,
913                tool_result,
914            ],
915            "served-hash-id",
916            Some(&template),
917            &ChatTemplateOptions::default(),
918            &[tool("weather")],
919            Some(&ToolChoice::Mode("auto".to_string())),
920            &[],
921            None,
922        )
923        .unwrap();
924
925        assert!(out.contains("<tools>weather</tools>"));
926        assert!(out.contains("<tool_call>weather:"), "{out}");
927        assert!(out.contains("\"city\""), "{out}");
928        assert!(out.contains("Paris"), "{out}");
929        assert!(out.contains("<tool_response id=\"call_1\">sunny</tool_response>"));
930        assert!(out.ends_with("[assistant]"));
931        assert!(
932            !out.contains("<|assistant|>"),
933            "tool requests with model templates must not use generic fallback: {out}"
934        );
935    }
936
937    #[test]
938    fn model_template_tools_supports_qwen3_template_primitives() {
939        let template = ModelChatTemplate::new(
940            "{% 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 %}",
941            "qwen3-tool-primitives",
942        );
943        let out = render_chat_prompt_with_tools_and_model_template(
944            &[
945                msg(MessageRole::User, "Use weather."),
946                msg(MessageRole::Assistant, "ok"),
947            ],
948            "served-hash-id",
949            Some(&template),
950            &ChatTemplateOptions::default(),
951            &[tool("weather")],
952            Some(&ToolChoice::Mode("auto".to_string())),
953            &[],
954            None,
955        )
956        .unwrap();
957
958        // tojson renders Python-json.dumps style (", " / ": " separators,
959        // insertion key order) for byte parity with transformers.
960        assert!(out.contains("\"name\": \"weather\""), "{out}");
961        assert!(
962            out.contains("{\"type\": \"function\", \"function\": {"),
963            "{out}"
964        );
965        assert!(out.contains("[assistant][user][assistant]"), "{out}");
966    }
967
968    #[test]
969    fn model_template_tool_arguments_are_parsed_for_hf_templates() {
970        let template = ModelChatTemplate::new(
971            "{% 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 %}",
972            "llama-tool-primitives",
973        );
974        let mut assistant = msg(MessageRole::Assistant, "");
975        assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
976            index: None,
977            id: "call_1".to_string(),
978            tool_type: "function".to_string(),
979            function: crate::openai::ChatFunctionCall {
980                name: "weather".to_string(),
981                arguments: "{\"city\":\"Paris\",\"unit\":\"celsius\"}".to_string(),
982            },
983        }]);
984
985        let out = render_chat_prompt_with_tools_and_model_template(
986            &[msg(MessageRole::User, "Use weather."), assistant],
987            "served-hash-id",
988            Some(&template),
989            &ChatTemplateOptions::default(),
990            &[tool("weather")],
991            Some(&ToolChoice::Mode("auto".to_string())),
992            &[],
993            None,
994        )
995        .unwrap();
996
997        assert!(out.contains("\"city\""), "{out}");
998        assert!(out.contains("\"Paris\""), "{out}");
999        assert!(out.contains("[city=Paris]"), "{out}");
1000        assert!(out.contains("[unit=celsius]"), "{out}");
1001        assert!(out.ends_with("[assistant]"));
1002    }
1003
1004    #[test]
1005    fn model_template_tool_result_content_stays_string_for_hf_templates() {
1006        let template = ModelChatTemplate::new(
1007            "{% 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 %}",
1008            "qwen-tool-result-primitives",
1009        );
1010        let mut tool_result = msg(
1011            MessageRole::Tool,
1012            "{\"city\":\"北京\",\"temp\":22,\"desc\":\"晴\"}",
1013        );
1014        tool_result.tool_call_id = Some("call_1".to_string());
1015
1016        let out = render_chat_prompt_with_tools_and_model_template(
1017            &[msg(MessageRole::User, "Use weather."), tool_result],
1018            "served-hash-id",
1019            Some(&template),
1020            &ChatTemplateOptions::default(),
1021            &[tool("weather")],
1022            Some(&ToolChoice::Mode("auto".to_string())),
1023            &[],
1024            None,
1025        )
1026        .unwrap();
1027
1028        assert!(out.contains("\"temp\""), "{out}");
1029        assert!(out.contains("22"), "{out}");
1030        assert!(out.contains("\"desc\":\"晴\""), "{out}");
1031        assert!(out.contains("<tool_response>"), "{out}");
1032        assert!(!out.contains("not-string"), "{out}");
1033        assert!(out.ends_with("[assistant]"));
1034    }
1035
1036    #[test]
1037    fn qwen_style_model_template_does_not_force_empty_think() {
1038        let template = ModelChatTemplate::new(
1039            "{%- for message in messages %}{{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>\\n' }}{%- endfor %}{%- if add_generation_prompt %}{{- '<|im_start|>assistant\\n' }}{%- endif %}",
1040            "qwen-template",
1041        );
1042        let out = render_chat_prompt_with_model_template(
1043            &[msg(MessageRole::User, "Hi")],
1044            "qwen3",
1045            Some(&template),
1046        )
1047        .unwrap();
1048        assert_eq!(
1049            out,
1050            "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
1051        );
1052        assert!(!out.contains("<think>"));
1053    }
1054
1055    #[test]
1056    fn omitted_thinking_option_preserves_model_template_default() {
1057        let template = ModelChatTemplate::new(
1058            "{%- 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' }}{%- else %}{{- '<think>\n' }}{%- endif %}{%- endif %}",
1059            "thinking-template",
1060        );
1061        let options = ChatTemplateOptions::default_for_template(Some(&template));
1062        assert_eq!(options.enable_thinking, None);
1063        let out = render_chat_prompt_with_model_template_options(
1064            &[msg(MessageRole::User, "Hi")],
1065            "served-model-alias",
1066            Some(&template),
1067            &options,
1068        )
1069        .unwrap();
1070        assert!(out.ends_with("<|im_start|>assistant\n<think>\n"));
1071    }
1072
1073    #[test]
1074    fn explicit_thinking_options_override_model_template_default() {
1075        let template = ModelChatTemplate::new(
1076            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
1077            "thinking-template",
1078        );
1079        let enabled = render_chat_prompt_with_model_template_options(
1080            &[msg(MessageRole::User, "Hi")],
1081            "Qwen/Qwen3-0.6B",
1082            Some(&template),
1083            &ChatTemplateOptions {
1084                enable_thinking: Some(true),
1085                ..Default::default()
1086            },
1087        )
1088        .unwrap();
1089        assert_eq!(enabled, "<assistant><think>\n");
1090
1091        let disabled = render_chat_prompt_with_model_template_options(
1092            &[msg(MessageRole::User, "Hi")],
1093            "Qwen/Qwen3-0.6B",
1094            Some(&template),
1095            &ChatTemplateOptions {
1096                enable_thinking: Some(false),
1097                ..Default::default()
1098            },
1099        )
1100        .unwrap();
1101        assert_eq!(disabled, "<assistant><think>\n\n</think>\n\n");
1102    }
1103
1104    #[test]
1105    fn qwen3_model_generated_thinking_is_a_typed_template_capability() {
1106        let template = ModelChatTemplate::new(
1107            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
1108            "qwen3-model-generated-thinking-template",
1109        );
1110
1111        assert_eq!(
1112            template.reasoning_protocol,
1113            ModelReasoningProtocol::ModelGenerated
1114        );
1115        assert!(template.reasoning_default_enabled);
1116        assert!(template.reasoning_enabled(None));
1117        assert!(template.reasoning_enabled(Some(true)));
1118        assert!(!template.reasoning_enabled(Some(false)));
1119    }
1120
1121    #[test]
1122    fn template_without_enable_thinking_does_not_get_thinking_default() {
1123        let template = ModelChatTemplate::new(
1124            "{% if add_generation_prompt %}<assistant>{% endif %}",
1125            "plain-template",
1126        );
1127        let options = ChatTemplateOptions::default_for_template(Some(&template));
1128        assert_eq!(options.enable_thinking, None);
1129        let out = render_chat_prompt_with_model_template_options(
1130            &[msg(MessageRole::User, "Hi")],
1131            "Qwen/Qwen3-0.6B",
1132            Some(&template),
1133            &options,
1134        )
1135        .unwrap();
1136        assert_eq!(out, "<assistant>");
1137    }
1138
1139    #[test]
1140    fn assistant_think_history_exposes_reasoning_content_to_model_template() {
1141        let template = ModelChatTemplate::new(
1142            "{% 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 %}",
1143            "reasoning-template",
1144        );
1145        // reasoning_content is only present when the client supplied it
1146        // separately (OpenAI `message.reasoning`); raw `<think>` blocks in
1147        // content are the template's business (covered by golden tests).
1148        let mut assistant = PromptMessage::new("assistant", "answer");
1149        assistant.reasoning_content = Some("reason".to_string());
1150        let out = render_prompt_messages(
1151            &[assistant, PromptMessage::new("user", "next")],
1152            "qwen3",
1153            Some(&template),
1154        )
1155        .unwrap();
1156        assert_eq!(out, "<r>reason</r>answernext<assistant>");
1157    }
1158
1159    #[test]
1160    fn hf_python_split_expressions_are_normalized_for_minijinja() {
1161        let template = ModelChatTemplate::new(
1162            "{% 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 %}",
1163            "split-template",
1164        );
1165        let out = render_prompt_messages(
1166            &[PromptMessage {
1167                role: "assistant".to_string(),
1168                content: "<think>\nreason\n</think>\n\nanswer".to_string(),
1169                reasoning_content: None,
1170                name: None,
1171                tool_calls: None,
1172                tool_call_id: None,
1173                function_call: None,
1174            }],
1175            "qwen3",
1176            Some(&template),
1177        )
1178        .unwrap();
1179        assert_eq!(out, "<r>reason</r>answer");
1180    }
1181
1182    #[test]
1183    fn qwen3_content_variable_split_expressions_are_normalized_for_minijinja() {
1184        let template = ModelChatTemplate::new(
1185            "{% 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 %}",
1186            "qwen3-content-split-template",
1187        );
1188        let out = render_prompt_messages(
1189            &[PromptMessage {
1190                role: "assistant".to_string(),
1191                content: "<think>\nreason\n</think>\n\nanswer".to_string(),
1192                reasoning_content: None,
1193                name: None,
1194                tool_calls: None,
1195                tool_call_id: None,
1196                function_call: None,
1197            }],
1198            "qwen3",
1199            Some(&template),
1200        )
1201        .unwrap();
1202        assert_eq!(out, "<r>reason</r>answer");
1203    }
1204
1205    #[test]
1206    fn qwen3_python_startswith_endswith_are_normalized_for_minijinja() {
1207        let template = ModelChatTemplate::new(
1208            "{% 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 %}",
1209            "qwen3-startswith-template",
1210        );
1211        let out = render_prompt_messages(
1212            &[
1213                PromptMessage::new("user", "hello"),
1214                PromptMessage::new("user", "<tool_response>ok</tool_response>"),
1215            ],
1216            "qwen3",
1217            Some(&template),
1218        )
1219        .unwrap();
1220        assert_eq!(out, "plaintool");
1221    }
1222
1223    #[test]
1224    fn multi_turn_preserves_order() {
1225        let out = render_chat_prompt(
1226            &[
1227                msg(MessageRole::User, "A"),
1228                msg(MessageRole::Assistant, "B"),
1229                msg(MessageRole::User, "C"),
1230            ],
1231            "qwen3",
1232        );
1233        let a_idx = out.find("A").unwrap();
1234        let b_idx = out.find("B").unwrap();
1235        let c_idx = out.find("C").unwrap();
1236        assert!(a_idx < b_idx && b_idx < c_idx);
1237    }
1238
1239    #[test]
1240    fn llama3_renders_header_format() {
1241        let out = render_chat_prompt(
1242            &[
1243                msg(MessageRole::System, "sys"),
1244                msg(MessageRole::User, "hi"),
1245            ],
1246            "meta-llama/Llama-3.2-1B-Instruct",
1247        );
1248        assert!(!out.starts_with("<|begin_of_text|>"));
1249        assert!(out.contains("<|start_header_id|>system<|end_header_id|>\n\nsys<|eot_id|>"));
1250        assert!(out.contains("<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>"));
1251        assert!(out.ends_with("<|start_header_id|>assistant<|end_header_id|>\n\n"));
1252    }
1253
1254    #[test]
1255    fn unknown_model_uses_tinyllama_fallback() {
1256        let out = render_chat_prompt(&[msg(MessageRole::User, "hi")], "mystery-model");
1257        assert!(out.contains("<|system|>"));
1258        assert!(out.contains("<|user|>\nhi</s>"));
1259        assert!(out.ends_with("<|assistant|>\n"));
1260    }
1261
1262    #[test]
1263    fn fallback_preserves_legacy_function_and_tool_roles() {
1264        let out = render_chat_prompt(
1265            &[
1266                msg(MessageRole::Function, "{\"city\":\"Paris\"}"),
1267                msg(MessageRole::Tool, "sunny"),
1268            ],
1269            "mystery-model",
1270        );
1271        assert!(out.contains("<|function|>\n{\"city\":\"Paris\"}</s>"));
1272        assert!(out.contains("<|tool|>\nsunny</s>"));
1273    }
1274
1275    #[test]
1276    fn qwen_renders_tool_definitions_and_assistant_tool_call_history() {
1277        let mut assistant = msg(MessageRole::Assistant, "");
1278        assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
1279            index: None,
1280            id: "call_1".to_string(),
1281            tool_type: "function".to_string(),
1282            function: crate::openai::ChatFunctionCall {
1283                name: "weather".to_string(),
1284                arguments: "{\"city\":\"Paris\"}".to_string(),
1285            },
1286        }]);
1287
1288        let out = render_chat_prompt_with_tools(
1289            &[
1290                msg(MessageRole::User, "Use weather."),
1291                assistant,
1292                msg(MessageRole::Tool, "sunny"),
1293            ],
1294            "qwen3",
1295            &[tool("weather")],
1296            Some(&ToolChoice::Mode("auto".to_string())),
1297            &[],
1298            None,
1299        );
1300
1301        assert!(out.contains("\"tools\":[{"));
1302        assert!(out.contains("\"type\":\"function\""));
1303        assert!(out.contains("\"tool_choice\":\"auto\""));
1304        assert!(out.contains("<|im_start|>assistant\n{"));
1305        assert!(out.contains("\"tool_calls\":[{"));
1306        assert!(out.contains("\"id\":\"call_1\""));
1307        assert!(out.contains("\"name\":\"weather\""));
1308        assert!(out.contains("<|im_start|>tool\nsunny<|im_end|>"));
1309    }
1310
1311    #[test]
1312    fn pycompat_python_string_methods_render_without_normalization() {
1313        // Bracket subscripts plus bare `.strip()` / `.split(..)[-1]` are
1314        // spellings `normalize_hf_chat_template` does not rewrite — they must
1315        // work via minijinja-contrib pycompat (DeepSeek-R1 distill templates
1316        // use them).
1317        let template = ModelChatTemplate::new(
1318            "{% for message in messages %}{% if message['role'] == 'assistant' %}{% set content = message['content'].split('</think>')[-1] %}{{ content.strip() }}{% endif %}{% endfor %}",
1319            "pycompat-template",
1320        );
1321        let out = render_prompt_messages(
1322            &[PromptMessage {
1323                role: "assistant".to_string(),
1324                content: "<think>\nreason\n</think>\n\nanswer".to_string(),
1325                reasoning_content: None,
1326                name: None,
1327                tool_calls: None,
1328                tool_call_id: None,
1329                function_call: None,
1330            }],
1331            "deepseek-distill",
1332            Some(&template),
1333        )
1334        .unwrap();
1335        assert_eq!(out, "answer");
1336    }
1337
1338    #[test]
1339    fn model_template_render_failure_is_an_error_not_a_silent_fallback() {
1340        let template =
1341            ModelChatTemplate::new("{{ messages | not_a_real_filter }}", "broken-template");
1342        let err = render_prompt_messages(
1343            &[PromptMessage::new("user", "hi")],
1344            "qwen3",
1345            Some(&template),
1346        )
1347        .unwrap_err();
1348        let message = format!("{err}");
1349        assert!(message.contains("broken-template"), "{message}");
1350        assert!(message.contains("failed to render"), "{message}");
1351    }
1352
1353    #[test]
1354    fn model_template_empty_render_is_an_error() {
1355        let template = ModelChatTemplate::new("{# renders nothing #}", "empty-template");
1356        let err = render_prompt_messages(
1357            &[PromptMessage::new("user", "hi")],
1358            "qwen3",
1359            Some(&template),
1360        )
1361        .unwrap_err();
1362        assert!(format!("{err}").contains("empty prompt"), "{err}");
1363    }
1364
1365    #[test]
1366    fn tools_unaware_template_injects_tool_spec_through_model_template() {
1367        // e.g. DeepSeek-R1 distill templates have no `tools` support; tool
1368        // definitions must still reach the model in its native prompt format
1369        // instead of being dropped or routed to the generic fallback.
1370        let template = ModelChatTemplate::new(
1371            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
1372            "no-tool-support-template",
1373        );
1374        let out = render_chat_prompt_with_tools_and_model_template(
1375            &[msg(MessageRole::User, "Use weather.")],
1376            "some-model",
1377            Some(&template),
1378            &ChatTemplateOptions::default(),
1379            &[tool("weather")],
1380            Some(&ToolChoice::Mode("auto".to_string())),
1381            &[],
1382            None,
1383        )
1384        .unwrap();
1385        assert!(out.starts_with("[system]"), "{out}");
1386        assert!(out.contains("\"tools\""), "{out}");
1387        assert!(out.contains("weather"), "{out}");
1388        assert!(out.ends_with("[assistant]"), "{out}");
1389        assert!(!out.contains("<|system|>"), "{out}");
1390    }
1391
1392    #[test]
1393    fn template_tools_support_detection_requires_standalone_identifier() {
1394        let aware = ModelChatTemplate::new("{% if tools %}x{% endif %}", "t");
1395        assert!(model_template_supports_tools(&aware));
1396        let history_only = ModelChatTemplate::new(
1397            "{% for m in messages %}{% if m.tool_calls %}y{% endif %}{% endfor %}",
1398            "t",
1399        );
1400        assert!(!model_template_supports_tools(&history_only));
1401    }
1402}