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/// A serde-free JSON value tree, built by the server (which owns serde_json) and handed to
25/// the gemma4 tools arm. The compact gemma dialect needs argument/schema TYPE fidelity that a
26/// pre-rendered string cannot carry — a string `"21"` and a number `21` render differently
27/// (`<|"|>21<|"|>` vs `21`), a bool is `true`/`false`, a null is `None`, and mappings/sequences
28/// recurse. `Num` keeps the exact numeric text (serde_json `Number::to_string()`) so the
29/// rendered bytes match jinja's `{{ number }}` (Python `str()`), which this crate cannot
30/// reproduce from an f64 alone. qwen/step arms ignore this; they use `ToolCall::params`.
31#[derive(Debug, Clone, PartialEq)]
32pub enum Val {
33    Null,
34    Bool(bool),
35    Num(String),
36    Str(String),
37    Arr(Vec<Val>),
38    /// Insertion-ordered object; the gemma dialect `dictsort`s keys (case-insensitive, stable)
39    /// at render time, so ties keep this insertion order — matching jinja's `| dictsort`.
40    Obj(Vec<(String, Val)>),
41}
42
43/// One tool call attached to a prior assistant turn.
44/// `params` values are pre-rendered strings for the qwen/step arms (string arguments raw,
45/// everything else JSON-rendered by the caller). `args`/`id` carry the gemma4 arm's typed
46/// arguments and the OpenAI `tool_calls[].id` used to resolve tool-response names.
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct ToolCall {
49    pub name: String,
50    pub params: Vec<(String, String)>,
51    /// gemma4: typed arguments, dictsorted and dialect-rendered by the gemma arm.
52    pub args: Vec<(String, Val)>,
53    /// gemma4: the call id, matched against a following tool turn's `tool_call_id`.
54    pub id: Option<String>,
55}
56
57/// One chat turn for the tools-capable renderer (`apply_chat_template_tools`).
58/// The `reasoning`/`tool_call_id`/`tool_name`/`tool_responses` fields are read ONLY by the
59/// gemma4 arm; the qwen/step arms use `role`/`content`/`tool_calls` and leave the rest default.
60#[derive(Debug, Clone, Default, PartialEq)]
61pub struct Turn {
62    pub role: String,
63    pub content: String,
64    pub tool_calls: Vec<ToolCall>,
65    /// gemma4: assistant reasoning re-rendered as a `<|channel>thought` span (only for a
66    /// tool_calls-carrying assistant after the last user message — the template's guard).
67    pub reasoning: Option<String>,
68    /// gemma4: on a role:"tool" turn, the OpenAI `tool_call_id` used to resolve the response
69    /// name against the preceding assistant's `tool_calls[].id`.
70    pub tool_call_id: Option<String>,
71    /// gemma4: on a role:"tool" turn, the message's own `name` field (fallback when the id
72    /// does not resolve).
73    pub tool_name: Option<String>,
74    /// gemma4 native (Google) responses embedded on an assistant turn: (name, response value).
75    /// OpenAI histories leave this empty and use role:"tool" turns instead.
76    pub tool_responses: Vec<(String, Val)>,
77}
78
79/// Thinking control (owner directive 2026-08-07: every supported model is a thinking model,
80/// one serve surface maps to each arch's native mechanism).
81///
82/// - `Default` = the template's OWN default, byte-identical to the pre-surface render:
83///   qwen class opens `<think>\n` (thinking ON), gemma4 renders the CLOSED thought channel
84///   (its `enable_thinking | default(false)`), hy3 renders `reasoning_effort:no_think`.
85/// - `NoThink` = thinking OFF via the arch's native off-switch: qwen
86///   `enable_thinking=false` (closed `<think>\n\n</think>\n\n`), gemma4 closed thought
87///   channel, hy3 `no_think`. On step35 — whose `<think>` tail is unconditional — it clamps
88///   to the lowest effort level instead (`Reasoning: low`).
89/// - `Think` = thinking explicitly ON: qwen open `<think>\n` (same bytes as its default),
90///   gemma4 `<|think|>\n` injected into the system turn + an OPEN generation turn, hy3
91///   an open `<think:opensource>` channel at the requested effort.
92///
93/// On templates with no switch at all the non-native direction is a graceful no-op.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ThinkMode {
96    Default,
97    NoThink,
98    Think,
99}
100
101/// Render messages into the prompt string.
102///
103/// `template` is the raw GGUF chat_template (used only to decide qwen3.5-vs-plain
104/// chatml behavior — we detect the `<think>` generation tail by substring). When
105/// `None`, plain ChatML is produced.
106pub fn apply_chat_template_str(
107    template: Option<&str>,
108    messages: &[(&str, &str)],
109    add_generation_prompt: bool,
110) -> String {
111    // Tencent Hy3 (`hy_v3`): a completely different special-token dialect (no ChatML).
112    // Detected by its `hy_User` token literal; rendered by the dedicated arm below.
113    // Legacy path = the template's own default ("no_think") — byte-identical to history.
114    if template.is_some_and(|t| t.contains("hy_User")) {
115        return apply_hy3_template(messages, add_generation_prompt, "no_think");
116    }
117    // StepFun Step-3.7-Flash (arch `step35`): a ChatML *dialect* — same `<|im_start|>` framing,
118    // different everything else (see `apply_step35_template`). Detected by its
119    // `render_message_content` macro, which no other committed template defines. This check MUST
120    // precede the qwen `<think>`-tail detection below: the step35 template contains both markers,
121    // so the qwen arm would produce the right generation tail with the wrong turn bodies.
122    if template.is_some_and(|t| t.contains("render_message_content")) {
123        let turns: Vec<Turn> = messages
124            .iter()
125            .map(|(r, c)| Turn {
126                role: r.to_string(),
127                content: c.to_string(),
128                tool_calls: Vec::new(),
129                ..Default::default()
130            })
131            .collect();
132        return apply_step35_template(&turns, add_generation_prompt, &[], None);
133    }
134    // gemma4: `<|turn>role\n{content}<turn|>\n` dialect; generation prompt appends
135    // `<|turn>model\n` + the CLOSED thought channel (`<|channel>thought\n<channel|>` — the
136    // template's enable_thinking-false default). bos comes from encode(add_special) — the
137    // template's `{{ bos_token }}` is NOT re-emitted here (double-BOS trap).
138    // Legacy path = thinking OFF (the template's `default(false)`) — byte-identical to history.
139    if template.is_some_and(|t| t.contains("<|turn>")) {
140        return apply_gemma4_template(messages, add_generation_prompt, false);
141    }
142    // qwen3.5 template emits a `<think>\n` tail on the generation prompt by default.
143    let qwen_think = template
144        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
145        .unwrap_or(false);
146
147    let mut out = String::new();
148    for (i, (role, content)) in messages.iter().enumerate() {
149        let content = content.trim();
150        match *role {
151            "system" => {
152                // template requires system at the beginning; we render it wherever
153                // it appears at index 0 (the common case).
154                let _ = i;
155                out.push_str("<|im_start|>system\n");
156                out.push_str(content);
157                out.push_str("<|im_end|>\n");
158            }
159            "user" => {
160                out.push_str("<|im_start|>user\n");
161                out.push_str(content);
162                out.push_str("<|im_end|>\n");
163            }
164            "assistant" => {
165                out.push_str("<|im_start|>assistant\n");
166                out.push_str(content);
167                out.push_str("<|im_end|>\n");
168            }
169            other => {
170                // unsupported role in this minimal renderer; emit as a generic turn.
171                out.push_str("<|im_start|>");
172                out.push_str(other);
173                out.push('\n');
174                out.push_str(content);
175                out.push_str("<|im_end|>\n");
176            }
177        }
178    }
179
180    if add_generation_prompt {
181        out.push_str("<|im_start|>assistant\n");
182        if qwen_think {
183            out.push_str("<think>\n");
184        }
185    }
186
187    out
188}
189
190/// The fixed tool-calling instruction block of the qwen3.5/3.6-class templates. Byte-for-byte
191/// the string literal shared by ornith9b / agentworld / ref-qwen36-35b
192/// (research/onboard-ornith-20260801/templates/*.jinja) and the deployed GGUF dumps.
193const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
194following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
195<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
196This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
197</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
198format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
199XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
200your function call in natural language BEFORE the function call, but NOT after\n- If there is \
201no function call available, answer the question like normal with your current knowledge and do \
202not tell the user about function calls\n</IMPORTANT>";
203
204/// Tools-capable chat rendering (serve-tools lane, 2026-08-02). Reproduces the TOOLS branch of
205/// the qwen3.5/3.6-class ChatML templates exactly (verified against the committed dumps AND the
206/// deployed GGUFs' embedded templates, byte-identical):
207///
208///   - tools present  -> `<|im_start|>system\n# Tools\n\nYou have access to the following
209///     functions:\n\n<tools>` + `\n{tool json}` each + `\n</tools>` + the fixed instruction
210///     block; a leading system turn's trimmed content is appended after `\n\n`; `<|im_end|>\n`.
211///   - assistant turns with `tool_calls` -> content then `<tool_call>\n<function=NAME>\n`
212///     (+`\n\n` separator when content is non-empty; later calls separated by `\n`),
213///     `<parameter=K>\nV\n</parameter>\n` each, `</function>\n</tool_call>`, then `<|im_end|>\n`.
214///   - `tool` turns -> grouped into ONE user turn: `<|im_start|>user` opens a run of
215///     consecutive tool messages, each `\n<tool_response>\n{content}\n</tool_response>`,
216///     `<|im_end|>\n` closes the run.
217///   - generation prompt -> `<|im_start|>assistant\n` + `<think>\n` (template default) or
218///     `<think>\n\n</think>\n\n` (`ThinkMode::NoThink` = the template's `enable_thinking=false`
219///     switch; ignored when the template has no `enable_thinking`).
220///
221/// The no-tools/no-tool-turns/`Default`-think case renders byte-identically to
222/// `apply_chat_template_str` (pinned by `tools_renderer_matches_legacy_when_plain`); callers
223/// that want the hard isolation guarantee keep calling the legacy function on that path.
224/// Errors (never on the plain path): tools/tool turns on a template without a tools branch
225/// (hy3 / gemma4 / bare ChatML).
226///
227/// `reasoning_effort` is the step35 dialect's three-level control ("low"/"medium"/"high" —
228/// a STRING rendered into the system turn, not a think switch; see `apply_step35_template`).
229/// Every other dialect ignores it (their templates have no `reasoning_effort` input), and
230/// `None` is the step35 template's own default (no `Reasoning:` line). The server only
231/// supplies `Some` for models whose template consumes it (`ModelCaps::effort_levels`), so
232/// non-step35 prompts stay byte-identical by construction, not by luck.
233pub fn apply_chat_template_tools(
234    template: Option<&str>,
235    turns: &[Turn],
236    add_generation_prompt: bool,
237    tools_json: &[String],
238    think: ThinkMode,
239    reasoning_effort: Option<&str>,
240) -> Result<String, String> {
241    // Compat entry (no structured tools): CLI bins + qwen/step/hy3 tests. The gemma4 arm
242    // needs typed tool DEFINITIONS, so the serve path calls `_ex` with them.
243    apply_chat_template_tools_ex(
244        template,
245        turns,
246        add_generation_prompt,
247        tools_json,
248        &[],
249        think,
250        reasoning_effort,
251    )
252}
253
254/// `apply_chat_template_tools` plus the gemma4 arm's structured tool `function` objects
255/// (`tools_struct`). Every non-gemma dialect ignores `tools_struct`.
256#[allow(clippy::too_many_arguments)]
257pub fn apply_chat_template_tools_ex(
258    template: Option<&str>,
259    turns: &[Turn],
260    add_generation_prompt: bool,
261    tools_json: &[String],
262    tools_struct: &[Val],
263    think: ThinkMode,
264    reasoning_effort: Option<&str>,
265) -> Result<String, String> {
266    let has_tool_features = !tools_json.is_empty()
267        || turns
268            .iter()
269            .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
270    // A template "has a tools branch" if it carries the qwen/step `<tools>` block OR the
271    // gemma4 tooluse dialect (`<|turn>` turn framing AND the `<|tool>` declaration marker).
272    let tools_branch = template.is_some_and(template_has_tools_branch);
273    if has_tool_features && !tools_branch {
274        return Err("model chat template has no tools branch".into());
275    }
276    // step35: its own dialect all the way through, tools included (unlike hy3/gemma4, which
277    // reject tool features — step35 HAS a tools branch and it is reproduced). Must precede the
278    // qwen arm: the step35 template contains `<tools>`, `<think>` and `add_generation_prompt`,
279    // so every qwen marker check below matches it. `ThinkMode` is ignored (no `enable_thinking`
280    // in this template => `think_switch` is false => NoThink is already a documented no-op);
281    // `reasoning_effort` is this dialect's own control and is honored here.
282    if template.is_some_and(|t| t.contains("render_message_content")) {
283        return Ok(apply_step35_template(
284            turns,
285            add_generation_prompt,
286            tools_json,
287            reasoning_effort,
288        ));
289    }
290    // gemma4 TOOLUSE dialect (`<|turn>` turn framing + the `<|tool>` declaration marker):
291    // the official Google tooluse template is the rendering LAW (research/gemma4-tools-20260817
292    // /official-tooluse-template.jinja). Engages for tool DEFINITIONS, tool_calls, tool-role
293    // turns AND plain/thinking requests on this trunk. A `<|turn>` template WITHOUT `<|tool>`
294    // has no committed tools reference and falls through to the reject/plain arm below.
295    // Must precede the hy3/`<|turn>` arm (which would otherwise reject tools) and the qwen
296    // marker checks (the tooluse template carries no `<tools>`, so it would not match those).
297    if template.is_some_and(|t| t.contains("<|turn>") && t.contains("<|tool>")) {
298        // QAT-trunk variant emits a CLOSED thought channel on the thinking-off generation
299        // prompt; the official served trunk emits a bare `<|turn>model\n`. Keyed on the exact
300        // gen-prompt literal, which is present only in the QAT template's tail (verified:
301        // research/gemma4-tools-20260817 template diff).
302        let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
303        return Ok(apply_gemma4_tools_template(
304            turns,
305            add_generation_prompt,
306            tools_struct,
307            think == ThinkMode::Think,
308            closed_tail,
309        ));
310    }
311    if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
312        // hy3 / plain-gemma4 dialects: no committed tools rendering reference — reject tool
313        // features even if the raw jinja happens to mention <tools>. ThinkMode maps to each
314        // arch's native mechanism (thinking goldens, render-thinking-goldens.py):
315        //   hy3    -> the template's own reasoning_effort input: no_think (its default,
316        //             = ThinkMode::Default/NoThink) or low/high (open think, ThinkMode::Think
317        //             at the level the caller resolved — effort carries it).
318        //   gemma4 -> enable_thinking: default(false) = Default/NoThink;
319        //             Think = <|think|> system token + open generation turn.
320        if has_tool_features {
321            return Err("tools are not supported on this model's chat-template dialect".into());
322        }
323        let messages: Vec<(&str, &str)> = turns
324            .iter()
325            .map(|t| (t.role.as_str(), t.content.as_str()))
326            .collect();
327        if template.is_some_and(|t| t.contains("hy_User")) {
328            // hy3's accepted set is exactly no_think|low|high; OpenAI medium clamps to low
329            // (the template has no medium level and raises on unknown strings).
330            let effort = match (think, reasoning_effort) {
331                (ThinkMode::Think, Some("high")) => "high",
332                (ThinkMode::Think, _) => "low",
333                _ => "no_think",
334            };
335            return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
336        }
337        return Ok(apply_gemma4_template(
338            &messages,
339            add_generation_prompt,
340            think == ThinkMode::Think,
341        ));
342    }
343    let qwen_think = template
344        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
345        .unwrap_or(false);
346    let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
347
348    let mut out = String::new();
349    // Tools system header replaces the plain system turn (template law: the leading system
350    // turn's content is folded INTO the tools block).
351    let mut skip_leading_system = false;
352    if !tools_json.is_empty() {
353        out.push_str("<|im_start|>system\n");
354        out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
355        for tool in tools_json {
356            out.push('\n');
357            out.push_str(tool);
358        }
359        out.push_str("\n</tools>");
360        out.push_str(QWEN_TOOLS_INSTRUCTION);
361        if let Some(first) = turns.first() {
362            if first.role == "system" {
363                skip_leading_system = true;
364                let content = first.content.trim();
365                if !content.is_empty() {
366                    out.push_str("\n\n");
367                    out.push_str(content);
368                }
369            }
370        }
371        out.push_str("<|im_end|>\n");
372    }
373
374    for (i, turn) in turns.iter().enumerate() {
375        if i == 0 && skip_leading_system {
376            continue;
377        }
378        let content = turn.content.trim();
379        match turn.role.as_str() {
380            "system" => {
381                out.push_str("<|im_start|>system\n");
382                out.push_str(content);
383                out.push_str("<|im_end|>\n");
384            }
385            "user" => {
386                out.push_str("<|im_start|>user\n");
387                out.push_str(content);
388                out.push_str("<|im_end|>\n");
389            }
390            "assistant" => {
391                out.push_str("<|im_start|>assistant\n");
392                out.push_str(content);
393                for (k, call) in turn.tool_calls.iter().enumerate() {
394                    if k == 0 {
395                        if !content.is_empty() {
396                            out.push_str("\n\n");
397                        }
398                    } else {
399                        out.push('\n');
400                    }
401                    out.push_str("<tool_call>\n<function=");
402                    out.push_str(&call.name);
403                    out.push_str(">\n");
404                    for (key, value) in &call.params {
405                        out.push_str("<parameter=");
406                        out.push_str(key);
407                        out.push_str(">\n");
408                        out.push_str(value);
409                        out.push_str("\n</parameter>\n");
410                    }
411                    out.push_str("</function>\n</tool_call>");
412                }
413                out.push_str("<|im_end|>\n");
414            }
415            "tool" => {
416                if i == 0 || turns[i - 1].role != "tool" {
417                    out.push_str("<|im_start|>user");
418                }
419                out.push_str("\n<tool_response>\n");
420                out.push_str(content);
421                out.push_str("\n</tool_response>");
422                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
423                    out.push_str("<|im_end|>\n");
424                }
425            }
426            other => {
427                // parity with the legacy renderer's generic-turn arm.
428                out.push_str("<|im_start|>");
429                out.push_str(other);
430                out.push('\n');
431                out.push_str(content);
432                out.push_str("<|im_end|>\n");
433            }
434        }
435    }
436
437    if add_generation_prompt {
438        out.push_str("<|im_start|>assistant\n");
439        if qwen_think {
440            if think == ThinkMode::NoThink && think_switch {
441                out.push_str("<think>\n\n</think>\n\n");
442            } else {
443                out.push_str("<think>\n");
444            }
445        }
446    }
447    Ok(out)
448}
449
450/// The fixed tool-calling instruction block of the StepFun `step35` template. NOT the same
451/// string as `QWEN_TOOLS_INSTRUCTION` — three differences, all load-bearing: the header says
452/// "in JSONSchema format", the nesting reminder carries literal `\n...\n` inside the
453/// `<function=...>` / `<tool_call>` examples, and the Reminder list has 2 bullets instead of 4
454/// (no "optional reasoning BEFORE the call" and no "answer normally if no function is
455/// available"). Copied byte-for-byte out of the shipped template
456/// (`research/step37-bringup-20260802/raw/chat_template.jinja`, == the GGUF's own
457/// `tokenizer.chat_template`).
458const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
459following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
460<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
461This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
462</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
463format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
464...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
465
466/// StepFun Step-3.7-Flash (GGUF arch `step35`) chat template.
467///
468/// A ChatML *dialect*, not ChatML: it shares the `<|im_start|>role\n…<|im_end|>\n` frame and
469/// nothing else. Reproduced from the shipped jinja, and pinned test-by-test against goldens
470/// rendered from that jinja under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF
471/// transformers and llama.cpp's minja both parse chat templates with
472/// (`research/step37-p2-20260806/render_step35_template.py`, goldens committed under `raw/`).
473///
474/// Where it differs from the qwen3.5/3.6 arms above — every one of these silently corrupts the
475/// prompt if the qwen arm is reused:
476///
477/// | | qwen3.5/3.6 | step35 |
478/// |---|---|---|
479/// | reasoning level | `enable_thinking` bool | `Reasoning: {low,medium,high}\n\n` prefix inside the system turn |
480/// | `<think>` tail | switchable | **unconditional** — no `enable_thinking`, so `ThinkMode::NoThink` is a no-op |
481/// | prior assistant turns | content only | turns AFTER the last real user query also carry `<think>\n{reasoning}\n</think>\n` |
482/// | 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 |
483/// | content | `\|trim`med | **not** trimmed |
484/// | tools header | `following functions:` | `following functions in JSONSchema format:` |
485/// | call separators | `\n\n` after content, `\n` between calls | **none** |
486/// | leading system + tools | appended AFTER the instruction block | folded in BEFORE `# Tools` |
487///
488/// `reasoning_effort` is the model's headline three-level control (low/medium/high per the
489/// StepFun model card). It is a parameter here rather than a `ThinkMode`: the value is a
490/// *string in the system turn*, so a bool cannot carry it. The serve path supplies it through
491/// `apply_chat_template_tools` (worker `Request::reasoning_effort`, mapped from the OpenAI
492/// `reasoning_effort` body field when `ModelCaps::effort_levels` is set); `None` — the
493/// legacy-str path and every non-step35 model — renders the template's own default
494/// (no `Reasoning:` line at all).
495///
496/// BOS is NOT emitted (the jinja's `{{bos_token}}` is dropped): memra's `encode(add_special)`
497/// prepends it from `tokenizer.ggml.add_bos_token`/`bos_token_id` — the same double-BOS trap the
498/// gemma4 arm documents.
499///
500/// ONE deliberate divergence: the jinja's body loop has no `else`, so a role outside
501/// {system, user, assistant, tool} renders as **nothing at all** — the turn silently vanishes
502/// from the prompt. memra renders it as a generic `<|im_start|>{role}\n{content}<|im_end|>\n`
503/// turn instead, matching the other arms here. A dropped turn is the worse failure, and this
504/// branch cannot fire on the serve surface: OpenAI roles are exactly system/user/assistant/tool,
505/// all four of which are reproduced byte-for-byte.
506///
507/// Not reproduced (needs data `Turn` does not carry, tracked, cannot fire from an OpenAI client):
508/// the `name == "observation"` alias that renames a non-leading `system` turn's role to
509/// `observation`, and the `<im_patch>` image-content path (this is a VLM; memra is text-only here).
510fn apply_step35_template(
511    turns: &[Turn],
512    add_generation_prompt: bool,
513    tools_json: &[String],
514    reasoning_effort: Option<&str>,
515) -> String {
516    let mut out = String::new();
517    let leading_system = turns.first().filter(|t| t.role == "system");
518
519    // --- system header. Two branches in the jinja, and the ORDER differs between them.
520    if !tools_json.is_empty() {
521        out.push_str("<|im_start|>system\n");
522        if let Some(effort) = reasoning_effort {
523            out.push_str("Reasoning: ");
524            out.push_str(effort);
525            out.push_str("\n\n");
526        }
527        if let Some(sys) = leading_system {
528            // unconditional `content + '\n\n'` — no emptiness check, unlike the qwen arm.
529            out.push_str(&sys.content);
530            out.push_str("\n\n");
531        }
532        out.push_str(
533            "# Tools\n\nYou have access to the following functions in JSONSchema \
534                      format:\n\n<tools>",
535        );
536        for tool in tools_json {
537            out.push('\n');
538            out.push_str(tool);
539        }
540        out.push_str("\n</tools>");
541        out.push_str(STEP35_TOOLS_INSTRUCTION);
542        out.push_str("<|im_end|>\n");
543    } else if let Some(sys) = leading_system {
544        out.push_str("<|im_start|>system\n");
545        if let Some(effort) = reasoning_effort {
546            out.push_str("Reasoning: ");
547            out.push_str(effort);
548            out.push_str("\n\n");
549        }
550        out.push_str(&sys.content);
551        out.push_str("<|im_end|>\n");
552    } else if let Some(effort) = reasoning_effort {
553        out.push_str("<|im_start|>system\nReasoning: ");
554        out.push_str(effort);
555        out.push_str("\n\n<|im_end|>\n");
556    }
557
558    // --- last_query_index: the index of the LAST `user` turn that is a real query, i.e. whose
559    // content is not itself a `<tool_response>…</tool_response>` wrapper (a client replaying tool
560    // output as a user turn must not reset the reasoning boundary). Default len-1 when there is
561    // no such turn, exactly as the jinja's namespace initializer does.
562    let last_query_index = turns
563        .iter()
564        .enumerate()
565        .rev()
566        .find(|(_, t)| {
567            t.role == "user"
568                && !(t.content.starts_with("<tool_response>")
569                    && t.content.ends_with("</tool_response>"))
570        })
571        .map(|(i, _)| i)
572        .unwrap_or(turns.len().saturating_sub(1));
573
574    for (i, turn) in turns.iter().enumerate() {
575        let content = &turn.content; // NOT trimmed: this template applies no `|trim`
576        match turn.role.as_str() {
577            // the leading system turn lives in the header above; later ones are body turns.
578            "system" if i == 0 => {}
579            "system" | "user" => {
580                out.push_str("<|im_start|>");
581                out.push_str(&turn.role);
582                out.push('\n');
583                out.push_str(content);
584                out.push_str("<|im_end|>\n");
585            }
586            "assistant" => {
587                // Split an inline `<think>…</think>` out of content, mirroring the jinja's
588                // string surgery exactly: reasoning = text before the FIRST `</think>`, with
589                // trailing newlines stripped, then everything after the LAST `<think>` in that
590                // prefix, with leading newlines stripped; body = after the LAST `</think>`,
591                // leading newlines stripped.
592                let (reasoning, body): (String, &str) = match content.find("</think>") {
593                    Some(first) => {
594                        let pre = content[..first].trim_end_matches('\n');
595                        let pre = match pre.rfind("<think>") {
596                            Some(o) => &pre[o + "<think>".len()..],
597                            None => pre,
598                        };
599                        let last = content.rfind("</think>").unwrap();
600                        (
601                            pre.trim_start_matches('\n').to_string(),
602                            content[last + "</think>".len()..].trim_start_matches('\n'),
603                        )
604                    }
605                    None => (String::new(), content.as_str()),
606                };
607                out.push_str("<|im_start|>assistant\n");
608                if i > last_query_index {
609                    out.push_str("<think>\n");
610                    out.push_str(&reasoning);
611                    out.push_str("\n</think>\n");
612                }
613                out.push_str(body);
614                // NO separator before or between calls (the qwen arm's `\n\n`/`\n` would corrupt).
615                for call in &turn.tool_calls {
616                    out.push_str("<tool_call>\n<function=");
617                    out.push_str(&call.name);
618                    out.push_str(">\n");
619                    for (key, value) in &call.params {
620                        out.push_str("<parameter=");
621                        out.push_str(key);
622                        out.push_str(">\n");
623                        out.push_str(value);
624                        out.push_str("\n</parameter>\n");
625                    }
626                    out.push_str("</function>\n</tool_call>");
627                }
628                out.push_str("<|im_end|>\n");
629            }
630            "tool" => {
631                // own role, and consecutive tool turns share ONE `tool_response` turn.
632                if i == 0 || turns[i - 1].role != "tool" {
633                    out.push_str("<|im_start|>tool_response\n");
634                }
635                out.push_str("<tool_response>");
636                out.push_str(content);
637                out.push_str("</tool_response>");
638                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
639                    out.push_str("<|im_end|>\n");
640                }
641            }
642            other => {
643                // the jinja drops this turn entirely; see the divergence note above.
644                out.push_str("<|im_start|>");
645                out.push_str(other);
646                out.push('\n');
647                out.push_str(content);
648                out.push_str("<|im_end|>\n");
649            }
650        }
651    }
652
653    if add_generation_prompt {
654        out.push_str("<|im_start|>assistant\n<think>\n");
655    }
656    out
657}
658
659/// Text-only reproduction of the Hy3 `chat_template.jinja` (no tools, no `is_training`).
660/// `effort` is the template's own `reasoning_effort` input — `"no_think"` / `"low"` /
661/// `"high"`, its full accepted set (the jinja `raise_exception`s on anything else; undefined
662/// defaults to `'no_think'`, so callers with no opinion pass `"no_think"`):
663///   - `{bos}{system…}<|reasoning_mode:opensource|>reasoning_effort:{effort}` header
664///     (system turns concatenate into the header, before any user turn);
665///   - `user`      -> `<|hy_User:opensource|>{content}`
666///   - `assistant` -> `<|hy_Assistant:opensource|><think:opensource></think:opensource>{content}<|hy_eos:opensource|>`
667///     (non-last turns; history turns render CLOSED think at every effort — the template
668///     opens only turns past `last_user_index`, and OpenAI history carries no reasoning);
669///   - generation prompt: `<|hy_Assistant:opensource|><think:opensource></think:opensource>`
670///     at no_think, `…<think:opensource>` (OPEN think) at low/high.
671/// Content is NOT trimmed (the Hy3 template applies no `|trim`). Goldens: rendered from the
672/// pinned tencent/Hy3 template (sha 7fc351fe…, snapshot 716aa724) by
673/// `research/step-sku-20260807/render-thinking-goldens.py`.
674fn apply_hy3_template(
675    messages: &[(&str, &str)],
676    add_generation_prompt: bool,
677    effort: &str,
678) -> String {
679    const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
680    const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
681    const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
682    const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
683    const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
684    const THINK_BEGIN: &str = "<think:opensource>";
685    const THINK_END: &str = "</think:opensource>";
686
687    debug_assert!(
688        matches!(effort, "no_think" | "low" | "high"),
689        "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
690    );
691    let mut out = String::from(BOS);
692    for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
693        let _ = role;
694        out.push_str(content);
695    }
696    out.push_str(REASONING);
697    out.push_str("reasoning_effort:");
698    out.push_str(effort);
699
700    let mut last_is_assistant = false;
701    let n = messages.len();
702    for (i, (role, content)) in messages.iter().enumerate() {
703        last_is_assistant = false;
704        match *role {
705            "user" => {
706                out.push_str(USER);
707                out.push_str(content);
708            }
709            "assistant" => {
710                out.push_str(ASSISTANT);
711                out.push_str(THINK_BEGIN);
712                out.push_str(THINK_END);
713                out.push_str(content);
714                if i + 1 < n {
715                    out.push_str(EOS);
716                } // template: `not loop.last` gets eos
717                last_is_assistant = true;
718            }
719            _ => {} // system handled in the header; tool turns are out of scope here
720        }
721    }
722    if add_generation_prompt && !last_is_assistant {
723        out.push_str(ASSISTANT);
724        out.push_str(THINK_BEGIN);
725        if effort == "no_think" {
726            out.push_str(THINK_END); // low/high leave the think channel OPEN (the golden)
727        }
728    }
729    out
730}
731
732/// gemma4 turn dialect (text-only path of the GGUF template, verified against the dumped
733/// jinja — sha 36e3a42e…, goldens `research/step-sku-20260807/raw/thinking-goldens.txt`):
734/// roles map assistant->model; each turn = `<|turn>{role}\n{content|trim}<turn|>\n`.
735///
736/// THINKING is `enable_thinking`, and its default is OFF (`enable_thinking | default(false)`)
737/// — the inverse of the qwen class:
738///   - thinking OFF (default): generation prompt = `<|turn>model\n<|channel>thought\n<channel|>`
739///     (the CLOSED thought channel — the model may not think);
740///   - thinking ON: a `<|think|>\n` token is injected at the very top of the FIRST system
741///     turn (a system turn is CREATED if the request has none), and the generation prompt is
742///     the bare `<|turn>model\n` — the thought channel is left to the model.
743fn apply_gemma4_template(
744    messages: &[(&str, &str)],
745    add_generation_prompt: bool,
746    thinking: bool,
747) -> String {
748    let mut out = String::new();
749    let mut msgs = messages;
750    // System header block: fires when thinking is on OR a leading system turn exists.
751    let leading_system = msgs.first().filter(|(r, _)| *r == "system");
752    if thinking || leading_system.is_some() {
753        out.push_str("<|turn>system\n");
754        if thinking {
755            out.push_str("<|think|>\n");
756        }
757        if let Some((_, content)) = leading_system {
758            out.push_str(content.trim());
759            msgs = &msgs[1..];
760        }
761        out.push_str("<turn|>\n");
762    }
763    for (role, content) in msgs {
764        let role = if *role == "assistant" { "model" } else { role };
765        out.push_str("<|turn>");
766        out.push_str(role);
767        out.push('\n');
768        out.push_str(content.trim());
769        out.push_str("<turn|>\n");
770    }
771    if add_generation_prompt {
772        out.push_str("<|turn>model\n");
773        if !thinking {
774            out.push_str("<|channel>thought\n<channel|>");
775        }
776    }
777    out
778}
779
780/// A template carries a tools branch iff it has the qwen/step `<tools>` block, or the gemma4
781/// tooluse dialect (both the `<|turn>` turn framing and the `<|tool>` declaration marker).
782/// hy3 (`hy_User`) never has one. Shared by the renderer dispatch and the worker caps probe.
783pub fn template_has_tools_branch(t: &str) -> bool {
784    if t.contains("hy_User") {
785        return false;
786    }
787    t.contains("<tools>") || (t.contains("<|turn>") && t.contains("<|tool>"))
788}
789
790// ---- gemma4 tooluse dialect ---------------------------------------------------------------
791// A faithful port of research/gemma4-tools-20260817/official-tooluse-template.jinja (extracted
792// byte-identical from the official Q8_0-MTP GGUF — the served trunk). The jinja is the LAW;
793// byte parity is pinned by research/gemma4-tools-20260817/fixtures (the `gemma4_tools_fixtures`
794// test in memra-server renders the official jinja under jinja2 and asserts equality). Deviation
795// from the jinja: an unresolved tool-response name falls back to "unknown" instead of crashing
796// on `str + None` (the jinja's `.get('name') | default('unknown')` renders None, then the
797// concat raises) — unreachable from OpenAI histories, where the id always resolves.
798
799/// jinja `| dictsort`: case-insensitive by key, STABLE (ties keep insertion order).
800fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
801    let mut v: Vec<&(String, Val)> = pairs.iter().collect();
802    v.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
803    v
804}
805
806/// jinja `format_argument(argument, escape_keys)`: strings wrapped in `<|"|>`, bools `true`/
807/// `false`, mappings `{k:v,...}` (keys bare unless `escape_keys`, dictsorted, recursive),
808/// sequences `[v,...]`, null -> `None` (jinja `{{ none }}`), numbers bare.
809fn format_argument(v: &Val, escape_keys: bool) -> String {
810    match v {
811        Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
812        Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
813        Val::Obj(pairs) => {
814            let mut out = String::from("{");
815            for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
816                if i > 0 {
817                    out.push(',');
818                }
819                if escape_keys {
820                    out.push_str(&format!("<|\"|>{k}<|\"|>"));
821                } else {
822                    out.push_str(k);
823                }
824                out.push(':');
825                out.push_str(&format_argument(val, escape_keys));
826            }
827            out.push('}');
828            out
829        }
830        Val::Arr(items) => {
831            let mut out = String::from("[");
832            for (i, item) in items.iter().enumerate() {
833                if i > 0 {
834                    out.push(',');
835                }
836                out.push_str(&format_argument(item, escape_keys));
837            }
838            out.push(']');
839            out
840        }
841        Val::Null => "None".to_string(),
842        Val::Num(s) => s.clone(),
843    }
844}
845
846/// jinja `strip_thinking(text)`: drop every `<|channel>...<channel|>` span, then `| trim`.
847/// Split on `<channel|>`; for each part, keep everything before a `<|channel>` (dropping the
848/// channel body), else keep the whole part.
849fn strip_thinking(text: &str) -> String {
850    let mut result = String::new();
851    for part in text.split("<channel|>") {
852        match part.find("<|channel>") {
853            Some(o) => result.push_str(&part[..o]),
854            None => result.push_str(part),
855        }
856    }
857    result.trim().to_string()
858}
859
860fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
861    obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
862}
863fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
864    match v {
865        Val::Obj(p) => Some(p),
866        _ => None,
867    }
868}
869fn as_str(v: &Val) -> Option<&str> {
870    match v {
871        Val::Str(s) => Some(s),
872        _ => None,
873    }
874}
875/// jinja truthiness for `if value[...]`: None/false/""/[]/{} are falsy.
876fn truthy(v: &Val) -> bool {
877    match v {
878        Val::Null => false,
879        Val::Bool(b) => *b,
880        Val::Str(s) => !s.is_empty(),
881        Val::Num(s) => s != "0" && s != "0.0",
882        Val::Arr(a) => !a.is_empty(),
883        Val::Obj(o) => !o.is_empty(),
884    }
885}
886
887/// jinja comma helper: emit ',' iff a prior element was written in THIS property object, then
888/// mark that at least one has been written.
889fn comma(out: &mut String, add: &mut bool) {
890    if *add {
891        out.push(',');
892    } else {
893        *add = true;
894    }
895}
896
897/// jinja `format_parameters(properties, _required_unused, filter_keys)`. The second jinja arg
898/// (`required`) is never referenced in the macro body, so it is dropped here.
899fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
900    const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
901    let mut found_first = false;
902    for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
903        if filter_keys && STANDARD.contains(&key.as_str()) {
904            continue;
905        }
906        if found_first {
907            out.push(',');
908        }
909        found_first = true;
910        out.push_str(key);
911        out.push_str(":{");
912        let vobj = as_obj(value);
913        let mut add = false;
914        // description
915        if let Some(d) = vobj
916            .and_then(|o| val_get(o, "description"))
917            .filter(|d| truthy(d))
918        {
919            out.push_str("description:<|\"|>");
920            out.push_str(as_str(d).unwrap_or(""));
921            out.push_str("<|\"|>");
922            add = true;
923        }
924        let ty_up = vobj
925            .and_then(|o| val_get(o, "type"))
926            .and_then(as_str)
927            .map(|s| s.to_uppercase());
928        match ty_up.as_deref() {
929            Some("STRING") => {
930                if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
931                    comma(out, &mut add);
932                    out.push_str("enum:");
933                    out.push_str(&format_argument(en, true));
934                }
935            }
936            Some("ARRAY") => {
937                if let Some(items) = vobj
938                    .and_then(|o| val_get(o, "items"))
939                    .filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
940                {
941                    comma(out, &mut add);
942                    out.push_str("items:{");
943                    format_items(out, as_obj(items).unwrap());
944                    out.push('}');
945                }
946            }
947            _ => {}
948        }
949        // nullable
950        if vobj
951            .and_then(|o| val_get(o, "nullable"))
952            .is_some_and(truthy)
953        {
954            comma(out, &mut add);
955            out.push_str("nullable:true");
956        }
957        // OBJECT: nested properties + required
958        if ty_up.as_deref() == Some("OBJECT") {
959            if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
960                comma(out, &mut add);
961                out.push_str("properties:{");
962                format_parameters(out, sub, false);
963                out.push('}');
964            } else if let Some(o) = vobj {
965                // no explicit `properties`: treat the value's own keys as sub-properties,
966                // filtering the standard schema keys (jinja `filter_keys=true` branch).
967                comma(out, &mut add);
968                out.push_str("properties:{");
969                format_parameters(out, o, true);
970                out.push('}');
971            }
972            if let Some(req) = vobj
973                .and_then(|o| val_get(o, "required"))
974                .filter(|r| truthy(r))
975            {
976                comma(out, &mut add);
977                out.push_str("required:[");
978                push_str_list(out, req);
979                out.push(']');
980            }
981        }
982        // closing `type:<|"|>UPPER<|"|>}` (always) — carries a leading comma iff anything above.
983        comma(out, &mut add);
984        out.push_str("type:<|\"|>");
985        out.push_str(ty_up.as_deref().unwrap_or(""));
986        out.push_str("<|\"|>}");
987    }
988}
989
990/// The ARRAY `items` mapping loop: dictsorts item keys, skips None values, and renders
991/// properties/required/type specially, else generic `key:format_argument(value)`.
992fn format_items(out: &mut String, items: &[(String, Val)]) {
993    let mut found_first = false;
994    for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
995        if matches!(v, Val::Null) {
996            continue;
997        }
998        if found_first {
999            out.push(',');
1000        }
1001        found_first = true;
1002        match k.as_str() {
1003            "properties" => {
1004                out.push_str("properties:{");
1005                if let Some(o) = as_obj(v) {
1006                    format_parameters(out, o, false);
1007                }
1008                out.push('}');
1009            }
1010            "required" => {
1011                out.push_str("required:[");
1012                push_str_list(out, v);
1013                out.push(']');
1014            }
1015            "type" => {
1016                out.push_str("type:");
1017                match v {
1018                    Val::Str(s) => {
1019                        out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
1020                    }
1021                    Val::Arr(a) => {
1022                        let upper: Vec<Val> = a
1023                            .iter()
1024                            .map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
1025                            .collect();
1026                        out.push_str(&format_argument(&Val::Arr(upper), true));
1027                    }
1028                    other => out.push_str(&format_argument(other, true)),
1029                }
1030            }
1031            _ => {
1032                out.push_str(k);
1033                out.push(':');
1034                out.push_str(&format_argument(v, true));
1035            }
1036        }
1037    }
1038}
1039
1040/// `[<|"|>a<|"|>,<|"|>b<|"|>]` body (without the brackets) from a Val::Arr of strings.
1041fn push_str_list(out: &mut String, v: &Val) {
1042    if let Val::Arr(items) = v {
1043        for (i, item) in items.iter().enumerate() {
1044            if i > 0 {
1045                out.push(',');
1046            }
1047            out.push_str("<|\"|>");
1048            out.push_str(as_str(item).unwrap_or(""));
1049            out.push_str("<|\"|>");
1050        }
1051    }
1052}
1053
1054/// jinja `format_function_declaration(tool_data)` — `func` is the tool's `function` object.
1055fn format_function_declaration(func: &[(String, Val)]) -> String {
1056    let mut out = String::new();
1057    out.push_str("declaration:");
1058    out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
1059    out.push_str("{description:<|\"|>");
1060    out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
1061    out.push_str("<|\"|>");
1062    if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
1063        let pobj = as_obj(params);
1064        out.push_str(",parameters:{");
1065        if let Some(props) = pobj
1066            .and_then(|o| val_get(o, "properties"))
1067            .filter(|p| truthy(p))
1068            .and_then(as_obj)
1069        {
1070            out.push_str("properties:{");
1071            format_parameters(&mut out, props, false);
1072            out.push_str("},");
1073        }
1074        if let Some(req) = pobj
1075            .and_then(|o| val_get(o, "required"))
1076            .filter(|r| truthy(r))
1077        {
1078            out.push_str("required:[");
1079            push_str_list(&mut out, req);
1080            out.push_str("],");
1081        }
1082        if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
1083            out.push_str("type:<|\"|>");
1084            out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
1085            out.push_str("<|\"|>}");
1086        }
1087    }
1088    if let Some(resp) = val_get(func, "response").and_then(as_obj) {
1089        out.push_str(",response:{");
1090        if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
1091            out.push_str("description:<|\"|>");
1092            out.push_str(as_str(d).unwrap_or(""));
1093            out.push_str("<|\"|>,");
1094        }
1095        if val_get(resp, "type")
1096            .and_then(as_str)
1097            .map(|s| s.to_uppercase())
1098            == Some("OBJECT".into())
1099        {
1100            out.push_str("type:<|\"|>OBJECT<|\"|>}");
1101        }
1102    }
1103    out.push('}');
1104    out
1105}
1106
1107/// jinja `format_tool_response_block(tool_name, response)`.
1108fn format_tool_response_block(name: &str, response: &Val) -> String {
1109    let mut out = String::from("<|tool_response>");
1110    match response {
1111        Val::Obj(pairs) => {
1112            out.push_str("response:");
1113            out.push_str(name);
1114            out.push('{');
1115            for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1116                if i > 0 {
1117                    out.push(',');
1118                }
1119                out.push_str(k);
1120                out.push(':');
1121                out.push_str(&format_argument(v, false));
1122            }
1123            out.push('}');
1124        }
1125        other => {
1126            out.push_str("response:");
1127            out.push_str(name);
1128            out.push_str("{value:");
1129            out.push_str(&format_argument(other, false));
1130            out.push('}');
1131        }
1132    }
1133    out.push_str("<tool_response|>");
1134    out
1135}
1136
1137/// gemma4 tooluse renderer. `tools` are the tool `function` objects; `thinking` = jinja
1138/// `enable_thinking`; `closed_tail` = the QAT-trunk variant that emits a closed thought
1139/// channel on the thinking-off generation prompt (the official served trunk does not). BOS is
1140/// NOT emitted (encode(add_special) supplies it — the jinja's `{{ bos_token }}` is dropped).
1141fn apply_gemma4_tools_template(
1142    turns: &[Turn],
1143    add_generation_prompt: bool,
1144    tools: &[Val],
1145    thinking: bool,
1146    closed_tail: bool,
1147) -> String {
1148    let mut out = String::new();
1149    let mut prev: Option<&str> = None;
1150    let mut msgs = turns;
1151    let is_sys = |r: &str| r == "system" || r == "developer";
1152
1153    let leading_system = msgs.first().filter(|t| is_sys(&t.role));
1154    if thinking || !tools.is_empty() || leading_system.is_some() {
1155        out.push_str("<|turn>system\n");
1156        if thinking {
1157            out.push_str("<|think|>\n");
1158            prev = Some("think");
1159        }
1160        if let Some(sys) = leading_system {
1161            out.push_str(sys.content.trim());
1162            msgs = &msgs[1..];
1163        }
1164        for tool in tools {
1165            out.push_str("<|tool>");
1166            if let Some(func) = as_obj(tool) {
1167                out.push_str(format_function_declaration(func).trim());
1168            }
1169            out.push_str("<tool|>");
1170        }
1171        if !tools.is_empty() {
1172            prev = Some("tool");
1173        }
1174        out.push_str("<turn|>\n");
1175    }
1176
1177    let last_user_idx: isize = msgs
1178        .iter()
1179        .enumerate()
1180        .rev()
1181        .find(|(_, t)| t.role == "user")
1182        .map(|(i, _)| i as isize)
1183        .unwrap_or(-1);
1184
1185    for (i, m) in msgs.iter().enumerate() {
1186        if m.role == "tool" {
1187            continue; // consumed by a preceding assistant's forward-scan
1188        }
1189        prev = None;
1190        let role = if m.role == "assistant" {
1191            "model"
1192        } else {
1193            m.role.as_str()
1194        };
1195        let prev_nt_role = (0..i)
1196            .rev()
1197            .map(|j| &msgs[j])
1198            .find(|t| t.role != "tool")
1199            .map(|t| t.role.as_str());
1200        let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
1201        if !continue_same_model_turn {
1202            out.push_str("<|turn>");
1203            out.push_str(role);
1204            out.push('\n');
1205        }
1206
1207        // reasoning re-render (tool_calls-carrying assistant after the last user turn)
1208        if let Some(rt) = m.reasoning.as_deref() {
1209            if !rt.is_empty() && (i as isize) > last_user_idx && !m.tool_calls.is_empty() {
1210                out.push_str("<|channel>thought\n");
1211                out.push_str(rt);
1212                out.push_str("\n<channel|>");
1213            }
1214        }
1215
1216        // tool_calls
1217        if !m.tool_calls.is_empty() {
1218            for tc in &m.tool_calls {
1219                out.push_str("<|tool_call>call:");
1220                out.push_str(&tc.name);
1221                out.push('{');
1222                for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
1223                    if j > 0 {
1224                        out.push(',');
1225                    }
1226                    out.push_str(k);
1227                    out.push(':');
1228                    out.push_str(&format_argument(v, false));
1229                }
1230                out.push_str("}<tool_call|>");
1231            }
1232            prev = Some("tool_call");
1233        }
1234
1235        // tool responses: native (Google) on the assistant, else OpenAI role:"tool" forward-scan
1236        let mut tr_flag = false;
1237        if !m.tool_responses.is_empty() {
1238            for (name, resp) in &m.tool_responses {
1239                out.push_str(&format_tool_response_block(name, resp));
1240                tr_flag = true;
1241                prev = Some("tool_response");
1242            }
1243        } else if !m.tool_calls.is_empty() {
1244            for k in (i + 1)..msgs.len() {
1245                let follow = &msgs[k];
1246                if follow.role != "tool" {
1247                    break;
1248                }
1249                let mut name = follow
1250                    .tool_name
1251                    .clone()
1252                    .unwrap_or_else(|| "unknown".to_string());
1253                if let Some(fid) = follow.tool_call_id.as_deref() {
1254                    for tc in &m.tool_calls {
1255                        if tc.id.as_deref() == Some(fid) {
1256                            name = tc.name.clone();
1257                        }
1258                    }
1259                }
1260                out.push_str(&format_tool_response_block(
1261                    &name,
1262                    &Val::Str(follow.content.clone()),
1263                ));
1264                tr_flag = true;
1265                prev = Some("tool_response");
1266            }
1267        }
1268
1269        // content (model content strips thought channels; other roles trim)
1270        let captured = if role == "model" {
1271            strip_thinking(&m.content)
1272        } else {
1273            m.content.trim().to_string()
1274        };
1275        out.push_str(&captured);
1276        let has_content = !captured.trim().is_empty();
1277
1278        if prev == Some("tool_call") && !tr_flag {
1279            out.push_str("<|tool_response>"); // dangling open: calls with no responses yet
1280        } else if !(tr_flag && !has_content) {
1281            out.push_str("<turn|>\n");
1282        }
1283    }
1284
1285    if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
1286        out.push_str("<|turn>model\n");
1287        if closed_tail && !thinking {
1288            out.push_str("<|channel>thought\n<channel|>");
1289        }
1290    }
1291    out
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297
1298    #[test]
1299    fn plain_chatml() {
1300        let s = apply_chat_template_str(None, &[("user", "Hello")], true);
1301        assert_eq!(
1302            s,
1303            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
1304        );
1305    }
1306
1307    /// A template stand-in carrying every marker the real qwen3.5/3.6 dumps carry
1308    /// (tools branch + think tail + enable_thinking switch).
1309    const QWEN_TOOLS_TMPL: &str =
1310        "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
1311
1312    /// Isolation contract: the tools renderer on a PLAIN request (no tools, no tool turns,
1313    /// Default think) is byte-identical to the legacy renderer, across the message shapes
1314    /// the serve path sees.
1315    #[test]
1316    fn tools_renderer_matches_legacy_when_plain() {
1317        let batteries: &[&[(&str, &str)]] = &[
1318            &[("user", "Hello")],
1319            &[("system", "You are helpful."), ("user", "Hi")],
1320            &[
1321                ("system", "rules"),
1322                ("user", "task"),
1323                ("assistant", "work"),
1324                ("user", "more"),
1325            ],
1326            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
1327        ];
1328        for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
1329            for msgs in batteries {
1330                let legacy = apply_chat_template_str(tmpl, msgs, true);
1331                let turns: Vec<Turn> = msgs
1332                    .iter()
1333                    .map(|(r, c)| Turn {
1334                        role: r.to_string(),
1335                        content: c.to_string(),
1336                        tool_calls: Vec::new(),
1337                        ..Default::default()
1338                    })
1339                    .collect();
1340                let ext =
1341                    apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
1342                        .unwrap();
1343                assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
1344            }
1345        }
1346    }
1347
1348    #[test]
1349    fn tools_header_and_tool_response_render_per_template_law() {
1350        let tools =
1351            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1352        let turns = vec![
1353            Turn {
1354                role: "system".into(),
1355                content: "Be terse.".into(),
1356                tool_calls: Vec::new(),
1357                ..Default::default()
1358            },
1359            Turn {
1360                role: "user".into(),
1361                content: "Weather in Paris?".into(),
1362                tool_calls: Vec::new(),
1363                ..Default::default()
1364            },
1365            Turn {
1366                role: "assistant".into(),
1367                content: "".into(),
1368                tool_calls: vec![ToolCall {
1369                    name: "get_weather".into(),
1370                    params: vec![("city".into(), "Paris".into())],
1371                    ..Default::default()
1372                }],
1373                ..Default::default()
1374            },
1375            Turn {
1376                role: "tool".into(),
1377                content: "{\"temp_c\": 21}".into(),
1378                tool_calls: Vec::new(),
1379                ..Default::default()
1380            },
1381        ];
1382        let s = apply_chat_template_tools(
1383            Some(QWEN_TOOLS_TMPL),
1384            &turns,
1385            true,
1386            &tools,
1387            ThinkMode::Default,
1388            None,
1389        )
1390        .unwrap();
1391        let expected = concat!(
1392            "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
1393            "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
1394            "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
1395            "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
1396            "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
1397            "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
1398            "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1399            "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
1400            "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
1401            "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
1402            "no function call available, answer the question like normal with your current knowledge ",
1403            "and do not tell the user about function calls\n</IMPORTANT>",
1404            "\n\nBe terse.<|im_end|>\n",
1405            "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1406            "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
1407            "</parameter>\n</function>\n</tool_call><|im_end|>\n",
1408            "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
1409            "<|im_start|>assistant\n<think>\n",
1410        );
1411        assert_eq!(s, expected);
1412    }
1413
1414    #[test]
1415    fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
1416        let turns = vec![
1417            Turn {
1418                role: "user".into(),
1419                content: "both".into(),
1420                tool_calls: Vec::new(),
1421                ..Default::default()
1422            },
1423            Turn {
1424                role: "assistant".into(),
1425                content: "checking".into(),
1426                tool_calls: vec![
1427                    ToolCall {
1428                        name: "a".into(),
1429                        params: vec![("x".into(), "1".into())],
1430                        ..Default::default()
1431                    },
1432                    ToolCall {
1433                        name: "b".into(),
1434                        params: Vec::new(),
1435                        ..Default::default()
1436                    },
1437                ],
1438                ..Default::default()
1439            },
1440            Turn {
1441                role: "tool".into(),
1442                content: "r1".into(),
1443                tool_calls: Vec::new(),
1444                ..Default::default()
1445            },
1446            Turn {
1447                role: "tool".into(),
1448                content: "r2".into(),
1449                tool_calls: Vec::new(),
1450                ..Default::default()
1451            },
1452        ];
1453        let s = apply_chat_template_tools(
1454            Some(QWEN_TOOLS_TMPL),
1455            &turns,
1456            false,
1457            &[],
1458            ThinkMode::Default,
1459            None,
1460        )
1461        .unwrap();
1462        assert_eq!(
1463            s,
1464            concat!(
1465                "<|im_start|>user\nboth<|im_end|>\n",
1466                "<|im_start|>assistant\nchecking\n\n",
1467                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
1468                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1469                "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
1470                "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
1471            )
1472        );
1473    }
1474
1475    #[test]
1476    fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
1477        let turns = vec![Turn {
1478            role: "user".into(),
1479            content: "hi".into(),
1480            tool_calls: Vec::new(),
1481            ..Default::default()
1482        }];
1483        // switch present: NoThink renders the closed think block.
1484        let s = apply_chat_template_tools(
1485            Some(QWEN_TOOLS_TMPL),
1486            &turns,
1487            true,
1488            &[],
1489            ThinkMode::NoThink,
1490            None,
1491        )
1492        .unwrap();
1493        assert!(
1494            s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
1495            "{s:?}"
1496        );
1497        // no enable_thinking switch: NoThink is ignored (template default stands).
1498        let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
1499        let s = apply_chat_template_tools(
1500            Some(tmpl_no_switch),
1501            &turns,
1502            true,
1503            &[],
1504            ThinkMode::NoThink,
1505            None,
1506        )
1507        .unwrap();
1508        assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
1509        // no template at all: plain ChatML, no tail either way.
1510        let s =
1511            apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
1512        assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
1513    }
1514
1515    #[test]
1516    fn tools_on_templates_without_tools_branch_error() {
1517        let turns = vec![Turn {
1518            role: "user".into(),
1519            content: "hi".into(),
1520            tool_calls: Vec::new(),
1521            ..Default::default()
1522        }];
1523        let tools = vec!["{}".to_string()];
1524        for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
1525            let err =
1526                apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
1527            assert!(err.is_err(), "template={tmpl:?}");
1528        }
1529        // tool-role turns need the branch too.
1530        let tool_turns = vec![Turn {
1531            role: "tool".into(),
1532            content: "r".into(),
1533            tool_calls: Vec::new(),
1534            ..Default::default()
1535        }];
1536        assert!(
1537            apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
1538                .is_err()
1539        );
1540    }
1541
1542    // ---- per-arch thinking control (owner directive 2026-08-07) -------------------------
1543    // Every `expected` below is the EXACT string the arch's REAL shipped template renders,
1544    // from research/step-sku-20260807/raw/thinking-goldens.txt (render-thinking-goldens.py:
1545    // jinja2 trim_blocks/lstrip_blocks over the pinned template dumps — gemma4 sha 36e3a42e
1546    // from the local QAT GGUF header, hy3 sha 7fc351fe from the pinned tencent/Hy3 snapshot).
1547
1548    fn one_user() -> Vec<Turn> {
1549        vec![turn("user", "Hi")]
1550    }
1551
1552    #[test]
1553    fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
1554        let g = |think: ThinkMode| {
1555            apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
1556                .unwrap()
1557        };
1558        // Default AND NoThink = the template's own default(false): closed thought channel.
1559        // Byte-identical to the legacy renderer (no silent behavior change).
1560        let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
1561        assert_eq!(g(ThinkMode::Default), closed);
1562        assert_eq!(g(ThinkMode::NoThink), closed);
1563        assert_eq!(
1564            apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
1565            closed,
1566            "legacy renderer = the default arm"
1567        );
1568        // Think = enable_thinking=true: <|think|> injected into a CREATED system turn and
1569        // the generation turn left open (golden: gemma4 enable_thinking=true, no system).
1570        assert_eq!(
1571            g(ThinkMode::Think),
1572            "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
1573        );
1574        // with a client system turn the token lands at the very top of it (golden).
1575        let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
1576        let s = apply_chat_template_tools(
1577            Some("... <|turn> ..."),
1578            &turns,
1579            true,
1580            &[],
1581            ThinkMode::Think,
1582            None,
1583        )
1584        .unwrap();
1585        assert_eq!(
1586            s,
1587            "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
1588                       <|turn>user\nHi<turn|>\n<|turn>model\n"
1589        );
1590    }
1591
1592    /// A QAT-tooluse stand-in: carries `<|turn>` + `<|tool>` (engages the gemma4 tools arm)
1593    /// AND the closed-tail literal (the QAT trunk's thinking-off generation tail). The
1594    /// official served trunk omits that literal, so its tools arm emits the bare `<|turn>model`
1595    /// on thinking-off — the fixtures cover that side.
1596    const GEMMA_TOOLUSE_QAT_TMPL: &str =
1597        "... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
1598
1599    #[test]
1600    fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
1601        // REGRESSION (deliverable 6): a NO-tools request through the gemma4 tools arm renders
1602        // byte-identically to the standalone gemma4 renderer, across think modes and message
1603        // shapes — the tool path never perturbs plain gemma traffic on the tooluse trunk.
1604        let batteries: &[&[(&str, &str)]] = &[
1605            &[("user", "Hi")],
1606            &[("system", "Be terse."), ("user", "Weather?")],
1607            &[
1608                ("system", "rules"),
1609                ("user", "task"),
1610                ("assistant", "work"),
1611                ("user", "more"),
1612            ],
1613            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
1614        ];
1615        for msgs in batteries {
1616            let turns: Vec<Turn> = msgs
1617                .iter()
1618                .map(|(r, c)| Turn {
1619                    role: r.to_string(),
1620                    content: c.to_string(),
1621                    ..Default::default()
1622                })
1623                .collect();
1624            for (mode, thinking) in [
1625                (ThinkMode::Default, false),
1626                (ThinkMode::NoThink, false),
1627                (ThinkMode::Think, true),
1628            ] {
1629                let legacy = apply_gemma4_template(msgs, true, thinking);
1630                let arm = apply_chat_template_tools(
1631                    Some(GEMMA_TOOLUSE_QAT_TMPL),
1632                    &turns,
1633                    true,
1634                    &[],
1635                    mode,
1636                    None,
1637                )
1638                .unwrap();
1639                assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
1640            }
1641        }
1642    }
1643
1644    #[test]
1645    fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
1646        // a `<|turn>` template WITHOUT `<|tool>` keeps rejecting tool features with the clear
1647        // error (no committed tools reference for that trunk).
1648        let turns = vec![turn("user", "Weather?")];
1649        let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
1650        let err = apply_chat_template_tools(
1651            Some("... <|turn> ..."),
1652            &turns,
1653            true,
1654            &tools,
1655            ThinkMode::Default,
1656            None,
1657        );
1658        assert!(err.is_err());
1659    }
1660
1661    #[test]
1662    fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
1663        const HY_TMPL: Option<&str> = Some("... hy_User ...");
1664        let h = |think: ThinkMode, effort: Option<&str>| {
1665            apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
1666        };
1667        // Default AND NoThink = the template's own default: no_think header + CLOSED think.
1668        // Byte-identical to the legacy renderer.
1669        let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
1670                      <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
1671                      <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
1672                      <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
1673                      <think:opensource></think:opensource>";
1674        assert_eq!(h(ThinkMode::Default, None), closed);
1675        assert_eq!(
1676            h(ThinkMode::NoThink, Some("low")),
1677            closed,
1678            "NoThink wins over a level: thinking off IS no_think"
1679        );
1680        assert_eq!(
1681            apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
1682            closed,
1683            "legacy renderer = the default arm"
1684        );
1685        // Think at low/high = the template's own open-think levels (goldens: header carries
1686        // the level, generation prompt ends with an OPEN <think:opensource>).
1687        let low = h(ThinkMode::Think, Some("low"));
1688        assert!(low.contains("reasoning_effort:low"), "{low:?}");
1689        assert!(low.ends_with("<think:opensource>"), "{low:?}");
1690        let high = h(ThinkMode::Think, Some("high"));
1691        assert!(high.contains("reasoning_effort:high"), "{high:?}");
1692        assert!(high.ends_with("<think:opensource>"), "{high:?}");
1693        // medium clamps to low (hy3's accepted set is exactly no_think|low|high — the jinja
1694        // raise_exceptions on anything else); Think with no level also lands at low.
1695        assert_eq!(h(ThinkMode::Think, Some("medium")), low);
1696        assert_eq!(h(ThinkMode::Think, None), low);
1697        // History assistant turns stay CLOSED-think at every effort (the template opens only
1698        // turns past last_user_index; golden: "hy3 assistant history stays closed-think").
1699        let turns = vec![
1700            turn("user", "q"),
1701            turn("assistant", "a"),
1702            turn("user", "more"),
1703        ];
1704        let s =
1705            apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
1706                .unwrap();
1707        assert_eq!(
1708            s,
1709            "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
1710                       <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
1711                       <\u{ff5c}hy_User:opensource\u{ff5c}>q\
1712                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
1713                       <think:opensource></think:opensource>a\
1714                       <\u{ff5c}hy_eos:opensource\u{ff5c}>\
1715                       <\u{ff5c}hy_User:opensource\u{ff5c}>more\
1716                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
1717        );
1718    }
1719
1720    #[test]
1721    fn qwen_think_mode_covers_all_three_directions() {
1722        let q = |think: ThinkMode| {
1723            apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
1724                .unwrap()
1725        };
1726        // qwen's template default IS thinking-on, so Default and Think render identically.
1727        assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
1728        assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
1729        assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
1730    }
1731
1732    // ---- StepFun Step-3.7-Flash (arch step35) -------------------------------------------
1733    // Every `expected` below is the EXACT string the shipped jinja renders, taken from
1734    // research/step37-p2-20260806/raw/step35-template-goldens.txt (generated by
1735    // render_step35_template.py under jinja2 with trim_blocks/lstrip_blocks — the settings HF
1736    // transformers and llama.cpp's minja use). `{{bos_token}}` renders as "" there because
1737    // encode(add_special) supplies BOS.
1738
1739    /// A step35 template stand-in: the real one is 5723 chars, and the detector keys on
1740    /// `render_message_content` (the macro no other committed template defines). The other
1741    /// markers are present to prove the step35 arm WINS the dispatch — a qwen-marker template
1742    /// carrying `<tools>`/`<think>`/`add_generation_prompt` would otherwise take the qwen arm.
1743    const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
1744
1745    fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
1746        apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
1747    }
1748
1749    fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
1750        apply_chat_template_tools(
1751            Some(STEP35_TMPL),
1752            &turns,
1753            genp,
1754            tools,
1755            ThinkMode::Default,
1756            None,
1757        )
1758        .unwrap()
1759    }
1760
1761    fn turn(role: &str, content: &str) -> Turn {
1762        Turn {
1763            role: role.into(),
1764            content: content.into(),
1765            tool_calls: Vec::new(),
1766            ..Default::default()
1767        }
1768    }
1769
1770    #[test]
1771    fn step35_plain_paths_match_the_shipped_jinja() {
1772        assert_eq!(
1773            s35(&[("user", "Hello")], true),
1774            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1775        );
1776        assert_eq!(
1777            s35(&[("user", "Hello")], false),
1778            "<|im_start|>user\nHello<|im_end|>\n"
1779        );
1780        assert_eq!(
1781            s35(&[("system", "You are helpful."), ("user", "Hi")], true),
1782            "<|im_start|>system\nYou are helpful.<|im_end|>\n\
1783                    <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1784        );
1785        // multi-turn: the prior assistant is BEFORE the last user query, so it carries NO
1786        // think block — the reasoning boundary the qwen arms have no concept of.
1787        assert_eq!(
1788            s35(
1789                &[
1790                    ("system", "rules"),
1791                    ("user", "task"),
1792                    ("assistant", "work"),
1793                    ("user", "more")
1794                ],
1795                true
1796            ),
1797            "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
1798             <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
1799             <|im_start|>assistant\n<think>\n"
1800        );
1801        // content is NOT trimmed (this template applies no `|trim`) — the qwen arms trim.
1802        assert_eq!(
1803            s35(&[("user", "  padded  ")], true),
1804            "<|im_start|>user\n  padded  <|im_end|>\n<|im_start|>assistant\n<think>\n"
1805        );
1806    }
1807
1808    #[test]
1809    fn step35_dispatch_beats_the_qwen_marker_arm() {
1810        // The step35 template carries every qwen marker. If the dispatch order regressed, the
1811        // think tail would still be right and the BODY would be wrong (trimmed content, wrong
1812        // tools header) — so assert a body-shaped difference, not the tail.
1813        let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
1814        let step = s35(&[("user", " pad ")], true);
1815        assert_eq!(
1816            qwen,
1817            "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
1818        );
1819        assert_eq!(
1820            step,
1821            "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
1822        );
1823        assert_ne!(qwen, step);
1824    }
1825
1826    #[test]
1827    fn step35_reasoning_effort_renders_in_the_system_turn() {
1828        assert_eq!(
1829            apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
1830            "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
1831             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1832        );
1833        assert_eq!(
1834            apply_step35_template(
1835                &[turn("system", "Be terse."), turn("user", "Hi")],
1836                true,
1837                &[],
1838                Some("low")
1839            ),
1840            "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
1841             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
1842        );
1843        // with tools the order flips: Reasoning, then the system content, then `# Tools`.
1844        let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
1845        let s = apply_step35_template(
1846            &[turn("system", "Be terse."), turn("user", "q")],
1847            true,
1848            &tools,
1849            Some("medium"),
1850        );
1851        assert!(
1852            s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
1853            "{s:?}"
1854        );
1855    }
1856
1857    #[test]
1858    fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
1859        // The serve path enters via apply_chat_template_tools: the level must land in the
1860        // rendered system turn on the step35 dialect...
1861        let turns = vec![turn("user", "Hi")];
1862        let s = apply_chat_template_tools(
1863            Some(STEP35_TMPL),
1864            &turns,
1865            true,
1866            &[],
1867            ThinkMode::Default,
1868            Some("high"),
1869        )
1870        .unwrap();
1871        assert!(
1872            s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
1873            "{s:?}"
1874        );
1875        // ...None keeps the template's own default (no Reasoning: line at all)...
1876        let s = apply_chat_template_tools(
1877            Some(STEP35_TMPL),
1878            &turns,
1879            true,
1880            &[],
1881            ThinkMode::Default,
1882            None,
1883        )
1884        .unwrap();
1885        assert!(!s.contains("Reasoning:"), "{s:?}");
1886        // ...and every non-step35 dialect ignores the parameter (their templates have no
1887        // reasoning_effort input) — byte-identical with and without it.
1888        for tmpl in [
1889            None,
1890            Some(QWEN_TOOLS_TMPL),
1891            Some("... hy_User ..."),
1892            Some("... <|turn> ..."),
1893        ] {
1894            let with = apply_chat_template_tools(
1895                tmpl,
1896                &turns,
1897                true,
1898                &[],
1899                ThinkMode::Default,
1900                Some("high"),
1901            )
1902            .unwrap();
1903            let without =
1904                apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
1905                    .unwrap();
1906            assert_eq!(with, without, "template={tmpl:?}");
1907        }
1908    }
1909
1910    #[test]
1911    fn step35_tools_header_is_not_the_qwen_header() {
1912        let tools = vec![
1913            r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
1914            r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
1915        ];
1916        let s = s35_turns(
1917            vec![
1918                turn("system", "Be terse."),
1919                turn("user", "Weather in Paris?"),
1920            ],
1921            true,
1922            &tools,
1923        );
1924        assert_eq!(
1925            s,
1926            concat!(
1927                // leading system folds in BEFORE `# Tools` (the qwen arm appends it AFTER the
1928                // instruction block), and the header says "in JSONSchema format".
1929                "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
1930                "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
1931                "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
1932                "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
1933                "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
1934                "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
1935                "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
1936                "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
1937                // the nesting reminder carries literal \n...\n INSIDE the example tags, and the
1938                // Reminder list stops after 2 bullets (the qwen block has 4).
1939                "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
1940                "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
1941                "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
1942                "<|im_end|>\n",
1943                "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
1944                "<|im_start|>assistant\n<think>\n",
1945            )
1946        );
1947        // and it is NOT the qwen instruction block.
1948        assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
1949    }
1950
1951    #[test]
1952    fn step35_tool_results_take_their_own_role_and_group() {
1953        let tools =
1954            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
1955        let turns = vec![
1956            turn("user", "both"),
1957            Turn {
1958                role: "assistant".into(),
1959                content: "checking".into(),
1960                tool_calls: vec![
1961                    ToolCall {
1962                        name: "a".into(),
1963                        params: vec![("x".into(), "1".into())],
1964                        ..Default::default()
1965                    },
1966                    ToolCall {
1967                        name: "b".into(),
1968                        params: Vec::new(),
1969                        ..Default::default()
1970                    },
1971                ],
1972                ..Default::default()
1973            },
1974            turn("tool", "r1"),
1975            turn("tool", "r2"),
1976        ];
1977        let s = s35_turns(turns, true, &tools);
1978        let body = s
1979            .split("<|im_end|>\n")
1980            .skip(1)
1981            .collect::<Vec<_>>()
1982            .join("<|im_end|>\n");
1983        assert_eq!(
1984            body,
1985            concat!(
1986                "<|im_start|>user\nboth<|im_end|>\n",
1987                // the assistant is AFTER the last user query, so it carries a think block — empty,
1988                // because its content has no `</think>` marker.
1989                "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
1990                // NO separator before the first call and NONE between calls.
1991                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
1992                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
1993                // own `tool_response` ROLE (not a user turn), and NO newlines inside the wrappers.
1994                "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
1995                "<tool_response>r2</tool_response><|im_end|>\n",
1996                "<|im_start|>assistant\n<think>\n",
1997            )
1998        );
1999    }
2000
2001    #[test]
2002    fn step35_assistant_think_split_and_the_reasoning_boundary() {
2003        // inline <think>…</think> in content splits into the reasoning block + body.
2004        assert_eq!(
2005            s35(
2006                &[
2007                    ("user", "q"),
2008                    ("assistant", "<think>\nreasoned\n</think>\nanswer")
2009                ],
2010                false
2011            ),
2012            "<|im_start|>user\nq<|im_end|>\n\
2013             <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
2014        );
2015        // no markers, but still after the last query -> an EMPTY reasoning block is emitted.
2016        assert_eq!(
2017            s35(&[("user", "q"), ("assistant", "plain")], false),
2018            "<|im_start|>user\nq<|im_end|>\n\
2019             <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
2020        );
2021        // a user turn that IS a <tool_response> wrapper does NOT move the boundary: the
2022        // assistant before it still counts as after-the-last-real-query.
2023        assert_eq!(
2024            s35(
2025                &[
2026                    ("user", "real question"),
2027                    ("assistant", "thinking about it"),
2028                    ("user", "<tool_response>r</tool_response>")
2029                ],
2030                true
2031            ),
2032            "<|im_start|>user\nreal question<|im_end|>\n\
2033             <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
2034             <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
2035             <|im_start|>assistant\n<think>\n"
2036        );
2037    }
2038
2039    #[test]
2040    fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
2041        // No `enable_thinking` in this template, so ThinkMode::NoThink cannot close the tail —
2042        // the same graceful-no-op contract the other switchless templates get. A NoThink that
2043        // silently emitted `<think>\n\n</think>\n\n` would be a prompt the model never saw.
2044        let turns = vec![turn("user", "hi")];
2045        for mode in [ThinkMode::Default, ThinkMode::NoThink] {
2046            let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
2047                .unwrap();
2048            assert!(
2049                s.ends_with("<|im_start|>assistant\n<think>\n"),
2050                "mode={mode:?} {s:?}"
2051            );
2052        }
2053    }
2054
2055    #[test]
2056    fn step35_plain_path_is_identical_through_both_renderers() {
2057        // same isolation contract the qwen arms hold: a plain request renders byte-identically
2058        // whether it enters via apply_chat_template_str or apply_chat_template_tools.
2059        let batteries: &[&[(&str, &str)]] = &[
2060            &[("user", "Hello")],
2061            &[("system", "You are helpful."), ("user", "Hi")],
2062            &[
2063                ("system", "rules"),
2064                ("user", "task"),
2065                ("assistant", "work"),
2066                ("user", "more"),
2067            ],
2068            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
2069        ];
2070        for msgs in batteries {
2071            let legacy = s35(msgs, true);
2072            let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
2073            assert_eq!(legacy, ext, "msgs={msgs:?}");
2074        }
2075    }
2076
2077    #[test]
2078    fn qwen_think_tail() {
2079        // a template string containing both markers triggers the <think> tail.
2080        let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
2081        let s = apply_chat_template_str(
2082            Some(tmpl),
2083            &[("system", "You are helpful."), ("user", "Hi")],
2084            true,
2085        );
2086        assert_eq!(
2087            s,
2088            "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2089        );
2090    }
2091}