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
14pub use ferrum_types::{ModelReasoningProtocol, ReasoningEffort, ReasoningEffortSupport};
16
17#[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 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 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 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 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
140fn 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 pub now_override: Option<chrono::NaiveDateTime>,
229}
230
231#[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 Self::default()
253 }
254}
255
256#[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#[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 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
382pub 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 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 #[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 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 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 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
734 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 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 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
893fn python_style_tojson(
899 value: minijinja::value::Value,
900 kwargs: minijinja::value::Kwargs,
901) -> Result<String, minijinja::Error> {
902 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
987pub 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 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 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 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
1345pub(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 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 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 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 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 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 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}