Skip to main content

orion_core/
template.rs

1use crate::messages::{Message, Role};
2use crate::tools::ToolSchema;
3
4/// Chat prompt template for formatting conversations.
5///
6/// Implementations convert system prompts, messages, and tool schemas
7/// into the prompt format expected by a model family (ChatML, Llama 3, etc.).
8/// Also used by the context pipeline for accurate token budget accounting.
9pub trait ChatTemplate: Send + Sync {
10    /// Template identifier (e.g., "chatml", "llama3", "mistral").
11    fn name(&self) -> &str;
12
13    /// Format a complete prompt from system prompt, messages, and tools.
14    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String;
15
16    /// Format the system block (system prompt + tool definitions).
17    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String;
18
19    /// Wrap a single conversation message in template markers.
20    /// Returns empty string for system messages (handled by `format_system`).
21    fn format_message(&self, message: &Message) -> String;
22
23    /// The string appended after all messages to open the assistant's turn.
24    fn assistant_prefix(&self) -> &str;
25}
26
27/// Render the shared tool-instruction block.
28///
29/// Every template advertises tools the same way (a description list plus a
30/// `tool_call` JSON convention) so the agent's tool-call parser stays
31/// format-agnostic. Returns an empty string when there are no tools.
32fn render_tools(tools: &[ToolSchema]) -> String {
33    if tools.is_empty() {
34        return String::new();
35    }
36    let mut s = String::from("\n\nYou have access to the following tools:\n\n");
37    for tool in tools {
38        s.push_str(&format!(
39            "### {}\n{}\nParameters: {}\n\n",
40            tool.name,
41            tool.description,
42            serde_json::to_string_pretty(&tool.parameters).unwrap_or_default()
43        ));
44    }
45    s.push_str(
46        "To use a tool, respond with a JSON block:\n\
47         ```tool_call\n\
48         {\"name\": \"tool_name\", \"arguments\": {...}}\n\
49         ```\n",
50    );
51    s
52}
53
54/// Render a tool result as plain text for templates that lack a dedicated
55/// tool role (Mistral, Alpaca, Vicuna). The agent surfaces these inside a
56/// user turn so the model can read the observation.
57fn render_tool_result(message: &Message) -> String {
58    format!("[Tool result]\n{}", message.content)
59}
60
61// --- ChatML ----------------------------------------------------------------
62
63/// ChatML template format (default).
64///
65/// ```text
66/// <|im_start|>system
67/// {system_prompt}<|im_end|>
68/// <|im_start|>user
69/// {message}<|im_end|>
70/// <|im_start|>assistant
71/// ```
72pub struct ChatMLTemplate;
73
74impl ChatTemplate for ChatMLTemplate {
75    fn name(&self) -> &str {
76        "chatml"
77    }
78
79    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
80        let mut prompt = self.format_system(system_prompt, tools);
81
82        for msg in messages.iter().filter(|m| m.role != Role::System) {
83            prompt.push_str(&self.format_message(msg));
84        }
85
86        prompt.push_str(self.assistant_prefix());
87        prompt
88    }
89
90    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
91        let mut s = String::from("<|im_start|>system\n");
92        s.push_str(system_prompt);
93        s.push_str(&render_tools(tools));
94        s.push_str("<|im_end|>\n");
95        s
96    }
97
98    fn format_message(&self, message: &Message) -> String {
99        let role_str = match message.role {
100            Role::User => "user",
101            Role::Assistant => "assistant",
102            Role::ToolResult => "tool",
103            Role::ToolCall => "assistant",
104            Role::System => return String::new(),
105        };
106        format!("<|im_start|>{role_str}\n{}<|im_end|>\n", message.content)
107    }
108
109    fn assistant_prefix(&self) -> &str {
110        "<|im_start|>assistant\n"
111    }
112}
113
114// --- Llama 3 ---------------------------------------------------------------
115
116/// Llama 3 / 3.1 template format.
117///
118/// ```text
119/// <|begin_of_text|><|start_header_id|>system<|end_header_id|>
120///
121/// {system_prompt}<|eot_id|><|start_header_id|>user<|end_header_id|>
122///
123/// {message}<|eot_id|><|start_header_id|>assistant<|end_header_id|>
124///
125/// ```
126pub struct Llama3Template;
127
128impl Llama3Template {
129    fn header(&self, role: &str, content: &str) -> String {
130        format!("<|start_header_id|>{role}<|end_header_id|>\n\n{content}<|eot_id|>")
131    }
132}
133
134impl ChatTemplate for Llama3Template {
135    fn name(&self) -> &str {
136        "llama3"
137    }
138
139    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
140        let mut prompt = self.format_system(system_prompt, tools);
141
142        for msg in messages.iter().filter(|m| m.role != Role::System) {
143            prompt.push_str(&self.format_message(msg));
144        }
145
146        prompt.push_str(self.assistant_prefix());
147        prompt
148    }
149
150    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
151        let mut content = String::from(system_prompt);
152        content.push_str(&render_tools(tools));
153        format!("<|begin_of_text|>{}", self.header("system", &content))
154    }
155
156    fn format_message(&self, message: &Message) -> String {
157        match message.role {
158            Role::User => self.header("user", &message.content),
159            Role::Assistant | Role::ToolCall => self.header("assistant", &message.content),
160            // Llama 3.1 uses the `ipython` role for tool outputs.
161            Role::ToolResult => self.header("ipython", &message.content),
162            Role::System => String::new(),
163        }
164    }
165
166    fn assistant_prefix(&self) -> &str {
167        "<|start_header_id|>assistant<|end_header_id|>\n\n"
168    }
169}
170
171// --- Mistral / Mixtral -----------------------------------------------------
172
173/// Mistral / Mixtral instruct template.
174///
175/// ```text
176/// <s>[INST] {system}
177///
178/// {user} [/INST] {assistant}</s>[INST] {user_2} [/INST]
179/// ```
180///
181/// Mistral has no dedicated system role — the system prompt is merged into
182/// the first user instruction. Because that merge needs cross-message state,
183/// `format()` is implemented directly; `format_system` / `format_message`
184/// return token-representative fragments for context-budget accounting.
185pub struct MistralTemplate;
186
187impl MistralTemplate {
188    fn system_text(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
189        let mut s = String::from(system_prompt);
190        s.push_str(&render_tools(tools));
191        s
192    }
193}
194
195impl ChatTemplate for MistralTemplate {
196    fn name(&self) -> &str {
197        "mistral"
198    }
199
200    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
201        let system = self.system_text(system_prompt, tools);
202        let mut out = String::from("<s>");
203        let mut system_pending = !system.is_empty();
204
205        for msg in messages.iter().filter(|m| m.role != Role::System) {
206            match msg.role {
207                Role::User | Role::ToolResult => {
208                    let body = if msg.role == Role::ToolResult {
209                        render_tool_result(msg)
210                    } else {
211                        msg.content.clone()
212                    };
213                    let body = if system_pending {
214                        system_pending = false;
215                        format!("{system}\n\n{body}")
216                    } else {
217                        body
218                    };
219                    out.push_str(&format!("[INST] {body} [/INST]"));
220                }
221                Role::Assistant | Role::ToolCall => {
222                    out.push_str(&format!(" {}</s>", msg.content));
223                }
224                Role::System => {}
225            }
226        }
227
228        out
229    }
230
231    // NOTE: `format_system` / `format_message` below are token-representative,
232    // not byte-exact. The context pipeline sums them to estimate cost before
233    // pruning; the prompt actually sent to the model always comes from
234    // `format()` above. Because Mistral fuses the system prompt into the first
235    // user `[INST]` (a whole-conversation operation these per-fragment hooks
236    // can't see), concatenating the fragments emits one extra `[INST]` when a
237    // system prompt is present — so the budget over-counts by ~2-3 tokens.
238    // That's deliberately conservative (reserves a hair more headroom, never
239    // under-counts). ChatML/Llama3/Alpaca/Vicuna have no such merge, so their
240    // fragments are exact.
241    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
242        let system = self.system_text(system_prompt, tools);
243        if system.is_empty() {
244            String::from("<s>")
245        } else {
246            format!("<s>[INST] {system}\n\n")
247        }
248    }
249
250    fn format_message(&self, message: &Message) -> String {
251        match message.role {
252            Role::User => format!("[INST] {} [/INST]", message.content),
253            Role::Assistant | Role::ToolCall => format!(" {}</s>", message.content),
254            Role::ToolResult => format!("[INST] {} [/INST]", render_tool_result(message)),
255            Role::System => String::new(),
256        }
257    }
258
259    fn assistant_prefix(&self) -> &str {
260        " "
261    }
262}
263
264// --- Alpaca ----------------------------------------------------------------
265
266/// Alpaca instruction template.
267///
268/// ```text
269/// {system}
270///
271/// ### Instruction:
272/// {user}
273///
274/// ### Response:
275/// {assistant}
276/// ```
277pub struct AlpacaTemplate;
278
279const ALPACA_PREAMBLE: &str =
280    "Below is an instruction that describes a task. Write a response that appropriately completes the request.";
281
282impl ChatTemplate for AlpacaTemplate {
283    fn name(&self) -> &str {
284        "alpaca"
285    }
286
287    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
288        let mut prompt = self.format_system(system_prompt, tools);
289
290        for msg in messages.iter().filter(|m| m.role != Role::System) {
291            prompt.push_str(&self.format_message(msg));
292        }
293
294        prompt.push_str(self.assistant_prefix());
295        prompt
296    }
297
298    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
299        let preamble = if system_prompt.is_empty() {
300            ALPACA_PREAMBLE
301        } else {
302            system_prompt
303        };
304        format!("{preamble}{}\n\n", render_tools(tools))
305    }
306
307    fn format_message(&self, message: &Message) -> String {
308        match message.role {
309            Role::User => format!("### Instruction:\n{}\n\n", message.content),
310            Role::Assistant | Role::ToolCall => format!("### Response:\n{}\n\n", message.content),
311            Role::ToolResult => {
312                format!("### Instruction:\n{}\n\n", render_tool_result(message))
313            }
314            Role::System => String::new(),
315        }
316    }
317
318    fn assistant_prefix(&self) -> &str {
319        "### Response:\n"
320    }
321}
322
323// --- Vicuna ----------------------------------------------------------------
324
325/// Vicuna v1.1 template.
326///
327/// ```text
328/// {system} USER: {user} ASSISTANT: {assistant}</s>USER: {user_2} ASSISTANT:
329/// ```
330pub struct VicunaTemplate;
331
332const VICUNA_PREAMBLE: &str = "A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed, and polite answers to the user's questions.";
333
334impl ChatTemplate for VicunaTemplate {
335    fn name(&self) -> &str {
336        "vicuna"
337    }
338
339    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
340        let mut prompt = self.format_system(system_prompt, tools);
341
342        for msg in messages.iter().filter(|m| m.role != Role::System) {
343            prompt.push_str(&self.format_message(msg));
344        }
345
346        prompt.push_str(self.assistant_prefix());
347        prompt
348    }
349
350    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
351        let preamble = if system_prompt.is_empty() {
352            VICUNA_PREAMBLE
353        } else {
354            system_prompt
355        };
356        format!("{preamble}{} ", render_tools(tools))
357    }
358
359    fn format_message(&self, message: &Message) -> String {
360        match message.role {
361            Role::User => format!("USER: {} ", message.content),
362            Role::Assistant | Role::ToolCall => format!("ASSISTANT: {}</s>", message.content),
363            Role::ToolResult => format!("USER: {} ", render_tool_result(message)),
364            Role::System => String::new(),
365        }
366    }
367
368    fn assistant_prefix(&self) -> &str {
369        "ASSISTANT: "
370    }
371}
372
373// --- Gemma -----------------------------------------------------------------
374
375/// Gemma / Gemma 2 instruction template.
376///
377/// ```text
378/// <bos><start_of_turn>user
379/// {system}
380///
381/// {user}<end_of_turn>
382/// <start_of_turn>model
383/// {assistant}<end_of_turn>
384/// <start_of_turn>model
385/// ```
386///
387/// Gemma has no dedicated system role, so (like Mistral) the system prompt is
388/// merged into the first user turn; `format()` is implemented directly while
389/// `format_system` / `format_message` return token-representative fragments.
390pub struct GemmaTemplate;
391
392impl GemmaTemplate {
393    fn system_text(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
394        let mut s = String::from(system_prompt);
395        s.push_str(&render_tools(tools));
396        s
397    }
398}
399
400impl ChatTemplate for GemmaTemplate {
401    fn name(&self) -> &str {
402        "gemma"
403    }
404
405    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
406        let system = self.system_text(system_prompt, tools);
407        let mut out = String::from("<bos>");
408        let mut system_pending = !system.is_empty();
409
410        for msg in messages.iter().filter(|m| m.role != Role::System) {
411            match msg.role {
412                Role::User | Role::ToolResult => {
413                    let body = if msg.role == Role::ToolResult {
414                        render_tool_result(msg)
415                    } else {
416                        msg.content.clone()
417                    };
418                    let body = if system_pending {
419                        system_pending = false;
420                        format!("{system}\n\n{body}")
421                    } else {
422                        body
423                    };
424                    out.push_str(&format!("<start_of_turn>user\n{body}<end_of_turn>\n"));
425                }
426                Role::Assistant | Role::ToolCall => {
427                    out.push_str(&format!(
428                        "<start_of_turn>model\n{}<end_of_turn>\n",
429                        msg.content
430                    ));
431                }
432                Role::System => {}
433            }
434        }
435
436        out.push_str(self.assistant_prefix());
437        out
438    }
439
440    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
441        let system = self.system_text(system_prompt, tools);
442        if system.is_empty() {
443            String::from("<bos>")
444        } else {
445            format!("<bos><start_of_turn>user\n{system}\n\n")
446        }
447    }
448
449    fn format_message(&self, message: &Message) -> String {
450        match message.role {
451            Role::User => format!("<start_of_turn>user\n{}<end_of_turn>\n", message.content),
452            Role::Assistant | Role::ToolCall => {
453                format!("<start_of_turn>model\n{}<end_of_turn>\n", message.content)
454            }
455            Role::ToolResult => format!(
456                "<start_of_turn>user\n{}<end_of_turn>\n",
457                render_tool_result(message)
458            ),
459            Role::System => String::new(),
460        }
461    }
462
463    fn assistant_prefix(&self) -> &str {
464        "<start_of_turn>model\n"
465    }
466}
467
468// --- Phi-3 -----------------------------------------------------------------
469
470/// Phi-3 template.
471///
472/// ```text
473/// <|system|>
474/// {system}<|end|>
475/// <|user|>
476/// {user}<|end|>
477/// <|assistant|>
478/// {assistant}<|end|>
479/// <|assistant|>
480/// ```
481///
482/// The system block is omitted entirely when there is no system prompt or tool
483/// schema to advertise.
484pub struct Phi3Template;
485
486impl ChatTemplate for Phi3Template {
487    fn name(&self) -> &str {
488        "phi3"
489    }
490
491    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
492        let mut prompt = self.format_system(system_prompt, tools);
493
494        for msg in messages.iter().filter(|m| m.role != Role::System) {
495            prompt.push_str(&self.format_message(msg));
496        }
497
498        prompt.push_str(self.assistant_prefix());
499        prompt
500    }
501
502    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
503        let tools_block = render_tools(tools);
504        if system_prompt.is_empty() && tools_block.is_empty() {
505            return String::new();
506        }
507        format!("<|system|>\n{system_prompt}{tools_block}<|end|>\n")
508    }
509
510    fn format_message(&self, message: &Message) -> String {
511        match message.role {
512            Role::User => format!("<|user|>\n{}<|end|>\n", message.content),
513            Role::Assistant | Role::ToolCall => {
514                format!("<|assistant|>\n{}<|end|>\n", message.content)
515            }
516            Role::ToolResult => format!("<|user|>\n{}<|end|>\n", render_tool_result(message)),
517            Role::System => String::new(),
518        }
519    }
520
521    fn assistant_prefix(&self) -> &str {
522        "<|assistant|>\n"
523    }
524}
525
526// --- DeepSeek --------------------------------------------------------------
527
528/// DeepSeek BOS token (the `▁` is U+2581, the SentencePiece space marker).
529const DEEPSEEK_BOS: &str = "<|begin▁of▁sentence|>";
530/// DeepSeek EOS token.
531const DEEPSEEK_EOS: &str = "<|end▁of▁sentence|>";
532
533/// DeepSeek-LLM chat template.
534///
535/// ```text
536/// <|begin▁of▁sentence|>{system}
537///
538/// User: {user}
539///
540/// Assistant: {assistant}<|end▁of▁sentence|>
541/// ```
542///
543/// The system prompt is emitted bare (no role marker) after the BOS token, then
544/// turns alternate `User:` / `Assistant:`. (DeepSeek-Coder uses an Alpaca-style
545/// `### Instruction:` format instead and resolves to [`AlpacaTemplate`].)
546pub struct DeepSeekTemplate;
547
548impl ChatTemplate for DeepSeekTemplate {
549    fn name(&self) -> &str {
550        "deepseek"
551    }
552
553    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
554        let mut prompt = self.format_system(system_prompt, tools);
555
556        for msg in messages.iter().filter(|m| m.role != Role::System) {
557            prompt.push_str(&self.format_message(msg));
558        }
559
560        prompt.push_str(self.assistant_prefix());
561        prompt
562    }
563
564    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
565        let mut s = String::from(system_prompt);
566        s.push_str(&render_tools(tools));
567        if s.is_empty() {
568            String::from(DEEPSEEK_BOS)
569        } else {
570            format!("{DEEPSEEK_BOS}{s}\n\n")
571        }
572    }
573
574    fn format_message(&self, message: &Message) -> String {
575        match message.role {
576            Role::User => format!("User: {}\n\n", message.content),
577            Role::Assistant | Role::ToolCall => {
578                format!("Assistant: {}{DEEPSEEK_EOS}", message.content)
579            }
580            Role::ToolResult => format!("User: {}\n\n", render_tool_result(message)),
581            Role::System => String::new(),
582        }
583    }
584
585    fn assistant_prefix(&self) -> &str {
586        "Assistant:"
587    }
588}
589
590// --- Command-R -------------------------------------------------------------
591
592/// Cohere Command-R / Command-R+ template.
593///
594/// ```text
595/// <BOS_TOKEN><|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|>{system}<|END_OF_TURN_TOKEN|>\
596/// <|START_OF_TURN_TOKEN|><|USER_TOKEN|>{user}<|END_OF_TURN_TOKEN|>\
597/// <|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>{assistant}<|END_OF_TURN_TOKEN|>\
598/// <|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>
599/// ```
600pub struct CommandRTemplate;
601
602impl ChatTemplate for CommandRTemplate {
603    fn name(&self) -> &str {
604        "command-r"
605    }
606
607    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
608        let mut prompt = self.format_system(system_prompt, tools);
609
610        for msg in messages.iter().filter(|m| m.role != Role::System) {
611            prompt.push_str(&self.format_message(msg));
612        }
613
614        prompt.push_str(self.assistant_prefix());
615        prompt
616    }
617
618    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
619        let mut s = String::from(system_prompt);
620        s.push_str(&render_tools(tools));
621        if s.is_empty() {
622            String::from("<BOS_TOKEN>")
623        } else {
624            format!("<BOS_TOKEN><|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|>{s}<|END_OF_TURN_TOKEN|>")
625        }
626    }
627
628    fn format_message(&self, message: &Message) -> String {
629        match message.role {
630            Role::User => format!(
631                "<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{}<|END_OF_TURN_TOKEN|>",
632                message.content
633            ),
634            Role::Assistant | Role::ToolCall => format!(
635                "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>{}<|END_OF_TURN_TOKEN|>",
636                message.content
637            ),
638            Role::ToolResult => format!(
639                "<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{}<|END_OF_TURN_TOKEN|>",
640                render_tool_result(message)
641            ),
642            Role::System => String::new(),
643        }
644    }
645
646    fn assistant_prefix(&self) -> &str {
647        "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>"
648    }
649}
650
651// --- Llama 2 ---------------------------------------------------------------
652
653/// Llama 2 chat template.
654///
655/// ```text
656/// <s>[INST] <<SYS>>
657/// {system}
658/// <</SYS>>
659///
660/// {user} [/INST] {assistant} </s><s>[INST] {user_2} [/INST]
661/// ```
662///
663/// Like Mistral, Llama 2 has no system role — the system prompt lives in a
664/// `<<SYS>>` block inside the first instruction — so `format()` is implemented
665/// directly while the per-message hooks are token-representative.
666pub struct Llama2Template;
667
668impl Llama2Template {
669    fn system_text(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
670        let mut s = String::from(system_prompt);
671        s.push_str(&render_tools(tools));
672        s
673    }
674}
675
676impl ChatTemplate for Llama2Template {
677    fn name(&self) -> &str {
678        "llama2"
679    }
680
681    fn format(&self, system_prompt: &str, messages: &[Message], tools: &[ToolSchema]) -> String {
682        let system = self.system_text(system_prompt, tools);
683        let mut out = String::new();
684        let mut system_pending = !system.is_empty();
685
686        for msg in messages.iter().filter(|m| m.role != Role::System) {
687            match msg.role {
688                Role::User | Role::ToolResult => {
689                    let body = if msg.role == Role::ToolResult {
690                        render_tool_result(msg)
691                    } else {
692                        msg.content.clone()
693                    };
694                    let inst = if system_pending {
695                        system_pending = false;
696                        format!("<<SYS>>\n{system}\n<</SYS>>\n\n{body}")
697                    } else {
698                        body
699                    };
700                    out.push_str(&format!("<s>[INST] {inst} [/INST]"));
701                }
702                Role::Assistant | Role::ToolCall => {
703                    out.push_str(&format!(" {} </s>", msg.content));
704                }
705                Role::System => {}
706            }
707        }
708
709        out
710    }
711
712    fn format_system(&self, system_prompt: &str, tools: &[ToolSchema]) -> String {
713        let system = self.system_text(system_prompt, tools);
714        if system.is_empty() {
715            String::from("<s>")
716        } else {
717            format!("<s>[INST] <<SYS>>\n{system}\n<</SYS>>\n\n")
718        }
719    }
720
721    fn format_message(&self, message: &Message) -> String {
722        match message.role {
723            Role::User => format!("<s>[INST] {} [/INST]", message.content),
724            Role::Assistant | Role::ToolCall => format!(" {} </s>", message.content),
725            Role::ToolResult => format!("<s>[INST] {} [/INST]", render_tool_result(message)),
726            Role::System => String::new(),
727        }
728    }
729
730    fn assistant_prefix(&self) -> &str {
731        " "
732    }
733}
734
735// --- Selection -------------------------------------------------------------
736
737/// Resolve a template by name (used by the manual override dropdown).
738///
739/// Returns `None` for names without an implementation so the caller can fall
740/// back to GGUF auto-detection or ChatML.
741pub fn template_from_name(name: &str) -> Option<Box<dyn ChatTemplate>> {
742    match name.trim().to_ascii_lowercase().as_str() {
743        "chatml" => Some(Box::new(ChatMLTemplate)),
744        "llama3" | "llama-3" | "llama3.1" | "llama-3.1" => Some(Box::new(Llama3Template)),
745        "llama2" | "llama-2" | "llama 2" => Some(Box::new(Llama2Template)),
746        "mistral" | "mixtral" => Some(Box::new(MistralTemplate)),
747        "alpaca" => Some(Box::new(AlpacaTemplate)),
748        "vicuna" => Some(Box::new(VicunaTemplate)),
749        "gemma" | "gemma2" => Some(Box::new(GemmaTemplate)),
750        "phi3" | "phi-3" | "phi" => Some(Box::new(Phi3Template)),
751        "deepseek" | "deepseek-llm" => Some(Box::new(DeepSeekTemplate)),
752        "command-r" | "commandr" | "command_r" | "cohere" => Some(Box::new(CommandRTemplate)),
753        // Returning `None` lets the caller fall back to GGUF auto-detection
754        // rather than forcing the wrong format. Add a case here to promote a
755        // family from "auto" to an explicit override.
756        _ => None,
757    }
758}
759
760/// Select a chat template based on GGUF metadata template string.
761///
762/// Inspects the Jinja template string and returns the matching implementation.
763/// Falls back to ChatML when no match is found or no template is provided.
764pub fn detect_template(gguf_template: Option<&str>) -> Box<dyn ChatTemplate> {
765    if let Some(tmpl) = gguf_template {
766        // Order matters: check the most specific markers first. Llama 3's header
767        // tokens are checked before ChatML (some Llama 3 GGUFs embed both);
768        // Llama 2's `<<SYS>>` is checked before Mistral's shared `[INST]`.
769        if tmpl.contains("<|start_header_id|>") || tmpl.contains("<|begin_of_text|>") {
770            return Box::new(Llama3Template);
771        }
772        if tmpl.contains("<|START_OF_TURN_TOKEN|>") || tmpl.contains("<|CHATBOT_TOKEN|>") {
773            return Box::new(CommandRTemplate);
774        }
775        if tmpl.contains("<|im_start|>") {
776            return Box::new(ChatMLTemplate);
777        }
778        if tmpl.contains("<|assistant|>")
779            && (tmpl.contains("<|user|>") || tmpl.contains("<|system|>"))
780        {
781            return Box::new(Phi3Template);
782        }
783        if tmpl.contains("<start_of_turn>") {
784            return Box::new(GemmaTemplate);
785        }
786        // Llama 2's `<<SYS>>` block must be matched before Mistral's `[INST]`.
787        if tmpl.contains("<<SYS>>") {
788            return Box::new(Llama2Template);
789        }
790        if tmpl.contains("[INST]") {
791            return Box::new(MistralTemplate);
792        }
793        if tmpl.contains("### Instruction:") {
794            return Box::new(AlpacaTemplate);
795        }
796        // DeepSeek-LLM chat uses title-case `User:` / `Assistant:` (Vicuna is
797        // upper-case), optionally with its `▁`-marked sentence tokens.
798        if (tmpl.contains("User:") && tmpl.contains("Assistant:")) || tmpl.contains("▁of▁sentence")
799        {
800            return Box::new(DeepSeekTemplate);
801        }
802        if tmpl.contains("ASSISTANT:") && tmpl.contains("USER:") {
803            return Box::new(VicunaTemplate);
804        }
805        log::debug!(
806            "Unknown chat template format (len={}), falling back to ChatML",
807            tmpl.len()
808        );
809    }
810    Box::new(ChatMLTemplate)
811}