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    /// deepseek-v4 quick-instruction task token (`action`/`query`/`authority`/`domain`/
78    /// `title`/`read_url`, encoding_dsv4 DS_TASK_SP_TOKENS). Set only by the dsv4 fixture
79    /// harness (the internal-classification heads); the OpenAI serve surface has no `task`
80    /// field, so every serve request leaves this None and every other dialect ignores it.
81    pub task: Option<String>,
82    /// deepseek-v4 per-turn tool `function` objects (encoding_dsv4 renders the tool
83    /// declaration on the message carrying them — system on the serve surface, or a developer
84    /// message in the search-pipeline fixtures). The serve path also passes request-level
85    /// tools via `tools_struct`, which the dsv4 arm folds onto the leading system turn.
86    /// Every other dialect ignores this.
87    pub tools: Vec<Val>,
88}
89
90/// Thinking control (owner directive 2026-08-07: every supported model is a thinking model,
91/// one serve surface maps to each arch's native mechanism).
92///
93/// - `Default` = the template's OWN default, byte-identical to the pre-surface render:
94///   qwen class opens `<think>\n` (thinking ON), gemma4 renders the CLOSED thought channel
95///   (its `enable_thinking | default(false)`), hy3 renders `reasoning_effort:no_think`.
96/// - `NoThink` = thinking OFF via the arch's native off-switch: qwen
97///   `enable_thinking=false` (closed `<think>\n\n</think>\n\n`), gemma4 closed thought
98///   channel, hy3 `no_think`. On step35 — whose `<think>` tail is unconditional — it clamps
99///   to the lowest effort level instead (`Reasoning: low`).
100/// - `Think` = thinking explicitly ON: qwen open `<think>\n` (same bytes as its default),
101///   gemma4 `<|think|>\n` injected into the system turn + an OPEN generation turn, hy3
102///   an open `<think:opensource>` channel at the requested effort.
103///
104/// On templates with no switch at all the non-native direction is a graceful no-op.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ThinkMode {
107    Default,
108    NoThink,
109    Think,
110}
111
112/// Which `encoding_dsv4.py` revision governs the deepseek-v4 REASONING-EFFORT ladder
113/// (0731 re-gate, 2026-08-18 — research/dsv4-template-20260818/ENCODING-DIFF.md).
114///
115/// The two shipped encodings differ ONLY here; every other rendering law (roles, tool
116/// blocks, transitions, special tokens, think-mode prefixes, parsing) is byte-identical:
117///
118/// | `reasoning_effort` | `Preview` (base repo @ 60d8d707)     | `V0731` (0731 @ 7872f01b)          |
119/// |--------------------|--------------------------------------|-------------------------------------|
120/// | None               | no prefix                            | no prefix (None == "low" default)   |
121/// | "low"              | INVALID upstream (assert) — renders as no prefix here | no prefix          |
122/// | "high"             | documented NO-OP (== None)           | `DS_EFFORT_ABSOLUTE_MAX` prefix     |
123/// | "max"              | `DS_EFFORT_ABSOLUTE_MAX` prefix      | `DS_EFFORT_BEYOND_MAX` prefix       |
124///
125/// The prefix (when non-empty) is injected once, before the first rendered message, in
126/// thinking mode only; chat mode never renders a prefix under either encoding.
127///
128/// DETECTION IS CONFIG-KEYED, never filename-keyed: the 0731 checkpoint added exactly four
129/// `dspark_*` keys to config.json in the same revision that remapped the ladder
130/// (`dspark_block_size`, `dspark_markov_rank`, `dspark_noise_token_id`,
131/// `dspark_target_layer_ids`); tokenizer/template files are byte-identical across the two
132/// checkpoints, so config.json is the artifact's only encoding marker. `Tokenizer::from_hf_dir`
133/// performs the census (all four -> `V0731`, none -> `Preview`, a partial set refuses to load).
134/// Callers that cannot know the revision pass `None`; rendering then REFUSES exactly the
135/// (thinking, "high"/"max") requests whose bytes differ between revisions and stays
136/// infallible everywhere the two encodings agree.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Dsv4Encoding {
139    /// deepseek-ai/DeepSeek-V4-Flash (preview) law: {None,"high"} no-op, "max" -> absolute.
140    Preview,
141    /// DeepSeek-V4-Flash-0731 law: None/"low" no prefix, "high" -> absolute, "max" -> beyond.
142    V0731,
143}
144
145/// Render messages into the prompt string.
146///
147/// `template` is the raw GGUF chat_template (used only to decide qwen3.5-vs-plain
148/// chatml behavior — we detect the `<think>` generation tail by substring). When
149/// `None`, plain ChatML is produced.
150/// ds4f rung-3 serve finding (2026-08-22): the REAL dsv4 artifacts ship their chat
151/// dialect as CODE (`encoding/encoding_dsv4.py`) — tokenizer_config.json carries NO
152/// `chat_template` string and no chat_template.jinja exists. Every template-STRING
153/// keyed dispatch therefore never fires on the artifact we serve, and the serve-st
154/// honesty gate 400s a model whose dialect is fully defined. The artifact-level truth
155/// is the config `dspark_*` census (`Dsv4Encoding`, already detected at tokenizer
156/// load): when it is present, the dsv4 renderer IS the model's template. This entry is
157/// the plain-path dispatch on that truth; `apply_chat_template_str` keeps its exact
158/// legacy bytes for every other family.
159pub fn apply_chat_template_enc(
160    template: Option<&str>,
161    messages: &[(&str, &str)],
162    add_generation_prompt: bool,
163    dsv4_encoding: Option<Dsv4Encoding>,
164) -> Result<String, String> {
165    if dsv4_encoding.is_some() && !template.is_some_and(template_is_dsv4) {
166        let msgs: Vec<Turn> = messages
167            .iter()
168            .map(|(r, c)| Turn {
169                role: r.to_string(),
170                content: c.to_string(),
171                ..Default::default()
172            })
173            .collect();
174        return apply_dsv4_template(
175            &msgs,
176            add_generation_prompt,
177            &[],
178            ThinkMode::Default,
179            None,
180            dsv4_encoding,
181        );
182    }
183    Ok(apply_chat_template_str(
184        template,
185        messages,
186        add_generation_prompt,
187    ))
188}
189
190pub fn apply_chat_template_str(
191    template: Option<&str>,
192    messages: &[(&str, &str)],
193    add_generation_prompt: bool,
194) -> String {
195    // Tencent Hy3 (`hy_v3`): a completely different special-token dialect (no ChatML).
196    // Detected by its `hy_User` token literal; rendered by the dedicated arm below.
197    // Legacy path = the template's own default ("no_think") — byte-identical to history.
198    if template.is_some_and(|t| t.contains("hy_User")) {
199        return apply_hy3_template(messages, add_generation_prompt, "no_think");
200    }
201    // StepFun Step-3.7-Flash (arch `step35`): a ChatML *dialect* — same `<|im_start|>` framing,
202    // different everything else (see `apply_step35_template`). Detected by its
203    // `render_message_content` macro, which no other committed template defines. This check MUST
204    // precede the qwen `<think>`-tail detection below: the step35 template contains both markers,
205    // so the qwen arm would produce the right generation tail with the wrong turn bodies.
206    if template.is_some_and(|t| t.contains("render_message_content")) {
207        let turns: Vec<Turn> = messages
208            .iter()
209            .map(|(r, c)| Turn {
210                role: r.to_string(),
211                content: c.to_string(),
212                tool_calls: Vec::new(),
213                ..Default::default()
214            })
215            .collect();
216        return apply_step35_template(&turns, add_generation_prompt, &[], None);
217    }
218    // deepseek-v4 (`encoding_dsv4`): `<|User|>`/`<|Assistant|>` turn dialect with three
219    // think modes + DSML tool calls. Detected by its two structural markers (`<|Assistant|>`
220    // AND `|DSML|`). MUST precede the qwen `<think>`-tail check: a faithful dsv4 template
221    // mentions `<think>` in its tools block, and the qwen detector would otherwise fire.
222    // Legacy path = the model's own default thinking mode (thinking; see ThinkMode docs); BOS
223    // IS emitted here (encoding_dsv4 owns the BOS — tokenizer_config add_bos_token is false).
224    if template.is_some_and(template_is_dsv4) {
225        let msgs: Vec<Turn> = messages
226            .iter()
227            .map(|(r, c)| Turn {
228                role: r.to_string(),
229                content: c.to_string(),
230                ..Default::default()
231            })
232            .collect();
233        return apply_dsv4_template(
234            &msgs,
235            add_generation_prompt,
236            &[],
237            ThinkMode::Default,
238            None,
239            None,
240        )
241        .expect("dsv4 render without reasoning_effort is encoding-independent");
242    }
243    // gemma4: `<|turn>role\n{content}<turn|>\n` dialect; generation prompt appends
244    // `<|turn>model\n` + the CLOSED thought channel (`<|channel>thought\n<channel|>` — the
245    // template's enable_thinking-false default). bos comes from encode(add_special) — the
246    // template's `{{ bos_token }}` is NOT re-emitted here (double-BOS trap).
247    // Legacy path = thinking OFF (the template's `default(false)`) — byte-identical to history.
248    if template.is_some_and(|t| t.contains("<|turn>")) {
249        return apply_gemma4_template(messages, add_generation_prompt, false);
250    }
251    // qwen3.5 template emits a `<think>\n` tail on the generation prompt by default.
252    let qwen_think = template
253        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
254        .unwrap_or(false);
255
256    let mut out = String::new();
257    for (i, (role, content)) in messages.iter().enumerate() {
258        let content = content.trim();
259        match *role {
260            "system" => {
261                // template requires system at the beginning; we render it wherever
262                // it appears at index 0 (the common case).
263                let _ = i;
264                out.push_str("<|im_start|>system\n");
265                out.push_str(content);
266                out.push_str("<|im_end|>\n");
267            }
268            "user" => {
269                out.push_str("<|im_start|>user\n");
270                out.push_str(content);
271                out.push_str("<|im_end|>\n");
272            }
273            "assistant" => {
274                out.push_str("<|im_start|>assistant\n");
275                out.push_str(content);
276                out.push_str("<|im_end|>\n");
277            }
278            other => {
279                // unsupported role in this minimal renderer; emit as a generic turn.
280                out.push_str("<|im_start|>");
281                out.push_str(other);
282                out.push('\n');
283                out.push_str(content);
284                out.push_str("<|im_end|>\n");
285            }
286        }
287    }
288
289    if add_generation_prompt {
290        out.push_str("<|im_start|>assistant\n");
291        if qwen_think {
292            out.push_str("<think>\n");
293        }
294    }
295
296    out
297}
298
299/// The fixed tool-calling instruction block of the qwen3.5/3.6-class templates. Byte-for-byte
300/// the string literal shared by ornith9b / agentworld / ref-qwen36-35b
301/// (research/onboard-ornith-20260801/templates/*.jinja) and the deployed GGUF dumps.
302const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
303following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
304<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
305This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
306</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
307format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
308XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
309your function call in natural language BEFORE the function call, but NOT after\n- If there is \
310no function call available, answer the question like normal with your current knowledge and do \
311not tell the user about function calls\n</IMPORTANT>";
312
313/// Tools-capable chat rendering (serve-tools lane, 2026-08-02). Reproduces the TOOLS branch of
314/// the qwen3.5/3.6-class ChatML templates exactly (verified against the committed dumps AND the
315/// deployed GGUFs' embedded templates, byte-identical):
316///
317///   - tools present  -> `<|im_start|>system\n# Tools\n\nYou have access to the following
318///     functions:\n\n<tools>` + `\n{tool json}` each + `\n</tools>` + the fixed instruction
319///     block; a leading system turn's trimmed content is appended after `\n\n`; `<|im_end|>\n`.
320///   - assistant turns with `tool_calls` -> content then `<tool_call>\n<function=NAME>\n`
321///     (+`\n\n` separator when content is non-empty; later calls separated by `\n`),
322///     `<parameter=K>\nV\n</parameter>\n` each, `</function>\n</tool_call>`, then `<|im_end|>\n`.
323///   - `tool` turns -> grouped into ONE user turn: `<|im_start|>user` opens a run of
324///     consecutive tool messages, each `\n<tool_response>\n{content}\n</tool_response>`,
325///     `<|im_end|>\n` closes the run.
326///   - generation prompt -> `<|im_start|>assistant\n` + `<think>\n` (template default) or
327///     `<think>\n\n</think>\n\n` (`ThinkMode::NoThink` = the template's `enable_thinking=false`
328///     switch; ignored when the template has no `enable_thinking`).
329///
330/// The no-tools/no-tool-turns/`Default`-think case renders byte-identically to
331/// `apply_chat_template_str` (pinned by `tools_renderer_matches_legacy_when_plain`); callers
332/// that want the hard isolation guarantee keep calling the legacy function on that path.
333/// Errors (never on the plain path): tools/tool turns on a template without a tools branch
334/// (hy3 / gemma4 / bare ChatML).
335///
336/// `reasoning_effort` is a per-dialect level STRING, never a think switch: step35 renders
337/// `Reasoning: {low|medium|high}` into the system turn (see `apply_step35_template`); hy3
338/// consumes `no_think|low|high` (medium clamps to low); deepseek-v4 resolves it through the
339/// artifact's encoding revision into the effort prompt prefix (see `Dsv4Encoding` — 0731
340/// ladder low/high/max, preview "max" only). Every other dialect ignores it (their templates
341/// have no `reasoning_effort` input), and `None` is each template's own default. The server
342/// only supplies `Some` for models whose template consumes it (`ModelCaps::effort_levels`
343/// or `ModelCaps::dsv4`), so other prompts stay byte-identical by construction, not by luck.
344pub fn apply_chat_template_tools(
345    template: Option<&str>,
346    turns: &[Turn],
347    add_generation_prompt: bool,
348    tools_json: &[String],
349    think: ThinkMode,
350    reasoning_effort: Option<&str>,
351) -> Result<String, String> {
352    // Compat entry (no structured tools, no dsv4 encoding revision): CLI bins +
353    // qwen/step/hy3 tests. The gemma4 arm needs typed tool DEFINITIONS and the dsv4 arm an
354    // encoding revision for the effort ladder, so the serve path calls `_ex` with them
355    // (a dsv4 "high"/"max" request through THIS entry refuses on the unknown revision).
356    apply_chat_template_tools_ex(
357        template,
358        turns,
359        add_generation_prompt,
360        tools_json,
361        &[],
362        think,
363        reasoning_effort,
364        None,
365    )
366}
367
368/// `apply_chat_template_tools` plus the gemma4 arm's structured tool `function` objects
369/// (`tools_struct`) and the dsv4 arm's encoding revision (`dsv4_encoding` — the effort
370/// ladder differs between the preview and 0731 checkpoints; see `Dsv4Encoding`). Every
371/// non-gemma dialect ignores `tools_struct`; every non-dsv4 dialect ignores `dsv4_encoding`.
372#[allow(clippy::too_many_arguments)]
373pub fn apply_chat_template_tools_ex(
374    template: Option<&str>,
375    turns: &[Turn],
376    add_generation_prompt: bool,
377    tools_json: &[String],
378    tools_struct: &[Val],
379    think: ThinkMode,
380    reasoning_effort: Option<&str>,
381    dsv4_encoding: Option<Dsv4Encoding>,
382) -> Result<String, String> {
383    let has_tool_features = !tools_json.is_empty()
384        || turns
385            .iter()
386            .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
387    // deepseek-v4 is template-STRING-less on the real artifacts (dialect ships as
388    // encoding code) — the detected encoding revision is the dispatch truth there.
389    let is_dsv4 = dsv4_encoding.is_some() || template.is_some_and(template_is_dsv4);
390    // A template "has a tools branch" if it carries the qwen/step `<tools>` block OR the
391    // gemma4 tooluse dialect (`<|turn>` turn framing AND the `<|tool>` declaration marker)
392    // OR it is the dsv4 dialect (DSML defines a full tool protocol).
393    let tools_branch = is_dsv4 || template.is_some_and(template_has_tools_branch);
394    if has_tool_features && !tools_branch {
395        return Err("model chat template has no tools branch".into());
396    }
397    // deepseek-v4 (`encoding_dsv4`): its own dialect all the way through, tools included.
398    // Detected by its two structural markers; MUST precede the qwen/step marker checks
399    // (a faithful dsv4 template mentions `<think>` in its tools block). Renders tool
400    // DEFINITIONS (into the system turn), assistant DSML tool_calls, and role:"tool" turns
401    // merged into user `<tool_result>` blocks. ThinkMode maps onto encoding_dsv4's
402    // thinking_mode + reasoning_effort (see `apply_dsv4_template`).
403    if is_dsv4 {
404        return apply_dsv4_template(
405            turns,
406            add_generation_prompt,
407            tools_struct,
408            think,
409            reasoning_effort,
410            dsv4_encoding,
411        );
412    }
413    // step35: its own dialect all the way through, tools included (unlike hy3/gemma4, which
414    // reject tool features — step35 HAS a tools branch and it is reproduced). Must precede the
415    // qwen arm: the step35 template contains `<tools>`, `<think>` and `add_generation_prompt`,
416    // so every qwen marker check below matches it. `ThinkMode` is ignored (no `enable_thinking`
417    // in this template => `think_switch` is false => NoThink is already a documented no-op);
418    // `reasoning_effort` is this dialect's own control and is honored here.
419    if template.is_some_and(|t| t.contains("render_message_content")) {
420        return Ok(apply_step35_template(
421            turns,
422            add_generation_prompt,
423            tools_json,
424            reasoning_effort,
425        ));
426    }
427    // gemma4 TOOLUSE dialect (`<|turn>` turn framing + the `<|tool>` declaration marker):
428    // the official Google tooluse template is the rendering LAW (research/gemma4-tools-20260817
429    // /official-tooluse-template.jinja). Engages for tool DEFINITIONS, tool_calls, tool-role
430    // turns AND plain/thinking requests on this trunk. A `<|turn>` template WITHOUT `<|tool>`
431    // has no committed tools reference and falls through to the reject/plain arm below.
432    // Must precede the hy3/`<|turn>` arm (which would otherwise reject tools) and the qwen
433    // marker checks (the tooluse template carries no `<tools>`, so it would not match those).
434    if template.is_some_and(|t| t.contains("<|turn>") && t.contains("<|tool>")) {
435        // QAT-trunk variant emits a CLOSED thought channel on the thinking-off generation
436        // prompt; the official served trunk emits a bare `<|turn>model\n`. Keyed on the exact
437        // gen-prompt literal, which is present only in the QAT template's tail (verified:
438        // research/gemma4-tools-20260817 template diff).
439        let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
440        return Ok(apply_gemma4_tools_template(
441            turns,
442            add_generation_prompt,
443            tools_struct,
444            think == ThinkMode::Think,
445            closed_tail,
446        ));
447    }
448    if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
449        // hy3 / plain-gemma4 dialects: no committed tools rendering reference — reject tool
450        // features even if the raw jinja happens to mention <tools>. ThinkMode maps to each
451        // arch's native mechanism (thinking goldens, render-thinking-goldens.py):
452        //   hy3    -> the template's own reasoning_effort input: no_think (its default,
453        //             = ThinkMode::Default/NoThink) or low/high (open think, ThinkMode::Think
454        //             at the level the caller resolved — effort carries it).
455        //   gemma4 -> enable_thinking: default(false) = Default/NoThink;
456        //             Think = <|think|> system token + open generation turn.
457        if has_tool_features {
458            return Err("tools are not supported on this model's chat-template dialect".into());
459        }
460        let messages: Vec<(&str, &str)> = turns
461            .iter()
462            .map(|t| (t.role.as_str(), t.content.as_str()))
463            .collect();
464        if template.is_some_and(|t| t.contains("hy_User")) {
465            // hy3's accepted set is exactly no_think|low|high; OpenAI medium clamps to low
466            // (the template has no medium level and raises on unknown strings).
467            let effort = match (think, reasoning_effort) {
468                (ThinkMode::Think, Some("high")) => "high",
469                (ThinkMode::Think, _) => "low",
470                _ => "no_think",
471            };
472            return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
473        }
474        return Ok(apply_gemma4_template(
475            &messages,
476            add_generation_prompt,
477            think == ThinkMode::Think,
478        ));
479    }
480    let qwen_think = template
481        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
482        .unwrap_or(false);
483    let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
484
485    let mut out = String::new();
486    // Tools system header replaces the plain system turn (template law: the leading system
487    // turn's content is folded INTO the tools block).
488    let mut skip_leading_system = false;
489    if !tools_json.is_empty() {
490        out.push_str("<|im_start|>system\n");
491        out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
492        for tool in tools_json {
493            out.push('\n');
494            out.push_str(tool);
495        }
496        out.push_str("\n</tools>");
497        out.push_str(QWEN_TOOLS_INSTRUCTION);
498        if let Some(first) = turns.first() {
499            if first.role == "system" {
500                skip_leading_system = true;
501                let content = first.content.trim();
502                if !content.is_empty() {
503                    out.push_str("\n\n");
504                    out.push_str(content);
505                }
506            }
507        }
508        out.push_str("<|im_end|>\n");
509    }
510
511    for (i, turn) in turns.iter().enumerate() {
512        if i == 0 && skip_leading_system {
513            continue;
514        }
515        let content = turn.content.trim();
516        match turn.role.as_str() {
517            "system" => {
518                out.push_str("<|im_start|>system\n");
519                out.push_str(content);
520                out.push_str("<|im_end|>\n");
521            }
522            "user" => {
523                out.push_str("<|im_start|>user\n");
524                out.push_str(content);
525                out.push_str("<|im_end|>\n");
526            }
527            "assistant" => {
528                out.push_str("<|im_start|>assistant\n");
529                out.push_str(content);
530                for (k, call) in turn.tool_calls.iter().enumerate() {
531                    if k == 0 {
532                        if !content.is_empty() {
533                            out.push_str("\n\n");
534                        }
535                    } else {
536                        out.push('\n');
537                    }
538                    out.push_str("<tool_call>\n<function=");
539                    out.push_str(&call.name);
540                    out.push_str(">\n");
541                    for (key, value) in &call.params {
542                        out.push_str("<parameter=");
543                        out.push_str(key);
544                        out.push_str(">\n");
545                        out.push_str(value);
546                        out.push_str("\n</parameter>\n");
547                    }
548                    out.push_str("</function>\n</tool_call>");
549                }
550                out.push_str("<|im_end|>\n");
551            }
552            "tool" => {
553                if i == 0 || turns[i - 1].role != "tool" {
554                    out.push_str("<|im_start|>user");
555                }
556                out.push_str("\n<tool_response>\n");
557                out.push_str(content);
558                out.push_str("\n</tool_response>");
559                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
560                    out.push_str("<|im_end|>\n");
561                }
562            }
563            other => {
564                // parity with the legacy renderer's generic-turn arm.
565                out.push_str("<|im_start|>");
566                out.push_str(other);
567                out.push('\n');
568                out.push_str(content);
569                out.push_str("<|im_end|>\n");
570            }
571        }
572    }
573
574    if add_generation_prompt {
575        out.push_str("<|im_start|>assistant\n");
576        if qwen_think {
577            if think == ThinkMode::NoThink && think_switch {
578                out.push_str("<think>\n\n</think>\n\n");
579            } else {
580                out.push_str("<think>\n");
581            }
582        }
583    }
584    Ok(out)
585}
586
587/// The fixed tool-calling instruction block of the StepFun `step35` template. NOT the same
588/// string as `QWEN_TOOLS_INSTRUCTION` — three differences, all load-bearing: the header says
589/// "in JSONSchema format", the nesting reminder carries literal `\n...\n` inside the
590/// `<function=...>` / `<tool_call>` examples, and the Reminder list has 2 bullets instead of 4
591/// (no "optional reasoning BEFORE the call" and no "answer normally if no function is
592/// available"). Copied byte-for-byte out of the shipped template
593/// (`research/step37-bringup-20260802/raw/chat_template.jinja`, == the GGUF's own
594/// `tokenizer.chat_template`).
595const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
596following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
597<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
598This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
599</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
600format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
601...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
602
603/// StepFun Step-3.7-Flash (GGUF arch `step35`) chat template.
604///
605/// A ChatML *dialect*, not ChatML: it shares the `<|im_start|>role\n…<|im_end|>\n` frame and
606/// nothing else. Reproduced from the shipped jinja, and pinned test-by-test against goldens
607/// rendered from that jinja under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF
608/// transformers and llama.cpp's minja both parse chat templates with
609/// (`research/step37-p2-20260806/render_step35_template.py`, goldens committed under `raw/`).
610///
611/// Where it differs from the qwen3.5/3.6 arms above — every one of these silently corrupts the
612/// prompt if the qwen arm is reused:
613///
614/// | | qwen3.5/3.6 | step35 |
615/// |---|---|---|
616/// | reasoning level | `enable_thinking` bool | `Reasoning: {low,medium,high}\n\n` prefix inside the system turn |
617/// | `<think>` tail | switchable | **unconditional** — no `enable_thinking`, so `ThinkMode::NoThink` is a no-op |
618/// | prior assistant turns | content only | turns AFTER the last real user query also carry `<think>\n{reasoning}\n</think>\n` |
619/// | 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 |
620/// | content | `\|trim`med | **not** trimmed |
621/// | tools header | `following functions:` | `following functions in JSONSchema format:` |
622/// | call separators | `\n\n` after content, `\n` between calls | **none** |
623/// | leading system + tools | appended AFTER the instruction block | folded in BEFORE `# Tools` |
624///
625/// `reasoning_effort` is the model's headline three-level control (low/medium/high per the
626/// StepFun model card). It is a parameter here rather than a `ThinkMode`: the value is a
627/// *string in the system turn*, so a bool cannot carry it. The serve path supplies it through
628/// `apply_chat_template_tools` (worker `Request::reasoning_effort`, mapped from the OpenAI
629/// `reasoning_effort` body field when `ModelCaps::effort_levels` is set); `None` — the
630/// legacy-str path and every non-step35 model — renders the template's own default
631/// (no `Reasoning:` line at all).
632///
633/// BOS is NOT emitted (the jinja's `{{bos_token}}` is dropped): memra's `encode(add_special)`
634/// prepends it from `tokenizer.ggml.add_bos_token`/`bos_token_id` — the same double-BOS trap the
635/// gemma4 arm documents.
636///
637/// ONE deliberate divergence: the jinja's body loop has no `else`, so a role outside
638/// {system, user, assistant, tool} renders as **nothing at all** — the turn silently vanishes
639/// from the prompt. memra renders it as a generic `<|im_start|>{role}\n{content}<|im_end|>\n`
640/// turn instead, matching the other arms here. A dropped turn is the worse failure, and this
641/// branch cannot fire on the serve surface: OpenAI roles are exactly system/user/assistant/tool,
642/// all four of which are reproduced byte-for-byte.
643///
644/// Not reproduced (needs data `Turn` does not carry, tracked, cannot fire from an OpenAI client):
645/// the `name == "observation"` alias that renames a non-leading `system` turn's role to
646/// `observation`, and the `<im_patch>` image-content path (this is a VLM; memra is text-only here).
647fn apply_step35_template(
648    turns: &[Turn],
649    add_generation_prompt: bool,
650    tools_json: &[String],
651    reasoning_effort: Option<&str>,
652) -> String {
653    let mut out = String::new();
654    let leading_system = turns.first().filter(|t| t.role == "system");
655
656    // --- system header. Two branches in the jinja, and the ORDER differs between them.
657    if !tools_json.is_empty() {
658        out.push_str("<|im_start|>system\n");
659        if let Some(effort) = reasoning_effort {
660            out.push_str("Reasoning: ");
661            out.push_str(effort);
662            out.push_str("\n\n");
663        }
664        if let Some(sys) = leading_system {
665            // unconditional `content + '\n\n'` — no emptiness check, unlike the qwen arm.
666            out.push_str(&sys.content);
667            out.push_str("\n\n");
668        }
669        out.push_str(
670            "# Tools\n\nYou have access to the following functions in JSONSchema \
671                      format:\n\n<tools>",
672        );
673        for tool in tools_json {
674            out.push('\n');
675            out.push_str(tool);
676        }
677        out.push_str("\n</tools>");
678        out.push_str(STEP35_TOOLS_INSTRUCTION);
679        out.push_str("<|im_end|>\n");
680    } else if let Some(sys) = leading_system {
681        out.push_str("<|im_start|>system\n");
682        if let Some(effort) = reasoning_effort {
683            out.push_str("Reasoning: ");
684            out.push_str(effort);
685            out.push_str("\n\n");
686        }
687        out.push_str(&sys.content);
688        out.push_str("<|im_end|>\n");
689    } else if let Some(effort) = reasoning_effort {
690        out.push_str("<|im_start|>system\nReasoning: ");
691        out.push_str(effort);
692        out.push_str("\n\n<|im_end|>\n");
693    }
694
695    // --- last_query_index: the index of the LAST `user` turn that is a real query, i.e. whose
696    // content is not itself a `<tool_response>…</tool_response>` wrapper (a client replaying tool
697    // output as a user turn must not reset the reasoning boundary). Default len-1 when there is
698    // no such turn, exactly as the jinja's namespace initializer does.
699    let last_query_index = turns
700        .iter()
701        .enumerate()
702        .rev()
703        .find(|(_, t)| {
704            t.role == "user"
705                && !(t.content.starts_with("<tool_response>")
706                    && t.content.ends_with("</tool_response>"))
707        })
708        .map(|(i, _)| i)
709        .unwrap_or(turns.len().saturating_sub(1));
710
711    for (i, turn) in turns.iter().enumerate() {
712        let content = &turn.content; // NOT trimmed: this template applies no `|trim`
713        match turn.role.as_str() {
714            // the leading system turn lives in the header above; later ones are body turns.
715            "system" if i == 0 => {}
716            "system" | "user" => {
717                out.push_str("<|im_start|>");
718                out.push_str(&turn.role);
719                out.push('\n');
720                out.push_str(content);
721                out.push_str("<|im_end|>\n");
722            }
723            "assistant" => {
724                // Split an inline `<think>…</think>` out of content, mirroring the jinja's
725                // string surgery exactly: reasoning = text before the FIRST `</think>`, with
726                // trailing newlines stripped, then everything after the LAST `<think>` in that
727                // prefix, with leading newlines stripped; body = after the LAST `</think>`,
728                // leading newlines stripped.
729                let (reasoning, body): (String, &str) = match content.find("</think>") {
730                    Some(first) => {
731                        let pre = content[..first].trim_end_matches('\n');
732                        let pre = match pre.rfind("<think>") {
733                            Some(o) => &pre[o + "<think>".len()..],
734                            None => pre,
735                        };
736                        let last = content.rfind("</think>").unwrap();
737                        (
738                            pre.trim_start_matches('\n').to_string(),
739                            content[last + "</think>".len()..].trim_start_matches('\n'),
740                        )
741                    }
742                    None => (String::new(), content.as_str()),
743                };
744                out.push_str("<|im_start|>assistant\n");
745                if i > last_query_index {
746                    out.push_str("<think>\n");
747                    out.push_str(&reasoning);
748                    out.push_str("\n</think>\n");
749                }
750                out.push_str(body);
751                // NO separator before or between calls (the qwen arm's `\n\n`/`\n` would corrupt).
752                for call in &turn.tool_calls {
753                    out.push_str("<tool_call>\n<function=");
754                    out.push_str(&call.name);
755                    out.push_str(">\n");
756                    for (key, value) in &call.params {
757                        out.push_str("<parameter=");
758                        out.push_str(key);
759                        out.push_str(">\n");
760                        out.push_str(value);
761                        out.push_str("\n</parameter>\n");
762                    }
763                    out.push_str("</function>\n</tool_call>");
764                }
765                out.push_str("<|im_end|>\n");
766            }
767            "tool" => {
768                // own role, and consecutive tool turns share ONE `tool_response` turn.
769                if i == 0 || turns[i - 1].role != "tool" {
770                    out.push_str("<|im_start|>tool_response\n");
771                }
772                out.push_str("<tool_response>");
773                out.push_str(content);
774                out.push_str("</tool_response>");
775                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
776                    out.push_str("<|im_end|>\n");
777                }
778            }
779            other => {
780                // the jinja drops this turn entirely; see the divergence note above.
781                out.push_str("<|im_start|>");
782                out.push_str(other);
783                out.push('\n');
784                out.push_str(content);
785                out.push_str("<|im_end|>\n");
786            }
787        }
788    }
789
790    if add_generation_prompt {
791        out.push_str("<|im_start|>assistant\n<think>\n");
792    }
793    out
794}
795
796/// Text-only reproduction of the Hy3 `chat_template.jinja` (no tools, no `is_training`).
797/// `effort` is the template's own `reasoning_effort` input — `"no_think"` / `"low"` /
798/// `"high"`, its full accepted set (the jinja `raise_exception`s on anything else; undefined
799/// defaults to `'no_think'`, so callers with no opinion pass `"no_think"`):
800///   - `{bos}{system…}<|reasoning_mode:opensource|>reasoning_effort:{effort}` header
801///     (system turns concatenate into the header, before any user turn);
802///   - `user`      -> `<|hy_User:opensource|>{content}`
803///   - `assistant` -> `<|hy_Assistant:opensource|><think:opensource></think:opensource>{content}<|hy_eos:opensource|>`
804///     (non-last turns; history turns render CLOSED think at every effort — the template
805///     opens only turns past `last_user_index`, and OpenAI history carries no reasoning);
806///   - generation prompt: `<|hy_Assistant:opensource|><think:opensource></think:opensource>`
807///     at no_think, `…<think:opensource>` (OPEN think) at low/high.
808/// Content is NOT trimmed (the Hy3 template applies no `|trim`). Goldens: rendered from the
809/// pinned tencent/Hy3 template (sha 7fc351fe…, snapshot 716aa724) by
810/// `research/step-sku-20260807/render-thinking-goldens.py`.
811fn apply_hy3_template(
812    messages: &[(&str, &str)],
813    add_generation_prompt: bool,
814    effort: &str,
815) -> String {
816    const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
817    const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
818    const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
819    const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
820    const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
821    const THINK_BEGIN: &str = "<think:opensource>";
822    const THINK_END: &str = "</think:opensource>";
823
824    debug_assert!(
825        matches!(effort, "no_think" | "low" | "high"),
826        "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
827    );
828    let mut out = String::from(BOS);
829    for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
830        let _ = role;
831        out.push_str(content);
832    }
833    out.push_str(REASONING);
834    out.push_str("reasoning_effort:");
835    out.push_str(effort);
836
837    let mut last_is_assistant = false;
838    let n = messages.len();
839    for (i, (role, content)) in messages.iter().enumerate() {
840        last_is_assistant = false;
841        match *role {
842            "user" => {
843                out.push_str(USER);
844                out.push_str(content);
845            }
846            "assistant" => {
847                out.push_str(ASSISTANT);
848                out.push_str(THINK_BEGIN);
849                out.push_str(THINK_END);
850                out.push_str(content);
851                if i + 1 < n {
852                    out.push_str(EOS);
853                } // template: `not loop.last` gets eos
854                last_is_assistant = true;
855            }
856            _ => {} // system handled in the header; tool turns are out of scope here
857        }
858    }
859    if add_generation_prompt && !last_is_assistant {
860        out.push_str(ASSISTANT);
861        out.push_str(THINK_BEGIN);
862        if effort == "no_think" {
863            out.push_str(THINK_END); // low/high leave the think channel OPEN (the golden)
864        }
865    }
866    out
867}
868
869/// gemma4 turn dialect (text-only path of the GGUF template, verified against the dumped
870/// jinja — sha 36e3a42e…, goldens `research/step-sku-20260807/raw/thinking-goldens.txt`):
871/// roles map assistant->model; each turn = `<|turn>{role}\n{content|trim}<turn|>\n`.
872///
873/// THINKING is `enable_thinking`, and its default is OFF (`enable_thinking | default(false)`)
874/// — the inverse of the qwen class:
875///   - thinking OFF (default): generation prompt = `<|turn>model\n<|channel>thought\n<channel|>`
876///     (the CLOSED thought channel — the model may not think);
877///   - thinking ON: a `<|think|>\n` token is injected at the very top of the FIRST system
878///     turn (a system turn is CREATED if the request has none), and the generation prompt is
879///     the bare `<|turn>model\n` — the thought channel is left to the model.
880fn apply_gemma4_template(
881    messages: &[(&str, &str)],
882    add_generation_prompt: bool,
883    thinking: bool,
884) -> String {
885    let mut out = String::new();
886    let mut msgs = messages;
887    // System header block: fires when thinking is on OR a leading system turn exists.
888    let leading_system = msgs.first().filter(|(r, _)| *r == "system");
889    if thinking || leading_system.is_some() {
890        out.push_str("<|turn>system\n");
891        if thinking {
892            out.push_str("<|think|>\n");
893        }
894        if let Some((_, content)) = leading_system {
895            out.push_str(content.trim());
896            msgs = &msgs[1..];
897        }
898        out.push_str("<turn|>\n");
899    }
900    for (role, content) in msgs {
901        let role = if *role == "assistant" { "model" } else { role };
902        out.push_str("<|turn>");
903        out.push_str(role);
904        out.push('\n');
905        out.push_str(content.trim());
906        out.push_str("<turn|>\n");
907    }
908    if add_generation_prompt {
909        out.push_str("<|turn>model\n");
910        if !thinking {
911            out.push_str("<|channel>thought\n<channel|>");
912        }
913    }
914    out
915}
916
917/// A template carries a tools branch iff it has the qwen/step `<tools>` block, or the gemma4
918/// tooluse dialect (both the `<|turn>` turn framing and the `<|tool>` declaration marker).
919/// hy3 (`hy_User`) never has one. Shared by the renderer dispatch and the worker caps probe.
920pub fn template_has_tools_branch(t: &str) -> bool {
921    if t.contains("hy_User") {
922        return false;
923    }
924    template_is_dsv4(t) || t.contains("<tools>") || (t.contains("<|turn>") && t.contains("<|tool>"))
925}
926
927/// deepseek-v4 (`encoding_dsv4`) template detector: the `<|Assistant|>` turn prefix AND the
928/// `|DSML|` tool-call markup token. Both are unique to the DeepSeek-V4 chat dialect (`|`
929/// is U+FF5C, `<think>` alone would be ambiguous with the qwen class). Shared by the renderer
930/// dispatch, the tools-branch probe, and the worker caps.
931pub fn template_is_dsv4(t: &str) -> bool {
932    t.contains("<\u{ff5c}Assistant\u{ff5c}>") && t.contains("\u{ff5c}DSML\u{ff5c}")
933}
934
935// ---- gemma4 tooluse dialect ---------------------------------------------------------------
936// A faithful port of research/gemma4-tools-20260817/official-tooluse-template.jinja (extracted
937// byte-identical from the official Q8_0-MTP GGUF — the served trunk). The jinja is the LAW;
938// byte parity is pinned by research/gemma4-tools-20260817/fixtures (the `gemma4_tools_fixtures`
939// test in memra-server renders the official jinja under jinja2 and asserts equality). Deviation
940// from the jinja: an unresolved tool-response name falls back to "unknown" instead of crashing
941// on `str + None` (the jinja's `.get('name') | default('unknown')` renders None, then the
942// concat raises) — unreachable from OpenAI histories, where the id always resolves.
943
944/// jinja `| dictsort`: case-insensitive by key, STABLE (ties keep insertion order).
945fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
946    let mut v: Vec<&(String, Val)> = pairs.iter().collect();
947    v.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
948    v
949}
950
951/// jinja `format_argument(argument, escape_keys)`: strings wrapped in `<|"|>`, bools `true`/
952/// `false`, mappings `{k:v,...}` (keys bare unless `escape_keys`, dictsorted, recursive),
953/// sequences `[v,...]`, null -> `None` (jinja `{{ none }}`), numbers bare.
954fn format_argument(v: &Val, escape_keys: bool) -> String {
955    match v {
956        Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
957        Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
958        Val::Obj(pairs) => {
959            let mut out = String::from("{");
960            for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
961                if i > 0 {
962                    out.push(',');
963                }
964                if escape_keys {
965                    out.push_str(&format!("<|\"|>{k}<|\"|>"));
966                } else {
967                    out.push_str(k);
968                }
969                out.push(':');
970                out.push_str(&format_argument(val, escape_keys));
971            }
972            out.push('}');
973            out
974        }
975        Val::Arr(items) => {
976            let mut out = String::from("[");
977            for (i, item) in items.iter().enumerate() {
978                if i > 0 {
979                    out.push(',');
980                }
981                out.push_str(&format_argument(item, escape_keys));
982            }
983            out.push(']');
984            out
985        }
986        Val::Null => "None".to_string(),
987        Val::Num(s) => s.clone(),
988    }
989}
990
991/// jinja `strip_thinking(text)`: drop every `<|channel>...<channel|>` span, then `| trim`.
992/// Split on `<channel|>`; for each part, keep everything before a `<|channel>` (dropping the
993/// channel body), else keep the whole part.
994fn strip_thinking(text: &str) -> String {
995    let mut result = String::new();
996    for part in text.split("<channel|>") {
997        match part.find("<|channel>") {
998            Some(o) => result.push_str(&part[..o]),
999            None => result.push_str(part),
1000        }
1001    }
1002    result.trim().to_string()
1003}
1004
1005fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
1006    obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1007}
1008fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
1009    match v {
1010        Val::Obj(p) => Some(p),
1011        _ => None,
1012    }
1013}
1014fn as_str(v: &Val) -> Option<&str> {
1015    match v {
1016        Val::Str(s) => Some(s),
1017        _ => None,
1018    }
1019}
1020/// jinja truthiness for `if value[...]`: None/false/""/[]/{} are falsy.
1021fn truthy(v: &Val) -> bool {
1022    match v {
1023        Val::Null => false,
1024        Val::Bool(b) => *b,
1025        Val::Str(s) => !s.is_empty(),
1026        Val::Num(s) => s != "0" && s != "0.0",
1027        Val::Arr(a) => !a.is_empty(),
1028        Val::Obj(o) => !o.is_empty(),
1029    }
1030}
1031
1032/// jinja comma helper: emit ',' iff a prior element was written in THIS property object, then
1033/// mark that at least one has been written.
1034fn comma(out: &mut String, add: &mut bool) {
1035    if *add {
1036        out.push(',');
1037    } else {
1038        *add = true;
1039    }
1040}
1041
1042/// jinja `format_parameters(properties, _required_unused, filter_keys)`. The second jinja arg
1043/// (`required`) is never referenced in the macro body, so it is dropped here.
1044fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
1045    const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
1046    let mut found_first = false;
1047    for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
1048        if filter_keys && STANDARD.contains(&key.as_str()) {
1049            continue;
1050        }
1051        if found_first {
1052            out.push(',');
1053        }
1054        found_first = true;
1055        out.push_str(key);
1056        out.push_str(":{");
1057        let vobj = as_obj(value);
1058        let mut add = false;
1059        // description
1060        if let Some(d) = vobj
1061            .and_then(|o| val_get(o, "description"))
1062            .filter(|d| truthy(d))
1063        {
1064            out.push_str("description:<|\"|>");
1065            out.push_str(as_str(d).unwrap_or(""));
1066            out.push_str("<|\"|>");
1067            add = true;
1068        }
1069        let ty_up = vobj
1070            .and_then(|o| val_get(o, "type"))
1071            .and_then(as_str)
1072            .map(|s| s.to_uppercase());
1073        match ty_up.as_deref() {
1074            Some("STRING") => {
1075                if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
1076                    comma(out, &mut add);
1077                    out.push_str("enum:");
1078                    out.push_str(&format_argument(en, true));
1079                }
1080            }
1081            Some("ARRAY") => {
1082                if let Some(items) = vobj
1083                    .and_then(|o| val_get(o, "items"))
1084                    .filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
1085                {
1086                    comma(out, &mut add);
1087                    out.push_str("items:{");
1088                    format_items(out, as_obj(items).unwrap());
1089                    out.push('}');
1090                }
1091            }
1092            _ => {}
1093        }
1094        // nullable
1095        if vobj
1096            .and_then(|o| val_get(o, "nullable"))
1097            .is_some_and(truthy)
1098        {
1099            comma(out, &mut add);
1100            out.push_str("nullable:true");
1101        }
1102        // OBJECT: nested properties + required
1103        if ty_up.as_deref() == Some("OBJECT") {
1104            if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
1105                comma(out, &mut add);
1106                out.push_str("properties:{");
1107                format_parameters(out, sub, false);
1108                out.push('}');
1109            } else if let Some(o) = vobj {
1110                // no explicit `properties`: treat the value's own keys as sub-properties,
1111                // filtering the standard schema keys (jinja `filter_keys=true` branch).
1112                comma(out, &mut add);
1113                out.push_str("properties:{");
1114                format_parameters(out, o, true);
1115                out.push('}');
1116            }
1117            if let Some(req) = vobj
1118                .and_then(|o| val_get(o, "required"))
1119                .filter(|r| truthy(r))
1120            {
1121                comma(out, &mut add);
1122                out.push_str("required:[");
1123                push_str_list(out, req);
1124                out.push(']');
1125            }
1126        }
1127        // closing `type:<|"|>UPPER<|"|>}` (always) — carries a leading comma iff anything above.
1128        comma(out, &mut add);
1129        out.push_str("type:<|\"|>");
1130        out.push_str(ty_up.as_deref().unwrap_or(""));
1131        out.push_str("<|\"|>}");
1132    }
1133}
1134
1135/// The ARRAY `items` mapping loop: dictsorts item keys, skips None values, and renders
1136/// properties/required/type specially, else generic `key:format_argument(value)`.
1137fn format_items(out: &mut String, items: &[(String, Val)]) {
1138    let mut found_first = false;
1139    for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
1140        if matches!(v, Val::Null) {
1141            continue;
1142        }
1143        if found_first {
1144            out.push(',');
1145        }
1146        found_first = true;
1147        match k.as_str() {
1148            "properties" => {
1149                out.push_str("properties:{");
1150                if let Some(o) = as_obj(v) {
1151                    format_parameters(out, o, false);
1152                }
1153                out.push('}');
1154            }
1155            "required" => {
1156                out.push_str("required:[");
1157                push_str_list(out, v);
1158                out.push(']');
1159            }
1160            "type" => {
1161                out.push_str("type:");
1162                match v {
1163                    Val::Str(s) => {
1164                        out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
1165                    }
1166                    Val::Arr(a) => {
1167                        let upper: Vec<Val> = a
1168                            .iter()
1169                            .map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
1170                            .collect();
1171                        out.push_str(&format_argument(&Val::Arr(upper), true));
1172                    }
1173                    other => out.push_str(&format_argument(other, true)),
1174                }
1175            }
1176            _ => {
1177                out.push_str(k);
1178                out.push(':');
1179                out.push_str(&format_argument(v, true));
1180            }
1181        }
1182    }
1183}
1184
1185/// `[<|"|>a<|"|>,<|"|>b<|"|>]` body (without the brackets) from a Val::Arr of strings.
1186fn push_str_list(out: &mut String, v: &Val) {
1187    if let Val::Arr(items) = v {
1188        for (i, item) in items.iter().enumerate() {
1189            if i > 0 {
1190                out.push(',');
1191            }
1192            out.push_str("<|\"|>");
1193            out.push_str(as_str(item).unwrap_or(""));
1194            out.push_str("<|\"|>");
1195        }
1196    }
1197}
1198
1199/// jinja `format_function_declaration(tool_data)` — `func` is the tool's `function` object.
1200fn format_function_declaration(func: &[(String, Val)]) -> String {
1201    let mut out = String::new();
1202    out.push_str("declaration:");
1203    out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
1204    out.push_str("{description:<|\"|>");
1205    out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
1206    out.push_str("<|\"|>");
1207    if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
1208        let pobj = as_obj(params);
1209        out.push_str(",parameters:{");
1210        if let Some(props) = pobj
1211            .and_then(|o| val_get(o, "properties"))
1212            .filter(|p| truthy(p))
1213            .and_then(as_obj)
1214        {
1215            out.push_str("properties:{");
1216            format_parameters(&mut out, props, false);
1217            out.push_str("},");
1218        }
1219        if let Some(req) = pobj
1220            .and_then(|o| val_get(o, "required"))
1221            .filter(|r| truthy(r))
1222        {
1223            out.push_str("required:[");
1224            push_str_list(&mut out, req);
1225            out.push_str("],");
1226        }
1227        if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
1228            out.push_str("type:<|\"|>");
1229            out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
1230            out.push_str("<|\"|>}");
1231        }
1232    }
1233    if let Some(resp) = val_get(func, "response").and_then(as_obj) {
1234        out.push_str(",response:{");
1235        if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
1236            out.push_str("description:<|\"|>");
1237            out.push_str(as_str(d).unwrap_or(""));
1238            out.push_str("<|\"|>,");
1239        }
1240        if val_get(resp, "type")
1241            .and_then(as_str)
1242            .map(|s| s.to_uppercase())
1243            == Some("OBJECT".into())
1244        {
1245            out.push_str("type:<|\"|>OBJECT<|\"|>}");
1246        }
1247    }
1248    out.push('}');
1249    out
1250}
1251
1252/// jinja `format_tool_response_block(tool_name, response)`.
1253fn format_tool_response_block(name: &str, response: &Val) -> String {
1254    let mut out = String::from("<|tool_response>");
1255    match response {
1256        Val::Obj(pairs) => {
1257            out.push_str("response:");
1258            out.push_str(name);
1259            out.push('{');
1260            for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1261                if i > 0 {
1262                    out.push(',');
1263                }
1264                out.push_str(k);
1265                out.push(':');
1266                out.push_str(&format_argument(v, false));
1267            }
1268            out.push('}');
1269        }
1270        other => {
1271            out.push_str("response:");
1272            out.push_str(name);
1273            out.push_str("{value:");
1274            out.push_str(&format_argument(other, false));
1275            out.push('}');
1276        }
1277    }
1278    out.push_str("<tool_response|>");
1279    out
1280}
1281
1282/// gemma4 tooluse renderer. `tools` are the tool `function` objects; `thinking` = jinja
1283/// `enable_thinking`; `closed_tail` = the QAT-trunk variant that emits a closed thought
1284/// channel on the thinking-off generation prompt (the official served trunk does not). BOS is
1285/// NOT emitted (encode(add_special) supplies it — the jinja's `{{ bos_token }}` is dropped).
1286fn apply_gemma4_tools_template(
1287    turns: &[Turn],
1288    add_generation_prompt: bool,
1289    tools: &[Val],
1290    thinking: bool,
1291    closed_tail: bool,
1292) -> String {
1293    let mut out = String::new();
1294    let mut prev: Option<&str> = None;
1295    let mut msgs = turns;
1296    let is_sys = |r: &str| r == "system" || r == "developer";
1297
1298    let leading_system = msgs.first().filter(|t| is_sys(&t.role));
1299    if thinking || !tools.is_empty() || leading_system.is_some() {
1300        out.push_str("<|turn>system\n");
1301        if thinking {
1302            out.push_str("<|think|>\n");
1303            prev = Some("think");
1304        }
1305        if let Some(sys) = leading_system {
1306            out.push_str(sys.content.trim());
1307            msgs = &msgs[1..];
1308        }
1309        for tool in tools {
1310            out.push_str("<|tool>");
1311            if let Some(func) = as_obj(tool) {
1312                out.push_str(format_function_declaration(func).trim());
1313            }
1314            out.push_str("<tool|>");
1315        }
1316        if !tools.is_empty() {
1317            prev = Some("tool");
1318        }
1319        out.push_str("<turn|>\n");
1320    }
1321
1322    let last_user_idx: isize = msgs
1323        .iter()
1324        .enumerate()
1325        .rev()
1326        .find(|(_, t)| t.role == "user")
1327        .map(|(i, _)| i as isize)
1328        .unwrap_or(-1);
1329
1330    for (i, m) in msgs.iter().enumerate() {
1331        if m.role == "tool" {
1332            continue; // consumed by a preceding assistant's forward-scan
1333        }
1334        prev = None;
1335        let role = if m.role == "assistant" {
1336            "model"
1337        } else {
1338            m.role.as_str()
1339        };
1340        let prev_nt_role = (0..i)
1341            .rev()
1342            .map(|j| &msgs[j])
1343            .find(|t| t.role != "tool")
1344            .map(|t| t.role.as_str());
1345        let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
1346        if !continue_same_model_turn {
1347            out.push_str("<|turn>");
1348            out.push_str(role);
1349            out.push('\n');
1350        }
1351
1352        // reasoning re-render (tool_calls-carrying assistant after the last user turn)
1353        if let Some(rt) = m.reasoning.as_deref() {
1354            if !rt.is_empty() && (i as isize) > last_user_idx && !m.tool_calls.is_empty() {
1355                out.push_str("<|channel>thought\n");
1356                out.push_str(rt);
1357                out.push_str("\n<channel|>");
1358            }
1359        }
1360
1361        // tool_calls
1362        if !m.tool_calls.is_empty() {
1363            for tc in &m.tool_calls {
1364                out.push_str("<|tool_call>call:");
1365                out.push_str(&tc.name);
1366                out.push('{');
1367                for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
1368                    if j > 0 {
1369                        out.push(',');
1370                    }
1371                    out.push_str(k);
1372                    out.push(':');
1373                    out.push_str(&format_argument(v, false));
1374                }
1375                out.push_str("}<tool_call|>");
1376            }
1377            prev = Some("tool_call");
1378        }
1379
1380        // tool responses: native (Google) on the assistant, else OpenAI role:"tool" forward-scan
1381        let mut tr_flag = false;
1382        if !m.tool_responses.is_empty() {
1383            for (name, resp) in &m.tool_responses {
1384                out.push_str(&format_tool_response_block(name, resp));
1385                tr_flag = true;
1386                prev = Some("tool_response");
1387            }
1388        } else if !m.tool_calls.is_empty() {
1389            for k in (i + 1)..msgs.len() {
1390                let follow = &msgs[k];
1391                if follow.role != "tool" {
1392                    break;
1393                }
1394                let mut name = follow
1395                    .tool_name
1396                    .clone()
1397                    .unwrap_or_else(|| "unknown".to_string());
1398                if let Some(fid) = follow.tool_call_id.as_deref() {
1399                    for tc in &m.tool_calls {
1400                        if tc.id.as_deref() == Some(fid) {
1401                            name = tc.name.clone();
1402                        }
1403                    }
1404                }
1405                out.push_str(&format_tool_response_block(
1406                    &name,
1407                    &Val::Str(follow.content.clone()),
1408                ));
1409                tr_flag = true;
1410                prev = Some("tool_response");
1411            }
1412        }
1413
1414        // content (model content strips thought channels; other roles trim)
1415        let captured = if role == "model" {
1416            strip_thinking(&m.content)
1417        } else {
1418            m.content.trim().to_string()
1419        };
1420        out.push_str(&captured);
1421        let has_content = !captured.trim().is_empty();
1422
1423        if prev == Some("tool_call") && !tr_flag {
1424            out.push_str("<|tool_response>"); // dangling open: calls with no responses yet
1425        } else if !(tr_flag && !has_content) {
1426            out.push_str("<turn|>\n");
1427        }
1428    }
1429
1430    if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
1431        out.push_str("<|turn>model\n");
1432        if closed_tail && !thinking {
1433            out.push_str("<|channel>thought\n<channel|>");
1434        }
1435    }
1436    out
1437}
1438
1439// ---- deepseek-v4 (encoding_dsv4) dialect --------------------------------------------------
1440// A faithful port of encoding_dsv4.py in BOTH shipped revisions: the preview oracle
1441// (research/dsv4-template-20260818/ref/encoding/encoding_dsv4.py, sha256 bdbd57c1…) and the
1442// 0731 oracle (…/ref-0731/encoding/encoding_dsv4.py, sha256 abc0d261…), which differ ONLY in
1443// the reasoning-effort ladder (full behavioral diff: ENCODING-DIFF.md; selection law:
1444// `Dsv4Encoding`). The python IS the law; byte parity is pinned by
1445// research/dsv4-template-20260818/fixtures (preview matrix) + fixtures-0731 (0731 matrix)
1446// plus the artifact's authoritative encoding/tests/test_output_{1..4} (byte-identical across
1447// both revisions). See TEMPLATE-SEMANTICS.md for the census + banked ambiguities. Deviation
1448// from the python: none in the renderer (the parser deviates on malformed spans per house
1449// policy — see toolcall.rs).
1450
1451// U+FF5C is the fullwidth vertical line `|` in every DeepSeek special token; U+2581 the ▁.
1452const DS_BOS: &str = "<\u{ff5c}begin\u{2581}of\u{2581}sentence\u{ff5c}>";
1453const DS_EOS: &str = "<\u{ff5c}end\u{2581}of\u{2581}sentence\u{ff5c}>";
1454const DS_USER: &str = "<\u{ff5c}User\u{ff5c}>";
1455const DS_ASSISTANT: &str = "<\u{ff5c}Assistant\u{ff5c}>";
1456const DS_REMINDER: &str = "<\u{ff5c}latest_reminder\u{ff5c}>";
1457const DS_THINK_START: &str = "<think>";
1458const DS_THINK_END: &str = "</think>";
1459const DS_DSML: &str = "\u{ff5c}DSML\u{ff5c}";
1460// preview encoding_dsv4 REASONING_EFFORT_MAX (E:64-68) == 0731 REASONING_EFFORT_PROMPTS["high"]
1461// (0731 E:64-77 — same bytes, one ladder rung lower). Ends with "\n\n".
1462const DS_EFFORT_ABSOLUTE_MAX: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
1463// 0731 REASONING_EFFORT_PROMPTS["max"] (0731 E:70-75) — the new, stronger top rung. The dash
1464// is U+2014 EM DASH in the source; ends with "\n\n". Not present in the preview encoding.
1465const DS_EFFORT_BEYOND_MAX: &str = "Reasoning Effort: Beyond maximum \u{2014} exhaustive, relentless, and uncompromising.\nYou MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\nDo not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n";
1466
1467/// The reasoning-effort prompt prefix for one render (encoding_dsv4 preview E:260-263 /
1468/// 0731 E:270-277). `Ok("")` = no prefix. Errs ONLY on the ambiguous cell: an effort level
1469/// whose bytes differ between the two encodings (`"high"`/`"max"` in thinking mode) with no
1470/// encoding revision supplied — every other input renders identically under both revisions,
1471/// so it stays infallible there (the legacy no-effort dispatch relies on that).
1472///
1473/// Levels outside the encoding's accepted set (e.g. OpenAI "medium", which neither revision
1474/// defines) render as the default level, i.e. no prefix — the renderer never corrupts a
1475/// prompt over a knob the template does not consume (hy3 medium-clamp precedent).
1476fn dsv4_effort_prefix(
1477    thinking: bool,
1478    effort: Option<&str>,
1479    encoding: Option<Dsv4Encoding>,
1480) -> Result<&'static str, String> {
1481    if !thinking {
1482        // chat mode: no prefix under either encoding (preview E:262 / 0731 E:275 both gate
1483        // on thinking_mode == "thinking").
1484        return Ok("");
1485    }
1486    match effort {
1487        // None: preview renders nothing; 0731 defaults None -> "low" -> "" (E:271, E:66).
1488        // "low": 0731 default rung (no prefix); the preview oracle rejects the string, and
1489        // rendering no prefix is the only never-corrupt reading (banked, ENCODING-DIFF.md).
1490        None | Some("low") => Ok(""),
1491        Some("high") => match encoding {
1492            Some(Dsv4Encoding::Preview) => Ok(""), // preview law: "high" == None (E:261-263)
1493            Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_ABSOLUTE_MAX),
1494            None => Err(
1495                "dsv4 reasoning_effort \"high\" renders differently on the preview vs 0731 \
1496                 encoding and this artifact's encoding revision is unknown (config.json \
1497                 dspark_* census unavailable) — refusing rather than guessing"
1498                    .into(),
1499            ),
1500        },
1501        Some("max") => match encoding {
1502            Some(Dsv4Encoding::Preview) => Ok(DS_EFFORT_ABSOLUTE_MAX),
1503            Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_BEYOND_MAX),
1504            None => Err(
1505                "dsv4 reasoning_effort \"max\" renders differently on the preview vs 0731 \
1506                 encoding and this artifact's encoding revision is unknown (config.json \
1507                 dspark_* census unavailable) — refusing rather than guessing"
1508                    .into(),
1509            ),
1510        },
1511        Some(_) => Ok(""),
1512    }
1513}
1514
1515/// encoding_dsv4 DS_TASK_SP_TOKENS (E:28-35). The task token for a quick-instruction head.
1516fn ds_task_token(task: &str) -> Option<&'static str> {
1517    match task {
1518        "action" => Some("<\u{ff5c}action\u{ff5c}>"),
1519        "query" => Some("<\u{ff5c}query\u{ff5c}>"),
1520        "authority" => Some("<\u{ff5c}authority\u{ff5c}>"),
1521        "domain" => Some("<\u{ff5c}domain\u{ff5c}>"),
1522        "title" => Some("<\u{ff5c}title\u{ff5c}>"),
1523        "read_url" => Some("<\u{ff5c}read_url\u{ff5c}>"),
1524        _ => None,
1525    }
1526}
1527
1528/// python `json.dumps(v, ensure_ascii=False)` over a `Val` (encoding_dsv4 `to_json`, E:101-106):
1529/// default separators `", "` / `": "`, insertion key order, non-ASCII raw, `Num` exact text.
1530/// serde-free (this crate ships no serde) — the escaper below matches json.dumps exactly.
1531fn dsv4_json(v: &Val, out: &mut String) {
1532    match v {
1533        Val::Null => out.push_str("null"),
1534        Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1535        Val::Num(s) => out.push_str(s),
1536        Val::Str(s) => {
1537            out.push('"');
1538            dsv4_json_escape(s, out);
1539            out.push('"');
1540        }
1541        Val::Arr(a) => {
1542            out.push('[');
1543            for (i, x) in a.iter().enumerate() {
1544                if i > 0 {
1545                    out.push_str(", ");
1546                }
1547                dsv4_json(x, out);
1548            }
1549            out.push(']');
1550        }
1551        Val::Obj(o) => {
1552            out.push('{');
1553            for (i, (k, val)) in o.iter().enumerate() {
1554                if i > 0 {
1555                    out.push_str(", ");
1556                }
1557                out.push('"');
1558                dsv4_json_escape(k, out);
1559                out.push_str("\": ");
1560                dsv4_json(val, out);
1561            }
1562            out.push('}');
1563        }
1564    }
1565}
1566
1567/// JSON string escaping matching python `json.dumps(ensure_ascii=False)`: `"` `\` and the
1568/// C0 escapes; other control chars < 0x20 become `\u00xx`; everything else (incl. non-ASCII)
1569/// passes through raw. json.dumps does NOT escape `/` or DEL.
1570fn dsv4_json_escape(s: &str, out: &mut String) {
1571    for c in s.chars() {
1572        match c {
1573            '"' => out.push_str("\\\""),
1574            '\\' => out.push_str("\\\\"),
1575            '\n' => out.push_str("\\n"),
1576            '\r' => out.push_str("\\r"),
1577            '\t' => out.push_str("\\t"),
1578            '\u{8}' => out.push_str("\\b"),
1579            '\u{c}' => out.push_str("\\f"),
1580            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1581            c => out.push(c),
1582        }
1583    }
1584}
1585
1586/// encoding_dsv4 `render_tools` (E:189-206) + TOOLS_TEMPLATE (E:70-95): the tool-declaration
1587/// block appended to a system/developer turn. `funcs` are the tool `function` objects
1588/// (encoding_dsv4 `tools_from_openai_format`). Ends with a trailing `\n`.
1589fn dsv4_render_tools(funcs: &[Val]) -> String {
1590    let mut schemas = String::new();
1591    for (i, f) in funcs.iter().enumerate() {
1592        if i > 0 {
1593            schemas.push('\n');
1594        }
1595        dsv4_json(f, &mut schemas);
1596    }
1597    format!(
1598        "## Tools\n\nYou have access to a set of tools to help answer the user's question. \
1599You can invoke tools by writing a \"<{d}tool_calls>\" block like the following:\n\n\
1600<{d}tool_calls>\n<{d}invoke name=\"$TOOL_NAME\">\n\
1601<{d}parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</{d}parameter>\n\
1602...\n</{d}invoke>\n<{d}invoke name=\"$TOOL_NAME2\">\n...\n</{d}invoke>\n</{d}tool_calls>\n\n\
1603String parameters should be specified as is and set `string=\"true\"`. For all other types \
1604(numbers, booleans, arrays, objects), pass the value in JSON format and set `string=\"false\"`.\
1605\n\nIf thinking_mode is enabled (triggered by {ts}), you MUST output your complete reasoning \
1606inside {ts}...{te} BEFORE any tool calls or final response.\n\nOtherwise, output directly \
1607after {te} with tool calls or final response.\n\n### Available Tool Schemas\n\n{schemas}\n\n\
1608You MUST strictly follow the above defined tool name and parameter schemas to invoke tool \
1609calls.\n",
1610        d = DS_DSML,
1611        ts = DS_THINK_START,
1612        te = DS_THINK_END,
1613        schemas = schemas,
1614    )
1615}
1616
1617/// One assistant tool_calls block (encoding_dsv4 E:52-58, E:139-166, E:323-336): the `\n\n`
1618/// prefix + `<|DSML|tool_calls>` wrapper + one `<|DSML|invoke>` per call, each argument a
1619/// `<|DSML|parameter>` line (string values raw with `string="true"`, everything else
1620/// json.dumps'd with `string="false"`). Argument order = insertion order (NO dictsort).
1621fn dsv4_render_tool_calls(calls: &[ToolCall]) -> String {
1622    let mut invokes = String::new();
1623    for (i, call) in calls.iter().enumerate() {
1624        if i > 0 {
1625            invokes.push('\n');
1626        }
1627        invokes.push_str(&format!(
1628            "<{d}invoke name=\"{n}\">\n",
1629            d = DS_DSML,
1630            n = call.name
1631        ));
1632        for (j, (k, v)) in call.args.iter().enumerate() {
1633            if j > 0 {
1634                invokes.push('\n');
1635            }
1636            let is_str = matches!(v, Val::Str(_));
1637            invokes.push_str(&format!(
1638                "<{d}parameter name=\"{k}\" string=\"{b}\">",
1639                d = DS_DSML,
1640                k = k,
1641                b = if is_str { "true" } else { "false" },
1642            ));
1643            match v {
1644                Val::Str(s) => invokes.push_str(s),
1645                other => dsv4_json(other, &mut invokes),
1646            }
1647            invokes.push_str(&format!("</{d}parameter>", d = DS_DSML));
1648        }
1649        invokes.push_str(&format!("\n</{d}invoke>", d = DS_DSML));
1650    }
1651    format!(
1652        "\n\n<{d}tool_calls>\n{invokes}\n</{d}tool_calls>",
1653        d = DS_DSML,
1654        invokes = invokes
1655    )
1656}
1657
1658/// One merged content block on a user turn (encoding_dsv4 content_blocks, E:289-309).
1659enum DsBlock {
1660    Text(String),
1661    ToolResult {
1662        content: String,
1663        tool_use_id: String,
1664    },
1665}
1666
1667/// One preprocessed message (post merge_tool_messages / sort). `blocks` is Some for user
1668/// turns (a merged run of user text + tool results); other roles carry `content`.
1669struct DsMsg {
1670    role: String,
1671    content: String,
1672    blocks: Option<Vec<DsBlock>>,
1673    reasoning: String,
1674    tool_calls: Vec<ToolCall>,
1675    tools: Vec<Val>,
1676    task: Option<String>,
1677}
1678
1679/// encoding_dsv4 `merge_tool_messages` (E:401-457): fold role:"tool" turns and consecutive
1680/// user turns into single `<|User|>` turns carrying `content_blocks`. `req_tools` are the
1681/// request-level tool `function` objects attached to the LEADING system turn (matching the
1682/// serve surface; a synthetic empty system turn is created when tools exist with no system
1683/// turn — the oracle's render of {"role":"system","content":"","tools":[...]}). A turn's own
1684/// `tools` (fixture harness, e.g. tools on a developer message) take precedence.
1685fn dsv4_merge(turns: &[Turn], req_tools: &[Val]) -> Vec<DsMsg> {
1686    let mut merged: Vec<DsMsg> = Vec::new();
1687    let any_turn_tools = turns.iter().any(|t| !t.tools.is_empty());
1688    // Serve surface: request-level tools ride the leading system turn (or a synthetic one).
1689    let mut leading_tools_pending = !req_tools.is_empty() && !any_turn_tools;
1690    if leading_tools_pending && !turns.first().map(|t| t.role == "system").unwrap_or(false) {
1691        merged.push(DsMsg {
1692            role: "system".into(),
1693            content: String::new(),
1694            blocks: None,
1695            reasoning: String::new(),
1696            tool_calls: Vec::new(),
1697            tools: req_tools.to_vec(),
1698            task: None,
1699        });
1700        leading_tools_pending = false;
1701    }
1702    for turn in turns {
1703        match turn.role.as_str() {
1704            "tool" => {
1705                let block = DsBlock::ToolResult {
1706                    content: turn.content.clone(),
1707                    tool_use_id: turn.tool_call_id.clone().unwrap_or_default(),
1708                };
1709                match merged.last_mut() {
1710                    Some(m) if m.role == "user" && m.blocks.is_some() => {
1711                        m.blocks.as_mut().unwrap().push(block);
1712                    }
1713                    _ => merged.push(DsMsg {
1714                        role: "user".into(),
1715                        content: String::new(),
1716                        blocks: Some(vec![block]),
1717                        reasoning: String::new(),
1718                        tool_calls: Vec::new(),
1719                        tools: Vec::new(),
1720                        task: None,
1721                    }),
1722                }
1723            }
1724            "user" => {
1725                let text = DsBlock::Text(turn.content.clone());
1726                match merged.last_mut() {
1727                    Some(m) if m.role == "user" && m.blocks.is_some() && m.task.is_none() => {
1728                        m.blocks.as_mut().unwrap().push(text);
1729                    }
1730                    _ => merged.push(DsMsg {
1731                        role: "user".into(),
1732                        content: turn.content.clone(),
1733                        blocks: Some(vec![text]),
1734                        reasoning: String::new(),
1735                        tool_calls: Vec::new(),
1736                        tools: turn.tools.clone(),
1737                        task: turn.task.clone(),
1738                    }),
1739                }
1740            }
1741            role => {
1742                let mut tools = turn.tools.clone();
1743                if role == "system" && leading_tools_pending && merged.is_empty() {
1744                    tools = req_tools.to_vec();
1745                    leading_tools_pending = false;
1746                }
1747                merged.push(DsMsg {
1748                    role: role.to_string(),
1749                    content: turn.content.clone(),
1750                    blocks: None,
1751                    reasoning: turn.reasoning.clone().unwrap_or_default(),
1752                    tool_calls: turn.tool_calls.clone(),
1753                    tools,
1754                    task: turn.task.clone(),
1755                });
1756            }
1757        }
1758    }
1759    merged
1760}
1761
1762/// encoding_dsv4 `sort_tool_results_by_call_order` (E:460-499): within a user turn holding
1763/// more than one tool_result block, order those blocks by the preceding assistant's
1764/// tool_calls id order (stable; an unknown id sorts as 0). Non-tool block positions are kept.
1765#[allow(clippy::needless_range_loop)] // indexed: reads earlier turns' order, mutates msgs[i]
1766fn dsv4_sort_tool_results(msgs: &mut [DsMsg]) {
1767    let mut order: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1768    // walk without holding an immutable borrow across the mutable block edit.
1769    for i in 0..msgs.len() {
1770        if msgs[i].role == "assistant" && !msgs[i].tool_calls.is_empty() {
1771            order.clear();
1772            for (idx, tc) in msgs[i].tool_calls.iter().enumerate() {
1773                if let Some(id) = tc.id.as_deref() {
1774                    if !id.is_empty() {
1775                        order.insert(id.to_string(), idx);
1776                    }
1777                }
1778            }
1779        } else if msgs[i].role == "user" {
1780            let n_tool = msgs[i]
1781                .blocks
1782                .as_ref()
1783                .map(|b| {
1784                    b.iter()
1785                        .filter(|x| matches!(x, DsBlock::ToolResult { .. }))
1786                        .count()
1787                })
1788                .unwrap_or(0);
1789            if n_tool > 1 && !order.is_empty() {
1790                let blocks = msgs[i].blocks.take().unwrap();
1791                // stable sort the tool_result blocks by call order; keep others in place.
1792                let mut tool_blocks: Vec<DsBlock> = Vec::new();
1793                let mut positions: Vec<bool> = Vec::new(); // true = tool_result slot
1794                let mut others: Vec<DsBlock> = Vec::new();
1795                for b in blocks {
1796                    match b {
1797                        DsBlock::ToolResult { .. } => {
1798                            positions.push(true);
1799                            tool_blocks.push(b);
1800                        }
1801                        other => {
1802                            positions.push(false);
1803                            others.push(other);
1804                        }
1805                    }
1806                }
1807                tool_blocks.sort_by_key(|b| match b {
1808                    DsBlock::ToolResult { tool_use_id, .. } => {
1809                        *order.get(tool_use_id).unwrap_or(&0)
1810                    }
1811                    _ => 0,
1812                });
1813                let mut ti = tool_blocks.into_iter();
1814                let mut oi = others.into_iter();
1815                let rebuilt: Vec<DsBlock> = positions
1816                    .into_iter()
1817                    .map(|is_tool| {
1818                        if is_tool {
1819                            ti.next().unwrap()
1820                        } else {
1821                            oi.next().unwrap()
1822                        }
1823                    })
1824                    .collect();
1825                msgs[i].blocks = Some(rebuilt);
1826            }
1827        }
1828    }
1829}
1830
1831/// index of the last user/developer message (encoding_dsv4 `find_last_user_index`, E:209-216).
1832fn dsv4_last_user_idx(msgs: &[DsMsg]) -> isize {
1833    for i in (0..msgs.len()).rev() {
1834        if msgs[i].role == "user" || msgs[i].role == "developer" {
1835            return i as isize;
1836        }
1837    }
1838    -1
1839}
1840
1841/// encoding_dsv4 `_drop_thinking_messages` (E:575-599): keep user/system/latest_reminder and
1842/// everything at/after the last user; strip reasoning from earlier assistants; drop earlier
1843/// developer (and other) turns entirely. Runs only in thinking mode with no tools declared.
1844fn dsv4_drop_thinking(msgs: Vec<DsMsg>) -> Vec<DsMsg> {
1845    let last = dsv4_last_user_idx(&msgs);
1846    let mut out = Vec::with_capacity(msgs.len());
1847    for (i, mut m) in msgs.into_iter().enumerate() {
1848        let keep_role = matches!(
1849            m.role.as_str(),
1850            "user" | "system" | "latest_reminder" | "direct_search_results"
1851        );
1852        if keep_role || (i as isize) >= last {
1853            out.push(m);
1854        } else if m.role == "assistant" {
1855            m.reasoning.clear();
1856            out.push(m);
1857        }
1858        // developer + others before the last user are dropped.
1859    }
1860    out
1861}
1862
1863/// Full port of encoding_dsv4 `encode_messages` (E:506-572) + `render_message` (E:223-394),
1864/// covering BOTH shipped encoding revisions (they differ only in the effort ladder — see
1865/// `Dsv4Encoding`).
1866///
1867/// ThinkMode maps onto encoding_dsv4's (thinking_mode, reasoning_effort):
1868///
1869///   - `Default` → thinking (the model has no template-own default; thinking_mode is a
1870///     REQUIRED arg and the README example + the model's agentic positioning make thinking
1871///     the honest default — see TEMPLATE-SEMANTICS.md finding #1);
1872///   - `Think`   → thinking;
1873///   - `NoThink` → chat (the DeepSeek "Non-think" mode: `<|Assistant|></think>`).
1874///
1875/// The `reasoning_effort` string resolves through `dsv4_effort_prefix` per the artifact's
1876/// `encoding` revision (preview: "max" prefix only, "high" a documented no-op; 0731:
1877/// low/high/max ladder). `Err` ONLY when the requested (thinking, effort) cell renders
1878/// differently across revisions and `encoding` is `None` — the refuse-on-ambiguity law.
1879/// On the serve path the encoding rides the `Tokenizer` (config.json dspark_* census at
1880/// `from_hf_dir`); the HTTP layer forwards the OpenAI level for dsv4 models
1881/// (`ModelCaps::dsv4`), so "high" now reaches the 0731 ladder for real.
1882///
1883/// `req_tools` are the request-level tool `function` objects (attached to the leading system
1884/// turn); `add_generation_prompt` gates ONLY the final-message generation-prompt transition
1885/// (mid-conversation continuation transitions are always emitted, matching the python's
1886/// unconditional transition law).
1887fn apply_dsv4_template(
1888    turns: &[Turn],
1889    add_generation_prompt: bool,
1890    req_tools: &[Val],
1891    think: ThinkMode,
1892    reasoning_effort: Option<&str>,
1893    encoding: Option<Dsv4Encoding>,
1894) -> Result<String, String> {
1895    let thinking = think != ThinkMode::NoThink; // Default + Think -> thinking; NoThink -> chat
1896    let effort_prefix = dsv4_effort_prefix(thinking, reasoning_effort, encoding)?;
1897
1898    let mut msgs = dsv4_merge(turns, req_tools);
1899    dsv4_sort_tool_results(&mut msgs);
1900    // effective drop_thinking: default True, auto-disabled when any message declares tools.
1901    let any_tools = msgs.iter().any(|m| !m.tools.is_empty());
1902    let effective_drop = !any_tools;
1903    if thinking && effective_drop {
1904        msgs = dsv4_drop_thinking(msgs);
1905    }
1906    let last_user = dsv4_last_user_idx(&msgs);
1907    let n = msgs.len();
1908
1909    let mut out = String::from(DS_BOS);
1910    for idx in 0..n {
1911        let m = &msgs[idx];
1912        if idx == 0 {
1913            // effort prefix before the first rendered message (preview E:262-263 / 0731
1914            // E:275-277); "" when no prefix applies, so this is a no-op push then.
1915            out.push_str(effort_prefix);
1916        }
1917        match m.role.as_str() {
1918            "system" => {
1919                out.push_str(&m.content);
1920                if !m.tools.is_empty() {
1921                    out.push_str("\n\n");
1922                    out.push_str(&dsv4_render_tools(&m.tools));
1923                }
1924            }
1925            "developer" => {
1926                out.push_str(DS_USER);
1927                out.push_str(&m.content);
1928                if !m.tools.is_empty() {
1929                    out.push_str("\n\n");
1930                    out.push_str(&dsv4_render_tools(&m.tools));
1931                }
1932            }
1933            "user" => {
1934                out.push_str(DS_USER);
1935                if let Some(blocks) = &m.blocks {
1936                    for (i, b) in blocks.iter().enumerate() {
1937                        if i > 0 {
1938                            out.push_str("\n\n");
1939                        }
1940                        match b {
1941                            DsBlock::Text(t) => out.push_str(t),
1942                            DsBlock::ToolResult { content, .. } => {
1943                                out.push_str("<tool_result>");
1944                                out.push_str(content);
1945                                out.push_str("</tool_result>");
1946                            }
1947                        }
1948                    }
1949                } else {
1950                    out.push_str(&m.content);
1951                }
1952            }
1953            "latest_reminder" => {
1954                out.push_str(DS_REMINDER);
1955                out.push_str(&m.content);
1956            }
1957            "assistant" => {
1958                let prev_has_task = idx > 0 && msgs[idx - 1].task.is_some();
1959                let mut thinking_part = String::new();
1960                if thinking && !prev_has_task && (!effective_drop || (idx as isize) > last_user) {
1961                    thinking_part.push_str(&m.reasoning);
1962                    thinking_part.push_str(DS_THINK_END);
1963                }
1964                out.push_str(&thinking_part);
1965                out.push_str(&m.content);
1966                if !m.tool_calls.is_empty() {
1967                    out.push_str(&dsv4_render_tool_calls(&m.tool_calls));
1968                }
1969                out.push_str(DS_EOS);
1970            }
1971            _ => {} // direct_search_results and unknown roles never render (E:362-363).
1972        }
1973
1974        // --- transition tokens (E:365-394) ---
1975        // Early-out: a non-final message whose next turn is NOT assistant/latest_reminder gets
1976        // no transition (the python's E:366 guard).
1977        if idx + 1 < n {
1978            let next = msgs[idx + 1].role.as_str();
1979            if next != "assistant" && next != "latest_reminder" {
1980                continue;
1981            }
1982        }
1983        let is_last = idx + 1 >= n;
1984        if let Some(task) = m.task.as_deref() {
1985            // generation-prompt-shaped: a task on the final message is gated on the gen prompt.
1986            if is_last && !add_generation_prompt {
1987                continue;
1988            }
1989            if let Some(tok) = ds_task_token(task) {
1990                if task != "action" {
1991                    out.push_str(tok);
1992                } else {
1993                    out.push_str(DS_ASSISTANT);
1994                    out.push_str(if thinking {
1995                        DS_THINK_START
1996                    } else {
1997                        DS_THINK_END
1998                    });
1999                    out.push_str(tok);
2000                }
2001            }
2002        } else if m.role == "user" || m.role == "developer" {
2003            if is_last && !add_generation_prompt {
2004                continue;
2005            }
2006            out.push_str(DS_ASSISTANT);
2007            // E:387-392: thinking opens `<think>` when drop_thinking is OFF (tools present)
2008            // OR (drop on) at/after the last user turn; else it closes `</think>`. chat mode
2009            // (thinking=false) always closes.
2010            if thinking && (!effective_drop || (idx as isize) >= last_user) {
2011                out.push_str(DS_THINK_START);
2012            } else {
2013                out.push_str(DS_THINK_END);
2014            }
2015        }
2016    }
2017    Ok(out)
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022    use super::*;
2023
2024    /// ds4f rung-3 regression (the first real serve 400): the REAL dsv4 artifacts
2025    /// ship NO chat_template string — dispatch and the tools branch must key on the
2026    /// detected encoding revision, or a fully-defined dialect 400s at the door.
2027    #[test]
2028    fn templateless_dsv4_artifact_dispatches_on_encoding() {
2029        let s =
2030            apply_chat_template_enc(None, &[("user", "Hello")], true, Some(Dsv4Encoding::V0731))
2031                .unwrap();
2032        assert!(
2033            s.contains("<\u{ff5c}User\u{ff5c}>") && s.contains("<\u{ff5c}Assistant\u{ff5c}>"),
2034            "encoding dispatch did not reach the dsv4 renderer: {s:?}"
2035        );
2036        assert!(!s.contains("<|im_start|>"), "fell back to ChatML: {s:?}");
2037        let legacy = apply_chat_template_enc(None, &[("user", "Hello")], true, None).unwrap();
2038        assert_eq!(
2039            legacy,
2040            apply_chat_template_str(None, &[("user", "Hello")], true)
2041        );
2042
2043        let turns = vec![Turn {
2044            role: "user".into(),
2045            content: "What is the weather in Paris? Use the tool.".into(),
2046            ..Default::default()
2047        }];
2048        let tj = vec![
2049            r#"{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}"#.to_string(),
2050        ];
2051        // the dsv4 renderer consumes the typed tree (tools_struct), like the gemma dialect
2052        let tv = vec![Val::Obj(vec![
2053            ("name".into(), Val::Str("get_weather".into())),
2054            (
2055                "description".into(),
2056                Val::Str("Get weather for a city".into()),
2057            ),
2058            (
2059                "parameters".into(),
2060                Val::Obj(vec![
2061                    ("type".into(), Val::Str("object".into())),
2062                    (
2063                        "properties".into(),
2064                        Val::Obj(vec![(
2065                            "city".into(),
2066                            Val::Obj(vec![("type".into(), Val::Str("string".into()))]),
2067                        )]),
2068                    ),
2069                ]),
2070            ),
2071        ])];
2072        let out = apply_chat_template_tools_ex(
2073            None,
2074            &turns,
2075            true,
2076            &tj,
2077            &tv,
2078            ThinkMode::Default,
2079            None,
2080            Some(Dsv4Encoding::V0731),
2081        )
2082        .expect("templateless dsv4 artifact must render tools (DSML is its protocol)");
2083        assert!(
2084            out.contains("\u{ff5c}DSML\u{ff5c}") || out.contains("get_weather"),
2085            "tools block missing from the DSML render: {out:?}"
2086        );
2087    }
2088
2089    #[test]
2090    fn plain_chatml() {
2091        let s = apply_chat_template_str(None, &[("user", "Hello")], true);
2092        assert_eq!(
2093            s,
2094            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
2095        );
2096    }
2097
2098    /// A template stand-in carrying every marker the real qwen3.5/3.6 dumps carry
2099    /// (tools branch + think tail + enable_thinking switch).
2100    const QWEN_TOOLS_TMPL: &str =
2101        "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
2102
2103    /// Isolation contract: the tools renderer on a PLAIN request (no tools, no tool turns,
2104    /// Default think) is byte-identical to the legacy renderer, across the message shapes
2105    /// the serve path sees.
2106    #[test]
2107    fn tools_renderer_matches_legacy_when_plain() {
2108        let batteries: &[&[(&str, &str)]] = &[
2109            &[("user", "Hello")],
2110            &[("system", "You are helpful."), ("user", "Hi")],
2111            &[
2112                ("system", "rules"),
2113                ("user", "task"),
2114                ("assistant", "work"),
2115                ("user", "more"),
2116            ],
2117            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
2118        ];
2119        for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
2120            for msgs in batteries {
2121                let legacy = apply_chat_template_str(tmpl, msgs, true);
2122                let turns: Vec<Turn> = msgs
2123                    .iter()
2124                    .map(|(r, c)| Turn {
2125                        role: r.to_string(),
2126                        content: c.to_string(),
2127                        tool_calls: Vec::new(),
2128                        ..Default::default()
2129                    })
2130                    .collect();
2131                let ext =
2132                    apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2133                        .unwrap();
2134                assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
2135            }
2136        }
2137    }
2138
2139    #[test]
2140    fn tools_header_and_tool_response_render_per_template_law() {
2141        let tools =
2142            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2143        let turns = vec![
2144            Turn {
2145                role: "system".into(),
2146                content: "Be terse.".into(),
2147                tool_calls: Vec::new(),
2148                ..Default::default()
2149            },
2150            Turn {
2151                role: "user".into(),
2152                content: "Weather in Paris?".into(),
2153                tool_calls: Vec::new(),
2154                ..Default::default()
2155            },
2156            Turn {
2157                role: "assistant".into(),
2158                content: "".into(),
2159                tool_calls: vec![ToolCall {
2160                    name: "get_weather".into(),
2161                    params: vec![("city".into(), "Paris".into())],
2162                    ..Default::default()
2163                }],
2164                ..Default::default()
2165            },
2166            Turn {
2167                role: "tool".into(),
2168                content: "{\"temp_c\": 21}".into(),
2169                tool_calls: Vec::new(),
2170                ..Default::default()
2171            },
2172        ];
2173        let s = apply_chat_template_tools(
2174            Some(QWEN_TOOLS_TMPL),
2175            &turns,
2176            true,
2177            &tools,
2178            ThinkMode::Default,
2179            None,
2180        )
2181        .unwrap();
2182        let expected = concat!(
2183            "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
2184            "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
2185            "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2186            "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2187            "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2188            "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2189            "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2190            "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
2191            "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
2192            "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
2193            "no function call available, answer the question like normal with your current knowledge ",
2194            "and do not tell the user about function calls\n</IMPORTANT>",
2195            "\n\nBe terse.<|im_end|>\n",
2196            "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2197            "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
2198            "</parameter>\n</function>\n</tool_call><|im_end|>\n",
2199            "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
2200            "<|im_start|>assistant\n<think>\n",
2201        );
2202        assert_eq!(s, expected);
2203    }
2204
2205    #[test]
2206    fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
2207        let turns = vec![
2208            Turn {
2209                role: "user".into(),
2210                content: "both".into(),
2211                tool_calls: Vec::new(),
2212                ..Default::default()
2213            },
2214            Turn {
2215                role: "assistant".into(),
2216                content: "checking".into(),
2217                tool_calls: vec![
2218                    ToolCall {
2219                        name: "a".into(),
2220                        params: vec![("x".into(), "1".into())],
2221                        ..Default::default()
2222                    },
2223                    ToolCall {
2224                        name: "b".into(),
2225                        params: Vec::new(),
2226                        ..Default::default()
2227                    },
2228                ],
2229                ..Default::default()
2230            },
2231            Turn {
2232                role: "tool".into(),
2233                content: "r1".into(),
2234                tool_calls: Vec::new(),
2235                ..Default::default()
2236            },
2237            Turn {
2238                role: "tool".into(),
2239                content: "r2".into(),
2240                tool_calls: Vec::new(),
2241                ..Default::default()
2242            },
2243        ];
2244        let s = apply_chat_template_tools(
2245            Some(QWEN_TOOLS_TMPL),
2246            &turns,
2247            false,
2248            &[],
2249            ThinkMode::Default,
2250            None,
2251        )
2252        .unwrap();
2253        assert_eq!(
2254            s,
2255            concat!(
2256                "<|im_start|>user\nboth<|im_end|>\n",
2257                "<|im_start|>assistant\nchecking\n\n",
2258                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
2259                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2260                "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
2261                "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
2262            )
2263        );
2264    }
2265
2266    #[test]
2267    fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
2268        let turns = vec![Turn {
2269            role: "user".into(),
2270            content: "hi".into(),
2271            tool_calls: Vec::new(),
2272            ..Default::default()
2273        }];
2274        // switch present: NoThink renders the closed think block.
2275        let s = apply_chat_template_tools(
2276            Some(QWEN_TOOLS_TMPL),
2277            &turns,
2278            true,
2279            &[],
2280            ThinkMode::NoThink,
2281            None,
2282        )
2283        .unwrap();
2284        assert!(
2285            s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
2286            "{s:?}"
2287        );
2288        // no enable_thinking switch: NoThink is ignored (template default stands).
2289        let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
2290        let s = apply_chat_template_tools(
2291            Some(tmpl_no_switch),
2292            &turns,
2293            true,
2294            &[],
2295            ThinkMode::NoThink,
2296            None,
2297        )
2298        .unwrap();
2299        assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
2300        // no template at all: plain ChatML, no tail either way.
2301        let s =
2302            apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
2303        assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
2304    }
2305
2306    #[test]
2307    fn tools_on_templates_without_tools_branch_error() {
2308        let turns = vec![Turn {
2309            role: "user".into(),
2310            content: "hi".into(),
2311            tool_calls: Vec::new(),
2312            ..Default::default()
2313        }];
2314        let tools = vec!["{}".to_string()];
2315        for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
2316            let err =
2317                apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
2318            assert!(err.is_err(), "template={tmpl:?}");
2319        }
2320        // tool-role turns need the branch too.
2321        let tool_turns = vec![Turn {
2322            role: "tool".into(),
2323            content: "r".into(),
2324            tool_calls: Vec::new(),
2325            ..Default::default()
2326        }];
2327        assert!(
2328            apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
2329                .is_err()
2330        );
2331    }
2332
2333    // ---- per-arch thinking control (owner directive 2026-08-07) -------------------------
2334    // Every `expected` below is the EXACT string the arch's REAL shipped template renders,
2335    // from research/step-sku-20260807/raw/thinking-goldens.txt (render-thinking-goldens.py:
2336    // jinja2 trim_blocks/lstrip_blocks over the pinned template dumps — gemma4 sha 36e3a42e
2337    // from the local QAT GGUF header, hy3 sha 7fc351fe from the pinned tencent/Hy3 snapshot).
2338
2339    fn one_user() -> Vec<Turn> {
2340        vec![turn("user", "Hi")]
2341    }
2342
2343    #[test]
2344    fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
2345        let g = |think: ThinkMode| {
2346            apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
2347                .unwrap()
2348        };
2349        // Default AND NoThink = the template's own default(false): closed thought channel.
2350        // Byte-identical to the legacy renderer (no silent behavior change).
2351        let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
2352        assert_eq!(g(ThinkMode::Default), closed);
2353        assert_eq!(g(ThinkMode::NoThink), closed);
2354        assert_eq!(
2355            apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
2356            closed,
2357            "legacy renderer = the default arm"
2358        );
2359        // Think = enable_thinking=true: <|think|> injected into a CREATED system turn and
2360        // the generation turn left open (golden: gemma4 enable_thinking=true, no system).
2361        assert_eq!(
2362            g(ThinkMode::Think),
2363            "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
2364        );
2365        // with a client system turn the token lands at the very top of it (golden).
2366        let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
2367        let s = apply_chat_template_tools(
2368            Some("... <|turn> ..."),
2369            &turns,
2370            true,
2371            &[],
2372            ThinkMode::Think,
2373            None,
2374        )
2375        .unwrap();
2376        assert_eq!(
2377            s,
2378            "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
2379                       <|turn>user\nHi<turn|>\n<|turn>model\n"
2380        );
2381    }
2382
2383    /// A QAT-tooluse stand-in: carries `<|turn>` + `<|tool>` (engages the gemma4 tools arm)
2384    /// AND the closed-tail literal (the QAT trunk's thinking-off generation tail). The
2385    /// official served trunk omits that literal, so its tools arm emits the bare `<|turn>model`
2386    /// on thinking-off — the fixtures cover that side.
2387    const GEMMA_TOOLUSE_QAT_TMPL: &str =
2388        "... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
2389
2390    #[test]
2391    fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
2392        // REGRESSION (deliverable 6): a NO-tools request through the gemma4 tools arm renders
2393        // byte-identically to the standalone gemma4 renderer, across think modes and message
2394        // shapes — the tool path never perturbs plain gemma traffic on the tooluse trunk.
2395        let batteries: &[&[(&str, &str)]] = &[
2396            &[("user", "Hi")],
2397            &[("system", "Be terse."), ("user", "Weather?")],
2398            &[
2399                ("system", "rules"),
2400                ("user", "task"),
2401                ("assistant", "work"),
2402                ("user", "more"),
2403            ],
2404            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
2405        ];
2406        for msgs in batteries {
2407            let turns: Vec<Turn> = msgs
2408                .iter()
2409                .map(|(r, c)| Turn {
2410                    role: r.to_string(),
2411                    content: c.to_string(),
2412                    ..Default::default()
2413                })
2414                .collect();
2415            for (mode, thinking) in [
2416                (ThinkMode::Default, false),
2417                (ThinkMode::NoThink, false),
2418                (ThinkMode::Think, true),
2419            ] {
2420                let legacy = apply_gemma4_template(msgs, true, thinking);
2421                let arm = apply_chat_template_tools(
2422                    Some(GEMMA_TOOLUSE_QAT_TMPL),
2423                    &turns,
2424                    true,
2425                    &[],
2426                    mode,
2427                    None,
2428                )
2429                .unwrap();
2430                assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
2431            }
2432        }
2433    }
2434
2435    #[test]
2436    fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
2437        // a `<|turn>` template WITHOUT `<|tool>` keeps rejecting tool features with the clear
2438        // error (no committed tools reference for that trunk).
2439        let turns = vec![turn("user", "Weather?")];
2440        let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
2441        let err = apply_chat_template_tools(
2442            Some("... <|turn> ..."),
2443            &turns,
2444            true,
2445            &tools,
2446            ThinkMode::Default,
2447            None,
2448        );
2449        assert!(err.is_err());
2450    }
2451
2452    #[test]
2453    fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
2454        const HY_TMPL: Option<&str> = Some("... hy_User ...");
2455        let h = |think: ThinkMode, effort: Option<&str>| {
2456            apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
2457        };
2458        // Default AND NoThink = the template's own default: no_think header + CLOSED think.
2459        // Byte-identical to the legacy renderer.
2460        let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
2461                      <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
2462                      <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
2463                      <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
2464                      <think:opensource></think:opensource>";
2465        assert_eq!(h(ThinkMode::Default, None), closed);
2466        assert_eq!(
2467            h(ThinkMode::NoThink, Some("low")),
2468            closed,
2469            "NoThink wins over a level: thinking off IS no_think"
2470        );
2471        assert_eq!(
2472            apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
2473            closed,
2474            "legacy renderer = the default arm"
2475        );
2476        // Think at low/high = the template's own open-think levels (goldens: header carries
2477        // the level, generation prompt ends with an OPEN <think:opensource>).
2478        let low = h(ThinkMode::Think, Some("low"));
2479        assert!(low.contains("reasoning_effort:low"), "{low:?}");
2480        assert!(low.ends_with("<think:opensource>"), "{low:?}");
2481        let high = h(ThinkMode::Think, Some("high"));
2482        assert!(high.contains("reasoning_effort:high"), "{high:?}");
2483        assert!(high.ends_with("<think:opensource>"), "{high:?}");
2484        // medium clamps to low (hy3's accepted set is exactly no_think|low|high — the jinja
2485        // raise_exceptions on anything else); Think with no level also lands at low.
2486        assert_eq!(h(ThinkMode::Think, Some("medium")), low);
2487        assert_eq!(h(ThinkMode::Think, None), low);
2488        // History assistant turns stay CLOSED-think at every effort (the template opens only
2489        // turns past last_user_index; golden: "hy3 assistant history stays closed-think").
2490        let turns = vec![
2491            turn("user", "q"),
2492            turn("assistant", "a"),
2493            turn("user", "more"),
2494        ];
2495        let s =
2496            apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
2497                .unwrap();
2498        assert_eq!(
2499            s,
2500            "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
2501                       <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
2502                       <\u{ff5c}hy_User:opensource\u{ff5c}>q\
2503                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
2504                       <think:opensource></think:opensource>a\
2505                       <\u{ff5c}hy_eos:opensource\u{ff5c}>\
2506                       <\u{ff5c}hy_User:opensource\u{ff5c}>more\
2507                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
2508        );
2509    }
2510
2511    #[test]
2512    fn qwen_think_mode_covers_all_three_directions() {
2513        let q = |think: ThinkMode| {
2514            apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
2515                .unwrap()
2516        };
2517        // qwen's template default IS thinking-on, so Default and Think render identically.
2518        assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
2519        assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
2520        assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
2521    }
2522
2523    // ---- StepFun Step-3.7-Flash (arch step35) -------------------------------------------
2524    // Every `expected` below is the EXACT string the shipped jinja renders, taken from
2525    // research/step37-p2-20260806/raw/step35-template-goldens.txt (generated by
2526    // render_step35_template.py under jinja2 with trim_blocks/lstrip_blocks — the settings HF
2527    // transformers and llama.cpp's minja use). `{{bos_token}}` renders as "" there because
2528    // encode(add_special) supplies BOS.
2529
2530    /// A step35 template stand-in: the real one is 5723 chars, and the detector keys on
2531    /// `render_message_content` (the macro no other committed template defines). The other
2532    /// markers are present to prove the step35 arm WINS the dispatch — a qwen-marker template
2533    /// carrying `<tools>`/`<think>`/`add_generation_prompt` would otherwise take the qwen arm.
2534    const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
2535
2536    fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
2537        apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
2538    }
2539
2540    fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
2541        apply_chat_template_tools(
2542            Some(STEP35_TMPL),
2543            &turns,
2544            genp,
2545            tools,
2546            ThinkMode::Default,
2547            None,
2548        )
2549        .unwrap()
2550    }
2551
2552    fn turn(role: &str, content: &str) -> Turn {
2553        Turn {
2554            role: role.into(),
2555            content: content.into(),
2556            tool_calls: Vec::new(),
2557            ..Default::default()
2558        }
2559    }
2560
2561    #[test]
2562    fn step35_plain_paths_match_the_shipped_jinja() {
2563        assert_eq!(
2564            s35(&[("user", "Hello")], true),
2565            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
2566        );
2567        assert_eq!(
2568            s35(&[("user", "Hello")], false),
2569            "<|im_start|>user\nHello<|im_end|>\n"
2570        );
2571        assert_eq!(
2572            s35(&[("system", "You are helpful."), ("user", "Hi")], true),
2573            "<|im_start|>system\nYou are helpful.<|im_end|>\n\
2574                    <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2575        );
2576        // multi-turn: the prior assistant is BEFORE the last user query, so it carries NO
2577        // think block — the reasoning boundary the qwen arms have no concept of.
2578        assert_eq!(
2579            s35(
2580                &[
2581                    ("system", "rules"),
2582                    ("user", "task"),
2583                    ("assistant", "work"),
2584                    ("user", "more")
2585                ],
2586                true
2587            ),
2588            "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
2589             <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
2590             <|im_start|>assistant\n<think>\n"
2591        );
2592        // content is NOT trimmed (this template applies no `|trim`) — the qwen arms trim.
2593        assert_eq!(
2594            s35(&[("user", "  padded  ")], true),
2595            "<|im_start|>user\n  padded  <|im_end|>\n<|im_start|>assistant\n<think>\n"
2596        );
2597    }
2598
2599    #[test]
2600    fn step35_dispatch_beats_the_qwen_marker_arm() {
2601        // The step35 template carries every qwen marker. If the dispatch order regressed, the
2602        // think tail would still be right and the BODY would be wrong (trimmed content, wrong
2603        // tools header) — so assert a body-shaped difference, not the tail.
2604        let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
2605        let step = s35(&[("user", " pad ")], true);
2606        assert_eq!(
2607            qwen,
2608            "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
2609        );
2610        assert_eq!(
2611            step,
2612            "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
2613        );
2614        assert_ne!(qwen, step);
2615    }
2616
2617    #[test]
2618    fn step35_reasoning_effort_renders_in_the_system_turn() {
2619        assert_eq!(
2620            apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
2621            "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
2622             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2623        );
2624        assert_eq!(
2625            apply_step35_template(
2626                &[turn("system", "Be terse."), turn("user", "Hi")],
2627                true,
2628                &[],
2629                Some("low")
2630            ),
2631            "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
2632             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2633        );
2634        // with tools the order flips: Reasoning, then the system content, then `# Tools`.
2635        let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
2636        let s = apply_step35_template(
2637            &[turn("system", "Be terse."), turn("user", "q")],
2638            true,
2639            &tools,
2640            Some("medium"),
2641        );
2642        assert!(
2643            s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
2644            "{s:?}"
2645        );
2646    }
2647
2648    #[test]
2649    fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
2650        // The serve path enters via apply_chat_template_tools: the level must land in the
2651        // rendered system turn on the step35 dialect...
2652        let turns = vec![turn("user", "Hi")];
2653        let s = apply_chat_template_tools(
2654            Some(STEP35_TMPL),
2655            &turns,
2656            true,
2657            &[],
2658            ThinkMode::Default,
2659            Some("high"),
2660        )
2661        .unwrap();
2662        assert!(
2663            s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
2664            "{s:?}"
2665        );
2666        // ...None keeps the template's own default (no Reasoning: line at all)...
2667        let s = apply_chat_template_tools(
2668            Some(STEP35_TMPL),
2669            &turns,
2670            true,
2671            &[],
2672            ThinkMode::Default,
2673            None,
2674        )
2675        .unwrap();
2676        assert!(!s.contains("Reasoning:"), "{s:?}");
2677        // ...and every non-step35 dialect ignores the parameter (their templates have no
2678        // reasoning_effort input) — byte-identical with and without it.
2679        for tmpl in [
2680            None,
2681            Some(QWEN_TOOLS_TMPL),
2682            Some("... hy_User ..."),
2683            Some("... <|turn> ..."),
2684        ] {
2685            let with = apply_chat_template_tools(
2686                tmpl,
2687                &turns,
2688                true,
2689                &[],
2690                ThinkMode::Default,
2691                Some("high"),
2692            )
2693            .unwrap();
2694            let without =
2695                apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2696                    .unwrap();
2697            assert_eq!(with, without, "template={tmpl:?}");
2698        }
2699    }
2700
2701    #[test]
2702    fn step35_tools_header_is_not_the_qwen_header() {
2703        let tools = vec![
2704            r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
2705            r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
2706        ];
2707        let s = s35_turns(
2708            vec![
2709                turn("system", "Be terse."),
2710                turn("user", "Weather in Paris?"),
2711            ],
2712            true,
2713            &tools,
2714        );
2715        assert_eq!(
2716            s,
2717            concat!(
2718                // leading system folds in BEFORE `# Tools` (the qwen arm appends it AFTER the
2719                // instruction block), and the header says "in JSONSchema format".
2720                "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
2721                "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
2722                "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
2723                "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
2724                "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2725                "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2726                "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2727                "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2728                // the nesting reminder carries literal \n...\n INSIDE the example tags, and the
2729                // Reminder list stops after 2 bullets (the qwen block has 4).
2730                "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2731                "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
2732                "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
2733                "<|im_end|>\n",
2734                "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2735                "<|im_start|>assistant\n<think>\n",
2736            )
2737        );
2738        // and it is NOT the qwen instruction block.
2739        assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
2740    }
2741
2742    #[test]
2743    fn step35_tool_results_take_their_own_role_and_group() {
2744        let tools =
2745            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2746        let turns = vec![
2747            turn("user", "both"),
2748            Turn {
2749                role: "assistant".into(),
2750                content: "checking".into(),
2751                tool_calls: vec![
2752                    ToolCall {
2753                        name: "a".into(),
2754                        params: vec![("x".into(), "1".into())],
2755                        ..Default::default()
2756                    },
2757                    ToolCall {
2758                        name: "b".into(),
2759                        params: Vec::new(),
2760                        ..Default::default()
2761                    },
2762                ],
2763                ..Default::default()
2764            },
2765            turn("tool", "r1"),
2766            turn("tool", "r2"),
2767        ];
2768        let s = s35_turns(turns, true, &tools);
2769        let body = s
2770            .split("<|im_end|>\n")
2771            .skip(1)
2772            .collect::<Vec<_>>()
2773            .join("<|im_end|>\n");
2774        assert_eq!(
2775            body,
2776            concat!(
2777                "<|im_start|>user\nboth<|im_end|>\n",
2778                // the assistant is AFTER the last user query, so it carries a think block — empty,
2779                // because its content has no `</think>` marker.
2780                "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
2781                // NO separator before the first call and NONE between calls.
2782                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
2783                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2784                // own `tool_response` ROLE (not a user turn), and NO newlines inside the wrappers.
2785                "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
2786                "<tool_response>r2</tool_response><|im_end|>\n",
2787                "<|im_start|>assistant\n<think>\n",
2788            )
2789        );
2790    }
2791
2792    #[test]
2793    fn step35_assistant_think_split_and_the_reasoning_boundary() {
2794        // inline <think>…</think> in content splits into the reasoning block + body.
2795        assert_eq!(
2796            s35(
2797                &[
2798                    ("user", "q"),
2799                    ("assistant", "<think>\nreasoned\n</think>\nanswer")
2800                ],
2801                false
2802            ),
2803            "<|im_start|>user\nq<|im_end|>\n\
2804             <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
2805        );
2806        // no markers, but still after the last query -> an EMPTY reasoning block is emitted.
2807        assert_eq!(
2808            s35(&[("user", "q"), ("assistant", "plain")], false),
2809            "<|im_start|>user\nq<|im_end|>\n\
2810             <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
2811        );
2812        // a user turn that IS a <tool_response> wrapper does NOT move the boundary: the
2813        // assistant before it still counts as after-the-last-real-query.
2814        assert_eq!(
2815            s35(
2816                &[
2817                    ("user", "real question"),
2818                    ("assistant", "thinking about it"),
2819                    ("user", "<tool_response>r</tool_response>")
2820                ],
2821                true
2822            ),
2823            "<|im_start|>user\nreal question<|im_end|>\n\
2824             <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
2825             <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
2826             <|im_start|>assistant\n<think>\n"
2827        );
2828    }
2829
2830    #[test]
2831    fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
2832        // No `enable_thinking` in this template, so ThinkMode::NoThink cannot close the tail —
2833        // the same graceful-no-op contract the other switchless templates get. A NoThink that
2834        // silently emitted `<think>\n\n</think>\n\n` would be a prompt the model never saw.
2835        let turns = vec![turn("user", "hi")];
2836        for mode in [ThinkMode::Default, ThinkMode::NoThink] {
2837            let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
2838                .unwrap();
2839            assert!(
2840                s.ends_with("<|im_start|>assistant\n<think>\n"),
2841                "mode={mode:?} {s:?}"
2842            );
2843        }
2844    }
2845
2846    #[test]
2847    fn step35_plain_path_is_identical_through_both_renderers() {
2848        // same isolation contract the qwen arms hold: a plain request renders byte-identically
2849        // whether it enters via apply_chat_template_str or apply_chat_template_tools.
2850        let batteries: &[&[(&str, &str)]] = &[
2851            &[("user", "Hello")],
2852            &[("system", "You are helpful."), ("user", "Hi")],
2853            &[
2854                ("system", "rules"),
2855                ("user", "task"),
2856                ("assistant", "work"),
2857                ("user", "more"),
2858            ],
2859            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
2860        ];
2861        for msgs in batteries {
2862            let legacy = s35(msgs, true);
2863            let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
2864            assert_eq!(legacy, ext, "msgs={msgs:?}");
2865        }
2866    }
2867
2868    #[test]
2869    fn qwen_think_tail() {
2870        // a template string containing both markers triggers the <think> tail.
2871        let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
2872        let s = apply_chat_template_str(
2873            Some(tmpl),
2874            &[("system", "You are helpful."), ("user", "Hi")],
2875            true,
2876        );
2877        assert_eq!(
2878            s,
2879            "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2880        );
2881    }
2882
2883    /// The dsv4 effort-prefix law across BOTH encoding revisions (0731 re-gate,
2884    /// ENCODING-DIFF.md): the exact (thinking, effort, encoding) -> prefix table, including
2885    /// the refuse-on-ambiguity cells (unknown revision where the two encodings' bytes
2886    /// differ) and the never-corrupt clamps ("low"/"medium"/unknown levels -> no prefix).
2887    #[test]
2888    fn dsv4_effort_prefix_law() {
2889        use Dsv4Encoding::{Preview, V0731};
2890        let p = dsv4_effort_prefix;
2891        // chat mode: never a prefix, under any encoding or level (incl. unknown revision).
2892        for enc in [None, Some(Preview), Some(V0731)] {
2893            for eff in [None, Some("low"), Some("high"), Some("max")] {
2894                assert_eq!(p(false, eff, enc), Ok(""), "chat eff={eff:?} enc={enc:?}");
2895            }
2896        }
2897        // encoding-independent thinking cells: None/"low"/foreign levels -> no prefix.
2898        for enc in [None, Some(Preview), Some(V0731)] {
2899            assert_eq!(p(true, None, enc), Ok(""));
2900            assert_eq!(p(true, Some("low"), enc), Ok(""));
2901            assert_eq!(p(true, Some("medium"), enc), Ok(""));
2902        }
2903        // preview law: "high" == None (documented no-op), "max" -> the absolute text.
2904        assert_eq!(p(true, Some("high"), Some(Preview)), Ok(""));
2905        assert_eq!(
2906            p(true, Some("max"), Some(Preview)),
2907            Ok(DS_EFFORT_ABSOLUTE_MAX)
2908        );
2909        // 0731 law: "high" -> the absolute text (the OLD max), "max" -> the new beyond text.
2910        assert_eq!(
2911            p(true, Some("high"), Some(V0731)),
2912            Ok(DS_EFFORT_ABSOLUTE_MAX)
2913        );
2914        assert_eq!(p(true, Some("max"), Some(V0731)), Ok(DS_EFFORT_BEYOND_MAX));
2915        // ambiguity refusal: exactly the two cells whose bytes differ across revisions.
2916        assert!(p(true, Some("high"), None).is_err());
2917        assert!(p(true, Some("max"), None).is_err());
2918        // prefix text invariants pinned against the oracle constants: both end "\n\n",
2919        // both open with the ladder header, and they are distinct rungs.
2920        assert!(DS_EFFORT_ABSOLUTE_MAX.starts_with("Reasoning Effort: Absolute maximum"));
2921        assert!(DS_EFFORT_BEYOND_MAX.starts_with("Reasoning Effort: Beyond maximum \u{2014}"));
2922        assert!(DS_EFFORT_ABSOLUTE_MAX.ends_with("\n\n"));
2923        assert!(DS_EFFORT_BEYOND_MAX.ends_with("\n\n"));
2924        assert_ne!(DS_EFFORT_ABSOLUTE_MAX, DS_EFFORT_BEYOND_MAX);
2925    }
2926
2927    /// End-to-end through the dispatch: the same request renders per-revision prefixes, and
2928    /// an unknown revision refuses ONLY when the requested cell is ambiguous.
2929    #[test]
2930    fn dsv4_effort_renders_per_encoding_through_dispatch() {
2931        const DSV4_TMPL: &str = "<\u{ff5c}Assistant\u{ff5c}> \u{ff5c}DSML\u{ff5c}";
2932        let turns = vec![Turn {
2933            role: "user".into(),
2934            content: "Hi".into(),
2935            ..Default::default()
2936        }];
2937        let render = |effort: Option<&str>, enc: Option<Dsv4Encoding>| {
2938            apply_chat_template_tools_ex(
2939                Some(DSV4_TMPL),
2940                &turns,
2941                true,
2942                &[],
2943                &[],
2944                ThinkMode::Think,
2945                effort,
2946                enc,
2947            )
2948        };
2949        let base = render(None, None).unwrap();
2950        // preview: high is a no-op; max prefixes the absolute text right after BOS.
2951        assert_eq!(
2952            render(Some("high"), Some(Dsv4Encoding::Preview)).unwrap(),
2953            base
2954        );
2955        let pv_max = render(Some("max"), Some(Dsv4Encoding::Preview)).unwrap();
2956        assert_eq!(
2957            pv_max,
2958            format!("{DS_BOS}{DS_EFFORT_ABSOLUTE_MAX}{}", &base[DS_BOS.len()..])
2959        );
2960        // 0731: low == default; high == the preview's max bytes; max is the new text.
2961        let v_low = render(Some("low"), Some(Dsv4Encoding::V0731)).unwrap();
2962        assert_eq!(v_low, base);
2963        let v_high = render(Some("high"), Some(Dsv4Encoding::V0731)).unwrap();
2964        assert_eq!(v_high, pv_max);
2965        let v_max = render(Some("max"), Some(Dsv4Encoding::V0731)).unwrap();
2966        assert_eq!(
2967            v_max,
2968            format!("{DS_BOS}{DS_EFFORT_BEYOND_MAX}{}", &base[DS_BOS.len()..])
2969        );
2970        // unknown revision: unambiguous cells render, ambiguous cells refuse.
2971        assert_eq!(render(Some("low"), None).unwrap(), base);
2972        assert!(render(Some("high"), None).is_err());
2973        assert!(render(Some("max"), None).is_err());
2974    }
2975}