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