Skip to main content

memra_tokenizer/
chat.rs

1//! Minimal chat-template renderer for the Qwen3.5 / ChatML format.
2//!
3//! The model's GGUF `tokenizer.chat_template` is a large jinja template covering
4//! tools, vision, and multi-step reasoning. We do NOT ship a jinja engine; instead
5//! we reproduce the text-only system/user/assistant path of that template exactly,
6//! which is the path memra's text-in/text-out CLI uses. The reproduced behavior
7//! (verified against the dumped template):
8//!
9//!   - a leading `system` turn renders `<|im_start|>system\n{content}<|im_end|>\n`
10//!   - `user`      -> `<|im_start|>user\n{content}<|im_end|>\n`
11//!   - `assistant` -> `<|im_start|>assistant\n{content}<|im_end|>\n`
12//!   - with `add_generation_prompt`, Qwen3.5 appends `<|im_start|>assistant\n<think>\n`
13//!     (its default, since `enable_thinking` is undefined => the else-branch fires).
14//!
15//! `content` is trimmed (the template applies `|trim`). If the GGUF has no template
16//! we fall back to plain ChatML (no `<think>` tail).
17
18/// One tool call attached to a prior assistant turn, pre-rendered for the template:
19/// `params` values are already strings per the template law (string arguments raw,
20/// everything else JSON-rendered by the caller — this crate stays serde-free).
21#[derive(Debug, Clone, PartialEq)]
22pub struct ToolCall {
23    pub name: String,
24    pub params: Vec<(String, String)>,
25}
26
27/// One chat turn for the tools-capable renderer (`apply_chat_template_tools`).
28#[derive(Debug, Clone, PartialEq)]
29pub struct Turn {
30    pub role: String,
31    pub content: String,
32    pub tool_calls: Vec<ToolCall>,
33}
34
35/// Generation-prompt think tail. `Default` = the template's own default (the qwen3.5/3.6
36/// class opens `<think>\n` — verified against the committed dumps in
37/// research/onboard-ornith-20260801/templates/); `NoThink` = the template's
38/// `enable_thinking=false` switch (closed `<think>\n\n</think>\n\n`). On templates
39/// without an `enable_thinking` switch the mode is ignored (graceful no-op).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ThinkMode {
42    Default,
43    NoThink,
44}
45
46/// Render messages into the prompt string.
47///
48/// `template` is the raw GGUF chat_template (used only to decide qwen3.5-vs-plain
49/// chatml behavior — we detect the `<think>` generation tail by substring). When
50/// `None`, plain ChatML is produced.
51pub fn apply_chat_template_str(
52    template: Option<&str>,
53    messages: &[(&str, &str)],
54    add_generation_prompt: bool,
55) -> String {
56    // Tencent Hy3 (`hy_v3`): a completely different special-token dialect (no ChatML).
57    // Detected by its `hy_User` token literal; rendered by the dedicated arm below.
58    if template.is_some_and(|t| t.contains("hy_User")) {
59        return apply_hy3_template(messages, add_generation_prompt);
60    }
61    // gemma4: `<|turn>role\n{content}<turn|>\n` dialect; generation prompt appends
62    // `<|turn>model\n` + the CLOSED thought channel (`<|channel>thought\n<channel|>` — the
63    // template's enable_thinking-false default). bos comes from encode(add_special) — the
64    // template's `{{ bos_token }}` is NOT re-emitted here (double-BOS trap).
65    if template.is_some_and(|t| t.contains("<|turn>")) {
66        return apply_gemma4_template(messages, add_generation_prompt);
67    }
68    // qwen3.5 template emits a `<think>\n` tail on the generation prompt by default.
69    let qwen_think = template
70        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
71        .unwrap_or(false);
72
73    let mut out = String::new();
74    for (i, (role, content)) in messages.iter().enumerate() {
75        let content = content.trim();
76        match *role {
77            "system" => {
78                // template requires system at the beginning; we render it wherever
79                // it appears at index 0 (the common case).
80                let _ = i;
81                out.push_str("<|im_start|>system\n");
82                out.push_str(content);
83                out.push_str("<|im_end|>\n");
84            }
85            "user" => {
86                out.push_str("<|im_start|>user\n");
87                out.push_str(content);
88                out.push_str("<|im_end|>\n");
89            }
90            "assistant" => {
91                out.push_str("<|im_start|>assistant\n");
92                out.push_str(content);
93                out.push_str("<|im_end|>\n");
94            }
95            other => {
96                // unsupported role in this minimal renderer; emit as a generic turn.
97                out.push_str("<|im_start|>");
98                out.push_str(other);
99                out.push('\n');
100                out.push_str(content);
101                out.push_str("<|im_end|>\n");
102            }
103        }
104    }
105
106    if add_generation_prompt {
107        out.push_str("<|im_start|>assistant\n");
108        if qwen_think {
109            out.push_str("<think>\n");
110        }
111    }
112
113    out
114}
115
116/// The fixed tool-calling instruction block of the qwen3.5/3.6-class templates. Byte-for-byte
117/// the string literal shared by ornith9b / agentworld / ref-qwen36-35b
118/// (research/onboard-ornith-20260801/templates/*.jinja) and the deployed GGUF dumps.
119const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
120following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
121<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
122This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
123</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
124format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
125XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
126your function call in natural language BEFORE the function call, but NOT after\n- If there is \
127no function call available, answer the question like normal with your current knowledge and do \
128not tell the user about function calls\n</IMPORTANT>";
129
130/// Tools-capable chat rendering (serve-tools lane, 2026-08-02). Reproduces the TOOLS branch of
131/// the qwen3.5/3.6-class ChatML templates exactly (verified against the committed dumps AND the
132/// deployed GGUFs' embedded templates, byte-identical):
133///
134///   - tools present  -> `<|im_start|>system\n# Tools\n\nYou have access to the following
135///     functions:\n\n<tools>` + `\n{tool json}` each + `\n</tools>` + the fixed instruction
136///     block; a leading system turn's trimmed content is appended after `\n\n`; `<|im_end|>\n`.
137///   - assistant turns with `tool_calls` -> content then `<tool_call>\n<function=NAME>\n`
138///     (+`\n\n` separator when content is non-empty; later calls separated by `\n`),
139///     `<parameter=K>\nV\n</parameter>\n` each, `</function>\n</tool_call>`, then `<|im_end|>\n`.
140///   - `tool` turns -> grouped into ONE user turn: `<|im_start|>user` opens a run of
141///     consecutive tool messages, each `\n<tool_response>\n{content}\n</tool_response>`,
142///     `<|im_end|>\n` closes the run.
143///   - generation prompt -> `<|im_start|>assistant\n` + `<think>\n` (template default) or
144///     `<think>\n\n</think>\n\n` (`ThinkMode::NoThink` = the template's `enable_thinking=false`
145///     switch; ignored when the template has no `enable_thinking`).
146///
147/// The no-tools/no-tool-turns/`Default`-think case renders byte-identically to
148/// `apply_chat_template_str` (pinned by `tools_renderer_matches_legacy_when_plain`); callers
149/// that want the hard isolation guarantee keep calling the legacy function on that path.
150/// Errors (never on the plain path): tools/tool turns on a template without a tools branch
151/// (hy3 / gemma4 / bare ChatML).
152pub fn apply_chat_template_tools(
153    template: Option<&str>,
154    turns: &[Turn],
155    add_generation_prompt: bool,
156    tools_json: &[String],
157    think: ThinkMode,
158) -> Result<String, String> {
159    let has_tool_features = !tools_json.is_empty()
160        || turns.iter().any(|t| t.role == "tool" || !t.tool_calls.is_empty());
161    let tools_branch = template.is_some_and(|t| t.contains("<tools>"));
162    if has_tool_features && !tools_branch {
163        return Err("model chat template has no tools branch".into());
164    }
165    if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
166        // hy3 / gemma4 dialects: no committed tools rendering reference — reject tool
167        // features even if the raw jinja happens to mention <tools>; the plain path stays
168        // on the legacy arms and ThinkMode is ignored (graceful, per the mission contract).
169        if has_tool_features {
170            return Err("tools are not supported on this model's chat-template dialect".into());
171        }
172        let messages: Vec<(&str, &str)> =
173            turns.iter().map(|t| (t.role.as_str(), t.content.as_str())).collect();
174        return Ok(apply_chat_template_str(template, &messages, add_generation_prompt));
175    }
176    let qwen_think = template
177        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
178        .unwrap_or(false);
179    let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
180
181    let mut out = String::new();
182    // Tools system header replaces the plain system turn (template law: the leading system
183    // turn's content is folded INTO the tools block).
184    let mut skip_leading_system = false;
185    if !tools_json.is_empty() {
186        out.push_str("<|im_start|>system\n");
187        out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
188        for tool in tools_json {
189            out.push('\n');
190            out.push_str(tool);
191        }
192        out.push_str("\n</tools>");
193        out.push_str(QWEN_TOOLS_INSTRUCTION);
194        if let Some(first) = turns.first() {
195            if first.role == "system" {
196                skip_leading_system = true;
197                let content = first.content.trim();
198                if !content.is_empty() {
199                    out.push_str("\n\n");
200                    out.push_str(content);
201                }
202            }
203        }
204        out.push_str("<|im_end|>\n");
205    }
206
207    for (i, turn) in turns.iter().enumerate() {
208        if i == 0 && skip_leading_system {
209            continue;
210        }
211        let content = turn.content.trim();
212        match turn.role.as_str() {
213            "system" => {
214                out.push_str("<|im_start|>system\n");
215                out.push_str(content);
216                out.push_str("<|im_end|>\n");
217            }
218            "user" => {
219                out.push_str("<|im_start|>user\n");
220                out.push_str(content);
221                out.push_str("<|im_end|>\n");
222            }
223            "assistant" => {
224                out.push_str("<|im_start|>assistant\n");
225                out.push_str(content);
226                for (k, call) in turn.tool_calls.iter().enumerate() {
227                    if k == 0 {
228                        if !content.is_empty() {
229                            out.push_str("\n\n");
230                        }
231                    } else {
232                        out.push('\n');
233                    }
234                    out.push_str("<tool_call>\n<function=");
235                    out.push_str(&call.name);
236                    out.push_str(">\n");
237                    for (key, value) in &call.params {
238                        out.push_str("<parameter=");
239                        out.push_str(key);
240                        out.push_str(">\n");
241                        out.push_str(value);
242                        out.push_str("\n</parameter>\n");
243                    }
244                    out.push_str("</function>\n</tool_call>");
245                }
246                out.push_str("<|im_end|>\n");
247            }
248            "tool" => {
249                if i == 0 || turns[i - 1].role != "tool" {
250                    out.push_str("<|im_start|>user");
251                }
252                out.push_str("\n<tool_response>\n");
253                out.push_str(content);
254                out.push_str("\n</tool_response>");
255                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
256                    out.push_str("<|im_end|>\n");
257                }
258            }
259            other => {
260                // parity with the legacy renderer's generic-turn arm.
261                out.push_str("<|im_start|>");
262                out.push_str(other);
263                out.push('\n');
264                out.push_str(content);
265                out.push_str("<|im_end|>\n");
266            }
267        }
268    }
269
270    if add_generation_prompt {
271        out.push_str("<|im_start|>assistant\n");
272        if qwen_think {
273            if think == ThinkMode::NoThink && think_switch {
274                out.push_str("<think>\n\n</think>\n\n");
275            } else {
276                out.push_str("<think>\n");
277            }
278        }
279    }
280    Ok(out)
281}
282
283/// Text-only reproduction of the Hy3 `chat_template.jinja` default path (no tools, no
284/// `is_training`, `reasoning_effort` undefined => template defaults it to `'no_think'`):
285///   - `{bos}{system…}<|reasoning_mode:opensource|>reasoning_effort:no_think` header
286///     (system turns concatenate into the header, before any user turn);
287///   - `user`      -> `<|hy_User:opensource|>{content}`
288///   - `assistant` -> `<|hy_Assistant:opensource|><think:opensource></think:opensource>{content}<|hy_eos:opensource|>`
289///     (non-last turns; thinking is not preserved on the text path);
290///   - generation prompt (no_think): `<|hy_Assistant:opensource|><think:opensource></think:opensource>`.
291/// Content is NOT trimmed (the Hy3 template applies no `|trim`).
292fn apply_hy3_template(messages: &[(&str, &str)], add_generation_prompt: bool) -> String {
293    const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
294    const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
295    const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
296    const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
297    const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
298    const THINK_BEGIN: &str = "<think:opensource>";
299    const THINK_END: &str = "</think:opensource>";
300
301    let mut out = String::from(BOS);
302    for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
303        let _ = role;
304        out.push_str(content);
305    }
306    out.push_str(REASONING);
307    out.push_str("reasoning_effort:no_think");
308
309    let mut last_is_assistant = false;
310    let n = messages.len();
311    for (i, (role, content)) in messages.iter().enumerate() {
312        last_is_assistant = false;
313        match *role {
314            "user" => { out.push_str(USER); out.push_str(content); }
315            "assistant" => {
316                out.push_str(ASSISTANT);
317                out.push_str(THINK_BEGIN);
318                out.push_str(THINK_END);
319                out.push_str(content);
320                if i + 1 < n { out.push_str(EOS); }   // template: `not loop.last` gets eos
321                last_is_assistant = true;
322            }
323            _ => {} // system handled in the header; tool turns are out of scope here
324        }
325    }
326    if add_generation_prompt && !last_is_assistant {
327        out.push_str(ASSISTANT);
328        out.push_str(THINK_BEGIN);
329        out.push_str(THINK_END);
330    }
331    out
332}
333
334
335/// gemma4 turn dialect (text-only path of the GGUF template, verified against the dumped
336/// jinja): roles map assistant->model; each turn = `<|turn>{role}\n{content|trim}<turn|>\n`;
337/// generation prompt = `<|turn>model\n<|channel>thought\n<channel|>`.
338fn apply_gemma4_template(messages: &[(&str, &str)], add_generation_prompt: bool) -> String {
339    let mut out = String::new();
340    for (role, content) in messages {
341        let role = if *role == "assistant" { "model" } else { role };
342        out.push_str("<|turn>");
343        out.push_str(role);
344        out.push('\n');
345        out.push_str(content.trim());
346        out.push_str("<turn|>\n");
347    }
348    if add_generation_prompt {
349        out.push_str("<|turn>model\n<|channel>thought\n<channel|>");
350    }
351    out
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn plain_chatml() {
360        let s = apply_chat_template_str(
361            None,
362            &[("user", "Hello")],
363            true,
364        );
365        assert_eq!(s, "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n");
366    }
367
368    /// A template stand-in carrying every marker the real qwen3.5/3.6 dumps carry
369    /// (tools branch + think tail + enable_thinking switch).
370    const QWEN_TOOLS_TMPL: &str =
371        "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
372
373    /// Isolation contract: the tools renderer on a PLAIN request (no tools, no tool turns,
374    /// Default think) is byte-identical to the legacy renderer, across the message shapes
375    /// the serve path sees.
376    #[test]
377    fn tools_renderer_matches_legacy_when_plain() {
378        let batteries: &[&[(&str, &str)]] = &[
379            &[("user", "Hello")],
380            &[("system", "You are helpful."), ("user", "Hi")],
381            &[("system", "rules"), ("user", "task"), ("assistant", "work"), ("user", "more")],
382            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
383        ];
384        for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
385            for msgs in batteries {
386                let legacy = apply_chat_template_str(tmpl, msgs, true);
387                let turns: Vec<Turn> = msgs.iter().map(|(r, c)| Turn {
388                    role: r.to_string(), content: c.to_string(), tool_calls: Vec::new(),
389                }).collect();
390                let ext = apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default)
391                    .unwrap();
392                assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
393            }
394        }
395    }
396
397    #[test]
398    fn tools_header_and_tool_response_render_per_template_law() {
399        let tools = vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
400        let turns = vec![
401            Turn { role: "system".into(), content: "Be terse.".into(), tool_calls: Vec::new() },
402            Turn { role: "user".into(), content: "Weather in Paris?".into(), tool_calls: Vec::new() },
403            Turn { role: "assistant".into(), content: "".into(), tool_calls: vec![ToolCall {
404                name: "get_weather".into(),
405                params: vec![("city".into(), "Paris".into())],
406            }] },
407            Turn { role: "tool".into(), content: "{\"temp_c\": 21}".into(), tool_calls: Vec::new() },
408        ];
409        let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &tools,
410                                          ThinkMode::Default).unwrap();
411        let expected = concat!(
412            "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
413            "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
414            "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
415            "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
416            "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
417            "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
418            "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
419            "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
420            "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
421            "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
422            "no function call available, answer the question like normal with your current knowledge ",
423            "and do not tell the user about function calls\n</IMPORTANT>",
424            "\n\nBe terse.<|im_end|>\n",
425            "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
426            "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
427            "</parameter>\n</function>\n</tool_call><|im_end|>\n",
428            "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
429            "<|im_start|>assistant\n<think>\n",
430        );
431        assert_eq!(s, expected);
432    }
433
434    #[test]
435    fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
436        let turns = vec![
437            Turn { role: "user".into(), content: "both".into(), tool_calls: Vec::new() },
438            Turn { role: "assistant".into(), content: "checking".into(), tool_calls: vec![
439                ToolCall { name: "a".into(), params: vec![("x".into(), "1".into())] },
440                ToolCall { name: "b".into(), params: Vec::new() },
441            ] },
442            Turn { role: "tool".into(), content: "r1".into(), tool_calls: Vec::new() },
443            Turn { role: "tool".into(), content: "r2".into(), tool_calls: Vec::new() },
444        ];
445        let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, false, &[],
446                                          ThinkMode::Default).unwrap();
447        assert_eq!(s, concat!(
448            "<|im_start|>user\nboth<|im_end|>\n",
449            "<|im_start|>assistant\nchecking\n\n",
450            "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
451            "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
452            "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
453            "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
454        ));
455    }
456
457    #[test]
458    fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
459        let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
460        // switch present: NoThink renders the closed think block.
461        let s = apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &turns, true, &[],
462                                          ThinkMode::NoThink).unwrap();
463        assert!(s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"), "{s:?}");
464        // no enable_thinking switch: NoThink is ignored (template default stands).
465        let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
466        let s = apply_chat_template_tools(Some(tmpl_no_switch), &turns, true, &[],
467                                          ThinkMode::NoThink).unwrap();
468        assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
469        // no template at all: plain ChatML, no tail either way.
470        let s = apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink).unwrap();
471        assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
472    }
473
474    #[test]
475    fn tools_on_templates_without_tools_branch_error() {
476        let turns = vec![Turn { role: "user".into(), content: "hi".into(), tool_calls: Vec::new() }];
477        let tools = vec!["{}".to_string()];
478        for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
479            let err = apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default);
480            assert!(err.is_err(), "template={tmpl:?}");
481        }
482        // tool-role turns need the branch too.
483        let tool_turns = vec![Turn { role: "tool".into(), content: "r".into(), tool_calls: Vec::new() }];
484        assert!(apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default).is_err());
485    }
486
487    #[test]
488    fn qwen_think_tail() {
489        // a template string containing both markers triggers the <think> tail.
490        let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
491        let s = apply_chat_template_str(
492            Some(tmpl),
493            &[("system", "You are helpful."), ("user", "Hi")],
494            true,
495        );
496        assert_eq!(
497            s,
498            "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
499        );
500    }
501}