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