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//! Non-qwen dialects each get their own arm, dispatched by a marker substring in the raw
19//! template: Tencent Hy3 (`hy_User`), gemma4 (`<|turn>`), and StepFun Step-3.7-Flash /
20//! arch `step35` (`render_message_content`). The step35 check must come BEFORE the qwen
21//! `<think>`-tail detection — its template contains every qwen marker, so the qwen arm would
22//! render the right generation tail on the wrong turn bodies.
23
24/// One tool call attached to a prior assistant turn, pre-rendered for the template:
25/// `params` values are already strings per the template law (string arguments raw,
26/// everything else JSON-rendered by the caller — this crate stays serde-free).
27#[derive(Debug, Clone, PartialEq)]
28pub struct ToolCall {
29    pub name: String,
30    pub params: Vec<(String, String)>,
31}
32
33/// One chat turn for the tools-capable renderer (`apply_chat_template_tools`).
34#[derive(Debug, Clone, PartialEq)]
35pub struct Turn {
36    pub role: String,
37    pub content: String,
38    pub tool_calls: Vec<ToolCall>,
39}
40
41/// Thinking control (owner directive 2026-08-07: every supported model is a thinking model,
42/// one serve surface maps to each arch's native mechanism).
43///
44/// - `Default` = the template's OWN default, byte-identical to the pre-surface render:
45///   qwen class opens `<think>\n` (thinking ON), gemma4 renders the CLOSED thought channel
46///   (its `enable_thinking | default(false)`), hy3 renders `reasoning_effort:no_think`.
47/// - `NoThink` = thinking OFF via the arch's native off-switch: qwen
48///   `enable_thinking=false` (closed `<think>\n\n</think>\n\n`), gemma4 closed thought
49///   channel, hy3 `no_think`. On step35 — whose `<think>` tail is unconditional — it clamps
50///   to the lowest effort level instead (`Reasoning: low`).
51/// - `Think` = thinking explicitly ON: qwen open `<think>\n` (same bytes as its default),
52///   gemma4 `<|think|>\n` injected into the system turn + an OPEN generation turn, hy3
53///   an open `<think:opensource>` channel at the requested effort.
54///
55/// On templates with no switch at all the non-native direction is a graceful no-op.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ThinkMode {
58    Default,
59    NoThink,
60    Think,
61}
62
63/// Render messages into the prompt string.
64///
65/// `template` is the raw GGUF chat_template (used only to decide qwen3.5-vs-plain
66/// chatml behavior — we detect the `<think>` generation tail by substring). When
67/// `None`, plain ChatML is produced.
68pub fn apply_chat_template_str(
69    template: Option<&str>,
70    messages: &[(&str, &str)],
71    add_generation_prompt: bool,
72) -> String {
73    // Tencent Hy3 (`hy_v3`): a completely different special-token dialect (no ChatML).
74    // Detected by its `hy_User` token literal; rendered by the dedicated arm below.
75    // Legacy path = the template's own default ("no_think") — byte-identical to history.
76    if template.is_some_and(|t| t.contains("hy_User")) {
77        return apply_hy3_template(messages, add_generation_prompt, "no_think");
78    }
79    // StepFun Step-3.7-Flash (arch `step35`): a ChatML *dialect* — same `<|im_start|>` framing,
80    // different everything else (see `apply_step35_template`). Detected by its
81    // `render_message_content` macro, which no other committed template defines. This check MUST
82    // precede the qwen `<think>`-tail detection below: the step35 template contains both markers,
83    // so the qwen arm would produce the right generation tail with the wrong turn bodies.
84    if template.is_some_and(|t| t.contains("render_message_content")) {
85        let turns: Vec<Turn> = messages
86            .iter()
87            .map(|(r, c)| Turn {
88                role: r.to_string(),
89                content: c.to_string(),
90                tool_calls: Vec::new(),
91            })
92            .collect();
93        return apply_step35_template(&turns, add_generation_prompt, &[], None);
94    }
95    // gemma4: `<|turn>role\n{content}<turn|>\n` dialect; generation prompt appends
96    // `<|turn>model\n` + the CLOSED thought channel (`<|channel>thought\n<channel|>` — the
97    // template's enable_thinking-false default). bos comes from encode(add_special) — the
98    // template's `{{ bos_token }}` is NOT re-emitted here (double-BOS trap).
99    // Legacy path = thinking OFF (the template's `default(false)`) — byte-identical to history.
100    if template.is_some_and(|t| t.contains("<|turn>")) {
101        return apply_gemma4_template(messages, add_generation_prompt, false);
102    }
103    // qwen3.5 template emits a `<think>\n` tail on the generation prompt by default.
104    let qwen_think = template
105        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
106        .unwrap_or(false);
107
108    let mut out = String::new();
109    for (i, (role, content)) in messages.iter().enumerate() {
110        let content = content.trim();
111        match *role {
112            "system" => {
113                // template requires system at the beginning; we render it wherever
114                // it appears at index 0 (the common case).
115                let _ = i;
116                out.push_str("<|im_start|>system\n");
117                out.push_str(content);
118                out.push_str("<|im_end|>\n");
119            }
120            "user" => {
121                out.push_str("<|im_start|>user\n");
122                out.push_str(content);
123                out.push_str("<|im_end|>\n");
124            }
125            "assistant" => {
126                out.push_str("<|im_start|>assistant\n");
127                out.push_str(content);
128                out.push_str("<|im_end|>\n");
129            }
130            other => {
131                // unsupported role in this minimal renderer; emit as a generic turn.
132                out.push_str("<|im_start|>");
133                out.push_str(other);
134                out.push('\n');
135                out.push_str(content);
136                out.push_str("<|im_end|>\n");
137            }
138        }
139    }
140
141    if add_generation_prompt {
142        out.push_str("<|im_start|>assistant\n");
143        if qwen_think {
144            out.push_str("<think>\n");
145        }
146    }
147
148    out
149}
150
151/// The fixed tool-calling instruction block of the qwen3.5/3.6-class templates. Byte-for-byte
152/// the string literal shared by ornith9b / agentworld / ref-qwen36-35b
153/// (research/onboard-ornith-20260801/templates/*.jinja) and the deployed GGUF dumps.
154const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
155following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
156<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
157This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
158</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
159format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
160XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
161your function call in natural language BEFORE the function call, but NOT after\n- If there is \
162no function call available, answer the question like normal with your current knowledge and do \
163not tell the user about function calls\n</IMPORTANT>";
164
165/// Tools-capable chat rendering (serve-tools lane, 2026-08-02). Reproduces the TOOLS branch of
166/// the qwen3.5/3.6-class ChatML templates exactly (verified against the committed dumps AND the
167/// deployed GGUFs' embedded templates, byte-identical):
168///
169///   - tools present  -> `<|im_start|>system\n# Tools\n\nYou have access to the following
170///     functions:\n\n<tools>` + `\n{tool json}` each + `\n</tools>` + the fixed instruction
171///     block; a leading system turn's trimmed content is appended after `\n\n`; `<|im_end|>\n`.
172///   - assistant turns with `tool_calls` -> content then `<tool_call>\n<function=NAME>\n`
173///     (+`\n\n` separator when content is non-empty; later calls separated by `\n`),
174///     `<parameter=K>\nV\n</parameter>\n` each, `</function>\n</tool_call>`, then `<|im_end|>\n`.
175///   - `tool` turns -> grouped into ONE user turn: `<|im_start|>user` opens a run of
176///     consecutive tool messages, each `\n<tool_response>\n{content}\n</tool_response>`,
177///     `<|im_end|>\n` closes the run.
178///   - generation prompt -> `<|im_start|>assistant\n` + `<think>\n` (template default) or
179///     `<think>\n\n</think>\n\n` (`ThinkMode::NoThink` = the template's `enable_thinking=false`
180///     switch; ignored when the template has no `enable_thinking`).
181///
182/// The no-tools/no-tool-turns/`Default`-think case renders byte-identically to
183/// `apply_chat_template_str` (pinned by `tools_renderer_matches_legacy_when_plain`); callers
184/// that want the hard isolation guarantee keep calling the legacy function on that path.
185/// Errors (never on the plain path): tools/tool turns on a template without a tools branch
186/// (hy3 / gemma4 / bare ChatML).
187///
188/// `reasoning_effort` is the step35 dialect's three-level control ("low"/"medium"/"high" —
189/// a STRING rendered into the system turn, not a think switch; see `apply_step35_template`).
190/// Every other dialect ignores it (their templates have no `reasoning_effort` input), and
191/// `None` is the step35 template's own default (no `Reasoning:` line). The server only
192/// supplies `Some` for models whose template consumes it (`ModelCaps::effort_levels`), so
193/// non-step35 prompts stay byte-identical by construction, not by luck.
194pub fn apply_chat_template_tools(
195    template: Option<&str>,
196    turns: &[Turn],
197    add_generation_prompt: bool,
198    tools_json: &[String],
199    think: ThinkMode,
200    reasoning_effort: Option<&str>,
201) -> Result<String, String> {
202    let has_tool_features = !tools_json.is_empty()
203        || turns
204            .iter()
205            .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
206    let tools_branch = template.is_some_and(|t| t.contains("<tools>"));
207    if has_tool_features && !tools_branch {
208        return Err("model chat template has no tools branch".into());
209    }
210    // step35: its own dialect all the way through, tools included (unlike hy3/gemma4, which
211    // reject tool features — step35 HAS a tools branch and it is reproduced). Must precede the
212    // qwen arm: the step35 template contains `<tools>`, `<think>` and `add_generation_prompt`,
213    // so every qwen marker check below matches it. `ThinkMode` is ignored (no `enable_thinking`
214    // in this template => `think_switch` is false => NoThink is already a documented no-op);
215    // `reasoning_effort` is this dialect's own control and is honored here.
216    if template.is_some_and(|t| t.contains("render_message_content")) {
217        return Ok(apply_step35_template(
218            turns,
219            add_generation_prompt,
220            tools_json,
221            reasoning_effort,
222        ));
223    }
224    if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
225        // hy3 / gemma4 dialects: no committed tools rendering reference — reject tool
226        // features even if the raw jinja happens to mention <tools>. ThinkMode maps to each
227        // arch's native mechanism (thinking goldens, render-thinking-goldens.py):
228        //   hy3    -> the template's own reasoning_effort input: no_think (its default,
229        //             = ThinkMode::Default/NoThink) or low/high (open think, ThinkMode::Think
230        //             at the level the caller resolved — effort carries it).
231        //   gemma4 -> enable_thinking: default(false) = Default/NoThink;
232        //             Think = <|think|> system token + open generation turn.
233        if has_tool_features {
234            return Err("tools are not supported on this model's chat-template dialect".into());
235        }
236        let messages: Vec<(&str, &str)> = turns
237            .iter()
238            .map(|t| (t.role.as_str(), t.content.as_str()))
239            .collect();
240        if template.is_some_and(|t| t.contains("hy_User")) {
241            // hy3's accepted set is exactly no_think|low|high; OpenAI medium clamps to low
242            // (the template has no medium level and raises on unknown strings).
243            let effort = match (think, reasoning_effort) {
244                (ThinkMode::Think, Some("high")) => "high",
245                (ThinkMode::Think, _) => "low",
246                _ => "no_think",
247            };
248            return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
249        }
250        return Ok(apply_gemma4_template(
251            &messages,
252            add_generation_prompt,
253            think == ThinkMode::Think,
254        ));
255    }
256    let qwen_think = template
257        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
258        .unwrap_or(false);
259    let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
260
261    let mut out = String::new();
262    // Tools system header replaces the plain system turn (template law: the leading system
263    // turn's content is folded INTO the tools block).
264    let mut skip_leading_system = false;
265    if !tools_json.is_empty() {
266        out.push_str("<|im_start|>system\n");
267        out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
268        for tool in tools_json {
269            out.push('\n');
270            out.push_str(tool);
271        }
272        out.push_str("\n</tools>");
273        out.push_str(QWEN_TOOLS_INSTRUCTION);
274        if let Some(first) = turns.first() {
275            if first.role == "system" {
276                skip_leading_system = true;
277                let content = first.content.trim();
278                if !content.is_empty() {
279                    out.push_str("\n\n");
280                    out.push_str(content);
281                }
282            }
283        }
284        out.push_str("<|im_end|>\n");
285    }
286
287    for (i, turn) in turns.iter().enumerate() {
288        if i == 0 && skip_leading_system {
289            continue;
290        }
291        let content = turn.content.trim();
292        match turn.role.as_str() {
293            "system" => {
294                out.push_str("<|im_start|>system\n");
295                out.push_str(content);
296                out.push_str("<|im_end|>\n");
297            }
298            "user" => {
299                out.push_str("<|im_start|>user\n");
300                out.push_str(content);
301                out.push_str("<|im_end|>\n");
302            }
303            "assistant" => {
304                out.push_str("<|im_start|>assistant\n");
305                out.push_str(content);
306                for (k, call) in turn.tool_calls.iter().enumerate() {
307                    if k == 0 {
308                        if !content.is_empty() {
309                            out.push_str("\n\n");
310                        }
311                    } else {
312                        out.push('\n');
313                    }
314                    out.push_str("<tool_call>\n<function=");
315                    out.push_str(&call.name);
316                    out.push_str(">\n");
317                    for (key, value) in &call.params {
318                        out.push_str("<parameter=");
319                        out.push_str(key);
320                        out.push_str(">\n");
321                        out.push_str(value);
322                        out.push_str("\n</parameter>\n");
323                    }
324                    out.push_str("</function>\n</tool_call>");
325                }
326                out.push_str("<|im_end|>\n");
327            }
328            "tool" => {
329                if i == 0 || turns[i - 1].role != "tool" {
330                    out.push_str("<|im_start|>user");
331                }
332                out.push_str("\n<tool_response>\n");
333                out.push_str(content);
334                out.push_str("\n</tool_response>");
335                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
336                    out.push_str("<|im_end|>\n");
337                }
338            }
339            other => {
340                // parity with the legacy renderer's generic-turn arm.
341                out.push_str("<|im_start|>");
342                out.push_str(other);
343                out.push('\n');
344                out.push_str(content);
345                out.push_str("<|im_end|>\n");
346            }
347        }
348    }
349
350    if add_generation_prompt {
351        out.push_str("<|im_start|>assistant\n");
352        if qwen_think {
353            if think == ThinkMode::NoThink && think_switch {
354                out.push_str("<think>\n\n</think>\n\n");
355            } else {
356                out.push_str("<think>\n");
357            }
358        }
359    }
360    Ok(out)
361}
362
363/// The fixed tool-calling instruction block of the StepFun `step35` template. NOT the same
364/// string as `QWEN_TOOLS_INSTRUCTION` — three differences, all load-bearing: the header says
365/// "in JSONSchema format", the nesting reminder carries literal `\n...\n` inside the
366/// `<function=...>` / `<tool_call>` examples, and the Reminder list has 2 bullets instead of 4
367/// (no "optional reasoning BEFORE the call" and no "answer normally if no function is
368/// available"). Copied byte-for-byte out of the shipped template
369/// (`research/step37-bringup-20260802/raw/chat_template.jinja`, == the GGUF's own
370/// `tokenizer.chat_template`).
371const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
372following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
373<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
374This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
375</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
376format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
377...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
378
379/// StepFun Step-3.7-Flash (GGUF arch `step35`) chat template.
380///
381/// A ChatML *dialect*, not ChatML: it shares the `<|im_start|>role\n…<|im_end|>\n` frame and
382/// nothing else. Reproduced from the shipped jinja, and pinned test-by-test against goldens
383/// rendered from that jinja under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF
384/// transformers and llama.cpp's minja both parse chat templates with
385/// (`research/step37-p2-20260806/render_step35_template.py`, goldens committed under `raw/`).
386///
387/// Where it differs from the qwen3.5/3.6 arms above — every one of these silently corrupts the
388/// prompt if the qwen arm is reused:
389///
390/// | | qwen3.5/3.6 | step35 |
391/// |---|---|---|
392/// | reasoning level | `enable_thinking` bool | `Reasoning: {low,medium,high}\n\n` prefix inside the system turn |
393/// | `<think>` tail | switchable | **unconditional** — no `enable_thinking`, so `ThinkMode::NoThink` is a no-op |
394/// | prior assistant turns | content only | turns AFTER the last real user query also carry `<think>\n{reasoning}\n</think>\n` |
395/// | tool results | grouped into a `user` turn, `\n<tool_response>\n…\n</tool_response>` | own **`tool_response`** role, `<tool_response>…</tool_response>` with NO inner newlines |
396/// | content | `\|trim`med | **not** trimmed |
397/// | tools header | `following functions:` | `following functions in JSONSchema format:` |
398/// | call separators | `\n\n` after content, `\n` between calls | **none** |
399/// | leading system + tools | appended AFTER the instruction block | folded in BEFORE `# Tools` |
400///
401/// `reasoning_effort` is the model's headline three-level control (low/medium/high per the
402/// StepFun model card). It is a parameter here rather than a `ThinkMode`: the value is a
403/// *string in the system turn*, so a bool cannot carry it. The serve path supplies it through
404/// `apply_chat_template_tools` (worker `Request::reasoning_effort`, mapped from the OpenAI
405/// `reasoning_effort` body field when `ModelCaps::effort_levels` is set); `None` — the
406/// legacy-str path and every non-step35 model — renders the template's own default
407/// (no `Reasoning:` line at all).
408///
409/// BOS is NOT emitted (the jinja's `{{bos_token}}` is dropped): memra's `encode(add_special)`
410/// prepends it from `tokenizer.ggml.add_bos_token`/`bos_token_id` — the same double-BOS trap the
411/// gemma4 arm documents.
412///
413/// ONE deliberate divergence: the jinja's body loop has no `else`, so a role outside
414/// {system, user, assistant, tool} renders as **nothing at all** — the turn silently vanishes
415/// from the prompt. memra renders it as a generic `<|im_start|>{role}\n{content}<|im_end|>\n`
416/// turn instead, matching the other arms here. A dropped turn is the worse failure, and this
417/// branch cannot fire on the serve surface: OpenAI roles are exactly system/user/assistant/tool,
418/// all four of which are reproduced byte-for-byte.
419///
420/// Not reproduced (needs data `Turn` does not carry, tracked, cannot fire from an OpenAI client):
421/// the `name == "observation"` alias that renames a non-leading `system` turn's role to
422/// `observation`, and the `<im_patch>` image-content path (this is a VLM; memra is text-only here).
423fn apply_step35_template(
424    turns: &[Turn],
425    add_generation_prompt: bool,
426    tools_json: &[String],
427    reasoning_effort: Option<&str>,
428) -> String {
429    let mut out = String::new();
430    let leading_system = turns.first().filter(|t| t.role == "system");
431
432    // --- system header. Two branches in the jinja, and the ORDER differs between them.
433    if !tools_json.is_empty() {
434        out.push_str("<|im_start|>system\n");
435        if let Some(effort) = reasoning_effort {
436            out.push_str("Reasoning: ");
437            out.push_str(effort);
438            out.push_str("\n\n");
439        }
440        if let Some(sys) = leading_system {
441            // unconditional `content + '\n\n'` — no emptiness check, unlike the qwen arm.
442            out.push_str(&sys.content);
443            out.push_str("\n\n");
444        }
445        out.push_str(
446            "# Tools\n\nYou have access to the following functions in JSONSchema \
447                      format:\n\n<tools>",
448        );
449        for tool in tools_json {
450            out.push('\n');
451            out.push_str(tool);
452        }
453        out.push_str("\n</tools>");
454        out.push_str(STEP35_TOOLS_INSTRUCTION);
455        out.push_str("<|im_end|>\n");
456    } else if let Some(sys) = leading_system {
457        out.push_str("<|im_start|>system\n");
458        if let Some(effort) = reasoning_effort {
459            out.push_str("Reasoning: ");
460            out.push_str(effort);
461            out.push_str("\n\n");
462        }
463        out.push_str(&sys.content);
464        out.push_str("<|im_end|>\n");
465    } else if let Some(effort) = reasoning_effort {
466        out.push_str("<|im_start|>system\nReasoning: ");
467        out.push_str(effort);
468        out.push_str("\n\n<|im_end|>\n");
469    }
470
471    // --- last_query_index: the index of the LAST `user` turn that is a real query, i.e. whose
472    // content is not itself a `<tool_response>…</tool_response>` wrapper (a client replaying tool
473    // output as a user turn must not reset the reasoning boundary). Default len-1 when there is
474    // no such turn, exactly as the jinja's namespace initializer does.
475    let last_query_index = turns
476        .iter()
477        .enumerate()
478        .rev()
479        .find(|(_, t)| {
480            t.role == "user"
481                && !(t.content.starts_with("<tool_response>")
482                    && t.content.ends_with("</tool_response>"))
483        })
484        .map(|(i, _)| i)
485        .unwrap_or(turns.len().saturating_sub(1));
486
487    for (i, turn) in turns.iter().enumerate() {
488        let content = &turn.content; // NOT trimmed: this template applies no `|trim`
489        match turn.role.as_str() {
490            // the leading system turn lives in the header above; later ones are body turns.
491            "system" if i == 0 => {}
492            "system" | "user" => {
493                out.push_str("<|im_start|>");
494                out.push_str(&turn.role);
495                out.push('\n');
496                out.push_str(content);
497                out.push_str("<|im_end|>\n");
498            }
499            "assistant" => {
500                // Split an inline `<think>…</think>` out of content, mirroring the jinja's
501                // string surgery exactly: reasoning = text before the FIRST `</think>`, with
502                // trailing newlines stripped, then everything after the LAST `<think>` in that
503                // prefix, with leading newlines stripped; body = after the LAST `</think>`,
504                // leading newlines stripped.
505                let (reasoning, body): (String, &str) = match content.find("</think>") {
506                    Some(first) => {
507                        let pre = content[..first].trim_end_matches('\n');
508                        let pre = match pre.rfind("<think>") {
509                            Some(o) => &pre[o + "<think>".len()..],
510                            None => pre,
511                        };
512                        let last = content.rfind("</think>").unwrap();
513                        (
514                            pre.trim_start_matches('\n').to_string(),
515                            content[last + "</think>".len()..].trim_start_matches('\n'),
516                        )
517                    }
518                    None => (String::new(), content.as_str()),
519                };
520                out.push_str("<|im_start|>assistant\n");
521                if i > last_query_index {
522                    out.push_str("<think>\n");
523                    out.push_str(&reasoning);
524                    out.push_str("\n</think>\n");
525                }
526                out.push_str(body);
527                // NO separator before or between calls (the qwen arm's `\n\n`/`\n` would corrupt).
528                for call in &turn.tool_calls {
529                    out.push_str("<tool_call>\n<function=");
530                    out.push_str(&call.name);
531                    out.push_str(">\n");
532                    for (key, value) in &call.params {
533                        out.push_str("<parameter=");
534                        out.push_str(key);
535                        out.push_str(">\n");
536                        out.push_str(value);
537                        out.push_str("\n</parameter>\n");
538                    }
539                    out.push_str("</function>\n</tool_call>");
540                }
541                out.push_str("<|im_end|>\n");
542            }
543            "tool" => {
544                // own role, and consecutive tool turns share ONE `tool_response` turn.
545                if i == 0 || turns[i - 1].role != "tool" {
546                    out.push_str("<|im_start|>tool_response\n");
547                }
548                out.push_str("<tool_response>");
549                out.push_str(content);
550                out.push_str("</tool_response>");
551                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
552                    out.push_str("<|im_end|>\n");
553                }
554            }
555            other => {
556                // the jinja drops this turn entirely; see the divergence note above.
557                out.push_str("<|im_start|>");
558                out.push_str(other);
559                out.push('\n');
560                out.push_str(content);
561                out.push_str("<|im_end|>\n");
562            }
563        }
564    }
565
566    if add_generation_prompt {
567        out.push_str("<|im_start|>assistant\n<think>\n");
568    }
569    out
570}
571
572/// Text-only reproduction of the Hy3 `chat_template.jinja` (no tools, no `is_training`).
573/// `effort` is the template's own `reasoning_effort` input — `"no_think"` / `"low"` /
574/// `"high"`, its full accepted set (the jinja `raise_exception`s on anything else; undefined
575/// defaults to `'no_think'`, so callers with no opinion pass `"no_think"`):
576///   - `{bos}{system…}<|reasoning_mode:opensource|>reasoning_effort:{effort}` header
577///     (system turns concatenate into the header, before any user turn);
578///   - `user`      -> `<|hy_User:opensource|>{content}`
579///   - `assistant` -> `<|hy_Assistant:opensource|><think:opensource></think:opensource>{content}<|hy_eos:opensource|>`
580///     (non-last turns; history turns render CLOSED think at every effort — the template
581///     opens only turns past `last_user_index`, and OpenAI history carries no reasoning);
582///   - generation prompt: `<|hy_Assistant:opensource|><think:opensource></think:opensource>`
583///     at no_think, `…<think:opensource>` (OPEN think) at low/high.
584/// Content is NOT trimmed (the Hy3 template applies no `|trim`). Goldens: rendered from the
585/// pinned tencent/Hy3 template (sha 7fc351fe…, snapshot 716aa724) by
586/// `research/step-sku-20260807/render-thinking-goldens.py`.
587fn apply_hy3_template(
588    messages: &[(&str, &str)],
589    add_generation_prompt: bool,
590    effort: &str,
591) -> String {
592    const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
593    const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
594    const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
595    const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
596    const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
597    const THINK_BEGIN: &str = "<think:opensource>";
598    const THINK_END: &str = "</think:opensource>";
599
600    debug_assert!(
601        matches!(effort, "no_think" | "low" | "high"),
602        "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
603    );
604    let mut out = String::from(BOS);
605    for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
606        let _ = role;
607        out.push_str(content);
608    }
609    out.push_str(REASONING);
610    out.push_str("reasoning_effort:");
611    out.push_str(effort);
612
613    let mut last_is_assistant = false;
614    let n = messages.len();
615    for (i, (role, content)) in messages.iter().enumerate() {
616        last_is_assistant = false;
617        match *role {
618            "user" => {
619                out.push_str(USER);
620                out.push_str(content);
621            }
622            "assistant" => {
623                out.push_str(ASSISTANT);
624                out.push_str(THINK_BEGIN);
625                out.push_str(THINK_END);
626                out.push_str(content);
627                if i + 1 < n {
628                    out.push_str(EOS);
629                } // template: `not loop.last` gets eos
630                last_is_assistant = true;
631            }
632            _ => {} // system handled in the header; tool turns are out of scope here
633        }
634    }
635    if add_generation_prompt && !last_is_assistant {
636        out.push_str(ASSISTANT);
637        out.push_str(THINK_BEGIN);
638        if effort == "no_think" {
639            out.push_str(THINK_END); // low/high leave the think channel OPEN (the golden)
640        }
641    }
642    out
643}
644
645/// gemma4 turn dialect (text-only path of the GGUF template, verified against the dumped
646/// jinja — sha 36e3a42e…, goldens `research/step-sku-20260807/raw/thinking-goldens.txt`):
647/// roles map assistant->model; each turn = `<|turn>{role}\n{content|trim}<turn|>\n`.
648///
649/// THINKING is `enable_thinking`, and its default is OFF (`enable_thinking | default(false)`)
650/// — the inverse of the qwen class:
651///   - thinking OFF (default): generation prompt = `<|turn>model\n<|channel>thought\n<channel|>`
652///     (the CLOSED thought channel — the model may not think);
653///   - thinking ON: a `<|think|>\n` token is injected at the very top of the FIRST system
654///     turn (a system turn is CREATED if the request has none), and the generation prompt is
655///     the bare `<|turn>model\n` — the thought channel is left to the model.
656fn apply_gemma4_template(
657    messages: &[(&str, &str)],
658    add_generation_prompt: bool,
659    thinking: bool,
660) -> String {
661    let mut out = String::new();
662    let mut msgs = messages;
663    // System header block: fires when thinking is on OR a leading system turn exists.
664    let leading_system = msgs.first().filter(|(r, _)| *r == "system");
665    if thinking || leading_system.is_some() {
666        out.push_str("<|turn>system\n");
667        if thinking {
668            out.push_str("<|think|>\n");
669        }
670        if let Some((_, content)) = leading_system {
671            out.push_str(content.trim());
672            msgs = &msgs[1..];
673        }
674        out.push_str("<turn|>\n");
675    }
676    for (role, content) in msgs {
677        let role = if *role == "assistant" { "model" } else { role };
678        out.push_str("<|turn>");
679        out.push_str(role);
680        out.push('\n');
681        out.push_str(content.trim());
682        out.push_str("<turn|>\n");
683    }
684    if add_generation_prompt {
685        out.push_str("<|turn>model\n");
686        if !thinking {
687            out.push_str("<|channel>thought\n<channel|>");
688        }
689    }
690    out
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn plain_chatml() {
699        let s = apply_chat_template_str(None, &[("user", "Hello")], true);
700        assert_eq!(
701            s,
702            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
703        );
704    }
705
706    /// A template stand-in carrying every marker the real qwen3.5/3.6 dumps carry
707    /// (tools branch + think tail + enable_thinking switch).
708    const QWEN_TOOLS_TMPL: &str =
709        "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
710
711    /// Isolation contract: the tools renderer on a PLAIN request (no tools, no tool turns,
712    /// Default think) is byte-identical to the legacy renderer, across the message shapes
713    /// the serve path sees.
714    #[test]
715    fn tools_renderer_matches_legacy_when_plain() {
716        let batteries: &[&[(&str, &str)]] = &[
717            &[("user", "Hello")],
718            &[("system", "You are helpful."), ("user", "Hi")],
719            &[
720                ("system", "rules"),
721                ("user", "task"),
722                ("assistant", "work"),
723                ("user", "more"),
724            ],
725            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
726        ];
727        for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
728            for msgs in batteries {
729                let legacy = apply_chat_template_str(tmpl, msgs, true);
730                let turns: Vec<Turn> = msgs
731                    .iter()
732                    .map(|(r, c)| Turn {
733                        role: r.to_string(),
734                        content: c.to_string(),
735                        tool_calls: Vec::new(),
736                    })
737                    .collect();
738                let ext =
739                    apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
740                        .unwrap();
741                assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
742            }
743        }
744    }
745
746    #[test]
747    fn tools_header_and_tool_response_render_per_template_law() {
748        let tools =
749            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
750        let turns = vec![
751            Turn {
752                role: "system".into(),
753                content: "Be terse.".into(),
754                tool_calls: Vec::new(),
755            },
756            Turn {
757                role: "user".into(),
758                content: "Weather in Paris?".into(),
759                tool_calls: Vec::new(),
760            },
761            Turn {
762                role: "assistant".into(),
763                content: "".into(),
764                tool_calls: vec![ToolCall {
765                    name: "get_weather".into(),
766                    params: vec![("city".into(), "Paris".into())],
767                }],
768            },
769            Turn {
770                role: "tool".into(),
771                content: "{\"temp_c\": 21}".into(),
772                tool_calls: Vec::new(),
773            },
774        ];
775        let s = apply_chat_template_tools(
776            Some(QWEN_TOOLS_TMPL),
777            &turns,
778            true,
779            &tools,
780            ThinkMode::Default,
781            None,
782        )
783        .unwrap();
784        let expected = concat!(
785            "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
786            "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
787            "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
788            "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
789            "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
790            "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
791            "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
792            "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
793            "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
794            "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
795            "no function call available, answer the question like normal with your current knowledge ",
796            "and do not tell the user about function calls\n</IMPORTANT>",
797            "\n\nBe terse.<|im_end|>\n",
798            "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
799            "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
800            "</parameter>\n</function>\n</tool_call><|im_end|>\n",
801            "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
802            "<|im_start|>assistant\n<think>\n",
803        );
804        assert_eq!(s, expected);
805    }
806
807    #[test]
808    fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
809        let turns = vec![
810            Turn {
811                role: "user".into(),
812                content: "both".into(),
813                tool_calls: Vec::new(),
814            },
815            Turn {
816                role: "assistant".into(),
817                content: "checking".into(),
818                tool_calls: vec![
819                    ToolCall {
820                        name: "a".into(),
821                        params: vec![("x".into(), "1".into())],
822                    },
823                    ToolCall {
824                        name: "b".into(),
825                        params: Vec::new(),
826                    },
827                ],
828            },
829            Turn {
830                role: "tool".into(),
831                content: "r1".into(),
832                tool_calls: Vec::new(),
833            },
834            Turn {
835                role: "tool".into(),
836                content: "r2".into(),
837                tool_calls: Vec::new(),
838            },
839        ];
840        let s = apply_chat_template_tools(
841            Some(QWEN_TOOLS_TMPL),
842            &turns,
843            false,
844            &[],
845            ThinkMode::Default,
846            None,
847        )
848        .unwrap();
849        assert_eq!(
850            s,
851            concat!(
852                "<|im_start|>user\nboth<|im_end|>\n",
853                "<|im_start|>assistant\nchecking\n\n",
854                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
855                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
856                "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
857                "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
858            )
859        );
860    }
861
862    #[test]
863    fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
864        let turns = vec![Turn {
865            role: "user".into(),
866            content: "hi".into(),
867            tool_calls: Vec::new(),
868        }];
869        // switch present: NoThink renders the closed think block.
870        let s = apply_chat_template_tools(
871            Some(QWEN_TOOLS_TMPL),
872            &turns,
873            true,
874            &[],
875            ThinkMode::NoThink,
876            None,
877        )
878        .unwrap();
879        assert!(
880            s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
881            "{s:?}"
882        );
883        // no enable_thinking switch: NoThink is ignored (template default stands).
884        let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
885        let s = apply_chat_template_tools(
886            Some(tmpl_no_switch),
887            &turns,
888            true,
889            &[],
890            ThinkMode::NoThink,
891            None,
892        )
893        .unwrap();
894        assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
895        // no template at all: plain ChatML, no tail either way.
896        let s =
897            apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
898        assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
899    }
900
901    #[test]
902    fn tools_on_templates_without_tools_branch_error() {
903        let turns = vec![Turn {
904            role: "user".into(),
905            content: "hi".into(),
906            tool_calls: Vec::new(),
907        }];
908        let tools = vec!["{}".to_string()];
909        for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
910            let err =
911                apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
912            assert!(err.is_err(), "template={tmpl:?}");
913        }
914        // tool-role turns need the branch too.
915        let tool_turns = vec![Turn {
916            role: "tool".into(),
917            content: "r".into(),
918            tool_calls: Vec::new(),
919        }];
920        assert!(
921            apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
922                .is_err()
923        );
924    }
925
926    // ---- per-arch thinking control (owner directive 2026-08-07) -------------------------
927    // Every `expected` below is the EXACT string the arch's REAL shipped template renders,
928    // from research/step-sku-20260807/raw/thinking-goldens.txt (render-thinking-goldens.py:
929    // jinja2 trim_blocks/lstrip_blocks over the pinned template dumps — gemma4 sha 36e3a42e
930    // from the local QAT GGUF header, hy3 sha 7fc351fe from the pinned tencent/Hy3 snapshot).
931
932    fn one_user() -> Vec<Turn> {
933        vec![turn("user", "Hi")]
934    }
935
936    #[test]
937    fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
938        let g = |think: ThinkMode| {
939            apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
940                .unwrap()
941        };
942        // Default AND NoThink = the template's own default(false): closed thought channel.
943        // Byte-identical to the legacy renderer (no silent behavior change).
944        let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
945        assert_eq!(g(ThinkMode::Default), closed);
946        assert_eq!(g(ThinkMode::NoThink), closed);
947        assert_eq!(
948            apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
949            closed,
950            "legacy renderer = the default arm"
951        );
952        // Think = enable_thinking=true: <|think|> injected into a CREATED system turn and
953        // the generation turn left open (golden: gemma4 enable_thinking=true, no system).
954        assert_eq!(
955            g(ThinkMode::Think),
956            "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
957        );
958        // with a client system turn the token lands at the very top of it (golden).
959        let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
960        let s = apply_chat_template_tools(
961            Some("... <|turn> ..."),
962            &turns,
963            true,
964            &[],
965            ThinkMode::Think,
966            None,
967        )
968        .unwrap();
969        assert_eq!(
970            s,
971            "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
972                       <|turn>user\nHi<turn|>\n<|turn>model\n"
973        );
974    }
975
976    #[test]
977    fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
978        const HY_TMPL: Option<&str> = Some("... hy_User ...");
979        let h = |think: ThinkMode, effort: Option<&str>| {
980            apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
981        };
982        // Default AND NoThink = the template's own default: no_think header + CLOSED think.
983        // Byte-identical to the legacy renderer.
984        let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
985                      <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
986                      <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
987                      <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
988                      <think:opensource></think:opensource>";
989        assert_eq!(h(ThinkMode::Default, None), closed);
990        assert_eq!(
991            h(ThinkMode::NoThink, Some("low")),
992            closed,
993            "NoThink wins over a level: thinking off IS no_think"
994        );
995        assert_eq!(
996            apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
997            closed,
998            "legacy renderer = the default arm"
999        );
1000        // Think at low/high = the template's own open-think levels (goldens: header carries
1001        // the level, generation prompt ends with an OPEN <think:opensource>).
1002        let low = h(ThinkMode::Think, Some("low"));
1003        assert!(low.contains("reasoning_effort:low"), "{low:?}");
1004        assert!(low.ends_with("<think:opensource>"), "{low:?}");
1005        let high = h(ThinkMode::Think, Some("high"));
1006        assert!(high.contains("reasoning_effort:high"), "{high:?}");
1007        assert!(high.ends_with("<think:opensource>"), "{high:?}");
1008        // medium clamps to low (hy3's accepted set is exactly no_think|low|high — the jinja
1009        // raise_exceptions on anything else); Think with no level also lands at low.
1010        assert_eq!(h(ThinkMode::Think, Some("medium")), low);
1011        assert_eq!(h(ThinkMode::Think, None), low);
1012        // History assistant turns stay CLOSED-think at every effort (the template opens only
1013        // turns past last_user_index; golden: "hy3 assistant history stays closed-think").
1014        let turns = vec![
1015            turn("user", "q"),
1016            turn("assistant", "a"),
1017            turn("user", "more"),
1018        ];
1019        let s =
1020            apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
1021                .unwrap();
1022        assert_eq!(
1023            s,
1024            "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
1025                       <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
1026                       <\u{ff5c}hy_User:opensource\u{ff5c}>q\
1027                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
1028                       <think:opensource></think:opensource>a\
1029                       <\u{ff5c}hy_eos:opensource\u{ff5c}>\
1030                       <\u{ff5c}hy_User:opensource\u{ff5c}>more\
1031                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
1032        );
1033    }
1034
1035    #[test]
1036    fn qwen_think_mode_covers_all_three_directions() {
1037        let q = |think: ThinkMode| {
1038            apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
1039                .unwrap()
1040        };
1041        // qwen's template default IS thinking-on, so Default and Think render identically.
1042        assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
1043        assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
1044        assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
1045    }
1046
1047    // ---- StepFun Step-3.7-Flash (arch step35) -------------------------------------------
1048    // Every `expected` below is the EXACT string the shipped jinja renders, taken from
1049    // research/step37-p2-20260806/raw/step35-template-goldens.txt (generated by
1050    // render_step35_template.py under jinja2 with trim_blocks/lstrip_blocks — the settings HF
1051    // transformers and llama.cpp's minja use). `{{bos_token}}` renders as "" there because
1052    // encode(add_special) supplies BOS.
1053
1054    /// A step35 template stand-in: the real one is 5723 chars, and the detector keys on
1055    /// `render_message_content` (the macro no other committed template defines). The other
1056    /// markers are present to prove the step35 arm WINS the dispatch — a qwen-marker template
1057    /// carrying `<tools>`/`<think>`/`add_generation_prompt` would otherwise take the qwen arm.
1058    const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
1059
1060    fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
1061        apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
1062    }
1063
1064    fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
1065        apply_chat_template_tools(
1066            Some(STEP35_TMPL),
1067            &turns,
1068            genp,
1069            tools,
1070            ThinkMode::Default,
1071            None,
1072        )
1073        .unwrap()
1074    }
1075
1076    fn turn(role: &str, content: &str) -> Turn {
1077        Turn {
1078            role: role.into(),
1079            content: content.into(),
1080            tool_calls: Vec::new(),
1081        }
1082    }
1083
1084    #[test]
1085    fn step35_plain_paths_match_the_shipped_jinja() {
1086        assert_eq!(
1087            s35(&[("user", "Hello")], true),
1088            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1089        );
1090        assert_eq!(
1091            s35(&[("user", "Hello")], false),
1092            "<|im_start|>user\nHello<|im_end|>\n"
1093        );
1094        assert_eq!(
1095            s35(&[("system", "You are helpful."), ("user", "Hi")], true),
1096            "<|im_start|>system\nYou are helpful.<|im_end|>\n\
1097                    <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1098        );
1099        // multi-turn: the prior assistant is BEFORE the last user query, so it carries NO
1100        // think block — the reasoning boundary the qwen arms have no concept of.
1101        assert_eq!(
1102            s35(
1103                &[
1104                    ("system", "rules"),
1105                    ("user", "task"),
1106                    ("assistant", "work"),
1107                    ("user", "more")
1108                ],
1109                true
1110            ),
1111            "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
1112             <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
1113             <|im_start|>assistant\n<think>\n"
1114        );
1115        // content is NOT trimmed (this template applies no `|trim`) — the qwen arms trim.
1116        assert_eq!(
1117            s35(&[("user", "  padded  ")], true),
1118            "<|im_start|>user\n  padded  <|im_end|>\n<|im_start|>assistant\n<think>\n"
1119        );
1120    }
1121
1122    #[test]
1123    fn step35_dispatch_beats_the_qwen_marker_arm() {
1124        // The step35 template carries every qwen marker. If the dispatch order regressed, the
1125        // think tail would still be right and the BODY would be wrong (trimmed content, wrong
1126        // tools header) — so assert a body-shaped difference, not the tail.
1127        let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
1128        let step = s35(&[("user", " pad ")], true);
1129        assert_eq!(
1130            qwen,
1131            "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
1132        );
1133        assert_eq!(
1134            step,
1135            "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
1136        );
1137        assert_ne!(qwen, step);
1138    }
1139
1140    #[test]
1141    fn step35_reasoning_effort_renders_in_the_system_turn() {
1142        assert_eq!(
1143            apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
1144            "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
1145             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1146        );
1147        assert_eq!(
1148            apply_step35_template(
1149                &[turn("system", "Be terse."), turn("user", "Hi")],
1150                true,
1151                &[],
1152                Some("low")
1153            ),
1154            "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
1155             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1156        );
1157        // with tools the order flips: Reasoning, then the system content, then `# Tools`.
1158        let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
1159        let s = apply_step35_template(
1160            &[turn("system", "Be terse."), turn("user", "q")],
1161            true,
1162            &tools,
1163            Some("medium"),
1164        );
1165        assert!(
1166            s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
1167            "{s:?}"
1168        );
1169    }
1170
1171    #[test]
1172    fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
1173        // The serve path enters via apply_chat_template_tools: the level must land in the
1174        // rendered system turn on the step35 dialect...
1175        let turns = vec![turn("user", "Hi")];
1176        let s = apply_chat_template_tools(
1177            Some(STEP35_TMPL),
1178            &turns,
1179            true,
1180            &[],
1181            ThinkMode::Default,
1182            Some("high"),
1183        )
1184        .unwrap();
1185        assert!(
1186            s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
1187            "{s:?}"
1188        );
1189        // ...None keeps the template's own default (no Reasoning: line at all)...
1190        let s = apply_chat_template_tools(
1191            Some(STEP35_TMPL),
1192            &turns,
1193            true,
1194            &[],
1195            ThinkMode::Default,
1196            None,
1197        )
1198        .unwrap();
1199        assert!(!s.contains("Reasoning:"), "{s:?}");
1200        // ...and every non-step35 dialect ignores the parameter (their templates have no
1201        // reasoning_effort input) — byte-identical with and without it.
1202        for tmpl in [
1203            None,
1204            Some(QWEN_TOOLS_TMPL),
1205            Some("... hy_User ..."),
1206            Some("... <|turn> ..."),
1207        ] {
1208            let with = apply_chat_template_tools(
1209                tmpl,
1210                &turns,
1211                true,
1212                &[],
1213                ThinkMode::Default,
1214                Some("high"),
1215            )
1216            .unwrap();
1217            let without =
1218                apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
1219                    .unwrap();
1220            assert_eq!(with, without, "template={tmpl:?}");
1221        }
1222    }
1223
1224    #[test]
1225    fn step35_tools_header_is_not_the_qwen_header() {
1226        let tools = vec![
1227            r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
1228            r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
1229        ];
1230        let s = s35_turns(
1231            vec![
1232                turn("system", "Be terse."),
1233                turn("user", "Weather in Paris?"),
1234            ],
1235            true,
1236            &tools,
1237        );
1238        assert_eq!(
1239            s,
1240            concat!(
1241                // leading system folds in BEFORE `# Tools` (the qwen arm appends it AFTER the
1242                // instruction block), and the header says "in JSONSchema format".
1243                "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
1244                "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
1245                "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
1246                "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
1247                "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
1248                "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
1249                "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
1250                "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
1251                // the nesting reminder carries literal \n...\n INSIDE the example tags, and the
1252                // Reminder list stops after 2 bullets (the qwen block has 4).
1253                "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1254                "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
1255                "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
1256                "<|im_end|>\n",
1257                "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1258                "<|im_start|>assistant\n<think>\n",
1259            )
1260        );
1261        // and it is NOT the qwen instruction block.
1262        assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
1263    }
1264
1265    #[test]
1266    fn step35_tool_results_take_their_own_role_and_group() {
1267        let tools =
1268            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1269        let turns = vec![
1270            turn("user", "both"),
1271            Turn {
1272                role: "assistant".into(),
1273                content: "checking".into(),
1274                tool_calls: vec![
1275                    ToolCall {
1276                        name: "a".into(),
1277                        params: vec![("x".into(), "1".into())],
1278                    },
1279                    ToolCall {
1280                        name: "b".into(),
1281                        params: Vec::new(),
1282                    },
1283                ],
1284            },
1285            turn("tool", "r1"),
1286            turn("tool", "r2"),
1287        ];
1288        let s = s35_turns(turns, true, &tools);
1289        let body = s
1290            .split("<|im_end|>\n")
1291            .skip(1)
1292            .collect::<Vec<_>>()
1293            .join("<|im_end|>\n");
1294        assert_eq!(
1295            body,
1296            concat!(
1297                "<|im_start|>user\nboth<|im_end|>\n",
1298                // the assistant is AFTER the last user query, so it carries a think block — empty,
1299                // because its content has no `</think>` marker.
1300                "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
1301                // NO separator before the first call and NONE between calls.
1302                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
1303                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1304                // own `tool_response` ROLE (not a user turn), and NO newlines inside the wrappers.
1305                "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
1306                "<tool_response>r2</tool_response><|im_end|>\n",
1307                "<|im_start|>assistant\n<think>\n",
1308            )
1309        );
1310    }
1311
1312    #[test]
1313    fn step35_assistant_think_split_and_the_reasoning_boundary() {
1314        // inline <think>…</think> in content splits into the reasoning block + body.
1315        assert_eq!(
1316            s35(
1317                &[
1318                    ("user", "q"),
1319                    ("assistant", "<think>\nreasoned\n</think>\nanswer")
1320                ],
1321                false
1322            ),
1323            "<|im_start|>user\nq<|im_end|>\n\
1324             <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
1325        );
1326        // no markers, but still after the last query -> an EMPTY reasoning block is emitted.
1327        assert_eq!(
1328            s35(&[("user", "q"), ("assistant", "plain")], false),
1329            "<|im_start|>user\nq<|im_end|>\n\
1330             <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
1331        );
1332        // a user turn that IS a <tool_response> wrapper does NOT move the boundary: the
1333        // assistant before it still counts as after-the-last-real-query.
1334        assert_eq!(
1335            s35(
1336                &[
1337                    ("user", "real question"),
1338                    ("assistant", "thinking about it"),
1339                    ("user", "<tool_response>r</tool_response>")
1340                ],
1341                true
1342            ),
1343            "<|im_start|>user\nreal question<|im_end|>\n\
1344             <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
1345             <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
1346             <|im_start|>assistant\n<think>\n"
1347        );
1348    }
1349
1350    #[test]
1351    fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
1352        // No `enable_thinking` in this template, so ThinkMode::NoThink cannot close the tail —
1353        // the same graceful-no-op contract the other switchless templates get. A NoThink that
1354        // silently emitted `<think>\n\n</think>\n\n` would be a prompt the model never saw.
1355        let turns = vec![turn("user", "hi")];
1356        for mode in [ThinkMode::Default, ThinkMode::NoThink] {
1357            let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
1358                .unwrap();
1359            assert!(
1360                s.ends_with("<|im_start|>assistant\n<think>\n"),
1361                "mode={mode:?} {s:?}"
1362            );
1363        }
1364    }
1365
1366    #[test]
1367    fn step35_plain_path_is_identical_through_both_renderers() {
1368        // same isolation contract the qwen arms hold: a plain request renders byte-identically
1369        // whether it enters via apply_chat_template_str or apply_chat_template_tools.
1370        let batteries: &[&[(&str, &str)]] = &[
1371            &[("user", "Hello")],
1372            &[("system", "You are helpful."), ("user", "Hi")],
1373            &[
1374                ("system", "rules"),
1375                ("user", "task"),
1376                ("assistant", "work"),
1377                ("user", "more"),
1378            ],
1379            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
1380        ];
1381        for msgs in batteries {
1382            let legacy = s35(msgs, true);
1383            let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
1384            assert_eq!(legacy, ext, "msgs={msgs:?}");
1385        }
1386    }
1387
1388    #[test]
1389    fn qwen_think_tail() {
1390        // a template string containing both markers triggers the <think> tail.
1391        let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
1392        let s = apply_chat_template_str(
1393            Some(tmpl),
1394            &[("system", "You are helpful."), ("user", "Hi")],
1395            true,
1396        );
1397        assert_eq!(
1398            s,
1399            "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1400        );
1401    }
1402}