Skip to main content

ferrum_server/
chat_template.rs

1use crate::openai::{
2    AssistantMessagePhase, ChatFunction, ChatMessage, ChatTool, FunctionCallChoice, MessageRole,
3    ToolChoice,
4};
5use ferrum_types::{
6    has_unclosed_model_reasoning_block, model_reasoning_markers, ApiToolCallProtocol, FerrumError,
7    ModelOutputProtocol,
8};
9use minijinja::Environment;
10use serde::ser::SerializeStruct;
11use serde::Serialize;
12use serde_json::Value;
13
14// Keep the existing server import path compatible with the shared capability type.
15pub use ferrum_types::{ModelReasoningProtocol, ReasoningEffort, ReasoningEffortSupport};
16
17/// Model-provided chat template, usually from GGUF or Hugging Face metadata.
18#[derive(Clone, Debug)]
19pub struct ModelChatTemplate {
20    pub template: String,
21    pub source: String,
22    pub bos_token: Option<String>,
23    pub eos_token: Option<String>,
24    pub tool_call_protocol: ApiToolCallProtocol,
25    pub output_protocol: ModelOutputProtocol,
26    pub reasoning_protocol: ModelReasoningProtocol,
27    pub reasoning_default_enabled: bool,
28    /// Declared by the model implementation, independently of output parsing.
29    pub reasoning_effort_support: ReasoningEffortSupport,
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            output_protocol: ModelOutputProtocol::Text,
38            template,
39            source: source.into(),
40            bos_token: None,
41            eos_token: None,
42            reasoning_protocol: ModelReasoningProtocol::None,
43            reasoning_default_enabled: false,
44            reasoning_effort_support: ReasoningEffortSupport::Unknown,
45        };
46        if model_template.tool_call_protocol == ApiToolCallProtocol::Json
47            && model_template_emits_native_json_tools(&model_template)
48        {
49            model_template.tool_call_protocol = ApiToolCallProtocol::NativeJson;
50        }
51        let (reasoning_protocol, reasoning_default_enabled) =
52            detect_model_reasoning_protocol(&model_template);
53        model_template.reasoning_protocol = reasoning_protocol;
54        model_template.reasoning_default_enabled = reasoning_default_enabled;
55        model_template
56    }
57
58    pub fn reasoning_enabled(&self, requested: Option<bool>) -> bool {
59        self.reasoning_protocol.supports_reasoning()
60            && requested.unwrap_or(self.reasoning_default_enabled)
61    }
62
63    /// The full output capability, including Harmony's separate message parser.
64    /// This observation does not change template rendering or reasoning defaults.
65    pub fn reasoning_capability(&self) -> ModelReasoningProtocol {
66        if self.output_protocol == ModelOutputProtocol::HarmonyGptOss {
67            ModelReasoningProtocol::ModelGenerated
68        } else {
69            self.reasoning_protocol
70        }
71    }
72
73    /// Observe an actual template switch. Reasoning output alone does not prove
74    /// that `enable_thinking` can disable it (an always-open template cannot).
75    pub fn supports_thinking_control(&self) -> bool {
76        let Some((opening, closing)) = model_reasoning_markers(self.output_protocol) else {
77            return false;
78        };
79        let (Some(enabled), Some(disabled)) = (
80            render_reasoning_probe(self, Some(true)),
81            render_reasoning_probe(self, Some(false)),
82        ) else {
83            return false;
84        };
85        let prompt_opened =
86            |prompt: &str| has_unclosed_model_reasoning_block(self.output_protocol, prompt);
87        if prompt_opened(&disabled) {
88            return false;
89        }
90        if prompt_opened(&enabled) {
91            return true;
92        }
93        let completed_blocks = |prompt: &str| {
94            prompt
95                .matches(opening)
96                .count()
97                .min(prompt.matches(closing).count())
98        };
99        completed_blocks(&disabled) > completed_blocks(&enabled)
100    }
101
102    /// Bind the resolved model capability after loading its unchanged template
103    /// bytes, then probe reasoning with that protocol's actual delimiters.
104    pub fn set_output_protocol(&mut self, protocol: ModelOutputProtocol) {
105        self.output_protocol = protocol;
106        let (reasoning_protocol, reasoning_default_enabled) = detect_model_reasoning_protocol(self);
107        self.reasoning_protocol = reasoning_protocol;
108        self.reasoning_default_enabled = reasoning_default_enabled;
109    }
110
111    pub fn validate_reasoning_effort(&self, effort: ReasoningEffort) -> ferrum_types::Result<()> {
112        if self.reasoning_effort_support.supports(effort) == Some(false) {
113            let supported = self
114                .reasoning_effort_support
115                .declared_efforts()
116                .expect("unsupported effort requires a declaration")
117                .iter()
118                .map(|value| value.as_str())
119                .collect::<Vec<_>>()
120                .join(", ");
121            return Err(FerrumError::invalid_request(format!(
122                "reasoning_effort '{effort}' is not supported by this model; declared supported values: {supported}"
123            )));
124        }
125        Ok(())
126    }
127}
128
129fn tool_call_protocol_for_template(template: &str) -> ApiToolCallProtocol {
130    if template.contains("<tool_call>")
131        && template.contains("<function=")
132        && template.contains("<parameter=")
133    {
134        ApiToolCallProtocol::FunctionParameterXml
135    } else {
136        ApiToolCallProtocol::Json
137    }
138}
139
140/// Observe the model-owned assistant-history wire, not just whether it accepts
141/// tool definitions. Other native formats must keep their existing fallback.
142fn model_template_emits_native_json_tools(template: &ModelChatTemplate) -> bool {
143    if !model_template_supports_tools(template) {
144        return false;
145    }
146    let probe = || -> Option<()> {
147        const SENTINEL: &str = "ferrum_tool_protocol_content_probe";
148        const NAME: &str = "ferrum_tool_protocol_probe";
149        let arguments = serde_json::json!({"value":"sample"});
150        let tools = [ChatTool {
151            tool_type: "function".into(),
152            function: ChatFunction {
153                name: NAME.into(),
154                description: None,
155                parameters: Some(serde_json::json!({
156                    "type":"object", "properties":{"value":{"type":"string"}},
157                    "required":["value"], "additionalProperties":false
158                })),
159                strict: None,
160            },
161        }];
162        let options = ChatTemplateOptions {
163            now_override: chrono::NaiveDate::from_ymd_opt(2000, 1, 1)
164                .and_then(|date| date.and_hms_opt(0, 0, 0)),
165            ..ChatTemplateOptions::default()
166        };
167        let render = |messages: &[PromptMessage]| {
168            render_model_template_once(
169                messages,
170                &[],
171                template,
172                &options,
173                Some(&tools),
174                None,
175                None,
176                None,
177                false,
178            )
179            .ok()
180        };
181        let mut messages = [
182            PromptMessage::new("user", "Use the supplied function."),
183            PromptMessage::new("assistant", SENTINEL),
184        ];
185        let baseline = render(&messages)?;
186        let (prefix, suffix) = baseline.split_once(SENTINEL)?;
187        if suffix.contains(SENTINEL) {
188            return None;
189        }
190        messages[1].content.clear();
191        messages[1].tool_calls = Some(vec![PromptToolCall {
192            index: None,
193            id: "a1b2c3d4e".into(),
194            tool_type: "function".into(),
195            function: PromptFunctionCall {
196                name: NAME.into(),
197                arguments: arguments.clone(),
198            },
199        }]);
200        let rendered = render(&messages)?;
201        let payload = rendered.strip_prefix(prefix)?.strip_suffix(suffix)?.trim();
202        let payload = if let Some(inner) = payload.strip_prefix("<tool_call>") {
203            inner.strip_suffix("</tool_call>")?.trim()
204        } else {
205            payload
206        };
207        let value: Value = serde_json::from_str(payload).ok()?;
208        let object = value.as_object()?;
209        let actual = match (object.get("arguments"), object.get("parameters")) {
210            (Some(arguments), None) | (None, Some(arguments)) => arguments,
211            _ => return None,
212        };
213        (object.len() == 2 && object.get("name")?.as_str()? == NAME && actual == &arguments)
214            .then_some(())
215    };
216    probe().is_some()
217}
218
219#[derive(Clone, Debug, Default, PartialEq, Eq)]
220pub struct ChatTemplateOptions {
221    pub enable_thinking: Option<bool>,
222    pub reasoning_effort: Option<ReasoningEffort>,
223    /// Clock seen by the template's `strftime_now` (Mistral-Small-3.2 and
224    /// Llama-3.x inject "today's date" into the system prompt). `None` =
225    /// local wall clock; golden tests pin the timestamp recorded at
226    /// fixture-generation time so byte comparison survives the date
227    /// changing.
228    pub now_override: Option<chrono::NaiveDateTime>,
229}
230
231/// The rendered prompt and whether its assistant generation suffix already
232/// opened the model's reasoning envelope.
233#[derive(Clone, Debug)]
234pub(crate) struct RenderedPrompt {
235    pub text: String,
236    pub reasoning_prefill: bool,
237}
238
239impl RenderedPrompt {
240    fn without_reasoning_prefill(text: String) -> Self {
241        Self {
242            text,
243            reasoning_prefill: false,
244        }
245    }
246}
247
248impl ChatTemplateOptions {
249    pub fn default_for_template(_model_template: Option<&ModelChatTemplate>) -> Self {
250        // Omission is a real third state: the model-owned template decides its
251        // default. Only explicit product controls may force true or false.
252        Self::default()
253    }
254}
255
256/// Common prompt-message shape used by both CLI `run` and OpenAI `serve`.
257#[derive(Clone, Debug)]
258pub struct PromptMessage {
259    pub role: String,
260    pub content: String,
261    pub reasoning_content: Option<String>,
262    pub name: Option<String>,
263    pub tool_calls: Option<Vec<PromptToolCall>>,
264    pub tool_call_id: Option<String>,
265    pub function_call: Option<crate::openai::ChatFunctionCall>,
266}
267
268impl Serialize for PromptMessage {
269    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
270    where
271        S: serde::Serializer,
272    {
273        let mut len = 2;
274        len += usize::from(self.reasoning_content.is_some());
275        len += usize::from(self.name.is_some());
276        len += usize::from(self.tool_calls.is_some());
277        len += usize::from(self.tool_call_id.is_some());
278        len += usize::from(self.function_call.is_some());
279        let mut state = serializer.serialize_struct("PromptMessage", len)?;
280        state.serialize_field("role", &self.role)?;
281        let content = template_content_value(&self.content);
282        state.serialize_field("content", &content)?;
283        if let Some(reasoning_content) = &self.reasoning_content {
284            state.serialize_field("reasoning_content", reasoning_content)?;
285        }
286        if let Some(name) = &self.name {
287            state.serialize_field("name", name)?;
288        }
289        if let Some(tool_calls) = &self.tool_calls {
290            state.serialize_field("tool_calls", tool_calls)?;
291        }
292        if let Some(tool_call_id) = &self.tool_call_id {
293            state.serialize_field("tool_call_id", tool_call_id)?;
294        }
295        if let Some(function_call) = &self.function_call {
296            state.serialize_field("function_call", function_call)?;
297        }
298        state.end()
299    }
300}
301
302/// Tool-call shape exposed to model chat templates.
303///
304/// OpenAI's wire format serializes `function.arguments` as a JSON string, but
305/// HuggingFace chat templates generally expect a parsed mapping so they can
306/// apply `tojson`, `items`, and similar template operations. Keep that internal
307/// shape separate from the API response type.
308#[derive(Clone, Debug, Serialize)]
309pub struct PromptToolCall {
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub index: Option<u32>,
312    pub id: String,
313    #[serde(rename = "type")]
314    pub tool_type: String,
315    pub function: PromptFunctionCall,
316}
317
318#[derive(Clone, Debug, Serialize)]
319pub struct PromptFunctionCall {
320    pub name: String,
321    pub arguments: Value,
322}
323
324impl From<&crate::openai::ChatToolCall> for PromptToolCall {
325    fn from(call: &crate::openai::ChatToolCall) -> Self {
326        Self {
327            index: call.index,
328            id: call.id.clone(),
329            tool_type: call.tool_type.clone(),
330            function: PromptFunctionCall {
331                name: call.function.name.clone(),
332                arguments: parse_template_arguments(&call.function.arguments),
333            },
334        }
335    }
336}
337
338fn parse_template_arguments(arguments: &str) -> Value {
339    serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
340}
341
342fn template_content_value(content: &str) -> Value {
343    Value::String(content.to_string())
344}
345
346impl PromptMessage {
347    /// Content is passed to the chat template verbatim — including any
348    /// `<think>...</think>` blocks in assistant history. Whether reasoning
349    /// is kept or stripped from history is a per-template policy (DeepSeek
350    /// strips it, Qwen3-Coder keeps it); pre-splitting here diverged from
351    /// what `transformers.apply_chat_template` feeds the same template.
352    /// `reasoning_content` is only set when the client supplies reasoning
353    /// as a separate field.
354    pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
355        Self {
356            role: role.into(),
357            content: content.into(),
358            reasoning_content: None,
359            name: None,
360            tool_calls: None,
361            tool_call_id: None,
362            function_call: None,
363        }
364    }
365
366    fn from_chat_message(message: &ChatMessage) -> Self {
367        let mut prompt = Self::new(template_role(message), message.content.clone());
368        if matches!(message.role, MessageRole::Assistant) && message.reasoning.is_some() {
369            prompt.reasoning_content = message.reasoning.clone();
370        }
371        prompt.name = message.name.clone();
372        prompt.tool_calls = message
373            .tool_calls
374            .as_ref()
375            .map(|calls| calls.iter().map(PromptToolCall::from).collect());
376        prompt.tool_call_id = message.tool_call_id.clone();
377        prompt.function_call = message.function_call.clone();
378        prompt
379    }
380}
381
382/// Render common chat messages into the prompt string the model was trained
383/// on. Prefer a model-provided chat template when available; otherwise use
384/// a centralized legacy fallback for model families ferrum already supports.
385///
386/// A model-provided template that fails to render (or renders empty) is a
387/// hard error: silently falling back to a generic prompt format feeds the
388/// model a prompt it was not trained on and degrades output quality without
389/// any visible failure.
390pub fn render_prompt_messages(
391    messages: &[PromptMessage],
392    model_id: &str,
393    model_template: Option<&ModelChatTemplate>,
394) -> ferrum_types::Result<String> {
395    render_prompt_messages_with_options(
396        messages,
397        model_id,
398        model_template,
399        &ChatTemplateOptions::default(),
400    )
401}
402
403pub fn render_prompt_messages_with_options(
404    messages: &[PromptMessage],
405    model_id: &str,
406    model_template: Option<&ModelChatTemplate>,
407    options: &ChatTemplateOptions,
408) -> ferrum_types::Result<String> {
409    render_prompt_messages_with_options_and_compatibility(
410        messages,
411        model_id,
412        model_template,
413        options,
414        true,
415        &[],
416    )
417}
418
419fn render_prompt_messages_with_options_and_compatibility(
420    messages: &[PromptMessage],
421    model_id: &str,
422    model_template: Option<&ModelChatTemplate>,
423    options: &ChatTemplateOptions,
424    coalesce_interleaved_system_messages: bool,
425    message_phases: &[Option<AssistantMessagePhase>],
426) -> ferrum_types::Result<String> {
427    if let Some(model_template) = model_template {
428        return match render_model_template(
429            messages,
430            message_phases,
431            model_template,
432            options,
433            coalesce_interleaved_system_messages,
434            None,
435            None,
436            None,
437            None,
438        ) {
439            Ok(prompt) if !prompt.trim().is_empty() => Ok(prompt),
440            Ok(_) => Err(chat_template_render_error(
441                model_template,
442                "template rendered an empty prompt",
443            )),
444            Err(e) => Err(chat_template_evaluation_error(model_template, e)),
445        };
446    }
447    Ok(render_fallback_prompt(messages, model_id, None))
448}
449
450fn chat_template_render_error(
451    template: &ModelChatTemplate,
452    reason: impl std::fmt::Display,
453) -> FerrumError {
454    FerrumError::model(format!(
455        "chat template from {} failed to render: {reason}. Refusing to fall back \
456         to a generic prompt format because that silently degrades output quality; \
457         fix the model's chat template or serve the model without one.",
458        template.source
459    ))
460}
461
462#[derive(Debug)]
463struct TemplateRequestRejection(String);
464
465impl std::fmt::Display for TemplateRequestRejection {
466    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        formatter.write_str(&self.0)
468    }
469}
470
471impl std::error::Error for TemplateRequestRejection {}
472
473fn chat_template_evaluation_error(
474    template: &ModelChatTemplate,
475    error: minijinja::Error,
476) -> FerrumError {
477    // A model-authored input check is distinct from a broken template. Use its
478    // typed source, not error text or a broad InvalidOperation classification.
479    let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error);
480    while let Some(current) = cause {
481        if current.is::<TemplateRequestRejection>() {
482            return FerrumError::invalid_request(format!(
483                "chat template rejected the request: {error}"
484            ));
485        }
486        cause = current.source();
487    }
488    chat_template_render_error(template, error)
489}
490
491#[derive(Serialize)]
492struct ModelTemplateContext<'a> {
493    messages: Vec<Value>,
494    add_generation_prompt: bool,
495    bos_token: &'a str,
496    eos_token: &'a str,
497    #[serde(skip_serializing_if = "Option::is_none")]
498    enable_thinking: Option<bool>,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    reasoning_effort: Option<ReasoningEffort>,
501    /// Pre-converted to `serde_json::Value`: minijinja serializes Rust
502    /// *structs* with alphabetically sorted fields, but a JSON map (with
503    /// serde_json `preserve_order`) keeps the OpenAI canonical key order
504    /// that transformers' `tojson` renders.
505    #[serde(skip_serializing_if = "Option::is_none")]
506    tools: Option<serde_json::Value>,
507    #[serde(skip_serializing_if = "Option::is_none")]
508    tool_choice: Option<&'a ToolChoice>,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    functions: Option<serde_json::Value>,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    function_call: Option<&'a FunctionCallChoice>,
513}
514
515fn model_template_message_values(
516    messages: &[PromptMessage],
517    phases: &[Option<AssistantMessagePhase>],
518) -> std::result::Result<Vec<Value>, minijinja::Error> {
519    if !phases.is_empty() && phases.len() != messages.len() {
520        return Err(minijinja::Error::new(
521            minijinja::ErrorKind::InvalidOperation,
522            "assistant phase metadata did not match prompt messages",
523        ));
524    }
525    messages
526        .iter()
527        .enumerate()
528        .map(|(index, message)| {
529            let mut value = serde_json::to_value(message).map_err(|error| {
530                minijinja::Error::new(
531                    minijinja::ErrorKind::InvalidOperation,
532                    format!("failed to serialize prompt message: {error}"),
533                )
534            })?;
535            if let Some(phase) = phases.get(index).copied().flatten() {
536                value
537                    .as_object_mut()
538                    .expect("PromptMessage serializes as an object")
539                    .insert("phase".to_string(), serde_json::json!(phase));
540            }
541            Ok(value)
542        })
543        .collect()
544}
545
546fn render_model_template(
547    messages: &[PromptMessage],
548    message_phases: &[Option<AssistantMessagePhase>],
549    model_template: &ModelChatTemplate,
550    options: &ChatTemplateOptions,
551    coalesce_interleaved_system_messages: bool,
552    tools: Option<&[ChatTool]>,
553    tool_choice: Option<&ToolChoice>,
554    functions: Option<&[ChatFunction]>,
555    function_call: Option<&FunctionCallChoice>,
556) -> std::result::Result<String, minijinja::Error> {
557    render_model_template_with_prefill(
558        messages,
559        message_phases,
560        model_template,
561        options,
562        coalesce_interleaved_system_messages,
563        tools,
564        tool_choice,
565        functions,
566        function_call,
567        false,
568    )
569    .map(|rendered| rendered.text)
570}
571
572fn render_model_template_with_prefill(
573    messages: &[PromptMessage],
574    message_phases: &[Option<AssistantMessagePhase>],
575    model_template: &ModelChatTemplate,
576    options: &ChatTemplateOptions,
577    coalesce_interleaved_system_messages: bool,
578    tools: Option<&[ChatTool]>,
579    tool_choice: Option<&ToolChoice>,
580    functions: Option<&[ChatFunction]>,
581    function_call: Option<&FunctionCallChoice>,
582    inspect_prefill: bool,
583) -> std::result::Result<RenderedPrompt, minijinja::Error> {
584    let mut fixed_options = options.clone();
585    if inspect_prefill && fixed_options.now_override.is_none() {
586        fixed_options.now_override = Some(chrono::Local::now().naive_local());
587    }
588    let render = |messages: &[PromptMessage], phases: &[Option<AssistantMessagePhase>]| {
589        let text = render_model_template_once(
590            messages,
591            phases,
592            model_template,
593            &fixed_options,
594            tools,
595            tool_choice,
596            functions,
597            function_call,
598            true,
599        )?;
600        let reasoning_prefill = if inspect_prefill {
601            let baseline = render_model_template_once(
602                messages,
603                phases,
604                model_template,
605                &fixed_options,
606                tools,
607                tool_choice,
608                functions,
609                function_call,
610                false,
611            )
612            .ok();
613            reasoning_prefill_from_generation_suffix(model_template, &text, baseline.as_deref())
614        } else {
615            false
616        };
617        Ok(RenderedPrompt {
618            text,
619            reasoning_prefill,
620        })
621    };
622    let original: std::result::Result<RenderedPrompt, minijinja::Error> =
623        render(messages, message_phases);
624    if original.is_ok()
625        || !coalesce_interleaved_system_messages
626        || !has_nonleading_system_message(messages)
627        || !original
628            .as_ref()
629            .err()
630            .is_some_and(is_system_message_position_error)
631    {
632        return original;
633    }
634
635    // Preserve system messages in place for templates that support them. If a
636    // model-owned template rejects that valid API history shape, retry with a
637    // single leading system message while preserving system and conversation
638    // order within their respective streams.
639    let (adapted_messages, adapted_phases) = coalesce_system_messages(messages, message_phases);
640    match render(&adapted_messages, &adapted_phases) {
641        Ok(prompt) => Ok(prompt),
642        Err(_) => original,
643    }
644}
645
646fn reasoning_prefill_from_generation_suffix(
647    template: &ModelChatTemplate,
648    prompt: &str,
649    without_generation_prompt: Option<&str>,
650) -> bool {
651    let Some((opening, _)) = model_reasoning_markers(template.output_protocol) else {
652        return false;
653    };
654    if let Some(suffix) = without_generation_prompt
655        .and_then(|baseline| prompt.strip_prefix(baseline))
656        .filter(|suffix| !suffix.is_empty())
657    {
658        return has_unclosed_model_reasoning_block(template.output_protocol, suffix);
659    }
660    // Some model templates ignore add_generation_prompt or change earlier
661    // rendering when it is disabled. Keep the narrow, declared prefill case;
662    // historical message/schema tags never justify scanning the whole prompt.
663    template.reasoning_protocol == ModelReasoningProtocol::PromptOpened
664        && prompt.trim_end().ends_with(opening.trim_end())
665}
666
667fn is_system_message_position_error(error: &minijinja::Error) -> bool {
668    let message = error.to_string().to_ascii_lowercase();
669    let identifies_system_message =
670        message.contains("system message") || message.contains("system role");
671    let identifies_position = message.contains("beginning")
672        || message.contains("must be first")
673        || message.contains("must be the first")
674        || message.contains("only be first")
675        || message.contains("only be the first")
676        || message.contains("must appear first")
677        || message.contains("can only appear first")
678        || message.contains("not allowed after")
679        || message.contains("cannot appear after");
680    identifies_system_message && identifies_position
681}
682
683fn has_nonleading_system_message(messages: &[PromptMessage]) -> bool {
684    messages
685        .iter()
686        .enumerate()
687        .any(|(index, message)| index > 0 && message.role == "system")
688}
689
690fn coalesce_system_messages(
691    messages: &[PromptMessage],
692    phases: &[Option<AssistantMessagePhase>],
693) -> (Vec<PromptMessage>, Vec<Option<AssistantMessagePhase>>) {
694    let mut first_system = None;
695    let mut system_parts = Vec::new();
696    let mut conversation = Vec::with_capacity(messages.len());
697    let mut conversation_phases = Vec::with_capacity(messages.len());
698    for (index, message) in messages.iter().enumerate() {
699        if message.role == "system" {
700            first_system.get_or_insert_with(|| message.clone());
701            if !message.content.is_empty() {
702                system_parts.push(message.content.clone());
703            }
704        } else {
705            conversation.push(message.clone());
706            conversation_phases.push(phases.get(index).copied().flatten());
707        }
708    }
709    if let Some(mut system) = first_system {
710        system.content = system_parts.join("\n\n");
711        conversation.insert(0, system);
712        conversation_phases.insert(0, None);
713    }
714    (conversation, conversation_phases)
715}
716
717fn render_model_template_once(
718    messages: &[PromptMessage],
719    message_phases: &[Option<AssistantMessagePhase>],
720    model_template: &ModelChatTemplate,
721    options: &ChatTemplateOptions,
722    tools: Option<&[ChatTool]>,
723    tool_choice: Option<&ToolChoice>,
724    functions: Option<&[ChatFunction]>,
725    function_call: Option<&FunctionCallChoice>,
726    add_generation_prompt: bool,
727) -> std::result::Result<String, minijinja::Error> {
728    let mut env = Environment::new();
729    // HF chat templates are written for Jinja2 and freely use Python string
730    // methods (`.split()`, `.strip()`, `.startswith()`, ...). pycompat
731    // resolves those at runtime; `normalize_hf_chat_template` below remains
732    // for the exact spellings it already rewrote before pycompat landed.
733    env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
734    // transformers compiles chat templates with
735    // `ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)`
736    // and a plain-`json.dumps` `tojson` filter; match both so rendering is
737    // byte-identical (verified by tests/chat_template_golden.rs).
738    env.set_trim_blocks(true);
739    env.set_lstrip_blocks(true);
740    env.add_filter("tojson", python_style_tojson);
741    env.add_filter("trim_newlines", |s: String| {
742        s.trim_matches('\n').to_string()
743    });
744    env.add_filter("trim_start_newlines", |s: String| {
745        s.trim_start_matches('\n').to_string()
746    });
747    env.add_filter("trim_end_newlines", |s: String| {
748        s.trim_end_matches('\n').to_string()
749    });
750    env.add_filter("starts_with", |s: String, prefix: String| {
751        s.starts_with(&prefix)
752    });
753    env.add_filter("ends_with", |s: String, suffix: String| {
754        s.ends_with(&suffix)
755    });
756    env.add_filter("after_think_end", |s: String| {
757        s.split("</think>")
758            .last()
759            .unwrap_or("")
760            .trim_start_matches('\n')
761            .to_string()
762    });
763    env.add_filter("reasoning_from_think", |s: String| {
764        s.split("</think>")
765            .next()
766            .unwrap_or("")
767            .trim_end_matches('\n')
768            .rsplit("<think>")
769            .next()
770            .unwrap_or("")
771            .trim_start_matches('\n')
772            .to_string()
773    });
774    // transformers exposes this helper to HuggingFace templates. Templates
775    // use it to reject invalid message shapes with a useful model-authored
776    // error instead of failing with an unrelated "unknown function" error.
777    env.add_function(
778        "raise_exception",
779        |message: String| -> std::result::Result<String, minijinja::Error> {
780            Err(
781                minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, message.clone())
782                    .with_source(TemplateRequestRejection(message)),
783            )
784        },
785    );
786    // transformers exposes `strftime_now(format)` = `datetime.now().strftime`
787    // to templates (Mistral-Small-3.2 / Llama-3.x date their system prompts
788    // with it). chrono's strftime covers the specifiers real templates use
789    // (%d %m %Y %b %H %M %S); an unsupported one is a render error, not a
790    // panic.
791    let now = options
792        .now_override
793        .unwrap_or_else(|| chrono::Local::now().naive_local());
794    env.add_function(
795        "strftime_now",
796        move |fmt: String| -> std::result::Result<String, minijinja::Error> {
797            use std::fmt::Write as _;
798            let mut out = String::new();
799            write!(out, "{}", now.format(&fmt)).map_err(|_| {
800                minijinja::Error::new(
801                    minijinja::ErrorKind::InvalidOperation,
802                    format!("strftime_now: unsupported format string {fmt:?}"),
803                )
804            })?;
805            Ok(out)
806        },
807    );
808    let template_messages = model_template_message_values(messages, message_phases)?;
809    let template = normalize_hf_chat_template(&model_template.template);
810    env.add_template("chat", &template)?;
811    let tmpl = env.get_template("chat")?;
812    tmpl.render(ModelTemplateContext {
813        messages: template_messages,
814        add_generation_prompt,
815        bos_token: model_template.bos_token.as_deref().unwrap_or(""),
816        eos_token: model_template.eos_token.as_deref().unwrap_or(""),
817        enable_thinking: options.enable_thinking,
818        reasoning_effort: options.reasoning_effort,
819        tools: tools.and_then(|t| serde_json::to_value(t).ok()),
820        tool_choice,
821        functions: functions.and_then(|f| serde_json::to_value(f).ok()),
822        function_call,
823    })
824}
825
826fn detect_model_reasoning_protocol(
827    model_template: &ModelChatTemplate,
828) -> (ModelReasoningProtocol, bool) {
829    let Some((opening, closing)) = model_reasoning_markers(model_template.output_protocol) else {
830        return (ModelReasoningProtocol::None, false);
831    };
832    let Some(enabled) = render_reasoning_probe(model_template, Some(true)) else {
833        return (ModelReasoningProtocol::Unknown, false);
834    };
835    let default = render_reasoning_probe(model_template, None);
836    let prompt_opened =
837        |prompt: &str| has_unclosed_model_reasoning_block(model_template.output_protocol, prompt);
838    if prompt_opened(&enabled) {
839        let default_enabled = default.as_deref().is_some_and(prompt_opened);
840        return (ModelReasoningProtocol::PromptOpened, default_enabled);
841    }
842    let Some(disabled) = render_reasoning_probe(model_template, Some(false)) else {
843        return (ModelReasoningProtocol::Unknown, false);
844    };
845    let completed_blocks = |prompt: &str| {
846        prompt
847            .matches(opening)
848            .count()
849            .min(prompt.matches(closing).count())
850    };
851    if completed_blocks(&disabled) > completed_blocks(&enabled) {
852        return (
853            ModelReasoningProtocol::ModelGenerated,
854            default.as_deref() == Some(enabled.as_str()),
855        );
856    }
857    (ModelReasoningProtocol::None, false)
858}
859
860fn render_reasoning_probe(
861    model_template: &ModelChatTemplate,
862    enable_thinking: Option<bool>,
863) -> Option<String> {
864    let messages = [PromptMessage {
865        role: "user".to_string(),
866        content: "reasoning protocol probe".to_string(),
867        reasoning_content: None,
868        name: None,
869        tool_calls: None,
870        tool_call_id: None,
871        function_call: None,
872    }];
873    let now =
874        chrono::NaiveDate::from_ymd_opt(2000, 1, 1).and_then(|date| date.and_hms_opt(0, 0, 0));
875    render_model_template(
876        &messages,
877        &[],
878        model_template,
879        &ChatTemplateOptions {
880            enable_thinking,
881            reasoning_effort: None,
882            now_override: now,
883        },
884        true,
885        None,
886        None,
887        None,
888        None,
889    )
890    .ok()
891}
892
893/// `tojson` matching Python's `json.dumps(..., ensure_ascii=False)` as used
894/// by transformers' chat-template environment: `", "` / `": "` separators and
895/// insertion key order (hence the minijinja `preserve_order` feature).
896/// minijinja's builtin emits compact separators, which breaks byte equality
897/// with transformers-rendered tool definitions.
898fn python_style_tojson(
899    value: minijinja::value::Value,
900    kwargs: minijinja::value::Kwargs,
901) -> Result<String, minijinja::Error> {
902    // transformers' `tojson` forwards kwargs to `json.dumps`; real templates
903    // use `indent=N` (Llama-3.x tool specs). Python's indented output equals
904    // serde_json's pretty formatter with an N-space indent. Anything else
905    // (sort_keys, separators) is unimplemented — erroring beats silently
906    // rendering a different prompt.
907    let indent = kwargs.get::<Option<usize>>("indent")?;
908    kwargs.assert_all_used()?;
909    if let Some(indent) = indent {
910        let indent_bytes = vec![b' '; indent];
911        let mut out = Vec::new();
912        let mut ser = serde_json::Serializer::with_formatter(
913            &mut out,
914            serde_json::ser::PrettyFormatter::with_indent(&indent_bytes),
915        );
916        serde::Serialize::serialize(&value, &mut ser).map_err(|e| {
917            minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string())
918        })?;
919        return String::from_utf8(out).map_err(|e| {
920            minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string())
921        });
922    }
923    struct PyFormatter;
924    impl serde_json::ser::Formatter for PyFormatter {
925        fn begin_object_key<W: ?Sized + std::io::Write>(
926            &mut self,
927            writer: &mut W,
928            first: bool,
929        ) -> std::io::Result<()> {
930            if !first {
931                writer.write_all(b", ")?;
932            }
933            Ok(())
934        }
935        fn begin_object_value<W: ?Sized + std::io::Write>(
936            &mut self,
937            writer: &mut W,
938        ) -> std::io::Result<()> {
939            writer.write_all(b": ")
940        }
941        fn begin_array_value<W: ?Sized + std::io::Write>(
942            &mut self,
943            writer: &mut W,
944            first: bool,
945        ) -> std::io::Result<()> {
946            if !first {
947                writer.write_all(b", ")?;
948            }
949            Ok(())
950        }
951    }
952
953    let mut out = Vec::new();
954    let mut ser = serde_json::Serializer::with_formatter(&mut out, PyFormatter);
955    serde::Serialize::serialize(&value, &mut ser).map_err(|e| {
956        minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string())
957    })?;
958    String::from_utf8(out)
959        .map_err(|e| minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string()))
960}
961
962fn normalize_hf_chat_template(template: &str) -> String {
963    template
964        .replace(
965            "message.content.split('</think>')[-1].lstrip('\\n')",
966            "message.content|after_think_end",
967        )
968        .replace(
969            "message.content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n')",
970            "message.content|reasoning_from_think",
971        )
972        .replace(
973            "content.split('</think>')[-1].lstrip('\\n')",
974            "content|after_think_end",
975        )
976        .replace(
977            "content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n')",
978            "content|reasoning_from_think",
979        )
980        .replace(".startswith(", "|starts_with(")
981        .replace(".endswith(", "|ends_with(")
982        .replace(".strip('\\n')", "|trim_newlines")
983        .replace(".lstrip('\\n')", "|trim_start_newlines")
984        .replace(".rstrip('\\n')", "|trim_end_newlines")
985}
986
987/// Render OpenAI-style chat messages into the prompt string the model was
988/// trained on.
989///
990/// Detects model family from the request's `model` field:
991///   - qwen (Qwen2 / Qwen2.5 / Qwen3): ChatML with `<|im_start|>` / `<|im_end|>`
992///   - llama 3: `<|start_header_id|>...<|end_header_id|>` + `<|eot_id|>`
993///   - fallback: TinyLlama-style `<|system|>` / `<|user|>` / `<|assistant|>`
994///     with `</s>` separators
995///
996/// All templates end with the assistant header so the first generated token
997/// becomes the reply content (no extra role prefix).
998pub fn render_chat_prompt(messages: &[ChatMessage], model_id: &str) -> String {
999    let prompt_messages = messages
1000        .iter()
1001        .map(PromptMessage::from_chat_message)
1002        .collect::<Vec<_>>();
1003    render_fallback_prompt(&prompt_messages, model_id, None)
1004}
1005
1006pub fn render_chat_prompt_with_model_template(
1007    messages: &[ChatMessage],
1008    model_id: &str,
1009    model_template: Option<&ModelChatTemplate>,
1010) -> ferrum_types::Result<String> {
1011    render_chat_prompt_with_model_template_options(
1012        messages,
1013        model_id,
1014        model_template,
1015        &ChatTemplateOptions::default(),
1016    )
1017}
1018
1019pub fn render_chat_prompt_with_model_template_options(
1020    messages: &[ChatMessage],
1021    model_id: &str,
1022    model_template: Option<&ModelChatTemplate>,
1023    options: &ChatTemplateOptions,
1024) -> ferrum_types::Result<String> {
1025    render_chat_prompt_with_model_template_options_and_compatibility(
1026        messages,
1027        model_id,
1028        model_template,
1029        options,
1030        true,
1031        None,
1032    )
1033}
1034
1035pub(crate) fn render_chat_prompt_with_model_template_options_and_compatibility(
1036    messages: &[ChatMessage],
1037    model_id: &str,
1038    model_template: Option<&ModelChatTemplate>,
1039    options: &ChatTemplateOptions,
1040    coalesce_interleaved_system_messages: bool,
1041    message_phases: Option<&[Option<AssistantMessagePhase>]>,
1042) -> ferrum_types::Result<String> {
1043    let prompt_messages = messages
1044        .iter()
1045        .map(PromptMessage::from_chat_message)
1046        .collect::<Vec<_>>();
1047    render_prompt_messages_with_options_and_compatibility(
1048        &prompt_messages,
1049        model_id,
1050        model_template,
1051        options,
1052        coalesce_interleaved_system_messages,
1053        message_phases.unwrap_or(&[]),
1054    )
1055}
1056
1057pub(crate) fn render_chat_prompt_with_model_template_options_and_compatibility_with_prefill(
1058    messages: &[ChatMessage],
1059    model_id: &str,
1060    model_template: Option<&ModelChatTemplate>,
1061    options: &ChatTemplateOptions,
1062    coalesce_interleaved_system_messages: bool,
1063    message_phases: Option<&[Option<AssistantMessagePhase>]>,
1064) -> ferrum_types::Result<RenderedPrompt> {
1065    let prompt_messages = messages
1066        .iter()
1067        .map(PromptMessage::from_chat_message)
1068        .collect::<Vec<_>>();
1069    if let Some(template) = model_template {
1070        return validate_rendered_prompt(
1071            template,
1072            render_model_template_with_prefill(
1073                &prompt_messages,
1074                message_phases.unwrap_or(&[]),
1075                template,
1076                options,
1077                coalesce_interleaved_system_messages,
1078                None,
1079                None,
1080                None,
1081                None,
1082                true,
1083            ),
1084        );
1085    }
1086    Ok(RenderedPrompt::without_reasoning_prefill(
1087        render_fallback_prompt(&prompt_messages, model_id, None),
1088    ))
1089}
1090
1091fn validate_rendered_prompt(
1092    template: &ModelChatTemplate,
1093    rendered: std::result::Result<RenderedPrompt, minijinja::Error>,
1094) -> ferrum_types::Result<RenderedPrompt> {
1095    match rendered {
1096        Ok(prompt) if !prompt.text.trim().is_empty() => Ok(prompt),
1097        Ok(_) => Err(chat_template_render_error(
1098            template,
1099            "template rendered an empty prompt",
1100        )),
1101        Err(error) => Err(chat_template_evaluation_error(template, error)),
1102    }
1103}
1104
1105fn render_fallback_prompt(
1106    messages: &[PromptMessage],
1107    model_id: &str,
1108    tool_spec: Option<String>,
1109) -> String {
1110    let model_lower = model_id.to_lowercase();
1111
1112    if model_lower.contains("qwen") {
1113        let mut prompt = String::new();
1114        if let Some(tool_spec) = tool_spec {
1115            prompt.push_str(&format!("<|im_start|>system\n{}<|im_end|>\n", tool_spec));
1116        }
1117        for msg in messages {
1118            prompt.push_str(&format!(
1119                "<|im_start|>{}\n{}<|im_end|>\n",
1120                msg.role, msg.content
1121            ));
1122        }
1123        prompt.push_str("<|im_start|>assistant\n");
1124        prompt
1125    } else if model_lower.contains("llama") && model_lower.contains("3") {
1126        // The engine encodes prompts with `add_special=true`, so do not
1127        // include `<|begin_of_text|>` here. Including it manually creates a
1128        // double-BOS prompt for Llama-3 tokenizers and degrades instruction
1129        // following.
1130        let mut prompt = String::new();
1131        if let Some(tool_spec) = tool_spec {
1132            prompt.push_str(&format!(
1133                "<|start_header_id|>system<|end_header_id|>\n\n{}<|eot_id|>",
1134                tool_spec
1135            ));
1136        }
1137        for msg in messages {
1138            prompt.push_str(&format!(
1139                "<|start_header_id|>{}<|end_header_id|>\n\n{}<|eot_id|>",
1140                msg.role, msg.content
1141            ));
1142        }
1143        prompt.push_str("<|start_header_id|>assistant<|end_header_id|>\n\n");
1144        prompt
1145    } else {
1146        // TinyLlama / generic chat format. Promote the first system message
1147        // to the top; subsequent ones (rare) are emitted inline.
1148        let has_system = messages.iter().any(|m| m.role == "system");
1149        let mut prompt = String::new();
1150        if let Some(tool_spec) = tool_spec {
1151            prompt.push_str(&format!("<|system|>\n{}</s>\n", tool_spec));
1152        } else if !has_system {
1153            prompt.push_str("<|system|>\nYou are a helpful assistant.</s>\n");
1154        }
1155        for msg in messages {
1156            prompt.push_str(&format!("<|{}|>\n{}</s>\n", msg.role, msg.content));
1157        }
1158        prompt.push_str("<|assistant|>\n");
1159        prompt
1160    }
1161}
1162
1163pub fn render_chat_prompt_with_tools(
1164    messages: &[ChatMessage],
1165    model_id: &str,
1166    tools: &[ChatTool],
1167    tool_choice: Option<&ToolChoice>,
1168    functions: &[ChatFunction],
1169    function_call: Option<&FunctionCallChoice>,
1170) -> String {
1171    let prompt_messages = messages
1172        .iter()
1173        .map(|msg| PromptMessage::new(template_role(msg), template_content(msg)))
1174        .collect::<Vec<_>>();
1175    render_fallback_prompt(
1176        &prompt_messages,
1177        model_id,
1178        render_tool_spec(tools, tool_choice, functions, function_call),
1179    )
1180}
1181
1182pub fn render_chat_prompt_with_tools_and_model_template(
1183    messages: &[ChatMessage],
1184    model_id: &str,
1185    model_template: Option<&ModelChatTemplate>,
1186    options: &ChatTemplateOptions,
1187    tools: &[ChatTool],
1188    tool_choice: Option<&ToolChoice>,
1189    functions: &[ChatFunction],
1190    function_call: Option<&FunctionCallChoice>,
1191) -> ferrum_types::Result<String> {
1192    render_chat_prompt_with_tools_and_model_template_compatibility(
1193        messages,
1194        model_id,
1195        model_template,
1196        options,
1197        tools,
1198        tool_choice,
1199        functions,
1200        function_call,
1201        true,
1202        None,
1203    )
1204}
1205
1206pub(crate) fn render_chat_prompt_with_tools_and_model_template_compatibility(
1207    messages: &[ChatMessage],
1208    model_id: &str,
1209    model_template: Option<&ModelChatTemplate>,
1210    options: &ChatTemplateOptions,
1211    tools: &[ChatTool],
1212    tool_choice: Option<&ToolChoice>,
1213    functions: &[ChatFunction],
1214    function_call: Option<&FunctionCallChoice>,
1215    coalesce_interleaved_system_messages: bool,
1216    message_phases: Option<&[Option<AssistantMessagePhase>]>,
1217) -> ferrum_types::Result<String> {
1218    render_chat_prompt_with_tools_and_model_template_prefill_mode(
1219        messages,
1220        model_id,
1221        model_template,
1222        options,
1223        tools,
1224        tool_choice,
1225        functions,
1226        function_call,
1227        coalesce_interleaved_system_messages,
1228        message_phases,
1229        false,
1230    )
1231    .map(|prompt| prompt.text)
1232}
1233
1234pub(crate) fn render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill(
1235    messages: &[ChatMessage],
1236    model_id: &str,
1237    model_template: Option<&ModelChatTemplate>,
1238    options: &ChatTemplateOptions,
1239    tools: &[ChatTool],
1240    tool_choice: Option<&ToolChoice>,
1241    functions: &[ChatFunction],
1242    function_call: Option<&FunctionCallChoice>,
1243    coalesce_interleaved_system_messages: bool,
1244    message_phases: Option<&[Option<AssistantMessagePhase>]>,
1245) -> ferrum_types::Result<RenderedPrompt> {
1246    render_chat_prompt_with_tools_and_model_template_prefill_mode(
1247        messages,
1248        model_id,
1249        model_template,
1250        options,
1251        tools,
1252        tool_choice,
1253        functions,
1254        function_call,
1255        coalesce_interleaved_system_messages,
1256        message_phases,
1257        true,
1258    )
1259}
1260
1261fn render_chat_prompt_with_tools_and_model_template_prefill_mode(
1262    messages: &[ChatMessage],
1263    model_id: &str,
1264    model_template: Option<&ModelChatTemplate>,
1265    options: &ChatTemplateOptions,
1266    tools: &[ChatTool],
1267    tool_choice: Option<&ToolChoice>,
1268    functions: &[ChatFunction],
1269    function_call: Option<&FunctionCallChoice>,
1270    coalesce_interleaved_system_messages: bool,
1271    message_phases: Option<&[Option<AssistantMessagePhase>]>,
1272    inspect_prefill: bool,
1273) -> ferrum_types::Result<RenderedPrompt> {
1274    if let Some(model_template) = model_template {
1275        if model_template_supports_tools(model_template) {
1276            let prompt_messages = messages
1277                .iter()
1278                .map(PromptMessage::from_chat_message)
1279                .collect::<Vec<_>>();
1280            return validate_rendered_prompt(
1281                model_template,
1282                render_model_template_with_prefill(
1283                    &prompt_messages,
1284                    message_phases.unwrap_or(&[]),
1285                    model_template,
1286                    options,
1287                    coalesce_interleaved_system_messages,
1288                    (!tools.is_empty()).then_some(tools),
1289                    tool_choice,
1290                    (!functions.is_empty()).then_some(functions),
1291                    function_call,
1292                    inspect_prefill,
1293                ),
1294            );
1295        }
1296
1297        // The model ships a chat template with no `tools` support (e.g. the
1298        // DeepSeek-R1 distills). Inject the generic tool spec as a leading
1299        // system message and render it *through the model's own template*,
1300        // so tool definitions still reach the model in its native prompt
1301        // format instead of being silently dropped.
1302        let mut prompt_messages = Vec::with_capacity(messages.len() + 1);
1303        let tool_spec = render_tool_spec(tools, tool_choice, functions, function_call);
1304        if let Some(spec) = tool_spec.as_ref() {
1305            prompt_messages.push(PromptMessage::new("system", spec));
1306        }
1307        prompt_messages.extend(messages.iter().map(PromptMessage::from_chat_message));
1308        let prompt_phases = message_phases.map(|phases| {
1309            let mut prompt_phases = Vec::with_capacity(prompt_messages.len());
1310            if tool_spec.is_some() {
1311                prompt_phases.push(None);
1312            }
1313            prompt_phases.extend_from_slice(phases);
1314            prompt_phases
1315        });
1316        return validate_rendered_prompt(
1317            model_template,
1318            render_model_template_with_prefill(
1319                &prompt_messages,
1320                prompt_phases.as_deref().unwrap_or(&[]),
1321                model_template,
1322                options,
1323                coalesce_interleaved_system_messages,
1324                None,
1325                None,
1326                None,
1327                None,
1328                inspect_prefill,
1329            ),
1330        );
1331    }
1332
1333    Ok(RenderedPrompt::without_reasoning_prefill(
1334        render_chat_prompt_with_tools(
1335            messages,
1336            model_id,
1337            tools,
1338            tool_choice,
1339            functions,
1340            function_call,
1341        ),
1342    ))
1343}
1344
1345/// Whether a chat template references the `tools` variable as a standalone
1346/// identifier (substring matching alone would not distinguish a template
1347/// that only handles `message.tool_calls` history from one that renders
1348/// tool definitions).
1349pub(crate) fn model_template_supports_tools(template: &ModelChatTemplate) -> bool {
1350    let src = template.template.as_bytes();
1351    let needle = b"tools";
1352    let mut start = 0;
1353    while let Some(pos) = template.template[start..].find("tools") {
1354        let abs = start + pos;
1355        let before_ok = abs == 0 || {
1356            let c = src[abs - 1];
1357            !(c.is_ascii_alphanumeric() || c == b'_')
1358        };
1359        let after = abs + needle.len();
1360        let after_ok = after >= src.len() || {
1361            let c = src[after];
1362            !(c.is_ascii_alphanumeric() || c == b'_')
1363        };
1364        if before_ok && after_ok {
1365            return true;
1366        }
1367        start = after;
1368    }
1369    false
1370}
1371
1372fn template_role(msg: &ChatMessage) -> &'static str {
1373    match msg.role {
1374        MessageRole::System => "system",
1375        MessageRole::User => "user",
1376        MessageRole::Assistant => "assistant",
1377        MessageRole::Function => "function",
1378        MessageRole::Tool => "tool",
1379    }
1380}
1381
1382fn template_content(msg: &ChatMessage) -> String {
1383    let mut parts = Vec::new();
1384    if !msg.content.is_empty() {
1385        parts.push(msg.content.clone());
1386    }
1387    if let Some(tool_calls) = msg.tool_calls.as_deref().filter(|calls| !calls.is_empty()) {
1388        parts.push(json_line(serde_json::json!({ "tool_calls": tool_calls })));
1389    }
1390    if let Some(function_call) = msg.function_call.as_ref() {
1391        parts.push(json_line(
1392            serde_json::json!({ "function_call": function_call }),
1393        ));
1394    }
1395    parts.join("\n")
1396}
1397
1398fn render_tool_spec(
1399    tools: &[ChatTool],
1400    tool_choice: Option<&ToolChoice>,
1401    functions: &[ChatFunction],
1402    function_call: Option<&FunctionCallChoice>,
1403) -> Option<String> {
1404    if tools.is_empty() && functions.is_empty() {
1405        return None;
1406    }
1407
1408    let mut spec = serde_json::Map::new();
1409    spec.insert(
1410        "instruction".to_string(),
1411        serde_json::Value::String(
1412            "When a tool is needed, respond with JSON matching the provided tool/function schema; otherwise answer normally."
1413                .to_string(),
1414        ),
1415    );
1416    if !tools.is_empty() {
1417        spec.insert("tools".to_string(), serde_json::json!(tools));
1418    }
1419    if let Some(choice) = tool_choice {
1420        spec.insert("tool_choice".to_string(), serde_json::json!(choice));
1421    }
1422    if !functions.is_empty() {
1423        spec.insert("functions".to_string(), serde_json::json!(functions));
1424    }
1425    if let Some(choice) = function_call {
1426        spec.insert("function_call".to_string(), serde_json::json!(choice));
1427    }
1428    Some(json_line(serde_json::Value::Object(spec)))
1429}
1430
1431fn json_line(value: serde_json::Value) -> String {
1432    serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438
1439    fn msg(role: MessageRole, content: &str) -> ChatMessage {
1440        ChatMessage {
1441            role,
1442            content: content.to_string(),
1443            reasoning: None,
1444            name: None,
1445            tool_calls: None,
1446            tool_call_id: None,
1447            function_call: None,
1448        }
1449    }
1450
1451    fn tool(name: &str) -> ChatTool {
1452        ChatTool {
1453            tool_type: "function".to_string(),
1454            function: ChatFunction {
1455                name: name.to_string(),
1456                description: Some("Get weather".to_string()),
1457                parameters: Some(serde_json::json!({
1458                    "type": "object",
1459                    "properties": {"city": {"type": "string"}},
1460                    "required": ["city"]
1461                })),
1462                strict: None,
1463            },
1464        }
1465    }
1466
1467    #[test]
1468    fn qwen3_renders_chatml_without_forced_think_marker() {
1469        let out = render_chat_prompt(
1470            &[
1471                msg(MessageRole::System, "You are helpful."),
1472                msg(MessageRole::User, "Hi"),
1473            ],
1474            "qwen3:0.6b",
1475        );
1476        assert!(out.contains("<|im_start|>system\nYou are helpful.<|im_end|>"));
1477        assert!(out.contains("<|im_start|>user\nHi<|im_end|>"));
1478        assert!(out.ends_with("<|im_start|>assistant\n"));
1479        assert!(!out.contains("<think>"));
1480    }
1481
1482    #[test]
1483    fn qwen2_renders_chatml_without_think() {
1484        let out = render_chat_prompt(&[msg(MessageRole::User, "Hi")], "Qwen/Qwen2.5-7B-Instruct");
1485        assert!(out.ends_with("<|im_start|>assistant\n"));
1486        assert!(!out.contains("<think>"));
1487    }
1488
1489    #[test]
1490    fn model_template_is_preferred_over_family_fallback() {
1491        let template = ModelChatTemplate::new(
1492            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
1493            "test-template",
1494        );
1495        let out = render_chat_prompt_with_model_template(
1496            &[msg(MessageRole::User, "Hi")],
1497            "qwen3",
1498            Some(&template),
1499        )
1500        .unwrap();
1501        assert_eq!(out, "[user]Hi[assistant]");
1502    }
1503
1504    #[test]
1505    fn model_template_is_used_for_tool_requests() {
1506        let template = ModelChatTemplate::new(
1507            "{% 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 %}",
1508            "tool-template",
1509        );
1510        let mut assistant = msg(MessageRole::Assistant, "");
1511        assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
1512            index: None,
1513            id: "call_1".to_string(),
1514            tool_type: "function".to_string(),
1515            function: crate::openai::ChatFunctionCall {
1516                name: "weather".to_string(),
1517                arguments: "{\"city\":\"Paris\"}".to_string(),
1518            },
1519        }]);
1520        let mut tool_result = msg(MessageRole::Tool, "sunny");
1521        tool_result.tool_call_id = Some("call_1".to_string());
1522
1523        let out = render_chat_prompt_with_tools_and_model_template(
1524            &[
1525                msg(MessageRole::User, "Use weather."),
1526                assistant,
1527                tool_result,
1528            ],
1529            "served-hash-id",
1530            Some(&template),
1531            &ChatTemplateOptions::default(),
1532            &[tool("weather")],
1533            Some(&ToolChoice::Mode("auto".to_string())),
1534            &[],
1535            None,
1536        )
1537        .unwrap();
1538
1539        assert!(out.contains("<tools>weather</tools>"));
1540        assert!(out.contains("<tool_call>weather:"), "{out}");
1541        assert!(out.contains("\"city\""), "{out}");
1542        assert!(out.contains("Paris"), "{out}");
1543        assert!(out.contains("<tool_response id=\"call_1\">sunny</tool_response>"));
1544        assert!(out.ends_with("[assistant]"));
1545        assert!(
1546            !out.contains("<|assistant|>"),
1547            "tool requests with model templates must not use generic fallback: {out}"
1548        );
1549    }
1550
1551    #[test]
1552    fn model_template_tools_supports_qwen3_template_primitives() {
1553        let template = ModelChatTemplate::new(
1554            "{% 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 %}",
1555            "qwen3-tool-primitives",
1556        );
1557        let out = render_chat_prompt_with_tools_and_model_template(
1558            &[
1559                msg(MessageRole::User, "Use weather."),
1560                msg(MessageRole::Assistant, "ok"),
1561            ],
1562            "served-hash-id",
1563            Some(&template),
1564            &ChatTemplateOptions::default(),
1565            &[tool("weather")],
1566            Some(&ToolChoice::Mode("auto".to_string())),
1567            &[],
1568            None,
1569        )
1570        .unwrap();
1571
1572        // tojson renders Python-json.dumps style (", " / ": " separators,
1573        // insertion key order) for byte parity with transformers.
1574        assert!(out.contains("\"name\": \"weather\""), "{out}");
1575        assert!(
1576            out.contains("{\"type\": \"function\", \"function\": {"),
1577            "{out}"
1578        );
1579        assert!(out.contains("[assistant][user][assistant]"), "{out}");
1580    }
1581
1582    #[test]
1583    fn model_template_tool_arguments_are_parsed_for_hf_templates() {
1584        let template = ModelChatTemplate::new(
1585            "{% 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 %}",
1586            "llama-tool-primitives",
1587        );
1588        let mut assistant = msg(MessageRole::Assistant, "");
1589        assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
1590            index: None,
1591            id: "call_1".to_string(),
1592            tool_type: "function".to_string(),
1593            function: crate::openai::ChatFunctionCall {
1594                name: "weather".to_string(),
1595                arguments: "{\"city\":\"Paris\",\"unit\":\"celsius\"}".to_string(),
1596            },
1597        }]);
1598
1599        let out = render_chat_prompt_with_tools_and_model_template(
1600            &[msg(MessageRole::User, "Use weather."), assistant],
1601            "served-hash-id",
1602            Some(&template),
1603            &ChatTemplateOptions::default(),
1604            &[tool("weather")],
1605            Some(&ToolChoice::Mode("auto".to_string())),
1606            &[],
1607            None,
1608        )
1609        .unwrap();
1610
1611        assert!(out.contains("\"city\""), "{out}");
1612        assert!(out.contains("\"Paris\""), "{out}");
1613        assert!(out.contains("[city=Paris]"), "{out}");
1614        assert!(out.contains("[unit=celsius]"), "{out}");
1615        assert!(out.ends_with("[assistant]"));
1616    }
1617
1618    #[test]
1619    fn model_template_tool_result_content_stays_string_for_hf_templates() {
1620        let template = ModelChatTemplate::new(
1621            "{% 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 %}",
1622            "qwen-tool-result-primitives",
1623        );
1624        let mut tool_result = msg(
1625            MessageRole::Tool,
1626            "{\"city\":\"北京\",\"temp\":22,\"desc\":\"晴\"}",
1627        );
1628        tool_result.tool_call_id = Some("call_1".to_string());
1629
1630        let out = render_chat_prompt_with_tools_and_model_template(
1631            &[msg(MessageRole::User, "Use weather."), tool_result],
1632            "served-hash-id",
1633            Some(&template),
1634            &ChatTemplateOptions::default(),
1635            &[tool("weather")],
1636            Some(&ToolChoice::Mode("auto".to_string())),
1637            &[],
1638            None,
1639        )
1640        .unwrap();
1641
1642        assert!(out.contains("\"temp\""), "{out}");
1643        assert!(out.contains("22"), "{out}");
1644        assert!(out.contains("\"desc\":\"晴\""), "{out}");
1645        assert!(out.contains("<tool_response>"), "{out}");
1646        assert!(!out.contains("not-string"), "{out}");
1647        assert!(out.ends_with("[assistant]"));
1648    }
1649
1650    #[test]
1651    fn qwen_style_model_template_does_not_force_empty_think() {
1652        let template = ModelChatTemplate::new(
1653            "{%- for message in messages %}{{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>\\n' }}{%- endfor %}{%- if add_generation_prompt %}{{- '<|im_start|>assistant\\n' }}{%- endif %}",
1654            "qwen-template",
1655        );
1656        let out = render_chat_prompt_with_model_template(
1657            &[msg(MessageRole::User, "Hi")],
1658            "qwen3",
1659            Some(&template),
1660        )
1661        .unwrap();
1662        assert_eq!(
1663            out,
1664            "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
1665        );
1666        assert!(!out.contains("<think>"));
1667    }
1668
1669    #[test]
1670    fn omitted_thinking_option_preserves_model_template_default() {
1671        let template = ModelChatTemplate::new(
1672            "{%- 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 %}",
1673            "thinking-template",
1674        );
1675        let options = ChatTemplateOptions::default_for_template(Some(&template));
1676        assert_eq!(options.enable_thinking, None);
1677        let out = render_chat_prompt_with_model_template_options(
1678            &[msg(MessageRole::User, "Hi")],
1679            "served-model-alias",
1680            Some(&template),
1681            &options,
1682        )
1683        .unwrap();
1684        assert!(out.ends_with("<|im_start|>assistant\n<think>\n"));
1685    }
1686
1687    #[test]
1688    fn typed_reasoning_effort_is_exposed_to_model_template() {
1689        let template = ModelChatTemplate::new(
1690            "{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% else %}Reasoning: model-default{% endif %}",
1691            "reasoning-effort-template",
1692        );
1693        let default_prompt = render_chat_prompt_with_model_template_options(
1694            &[msg(MessageRole::User, "Hi")],
1695            "served-model-alias",
1696            Some(&template),
1697            &ChatTemplateOptions::default(),
1698        )
1699        .unwrap();
1700        assert_eq!(default_prompt, "Reasoning: model-default");
1701
1702        let low_prompt = render_chat_prompt_with_model_template_options(
1703            &[msg(MessageRole::User, "Hi")],
1704            "served-model-alias",
1705            Some(&template),
1706            &ChatTemplateOptions {
1707                reasoning_effort: Some(ReasoningEffort::Low),
1708                ..Default::default()
1709            },
1710        )
1711        .unwrap();
1712        assert_eq!(low_prompt, "Reasoning: low");
1713    }
1714
1715    #[test]
1716    fn explicit_thinking_options_override_model_template_default() {
1717        let template = ModelChatTemplate::new(
1718            "{% 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 %}",
1719            "thinking-template",
1720        );
1721        let enabled = render_chat_prompt_with_model_template_options(
1722            &[msg(MessageRole::User, "Hi")],
1723            "Qwen/Qwen3-0.6B",
1724            Some(&template),
1725            &ChatTemplateOptions {
1726                enable_thinking: Some(true),
1727                ..Default::default()
1728            },
1729        )
1730        .unwrap();
1731        assert_eq!(enabled, "<assistant><think>\n");
1732
1733        let disabled = render_chat_prompt_with_model_template_options(
1734            &[msg(MessageRole::User, "Hi")],
1735            "Qwen/Qwen3-0.6B",
1736            Some(&template),
1737            &ChatTemplateOptions {
1738                enable_thinking: Some(false),
1739                ..Default::default()
1740            },
1741        )
1742        .unwrap();
1743        assert_eq!(disabled, "<assistant><think>\n\n</think>\n\n");
1744    }
1745
1746    #[test]
1747    fn qwen3_model_generated_thinking_is_a_typed_template_capability() {
1748        let template = ModelChatTemplate::new(
1749            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
1750            "qwen3-model-generated-thinking-template",
1751        );
1752
1753        assert_eq!(
1754            template.reasoning_protocol,
1755            ModelReasoningProtocol::ModelGenerated
1756        );
1757        assert!(template.reasoning_default_enabled);
1758        assert!(template.reasoning_enabled(None));
1759        assert!(template.reasoning_enabled(Some(true)));
1760        assert!(!template.reasoning_enabled(Some(false)));
1761    }
1762
1763    #[test]
1764    fn declared_gemma_protocol_recomputes_template_reasoning_capability() {
1765        // Preserve the canonical template's generation-tail decisions without
1766        // copying its tool-schema formatting or other unrelated metadata.
1767        let source = concat!(
1768            "{% set enable_thinking = enable_thinking | default(false) %}",
1769            "{% if enable_thinking %}<|turn>system\n<|think|>\n<turn|>\n{% endif %}",
1770            "{% if add_generation_prompt %}",
1771            "{% if messages[-1].role == 'tool' %}",
1772            "<|turn>model\n<|tool_response>{{ messages[-1].content }}<tool_response|>",
1773            "{% if enable_thinking %}<|channel>thought\n{% endif %}",
1774            "{% else %}<|turn>model\n",
1775            "{% if not enable_thinking %}<|channel>thought\n<channel|>{% endif %}",
1776            "{% endif %}{% endif %}",
1777        );
1778        let mut template = ModelChatTemplate::new(source, "declared-thought-template");
1779        assert_eq!(template.reasoning_protocol, ModelReasoningProtocol::None);
1780        template.set_output_protocol(ModelOutputProtocol::GemmaThought);
1781        assert_eq!(template.template, source);
1782        assert_eq!(
1783            template.reasoning_protocol,
1784            ModelReasoningProtocol::ModelGenerated
1785        );
1786        assert!(!template.reasoning_default_enabled);
1787        assert!(!template.reasoning_enabled(None));
1788        assert!(!template.reasoning_enabled(Some(false)));
1789        assert!(template.reasoning_enabled(Some(true)));
1790
1791        for (role, enabled, expected_tail, opened) in [
1792            (
1793                MessageRole::User,
1794                false,
1795                "<|turn>model\n<|channel>thought\n<channel|>",
1796                false,
1797            ),
1798            (MessageRole::User, true, "<|turn>model\n", false),
1799            (
1800                MessageRole::Tool,
1801                false,
1802                "<|tool_response>579<tool_response|>",
1803                false,
1804            ),
1805            (
1806                MessageRole::Tool,
1807                true,
1808                "<|tool_response>579<tool_response|><|channel>thought\n",
1809                true,
1810            ),
1811        ] {
1812            let prompt = render_chat_prompt_with_model_template_options(
1813                &[msg(role, "579")],
1814                "loaded-model-alias",
1815                Some(&template),
1816                &ChatTemplateOptions {
1817                    enable_thinking: Some(enabled),
1818                    ..Default::default()
1819                },
1820            )
1821            .unwrap();
1822            assert!(prompt.ends_with(expected_tail), "{prompt:?}");
1823            assert_eq!(
1824                has_unclosed_model_reasoning_block(template.output_protocol, &prompt),
1825                opened
1826            );
1827        }
1828
1829        template.set_output_protocol(ModelOutputProtocol::Text);
1830        assert_eq!(template.reasoning_protocol, ModelReasoningProtocol::None);
1831        assert!(!template.reasoning_enabled(Some(true)));
1832    }
1833
1834    #[test]
1835    fn template_without_enable_thinking_does_not_get_thinking_default() {
1836        let template = ModelChatTemplate::new(
1837            "{% if add_generation_prompt %}<assistant>{% endif %}",
1838            "plain-template",
1839        );
1840        let options = ChatTemplateOptions::default_for_template(Some(&template));
1841        assert_eq!(options.enable_thinking, None);
1842        let out = render_chat_prompt_with_model_template_options(
1843            &[msg(MessageRole::User, "Hi")],
1844            "Qwen/Qwen3-0.6B",
1845            Some(&template),
1846            &options,
1847        )
1848        .unwrap();
1849        assert_eq!(out, "<assistant>");
1850    }
1851
1852    #[test]
1853    fn assistant_think_history_exposes_reasoning_content_to_model_template() {
1854        let template = ModelChatTemplate::new(
1855            "{% 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 %}",
1856            "reasoning-template",
1857        );
1858        // reasoning_content is only present when the client supplied it
1859        // separately (OpenAI `message.reasoning`); raw `<think>` blocks in
1860        // content are the template's business (covered by golden tests).
1861        let mut assistant = PromptMessage::new("assistant", "answer");
1862        assistant.reasoning_content = Some("reason".to_string());
1863        let out = render_prompt_messages(
1864            &[assistant, PromptMessage::new("user", "next")],
1865            "qwen3",
1866            Some(&template),
1867        )
1868        .unwrap();
1869        assert_eq!(out, "<r>reason</r>answernext<assistant>");
1870    }
1871
1872    #[test]
1873    fn assistant_phase_is_visible_to_model_template() {
1874        let template = ModelChatTemplate::new(
1875            "{% for message in messages %}[{{ message.role }}{% if message.phase is defined %}:{{ message.phase }}{% endif %}]{{ message.content }}{% endfor %}",
1876            "phase-template",
1877        );
1878        let messages = [
1879            msg(MessageRole::Assistant, "Still working"),
1880            msg(MessageRole::User, "Continue"),
1881        ];
1882        let phases = [Some(AssistantMessagePhase::Commentary), None];
1883        let out = render_chat_prompt_with_model_template_options_and_compatibility(
1884            &messages,
1885            "local-model",
1886            Some(&template),
1887            &ChatTemplateOptions::default(),
1888            true,
1889            Some(&phases),
1890        )
1891        .unwrap();
1892        assert_eq!(out, "[assistant:commentary]Still working[user]Continue");
1893    }
1894
1895    #[test]
1896    fn hf_python_split_expressions_are_normalized_for_minijinja() {
1897        let template = ModelChatTemplate::new(
1898            "{% 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 %}",
1899            "split-template",
1900        );
1901        let out = render_prompt_messages(
1902            &[PromptMessage {
1903                role: "assistant".to_string(),
1904                content: "<think>\nreason\n</think>\n\nanswer".to_string(),
1905                reasoning_content: None,
1906                name: None,
1907                tool_calls: None,
1908                tool_call_id: None,
1909                function_call: None,
1910            }],
1911            "qwen3",
1912            Some(&template),
1913        )
1914        .unwrap();
1915        assert_eq!(out, "<r>reason</r>answer");
1916    }
1917
1918    #[test]
1919    fn qwen3_content_variable_split_expressions_are_normalized_for_minijinja() {
1920        let template = ModelChatTemplate::new(
1921            "{% 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 %}",
1922            "qwen3-content-split-template",
1923        );
1924        let out = render_prompt_messages(
1925            &[PromptMessage {
1926                role: "assistant".to_string(),
1927                content: "<think>\nreason\n</think>\n\nanswer".to_string(),
1928                reasoning_content: None,
1929                name: None,
1930                tool_calls: None,
1931                tool_call_id: None,
1932                function_call: None,
1933            }],
1934            "qwen3",
1935            Some(&template),
1936        )
1937        .unwrap();
1938        assert_eq!(out, "<r>reason</r>answer");
1939    }
1940
1941    #[test]
1942    fn qwen3_python_startswith_endswith_are_normalized_for_minijinja() {
1943        let template = ModelChatTemplate::new(
1944            "{% 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 %}",
1945            "qwen3-startswith-template",
1946        );
1947        let out = render_prompt_messages(
1948            &[
1949                PromptMessage::new("user", "hello"),
1950                PromptMessage::new("user", "<tool_response>ok</tool_response>"),
1951            ],
1952            "qwen3",
1953            Some(&template),
1954        )
1955        .unwrap();
1956        assert_eq!(out, "plaintool");
1957    }
1958
1959    #[test]
1960    fn multi_turn_preserves_order() {
1961        let out = render_chat_prompt(
1962            &[
1963                msg(MessageRole::User, "A"),
1964                msg(MessageRole::Assistant, "B"),
1965                msg(MessageRole::User, "C"),
1966            ],
1967            "qwen3",
1968        );
1969        let a_idx = out.find("A").unwrap();
1970        let b_idx = out.find("B").unwrap();
1971        let c_idx = out.find("C").unwrap();
1972        assert!(a_idx < b_idx && b_idx < c_idx);
1973    }
1974
1975    #[test]
1976    fn llama3_renders_header_format() {
1977        let out = render_chat_prompt(
1978            &[
1979                msg(MessageRole::System, "sys"),
1980                msg(MessageRole::User, "hi"),
1981            ],
1982            "meta-llama/Llama-3.2-1B-Instruct",
1983        );
1984        assert!(!out.starts_with("<|begin_of_text|>"));
1985        assert!(out.contains("<|start_header_id|>system<|end_header_id|>\n\nsys<|eot_id|>"));
1986        assert!(out.contains("<|start_header_id|>user<|end_header_id|>\n\nhi<|eot_id|>"));
1987        assert!(out.ends_with("<|start_header_id|>assistant<|end_header_id|>\n\n"));
1988    }
1989
1990    #[test]
1991    fn unknown_model_uses_tinyllama_fallback() {
1992        let out = render_chat_prompt(&[msg(MessageRole::User, "hi")], "mystery-model");
1993        assert!(out.contains("<|system|>"));
1994        assert!(out.contains("<|user|>\nhi</s>"));
1995        assert!(out.ends_with("<|assistant|>\n"));
1996    }
1997
1998    #[test]
1999    fn fallback_preserves_legacy_function_and_tool_roles() {
2000        let out = render_chat_prompt(
2001            &[
2002                msg(MessageRole::Function, "{\"city\":\"Paris\"}"),
2003                msg(MessageRole::Tool, "sunny"),
2004            ],
2005            "mystery-model",
2006        );
2007        assert!(out.contains("<|function|>\n{\"city\":\"Paris\"}</s>"));
2008        assert!(out.contains("<|tool|>\nsunny</s>"));
2009    }
2010
2011    #[test]
2012    fn qwen_renders_tool_definitions_and_assistant_tool_call_history() {
2013        let mut assistant = msg(MessageRole::Assistant, "");
2014        assistant.tool_calls = Some(vec![crate::openai::ChatToolCall {
2015            index: None,
2016            id: "call_1".to_string(),
2017            tool_type: "function".to_string(),
2018            function: crate::openai::ChatFunctionCall {
2019                name: "weather".to_string(),
2020                arguments: "{\"city\":\"Paris\"}".to_string(),
2021            },
2022        }]);
2023
2024        let out = render_chat_prompt_with_tools(
2025            &[
2026                msg(MessageRole::User, "Use weather."),
2027                assistant,
2028                msg(MessageRole::Tool, "sunny"),
2029            ],
2030            "qwen3",
2031            &[tool("weather")],
2032            Some(&ToolChoice::Mode("auto".to_string())),
2033            &[],
2034            None,
2035        );
2036
2037        assert!(out.contains("\"tools\":[{"));
2038        assert!(out.contains("\"type\":\"function\""));
2039        assert!(out.contains("\"tool_choice\":\"auto\""));
2040        assert!(out.contains("<|im_start|>assistant\n{"));
2041        assert!(out.contains("\"tool_calls\":[{"));
2042        assert!(out.contains("\"id\":\"call_1\""));
2043        assert!(out.contains("\"name\":\"weather\""));
2044        assert!(out.contains("<|im_start|>tool\nsunny<|im_end|>"));
2045    }
2046
2047    #[test]
2048    fn pycompat_python_string_methods_render_without_normalization() {
2049        // Bracket subscripts plus bare `.strip()` / `.split(..)[-1]` are
2050        // spellings `normalize_hf_chat_template` does not rewrite — they must
2051        // work via minijinja-contrib pycompat (DeepSeek-R1 distill templates
2052        // use them).
2053        let template = ModelChatTemplate::new(
2054            "{% for message in messages %}{% if message['role'] == 'assistant' %}{% set content = message['content'].split('</think>')[-1] %}{{ content.strip() }}{% endif %}{% endfor %}",
2055            "pycompat-template",
2056        );
2057        let out = render_prompt_messages(
2058            &[PromptMessage {
2059                role: "assistant".to_string(),
2060                content: "<think>\nreason\n</think>\n\nanswer".to_string(),
2061                reasoning_content: None,
2062                name: None,
2063                tool_calls: None,
2064                tool_call_id: None,
2065                function_call: None,
2066            }],
2067            "deepseek-distill",
2068            Some(&template),
2069        )
2070        .unwrap();
2071        assert_eq!(out, "answer");
2072    }
2073
2074    #[test]
2075    fn model_template_render_failure_is_an_error_not_a_silent_fallback() {
2076        let template =
2077            ModelChatTemplate::new("{{ messages | not_a_real_filter }}", "broken-template");
2078        let err = render_prompt_messages(
2079            &[PromptMessage::new("user", "hi")],
2080            "qwen3",
2081            Some(&template),
2082        )
2083        .unwrap_err();
2084        let message = format!("{err}");
2085        assert!(message.contains("broken-template"), "{message}");
2086        assert!(message.contains("failed to render"), "{message}");
2087    }
2088
2089    #[test]
2090    fn hf_raise_exception_reports_the_template_error() {
2091        let template = ModelChatTemplate::new(
2092            "{{ raise_exception('System message must be at the beginning.') }}",
2093            "strict-template",
2094        );
2095        let err = render_prompt_messages(
2096            &[PromptMessage::new("user", "hi")],
2097            "qwen3",
2098            Some(&template),
2099        )
2100        .unwrap_err();
2101        let message = format!("{err}");
2102        assert!(
2103            message.contains("System message must be at the beginning."),
2104            "{message}"
2105        );
2106        assert!(!message.contains("unknown function"), "{message}");
2107    }
2108
2109    #[test]
2110    fn permissive_template_preserves_interleaved_system_position() {
2111        let template = ModelChatTemplate::new(
2112            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}",
2113            "system-in-place-template",
2114        );
2115        let out = render_prompt_messages(
2116            &[
2117                PromptMessage::new("system", "Initial"),
2118                PromptMessage::new("user", "Question"),
2119                PromptMessage::new("system", "Deferred"),
2120            ],
2121            "model-with-system-in-place",
2122            Some(&template),
2123        )
2124        .unwrap();
2125        assert_eq!(out, "[system]Initial[user]Question[system]Deferred");
2126    }
2127
2128    #[test]
2129    fn interleaved_system_coalescing_can_be_disabled() {
2130        let template = ModelChatTemplate::new(
2131            "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
2132            "strict-leading-system-template",
2133        );
2134        let error = render_prompt_messages_with_options_and_compatibility(
2135            &[
2136                PromptMessage::new("system", "Initial"),
2137                PromptMessage::new("user", "Question"),
2138                PromptMessage::new("system", "Deferred"),
2139            ],
2140            "strict-model",
2141            Some(&template),
2142            &ChatTemplateOptions::default(),
2143            false,
2144            &[],
2145        )
2146        .unwrap_err();
2147        assert!(
2148            error
2149                .to_string()
2150                .contains("System message must be at the beginning."),
2151            "{error}"
2152        );
2153    }
2154
2155    #[test]
2156    fn strict_template_coalesces_consecutive_leading_system_messages() {
2157        let template = ModelChatTemplate::new(
2158            "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be the first message.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
2159            "strict-single-system-template",
2160        );
2161        let out = render_prompt_messages(
2162            &[
2163                PromptMessage::new("system", "Initial"),
2164                PromptMessage::new("system", "Additional"),
2165                PromptMessage::new("user", "Question"),
2166            ],
2167            "strict-model",
2168            Some(&template),
2169        )
2170        .unwrap();
2171        assert_eq!(out, "[system]Initial\n\nAdditional[user]Question");
2172    }
2173
2174    #[test]
2175    fn interleaved_system_retry_does_not_hide_an_unrelated_template_error() {
2176        let template = ModelChatTemplate::new(
2177            "{% if messages|length == 3 %}{{ raise_exception('tool schema rejected') }}{% endif %}ok",
2178            "unrelated-error-template",
2179        );
2180        let error = render_prompt_messages(
2181            &[
2182                PromptMessage::new("system", "Initial"),
2183                PromptMessage::new("user", "Question"),
2184                PromptMessage::new("system", "Deferred"),
2185            ],
2186            "strict-model",
2187            Some(&template),
2188        )
2189        .unwrap_err();
2190        assert!(
2191            error.to_string().contains("tool schema rejected"),
2192            "{error}"
2193        );
2194    }
2195
2196    #[test]
2197    fn model_template_empty_render_is_an_error() {
2198        let template = ModelChatTemplate::new("{# renders nothing #}", "empty-template");
2199        let err = render_prompt_messages(
2200            &[PromptMessage::new("user", "hi")],
2201            "qwen3",
2202            Some(&template),
2203        )
2204        .unwrap_err();
2205        assert!(format!("{err}").contains("empty prompt"), "{err}");
2206    }
2207
2208    #[test]
2209    fn tools_unaware_template_injects_tool_spec_through_model_template() {
2210        // e.g. DeepSeek-R1 distill templates have no `tools` support; tool
2211        // definitions must still reach the model in its native prompt format
2212        // instead of being dropped or routed to the generic fallback.
2213        let template = ModelChatTemplate::new(
2214            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
2215            "no-tool-support-template",
2216        );
2217        let out = render_chat_prompt_with_tools_and_model_template(
2218            &[msg(MessageRole::User, "Use weather.")],
2219            "some-model",
2220            Some(&template),
2221            &ChatTemplateOptions::default(),
2222            &[tool("weather")],
2223            Some(&ToolChoice::Mode("auto".to_string())),
2224            &[],
2225            None,
2226        )
2227        .unwrap();
2228        assert!(out.starts_with("[system]"), "{out}");
2229        assert!(out.contains("\"tools\""), "{out}");
2230        assert!(out.contains("weather"), "{out}");
2231        assert!(out.ends_with("[assistant]"), "{out}");
2232        assert!(!out.contains("<|system|>"), "{out}");
2233    }
2234
2235    #[test]
2236    fn template_tools_support_detection_requires_standalone_identifier() {
2237        let aware = ModelChatTemplate::new("{% if tools %}x{% endif %}", "t");
2238        assert!(model_template_supports_tools(&aware));
2239        let history_only = ModelChatTemplate::new(
2240            "{% for m in messages %}{% if m.tool_calls %}y{% endif %}{% endfor %}",
2241            "t",
2242        );
2243        assert!(!model_template_supports_tools(&history_only));
2244    }
2245
2246    #[test]
2247    fn reasoning_capability_observes_templates_without_model_name_inference() {
2248        for source in [
2249            include_str!("../tests/fixtures/chat_template/unsloth__Meta-Llama-3.1-8B-Instruct/template.jinja"),
2250            include_str!("../tests/fixtures/chat_template/Qwen__Qwen3-Coder-30B-A3B-Instruct/template.jinja"),
2251        ] {
2252            let plain = ModelChatTemplate::new(source, "arbitrary-source-label");
2253            assert_eq!(plain.reasoning_capability(), ModelReasoningProtocol::None);
2254        }
2255        for source in [
2256            include_str!("../tests/fixtures/chat_template/Qwen__Qwen3.5-35B-A3B/template.jinja"),
2257            include_str!("../tests/fixtures/chat_template/Qwen__Qwen3.6-35B-A3B/template.jinja"),
2258            include_str!(
2259                "../tests/fixtures/chat_template/cyankiwi__Qwen3.8-27B-AWQ-INT4/template.jinja"
2260            ),
2261        ] {
2262            let thinking = ModelChatTemplate::new(source, "arbitrary-source-label");
2263            assert_eq!(
2264                thinking.reasoning_capability(),
2265                ModelReasoningProtocol::PromptOpened
2266            );
2267        }
2268        let model_generated = ModelChatTemplate::new(
2269            include_str!("../tests/fixtures/chat_template/Qwen__Qwen3-0.6B/template.jinja"),
2270            "arbitrary-source-label",
2271        );
2272        assert_eq!(
2273            model_generated.reasoning_capability(),
2274            ModelReasoningProtocol::ModelGenerated
2275        );
2276        let mut separate_protocol = ModelChatTemplate::new(
2277            "{% if add_generation_prompt %}<assistant>{% endif %}",
2278            "arbitrary-source-label",
2279        );
2280        separate_protocol.set_output_protocol(ModelOutputProtocol::HarmonyGptOss);
2281        assert_eq!(
2282            separate_protocol.reasoning_capability(),
2283            ModelReasoningProtocol::ModelGenerated
2284        );
2285        // Harmony remains on its existing parser; capability reporting must not
2286        // enable the ordinary delimited-block parsing path.
2287        assert_eq!(
2288            separate_protocol.reasoning_protocol,
2289            ModelReasoningProtocol::None
2290        );
2291        assert!(!separate_protocol.reasoning_enabled(Some(true)));
2292        let invalid = ModelChatTemplate::new("{{ raise_exception('cannot probe') }}", "invalid");
2293        assert_eq!(
2294            invalid.reasoning_capability(),
2295            ModelReasoningProtocol::Unknown
2296        );
2297        assert!(!invalid.reasoning_enabled(Some(true)));
2298    }
2299
2300    fn prefill_render(
2301        template: &ModelChatTemplate,
2302        enable_thinking: Option<bool>,
2303    ) -> RenderedPrompt {
2304        render_chat_prompt_with_model_template_options_and_compatibility_with_prefill(
2305            &[
2306                msg(MessageRole::System, "Schema: {\"const\":\"<think>Paris\"}"),
2307                msg(MessageRole::User, "valid user data ending in <think>"),
2308            ],
2309            "unused-model-name",
2310            Some(template),
2311            &ChatTemplateOptions {
2312                enable_thinking,
2313                ..Default::default()
2314            },
2315            true,
2316            None,
2317        )
2318        .unwrap()
2319    }
2320
2321    #[test]
2322    fn generation_prefill_ignores_message_tags_and_respects_actual_thinking_switch() {
2323        let plain = ModelChatTemplate::new(
2324            "{% for message in messages %}{{ message.content }}{% endfor %}",
2325            "plain",
2326        );
2327        assert_eq!(plain.reasoning_protocol, ModelReasoningProtocol::None);
2328        assert!(!prefill_render(&plain, None).reasoning_prefill);
2329        for (source, capability, enabled_prefill) in [
2330            (
2331                include_str!(
2332                    "../tests/fixtures/chat_template/Qwen__Qwen3.5-35B-A3B/template.jinja"
2333                ),
2334                ModelReasoningProtocol::PromptOpened,
2335                true,
2336            ),
2337            (
2338                include_str!("../tests/fixtures/chat_template/Qwen__Qwen3-0.6B/template.jinja"),
2339                ModelReasoningProtocol::ModelGenerated,
2340                false,
2341            ),
2342        ] {
2343            let template = ModelChatTemplate::new(source, "actual-template");
2344            assert_eq!(template.reasoning_protocol, capability);
2345            assert_eq!(
2346                prefill_render(&template, Some(true)).reasoning_prefill,
2347                enabled_prefill
2348            );
2349            assert!(!prefill_render(&template, Some(false)).reasoning_prefill);
2350        }
2351        let ignores_switch = ModelChatTemplate::new(
2352            "{% for message in messages %}{{ message.content }}{% endfor %}<assistant><think>\n",
2353            "fixed-opener",
2354        );
2355        assert!(
2356            prefill_render(&ignores_switch, Some(false)).reasoning_prefill,
2357            "the rendered model prefix still needs its closer when a template ignores the switch"
2358        );
2359    }
2360
2361    #[test]
2362    fn generation_prefill_accepts_unknown_template_only_with_suffix_evidence() {
2363        let template = ModelChatTemplate::new(
2364            "{% if 'valid' not in messages[-1].content %}{{ raise_exception('needs actual request') }}{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}", "conditional");
2365        assert_eq!(template.reasoning_protocol, ModelReasoningProtocol::Unknown);
2366        assert!(prefill_render(&template, None).reasoning_prefill);
2367        for protocol in [
2368            ModelReasoningProtocol::None,
2369            ModelReasoningProtocol::Unknown,
2370            ModelReasoningProtocol::ModelGenerated,
2371            ModelReasoningProtocol::PromptOpened,
2372        ] {
2373            let mut declared = template.clone();
2374            declared.reasoning_protocol = protocol;
2375            for baseline in [
2376                None,
2377                Some("unrelated baseline"),
2378                Some("<assistant><think>\n"),
2379            ] {
2380                assert_eq!(
2381                    reasoning_prefill_from_generation_suffix(
2382                        &declared,
2383                        "<assistant><think>\n",
2384                        baseline
2385                    ),
2386                    protocol == ModelReasoningProtocol::PromptOpened
2387                );
2388                assert!(!reasoning_prefill_from_generation_suffix(
2389                    &declared,
2390                    "user mentions <think> then assistant starts here",
2391                    baseline
2392                ));
2393            }
2394        }
2395        let baseline_error = ModelChatTemplate::new(
2396            "{% if not add_generation_prompt %}{{ raise_exception('generation required') }}{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}<assistant><think>\n", "baseline-error");
2397        assert!(
2398            prefill_render(&baseline_error, None).reasoning_prefill,
2399            "a diagnostic baseline failure must not reject a valid prompt"
2400        );
2401    }
2402
2403    #[test]
2404    fn generation_prefill_uses_successful_coalesced_tool_render_and_preserves_prompt() {
2405        let source = "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be first') }}{% endif %}{{ message.content }}{% endfor %}{% if tools %}{{ tools | tojson }}{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}";
2406        let messages = [
2407            msg(MessageRole::User, "Hi"),
2408            msg(MessageRole::System, "schema <think>"),
2409            msg(MessageRole::User, "Again"),
2410        ];
2411        for source in [
2412            source.to_string(),
2413            source.replace("{% if tools %}{{ tools | tojson }}{% endif %}", ""),
2414        ] {
2415            let template = ModelChatTemplate::new(source, "coalescing");
2416            let options = ChatTemplateOptions::default();
2417            let tools = [tool("weather")];
2418            let rendered =
2419                render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill(
2420                    &messages,
2421                    "unused",
2422                    Some(&template),
2423                    &options,
2424                    &tools,
2425                    None,
2426                    &[],
2427                    None,
2428                    true,
2429                    None,
2430                )
2431                .unwrap();
2432            let legacy = render_chat_prompt_with_tools_and_model_template_compatibility(
2433                &messages,
2434                "unused",
2435                Some(&template),
2436                &options,
2437                &tools,
2438                None,
2439                &[],
2440                None,
2441                true,
2442                None,
2443            )
2444            .unwrap();
2445            assert_eq!(rendered.text, legacy);
2446            assert!(rendered.reasoning_prefill);
2447            assert!(
2448                render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill(
2449                    &messages,
2450                    "unused",
2451                    Some(&template),
2452                    &options,
2453                    &tools,
2454                    None,
2455                    &[],
2456                    None,
2457                    false,
2458                    None
2459                )
2460                .is_err()
2461            );
2462        }
2463    }
2464
2465    #[test]
2466    fn generation_prefill_uses_declared_native_reasoning_markers() {
2467        let mut template = ModelChatTemplate::new(
2468            "{% for message in messages %}{{ message.content }}{% endfor %}{% if add_generation_prompt %}<|turn>model\n<|channel>thought\n{% if enable_thinking is defined and enable_thinking is false %}<channel|>{% endif %}{% endif %}", "gemma");
2469        template.set_output_protocol(ModelOutputProtocol::GemmaThought);
2470        assert!(prefill_render(&template, Some(true)).reasoning_prefill);
2471        assert!(!prefill_render(&template, Some(false)).reasoning_prefill);
2472        template.set_output_protocol(ModelOutputProtocol::HarmonyGptOss);
2473        assert!(!prefill_render(&template, Some(true)).reasoning_prefill);
2474    }
2475}