Skip to main content

aria_inference/
chat.rs

1//! Family chat templates (OpenAI `messages` → prompt string).
2//!
3//! Qwen3 instruct models require ChatML + a closed `<think>` block when thinking
4//! is off; raw user text (e.g. `"Hello"`) yields garbage completions.
5
6#[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
21/// Render messages with a generation prompt for the next assistant turn.
22pub 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        // Instruct Qwen3 defaults to thinking; close the block so chat answers directly.
32        qwen_chatml(messages, /*empty_think=*/ true)
33    } else {
34        // Qwen2, LFM, Nanbeige, Bonsai, Inkling: ChatML without think tags.
35        qwen_chatml(messages, /*empty_think=*/ 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
47/// Qwen ChatML. `empty_think` injects a closed `<think>` (Qwen3 non-thinking).
48fn 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
66/// Drop Qwen3 thinking and ChatML specials from decoded assistant text.
67///
68/// If the model closes `</think>` but `max_tokens` runs out before the answer,
69/// keep the think body instead of returning an empty chat `content`.
70pub 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
116/// Gemma-4-it: `<|turn>` / `<turn|>` (not Gemma-2/3 `<start_of_turn>`).
117/// HuggingFace `apply_chat_template` for google/gemma-4-E2B-it Hello is 10 ids;
118/// encoding the Gemma-3 markers fragments into 28 pieces and serve garbage.
119fn 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}