1#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ChatTurn {
8 pub role: String,
9 pub content: String,
10}
11
12impl ChatTurn {
13 pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
14 Self {
15 role: role.into(),
16 content: content.into(),
17 }
18 }
19}
20
21pub fn apply_chat_template(family_path: &str, messages: &[ChatTurn]) -> String {
23 let path = family_path.to_ascii_lowercase();
24 if path.contains("gemma-4") {
25 gemma4_it(messages)
26 } else if path.contains("gemma") {
27 gemma_it(messages)
28 } else if path.contains("llama") {
29 llama3(messages)
30 } else if path.contains("qwen3") {
31 qwen_chatml(messages, true)
33 } else {
34 qwen_chatml(messages, false)
36 }
37}
38
39fn map_role(role: &str) -> &str {
40 match role {
41 "assistant" | "model" => "assistant",
42 "system" => "system",
43 _ => "user",
44 }
45}
46
47fn qwen_chatml(messages: &[ChatTurn], empty_think: bool) -> String {
49 let mut out = String::new();
50 for m in messages {
51 let role = map_role(&m.role);
52 out.push_str("<|im_start|>");
53 out.push_str(role);
54 out.push('\n');
55 out.push_str(&m.content);
56 out.push_str("<|im_end|>\n");
57 }
58 if empty_think {
59 out.push_str("<|im_start|>assistant\n<think>\n\n</think>\n\n");
60 } else {
61 out.push_str("<|im_start|>assistant\n");
62 }
63 out
64}
65
66pub fn strip_assistant_visible(raw: &str) -> String {
71 let mut s = raw
72 .replace("<|im_end|>", "")
73 .replace("<|im_start|>", "")
74 .replace("<turn|>", "")
75 .replace("<|turn>", "");
76 if let Some(idx) = s.rfind("</think>") {
77 let after = s[idx + "</think>".len()..].trim().to_string();
78 if !after.is_empty() {
79 return after;
80 }
81 let before = &s[..idx];
82 if let Some(start) = before.rfind("<think>") {
83 let body = before[start + "<think>".len()..].trim().to_string();
84 if !body.is_empty() {
85 return body;
86 }
87 }
88 return String::new();
89 } else if let Some(idx) = s.find("<think>") {
90 s = s[idx + "<think>".len()..].to_string();
91 }
92 s.trim().to_string()
93}
94
95fn gemma_it(messages: &[ChatTurn]) -> String {
96 let mut out = String::from("<bos>");
97 for m in messages {
98 let role = match map_role(&m.role) {
99 "assistant" => "model",
100 "system" => "user",
101 other => other,
102 };
103 out.push_str("<start_of_turn>");
104 out.push_str(role);
105 out.push('\n');
106 if map_role(&m.role) == "system" {
107 out.push_str("System: ");
108 }
109 out.push_str(&m.content);
110 out.push_str("<end_of_turn>\n");
111 }
112 out.push_str("<start_of_turn>model\n");
113 out
114}
115
116fn gemma4_it(messages: &[ChatTurn]) -> String {
120 let mut out = String::from("<bos>");
121 for m in messages {
122 let role = match map_role(&m.role) {
123 "assistant" => "model",
124 other => other,
125 };
126 out.push_str("<|turn>");
127 out.push_str(role);
128 out.push('\n');
129 out.push_str(&m.content);
130 out.push_str("<turn|>\n");
131 }
132 out.push_str("<|turn>model\n");
133 out
134}
135
136fn llama3(messages: &[ChatTurn]) -> String {
137 let mut out = String::from("<|begin_of_text|>");
138 for m in messages {
139 let role = map_role(&m.role);
140 out.push_str("<|start_header_id|>");
141 out.push_str(role);
142 out.push_str("<|end_header_id|>\n\n");
143 out.push_str(&m.content);
144 out.push_str("<|eot_id|>");
145 }
146 out.push_str("<|start_header_id|>assistant<|end_header_id|>\n\n");
147 out
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn qwen3_wraps_user_and_closes_think() {
156 let s = apply_chat_template("qwen/qwen3-0.6b", &[ChatTurn::new("user", "Hello")]);
157 assert!(s.contains("<|im_start|>user\nHello<|im_end|>"), "{s}");
158 assert!(
159 s.contains("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
160 "{s}"
161 );
162 assert!(!s.ends_with("Hello"), "{s}");
163 }
164
165 #[test]
166 fn gemma4_uses_pipe_turn_markers() {
167 let s = apply_chat_template("gemma/gemma-4-e2b-it", &[ChatTurn::new("user", "Hello")]);
168 assert_eq!(s, "<bos><|turn>user\nHello<turn|>\n<|turn>model\n");
169 }
170
171 #[test]
172 fn gemma3_keeps_start_of_turn() {
173 let s = apply_chat_template("gemma/gemma-3-1b-it", &[ChatTurn::new("user", "Hi")]);
174 assert!(s.contains("<start_of_turn>user\nHi<end_of_turn>"), "{s}");
175 assert!(s.ends_with("<start_of_turn>model\n"), "{s}");
176 }
177
178 #[test]
179 fn system_and_user_order_preserved() {
180 let s = apply_chat_template(
181 "qwen/qwen3-0.6b",
182 &[
183 ChatTurn::new("system", "Be brief."),
184 ChatTurn::new("user", "Hi"),
185 ],
186 );
187 let sys = s.find("<|im_start|>system").unwrap();
188 let usr = s.find("<|im_start|>user").unwrap();
189 assert!(sys < usr);
190 }
191
192 #[test]
193 fn strip_think_keeps_answer() {
194 let raw = "<think>\nreason\n</think>\n\nHello there<|im_end|>";
195 assert_eq!(strip_assistant_visible(raw), "Hello there");
196 }
197
198 #[test]
199 fn strip_closed_think_without_answer_keeps_body() {
200 let raw = "<think>\nI should greet the user.\n</think>\n\n";
201 assert_eq!(strip_assistant_visible(raw), "I should greet the user.");
202 }
203}