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/HY3 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` field is consumed by gemma4 and HY3; `tool_call_id`/`tool_name`/
59/// `tool_responses` are gemma4-only. The qwen/step arms use role/content/tool_calls.
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    // GLM-5.3-Flash (`glm5_next`): `[gMASK]<sop>` + `<|user|>`/`<|assistant|>`/`<|observation|>`
244    // turn dialect with an always-open `<think>` tail and an always-rendered reasoning-effort
245    // system line. Detected by its two structural markers. MUST precede the qwen `<think>`-tail
246    // check below: this template contains BOTH `<think>` and `add_generation_prompt`, so the
247    // qwen detector fires on it and used to render every GLM prompt as ChatML.
248    // Legacy path = the template's own effort default (`max`) — the only default it has.
249    if template.is_some_and(template_is_glm5) {
250        let turns: Vec<Turn> = messages
251            .iter()
252            .map(|(r, c)| Turn {
253                role: r.to_string(),
254                content: c.to_string(),
255                ..Default::default()
256            })
257            .collect();
258        return apply_glm5_template(&turns, add_generation_prompt, &[], None)
259            .expect("glm5 render without reasoning_effort takes the template's own default");
260    }
261    // gemma4: `<|turn>role\n{content}<turn|>\n` dialect; generation prompt appends
262    // `<|turn>model\n` + the CLOSED thought channel (`<|channel>thought\n<channel|>` — the
263    // template's enable_thinking-false default). bos comes from encode(add_special) — the
264    // template's `{{ bos_token }}` is NOT re-emitted here (double-BOS trap).
265    // Legacy path = thinking OFF (the template's `default(false)`) — byte-identical to history.
266    if template.is_some_and(|t| t.contains("<|turn>")) {
267        return apply_gemma4_template(messages, add_generation_prompt, false);
268    }
269    // qwen3.5 template emits a `<think>\n` tail on the generation prompt by default.
270    let qwen_think = template
271        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
272        .unwrap_or(false);
273
274    let mut out = String::new();
275    for (i, (role, content)) in messages.iter().enumerate() {
276        let content = content.trim();
277        match *role {
278            "system" => {
279                // template requires system at the beginning; we render it wherever
280                // it appears at index 0 (the common case).
281                let _ = i;
282                out.push_str("<|im_start|>system\n");
283                out.push_str(content);
284                out.push_str("<|im_end|>\n");
285            }
286            "user" => {
287                out.push_str("<|im_start|>user\n");
288                out.push_str(content);
289                out.push_str("<|im_end|>\n");
290            }
291            "assistant" => {
292                out.push_str("<|im_start|>assistant\n");
293                out.push_str(content);
294                out.push_str("<|im_end|>\n");
295            }
296            other => {
297                // unsupported role in this minimal renderer; emit as a generic turn.
298                out.push_str("<|im_start|>");
299                out.push_str(other);
300                out.push('\n');
301                out.push_str(content);
302                out.push_str("<|im_end|>\n");
303            }
304        }
305    }
306
307    if add_generation_prompt {
308        out.push_str("<|im_start|>assistant\n");
309        if qwen_think {
310            out.push_str("<think>\n");
311        }
312    }
313
314    out
315}
316
317/// The fixed tool-calling instruction block of the qwen3.5/3.6-class templates. Byte-for-byte
318/// the string literal shared by ornith9b / agentworld / ref-qwen36-35b
319/// (research/onboard-ornith-20260801/templates/*.jinja) and the deployed GGUF dumps.
320const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
321following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
322<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
323This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
324</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
325format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
326XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
327your function call in natural language BEFORE the function call, but NOT after\n- If there is \
328no function call available, answer the question like normal with your current knowledge and do \
329not tell the user about function calls\n</IMPORTANT>";
330
331/// Qwen3.8's REASONING-EFFORT LADDER — the two instruction sentences its chat template injects
332/// at the head of the system turn, reproduced byte-for-byte out of the shipped template
333/// (`research/reasoning-schema-20260823/qwen38-27b.chat_template.jinja`, == the served GGUF's
334/// own `tokenizer.chat_template`; the BF16 and NVFP4-Q5K mints carry the identical 9993-byte
335/// string).
336///
337/// THE DEFECT THIS EXISTS TO CLOSE (lane/reasoning-schema-20260823): the template's ladder is
338/// `reasoning_effort|default('xhigh')` over `xhigh|medium|low` (with `high` aliased to
339/// `xhigh`), but `ModelCaps::effort_levels` probed for the substring `reasoning_effort is
340/// defined` — which this template does not contain. So the level never reached the render,
341/// `reasoning_effort: low|medium|high` was accepted-and-ignored on every qwen3.8 request, AND
342/// the template's own `xhigh` default never rendered either.
343///
344/// Note which rungs carry a sentence: `xhigh` and `low` do; **`medium` deliberately renders
345/// NOTHING** (the template sets no `reasoning_instructions` for it), so `medium` is the
346/// template's own "no steering" rung, not a missing case.
347const QWEN38_EFFORT_XHIGH: &str = "Reasoning effort is set to xhigh. Please think carefully \
348through the task, validate key assumptions, consider plausible alternatives, and prioritize \
349correctness, consistency, and clarity in the final answer.";
350const QWEN38_EFFORT_LOW: &str = "Reasoning effort is set to low. Keep your thinking brief and \
351focused, moving directly to the conclusion without unnecessary elaboration.";
352
353/// Does this template carry the Qwen3.8 reasoning-effort ladder?
354///
355/// Keyed on the two instruction SENTENCES this renderer reproduces, not on the jinja control
356/// flow around them. That is the strongest form of the house's template-marker law: the probe
357/// passes only when the literal we are about to emit is the literal the template emits, so a
358/// vendor or mint that reworded a rung fails the probe and falls back to the plain qwen arm
359/// (byte-identical prompts) instead of silently rendering a sentence that model never saw.
360pub fn template_has_qwen_effort(template: &str) -> bool {
361    template.contains(QWEN38_EFFORT_XHIGH) && template.contains(QWEN38_EFFORT_LOW)
362}
363
364/// Resolve the Qwen3.8 ladder: `(think, level)` -> the instruction sentence to inject.
365///
366/// Faithful to the template's own arithmetic, in its order:
367///   1. thinking OFF (`enable_thinking is false`) => the whole `reasoning_instructions` block
368///      is skipped, so NO sentence — a thinking-off prompt carries no effort steering.
369///   2. `resolved = level | default('xhigh')`, then `high -> xhigh`.
370///   3. `xhigh -> XHIGH sentence`, `low -> LOW sentence`, `medium -> '' (no sentence)`.
371///   4. anything else => the template calls `raise_exception`, so we refuse too rather than
372///      render a rung this model was never trained on.
373fn qwen38_effort_instructions(
374    think: ThinkMode,
375    reasoning_effort: Option<&str>,
376) -> Result<&'static str, String> {
377    if think == ThinkMode::NoThink {
378        return Ok("");
379    }
380    match reasoning_effort {
381        // `None` is the template's own `default('xhigh')`; `high` is aliased to `xhigh` by the
382        // template itself, and the server's canonical table already folds xhigh/max/ultra into
383        // `high`, so these three are one rung by the model's own definition.
384        None | Some("high") | Some("xhigh") => Ok(QWEN38_EFFORT_XHIGH),
385        Some("medium") => Ok(""),
386        Some("low") => Ok(QWEN38_EFFORT_LOW),
387        Some(other) => Err(format!(
388            "reasoning effort {other:?} is not a level this chat template defines \
389             (low|medium|high; the template's own ladder is xhigh|medium|low with high \
390             aliased to xhigh)"
391        )),
392    }
393}
394
395/// Tools-capable chat rendering (serve-tools lane, 2026-08-02). Reproduces the TOOLS branch of
396/// the qwen3.5/3.6-class ChatML templates exactly (verified against the committed dumps AND the
397/// deployed GGUFs' embedded templates, byte-identical):
398///
399///   - tools present  -> `<|im_start|>system\n# Tools\n\nYou have access to the following
400///     functions:\n\n<tools>` + `\n{tool json}` each + `\n</tools>` + the fixed instruction
401///     block; a leading system turn's trimmed content is appended after `\n\n`; `<|im_end|>\n`.
402///   - assistant turns with `tool_calls` -> content then `<tool_call>\n<function=NAME>\n`
403///     (+`\n\n` separator when content is non-empty; later calls separated by `\n`),
404///     `<parameter=K>\nV\n</parameter>\n` each, `</function>\n</tool_call>`, then `<|im_end|>\n`.
405///   - `tool` turns -> grouped into ONE user turn: `<|im_start|>user` opens a run of
406///     consecutive tool messages, each `\n<tool_response>\n{content}\n</tool_response>`,
407///     `<|im_end|>\n` closes the run.
408///   - generation prompt -> `<|im_start|>assistant\n` + `<think>\n` (template default) or
409///     `<think>\n\n</think>\n\n` (`ThinkMode::NoThink` = the template's `enable_thinking=false`
410///     switch; ignored when the template has no `enable_thinking`).
411///
412/// The no-tools/no-tool-turns/`Default`-think case renders byte-identically to
413/// `apply_chat_template_str` (pinned by `tools_renderer_matches_legacy_when_plain`); callers
414/// that want the hard isolation guarantee keep calling the legacy function on that path.
415/// Errors (never on the plain path): tools/tool turns on a template without a tools branch
416/// (hy3 / gemma4 / bare ChatML).
417///
418/// `reasoning_effort` is a per-dialect level STRING, never a think switch: step35 renders
419/// `Reasoning: {low|medium|high}` into the system turn (see `apply_step35_template`); hy3
420/// consumes `no_think|low|high` (medium clamps to low); deepseek-v4 resolves it through the
421/// artifact's encoding revision into the effort prompt prefix (see `Dsv4Encoding` — 0731
422/// ladder low/high/max, preview "max" only). Every other dialect ignores it (their templates
423/// have no `reasoning_effort` input), and `None` is each template's own default. The server
424/// only supplies `Some` for models whose template consumes it (`ModelCaps::effort_levels`
425/// or `ModelCaps::dsv4`), so other prompts stay byte-identical by construction, not by luck.
426pub fn apply_chat_template_tools(
427    template: Option<&str>,
428    turns: &[Turn],
429    add_generation_prompt: bool,
430    tools_json: &[String],
431    think: ThinkMode,
432    reasoning_effort: Option<&str>,
433) -> Result<String, String> {
434    // Compat entry (no structured tools, no dsv4 encoding revision): CLI bins +
435    // qwen/step/hy3 tests. The gemma4 arm needs typed tool DEFINITIONS and the dsv4 arm an
436    // encoding revision for the effort ladder, so the serve path calls `_ex` with them
437    // (a dsv4 "high"/"max" request through THIS entry refuses on the unknown revision).
438    apply_chat_template_tools_ex(
439        template,
440        turns,
441        add_generation_prompt,
442        tools_json,
443        &[],
444        think,
445        reasoning_effort,
446        None,
447    )
448}
449
450/// `apply_chat_template_tools` plus the gemma4 arm's structured tool `function` objects
451/// (`tools_struct`) and the dsv4 arm's encoding revision (`dsv4_encoding` — the effort
452/// ladder differs between the preview and 0731 checkpoints; see `Dsv4Encoding`). Every
453/// non-gemma dialect ignores `tools_struct`; every non-dsv4 dialect ignores `dsv4_encoding`.
454#[allow(clippy::too_many_arguments)]
455pub fn apply_chat_template_tools_ex(
456    template: Option<&str>,
457    turns: &[Turn],
458    add_generation_prompt: bool,
459    tools_json: &[String],
460    tools_struct: &[Val],
461    think: ThinkMode,
462    reasoning_effort: Option<&str>,
463    dsv4_encoding: Option<Dsv4Encoding>,
464) -> Result<String, String> {
465    let has_tool_features = !tools_json.is_empty()
466        || turns
467            .iter()
468            .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
469    // deepseek-v4 is template-STRING-less on the real artifacts (dialect ships as
470    // encoding code) — the detected encoding revision is the dispatch truth there.
471    let is_dsv4 = dsv4_encoding.is_some() || template.is_some_and(template_is_dsv4);
472    // A template "has a tools branch" if it carries the qwen/step `<tools>` block OR the
473    // gemma4 tooluse dialect (`<|turn>` turn framing AND the `<|tool>` declaration marker)
474    // OR it is the dsv4 dialect (DSML defines a full tool protocol).
475    let tools_branch = is_dsv4 || template.is_some_and(template_has_tools_branch);
476    if has_tool_features && !tools_branch {
477        return Err("model chat template has no tools branch".into());
478    }
479    // deepseek-v4 (`encoding_dsv4`): its own dialect all the way through, tools included.
480    // Detected by its two structural markers; MUST precede the qwen/step marker checks
481    // (a faithful dsv4 template mentions `<think>` in its tools block). Renders tool
482    // DEFINITIONS (into the system turn), assistant DSML tool_calls, and role:"tool" turns
483    // merged into user `<tool_result>` blocks. ThinkMode maps onto encoding_dsv4's
484    // thinking_mode + reasoning_effort (see `apply_dsv4_template`).
485    if is_dsv4 {
486        return apply_dsv4_template(
487            turns,
488            add_generation_prompt,
489            tools_struct,
490            think,
491            reasoning_effort,
492            dsv4_encoding,
493        );
494    }
495    // step35: its own dialect all the way through, tools included. Must precede the
496    // qwen arm: the step35 template contains `<tools>`, `<think>` and `add_generation_prompt`,
497    // so every qwen marker check below matches it. `ThinkMode` is ignored (no `enable_thinking`
498    // in this template => `think_switch` is false => NoThink is already a documented no-op);
499    // `reasoning_effort` is this dialect's own control and is honored here.
500    if template.is_some_and(|t| t.contains("render_message_content")) {
501        return Ok(apply_step35_template(
502            turns,
503            add_generation_prompt,
504            tools_json,
505            reasoning_effort,
506        ));
507    }
508    // GLM-5.3-Flash (`glm5_next`): its own dialect all the way through, tools included. Must
509    // precede the qwen arm below, which every one of this template's `<think>` /
510    // `add_generation_prompt` / `<tools>` markers would otherwise match. `ThinkMode` is ignored
511    // (the template has no off switch at all — `think_switch` is false and an explicit client
512    // off-request is refused upstream); `reasoning_effort` is this dialect's own control and is
513    // honored here. Tool DEFINITIONS come from `tools_struct` (the unwrapped `function`
514    // objects), not the qwen `tools_json` strings, because the template renders the function
515    // object alone and drops its `defer_loading`/`strict` keys.
516    if template.is_some_and(template_is_glm5) {
517        // A caller that has tools but no `tools_struct` reached the compat entry
518        // (`apply_chat_template_tools`, which passes `&[]`). Rendering the prompt WITHOUT the
519        // tools block there would be a silent downgrade — the model would be asked to call a
520        // function it was never shown. Name it instead; the serve path always calls `_ex`.
521        if !tools_json.is_empty() && tools_struct.is_empty() {
522            return Err(
523                "glm5 tool definitions need the structured `tools_struct` (the unwrapped \
524                 `function` objects) — call apply_chat_template_tools_ex"
525                    .into(),
526            );
527        }
528        return apply_glm5_template(turns, add_generation_prompt, tools_struct, reasoning_effort);
529    }
530    // Tencent HY3: the pinned shipping template has a complete tools branch using suffixed
531    // special tokens (`<tool_calls:opensource>`, `<arg_key:opensource>`, ...). It also owns
532    // the no_think/low/high reasoning ladder. Reproduce the one template for plain, reasoning,
533    // declarations, assistant calls and tool-result history so those surfaces cannot drift.
534    if template.is_some_and(|t| t.contains("hy_User")) {
535        let effort = match (think, reasoning_effort) {
536            (ThinkMode::Think, Some("high")) => "high",
537            (ThinkMode::Think, _) => "low",
538            _ => "no_think",
539        };
540        return Ok(apply_hy3_template_tools(
541            turns,
542            add_generation_prompt,
543            tools_json,
544            effort,
545        ));
546    }
547    // gemma4 TOOLUSE dialect (`<|turn>` turn framing + the `<|tool>` declaration marker):
548    // the official Google tooluse template is the rendering LAW (research/gemma4-tools-20260817
549    // /official-tooluse-template.jinja). Engages for tool DEFINITIONS, tool_calls, tool-role
550    // turns AND plain/thinking requests on this trunk. A `<|turn>` template WITHOUT `<|tool>`
551    // has no committed tools reference and falls through to the reject/plain arm below.
552    // Must precede the plain `<|turn>` arm and the qwen marker checks (the tooluse template
553    // carries no `<tools>`, so it would not match those).
554    if template.is_some_and(|t| t.contains("<|turn>") && t.contains("<|tool>")) {
555        // QAT-trunk variant emits a CLOSED thought channel on the thinking-off generation
556        // prompt; the official served trunk emits a bare `<|turn>model\n`. Keyed on the exact
557        // gen-prompt literal, which is present only in the QAT template's tail (verified:
558        // research/gemma4-tools-20260817 template diff).
559        let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
560        return Ok(apply_gemma4_tools_template(
561            turns,
562            add_generation_prompt,
563            tools_struct,
564            think == ThinkMode::Think,
565            closed_tail,
566        ));
567    }
568    if template.is_some_and(|t| t.contains("<|turn>")) {
569        // Plain-gemma4 dialect: no committed tools rendering reference. ThinkMode maps to the
570        // arch's native mechanism (thinking goldens, render-thinking-goldens.py):
571        //   gemma4 -> enable_thinking: default(false) = Default/NoThink;
572        //             Think = <|think|> system token + open generation turn.
573        if has_tool_features {
574            return Err("tools are not supported on this model's chat-template dialect".into());
575        }
576        let messages: Vec<(&str, &str)> = turns
577            .iter()
578            .map(|t| (t.role.as_str(), t.content.as_str()))
579            .collect();
580        return Ok(apply_gemma4_template(
581            &messages,
582            add_generation_prompt,
583            think == ThinkMode::Think,
584        ));
585    }
586    let qwen_think = template
587        .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
588        .unwrap_or(false);
589    let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
590    // Qwen3.8's reasoning-effort ladder. Gated on the template carrying the two instruction
591    // sentences this renderer reproduces, so every OTHER qwen-class template (ornith15,
592    // agentworld, ref-qwen36 — binary `enable_thinking` and no ladder) renders byte-identically
593    // to before, by construction rather than by luck.
594    let effort_ladder = template.is_some_and(template_has_qwen_effort);
595    let effort_instructions = if effort_ladder {
596        qwen38_effort_instructions(think, reasoning_effort)?
597    } else {
598        ""
599    };
600
601    // LEADING SYSTEM RUN. The qwen3.8 template MERGES the whole leading run of system/developer
602    // turns into ONE system turn, joining trimmed non-empty contents with `\n`, and its body loop
603    // then refuses a system message that appears later (`System message must be at the
604    // beginning.`). memra's historical qwen arm emits one `<|im_start|>system` turn PER message,
605    // which diverges from that the moment a request carries two — a shape this server produces
606    // itself, since it normalizes OpenAI's `developer` role to `system`.
607    //
608    // The merge is scoped to LADDER templates (`qwen_effort`) on purpose, and the scope is
609    // measured rather than assumed: rendering `[system, system, user]` through the shipped jinja
610    // gives one merged turn on qwen3.8 and `raise_exception` on ornith15, so the two dialects do
611    // NOT share this law. Every non-ladder template therefore keeps its exact historical bytes.
612    let merge_leading_system = effort_ladder;
613    let n_leading_system = if merge_leading_system {
614        turns
615            .iter()
616            .take_while(|t| t.role == "system" || t.role == "developer")
617            .count()
618    } else {
619        usize::from(!tools_json.is_empty() && turns.first().is_some_and(|t| t.role == "system"))
620    };
621    let merged_system = if merge_leading_system {
622        turns[..n_leading_system]
623            .iter()
624            .map(|t| t.content.trim())
625            .filter(|c| !c.is_empty())
626            .collect::<Vec<_>>()
627            .join("\n")
628    } else {
629        turns
630            .first()
631            .filter(|_| n_leading_system > 0)
632            .map(|t| t.content.trim().to_string())
633            .unwrap_or_default()
634    };
635
636    let mut out = String::new();
637    // NO-TOOLS placement of the effort instruction (template law, verified against the shipped
638    // jinja): the sentence is PREPENDED to the merged system turn across a blank line; when the
639    // request carries no leading system content the sentence becomes a system turn of its own.
640    // Emitted here, ahead of the message loop, because that is where the template emits it.
641    if tools_json.is_empty()
642        && merge_leading_system
643        && (!effort_instructions.is_empty() || !merged_system.is_empty())
644    {
645        out.push_str("<|im_start|>system\n");
646        if !effort_instructions.is_empty() {
647            out.push_str(effort_instructions);
648            if !merged_system.is_empty() {
649                out.push_str("\n\n");
650            }
651        }
652        out.push_str(&merged_system);
653        out.push_str("<|im_end|>\n");
654    }
655    // Tools system header replaces the plain system turn (template law: the leading system
656    // turn's content is folded INTO the tools block).
657    if !tools_json.is_empty() {
658        out.push_str("<|im_start|>system\n");
659        // TOOLS placement: the effort sentence precedes the `# Tools` header inside the one
660        // system turn (template law: `reasoning_instructions + '\n\n'` then the header).
661        if !effort_instructions.is_empty() {
662            out.push_str(effort_instructions);
663            out.push_str("\n\n");
664        }
665        out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
666        for tool in tools_json {
667            out.push('\n');
668            out.push_str(tool);
669        }
670        out.push_str("\n</tools>");
671        out.push_str(QWEN_TOOLS_INSTRUCTION);
672        if !merged_system.is_empty() {
673            out.push_str("\n\n");
674            out.push_str(&merged_system);
675        }
676        out.push_str("<|im_end|>\n");
677    }
678
679    for (i, turn) in turns.iter().enumerate() {
680        // The leading system run was already emitted (merged, or folded into the tools header).
681        if i < n_leading_system {
682            continue;
683        }
684        let content = turn.content.trim();
685        match turn.role.as_str() {
686            // A ladder template's leading system run never reaches here (merged above), so this
687            // arm is the unchanged historical path for every other dialect — and for a system
688            // message that appears AFTER a user turn, which the vendor jinja refuses outright and
689            // this renderer still passes through (pre-existing, out of this lane's scope).
690            "system" => {
691                out.push_str("<|im_start|>system\n");
692                out.push_str(content);
693                out.push_str("<|im_end|>\n");
694            }
695            "user" => {
696                out.push_str("<|im_start|>user\n");
697                out.push_str(content);
698                out.push_str("<|im_end|>\n");
699            }
700            "assistant" => {
701                out.push_str("<|im_start|>assistant\n");
702                // LADDER templates replay the prior turn's `<think>` block (vendor law:
703                // `preserve_thinking is undefined or preserve_thinking is true` — the ABSENT
704                // default is replay, `reasoning_content|trim` inside, EMPTY when the client
705                // sent none). memra historically rendered assistant turns as content only,
706                // a named gap off the vendor's bytes (see the server's preserve_thinking
707                // kwarg doc) — and the byte that kept every multi-turn conversation from
708                // ever matching a parked session's stream: the generation prompt ends in a
709                // `<think>` block, so the live stream carries it while the re-render did
710                // not. Scoped to `effort_ladder` so every other qwen-class template keeps
711                // its exact historical bytes, by construction.
712                if effort_ladder {
713                    out.push_str("<think>\n");
714                    out.push_str(turn.reasoning.as_deref().map(str::trim).unwrap_or(""));
715                    out.push_str("\n</think>\n\n");
716                }
717                out.push_str(content);
718                for (k, call) in turn.tool_calls.iter().enumerate() {
719                    if k == 0 {
720                        if !content.is_empty() {
721                            out.push_str("\n\n");
722                        }
723                    } else {
724                        out.push('\n');
725                    }
726                    out.push_str("<tool_call>\n<function=");
727                    out.push_str(&call.name);
728                    out.push_str(">\n");
729                    for (key, value) in &call.params {
730                        out.push_str("<parameter=");
731                        out.push_str(key);
732                        out.push_str(">\n");
733                        out.push_str(value);
734                        out.push_str("\n</parameter>\n");
735                    }
736                    out.push_str("</function>\n</tool_call>");
737                }
738                out.push_str("<|im_end|>\n");
739            }
740            "tool" => {
741                if i == 0 || turns[i - 1].role != "tool" {
742                    out.push_str("<|im_start|>user");
743                }
744                out.push_str("\n<tool_response>\n");
745                out.push_str(content);
746                out.push_str("\n</tool_response>");
747                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
748                    out.push_str("<|im_end|>\n");
749                }
750            }
751            other => {
752                // parity with the legacy renderer's generic-turn arm.
753                out.push_str("<|im_start|>");
754                out.push_str(other);
755                out.push('\n');
756                out.push_str(content);
757                out.push_str("<|im_end|>\n");
758            }
759        }
760    }
761
762    if add_generation_prompt {
763        out.push_str("<|im_start|>assistant\n");
764        if qwen_think {
765            if think == ThinkMode::NoThink && think_switch {
766                out.push_str("<think>\n\n</think>\n\n");
767            } else {
768                out.push_str("<think>\n");
769            }
770        }
771    }
772    Ok(out)
773}
774
775/// The fixed tool-calling instruction block of the StepFun `step35` template. NOT the same
776/// string as `QWEN_TOOLS_INSTRUCTION` — three differences, all load-bearing: the header says
777/// "in JSONSchema format", the nesting reminder carries literal `\n...\n` inside the
778/// `<function=...>` / `<tool_call>` examples, and the Reminder list has 2 bullets instead of 4
779/// (no "optional reasoning BEFORE the call" and no "answer normally if no function is
780/// available"). Copied byte-for-byte out of the shipped template
781/// (`research/step37-bringup-20260802/raw/chat_template.jinja`, == the GGUF's own
782/// `tokenizer.chat_template`).
783const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
784following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
785<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
786This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
787</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
788format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
789...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
790
791/// StepFun Step-3.7-Flash (GGUF arch `step35`) chat template.
792///
793/// A ChatML *dialect*, not ChatML: it shares the `<|im_start|>role\n…<|im_end|>\n` frame and
794/// nothing else. Reproduced from the shipped jinja, and pinned test-by-test against goldens
795/// rendered from that jinja under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF
796/// transformers and llama.cpp's minja both parse chat templates with
797/// (`research/step37-p2-20260806/render_step35_template.py`, goldens committed under `raw/`).
798///
799/// Where it differs from the qwen3.5/3.6 arms above — every one of these silently corrupts the
800/// prompt if the qwen arm is reused:
801///
802/// | | qwen3.5/3.6 | step35 |
803/// |---|---|---|
804/// | reasoning level | `enable_thinking` bool | `Reasoning: {low,medium,high}\n\n` prefix inside the system turn |
805/// | `<think>` tail | switchable | **unconditional** — no `enable_thinking`, so `ThinkMode::NoThink` is a no-op |
806/// | prior assistant turns | content only | turns AFTER the last real user query also carry `<think>\n{reasoning}\n</think>\n` |
807/// | 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 |
808/// | content | `\|trim`med | **not** trimmed |
809/// | tools header | `following functions:` | `following functions in JSONSchema format:` |
810/// | call separators | `\n\n` after content, `\n` between calls | **none** |
811/// | leading system + tools | appended AFTER the instruction block | folded in BEFORE `# Tools` |
812///
813/// `reasoning_effort` is the model's headline three-level control (low/medium/high per the
814/// StepFun model card). It is a parameter here rather than a `ThinkMode`: the value is a
815/// *string in the system turn*, so a bool cannot carry it. The serve path supplies it through
816/// `apply_chat_template_tools` (worker `Request::reasoning_effort`, mapped from the OpenAI
817/// `reasoning_effort` body field when `ModelCaps::effort_levels` is set); `None` — the
818/// legacy-str path and every non-step35 model — renders the template's own default
819/// (no `Reasoning:` line at all).
820///
821/// BOS is NOT emitted (the jinja's `{{bos_token}}` is dropped): memra's `encode(add_special)`
822/// prepends it from `tokenizer.ggml.add_bos_token`/`bos_token_id` — the same double-BOS trap the
823/// gemma4 arm documents.
824///
825/// ONE deliberate divergence: the jinja's body loop has no `else`, so a role outside
826/// {system, user, assistant, tool} renders as **nothing at all** — the turn silently vanishes
827/// from the prompt. memra renders it as a generic `<|im_start|>{role}\n{content}<|im_end|>\n`
828/// turn instead, matching the other arms here. A dropped turn is the worse failure, and this
829/// branch cannot fire on the serve surface: OpenAI roles are exactly system/user/assistant/tool,
830/// all four of which are reproduced byte-for-byte.
831///
832/// Not reproduced (needs data `Turn` does not carry, tracked, cannot fire from an OpenAI client):
833/// the `name == "observation"` alias that renames a non-leading `system` turn's role to
834/// `observation`. The `<im_patch>` image-content path is handled UPSTREAM of this template
835/// (lane/step37-vision, 2026-08-30): when the step vision seam is armed, the server's
836/// content walker (`content_to_text_vision_step`) renders each image part's full pad-token
837/// expansion and the template macro's text-separator law into the turn content string, so
838/// the content arrives here as literal text and passes through verbatim.
839fn apply_step35_template(
840    turns: &[Turn],
841    add_generation_prompt: bool,
842    tools_json: &[String],
843    reasoning_effort: Option<&str>,
844) -> String {
845    let mut out = String::new();
846    let leading_system = turns.first().filter(|t| t.role == "system");
847
848    // --- system header. Two branches in the jinja, and the ORDER differs between them.
849    if !tools_json.is_empty() {
850        out.push_str("<|im_start|>system\n");
851        if let Some(effort) = reasoning_effort {
852            out.push_str("Reasoning: ");
853            out.push_str(effort);
854            out.push_str("\n\n");
855        }
856        if let Some(sys) = leading_system {
857            // unconditional `content + '\n\n'` — no emptiness check, unlike the qwen arm.
858            out.push_str(&sys.content);
859            out.push_str("\n\n");
860        }
861        out.push_str(
862            "# Tools\n\nYou have access to the following functions in JSONSchema \
863                      format:\n\n<tools>",
864        );
865        for tool in tools_json {
866            out.push('\n');
867            out.push_str(tool);
868        }
869        out.push_str("\n</tools>");
870        out.push_str(STEP35_TOOLS_INSTRUCTION);
871        out.push_str("<|im_end|>\n");
872    } else if let Some(sys) = leading_system {
873        out.push_str("<|im_start|>system\n");
874        if let Some(effort) = reasoning_effort {
875            out.push_str("Reasoning: ");
876            out.push_str(effort);
877            out.push_str("\n\n");
878        }
879        out.push_str(&sys.content);
880        out.push_str("<|im_end|>\n");
881    } else if let Some(effort) = reasoning_effort {
882        out.push_str("<|im_start|>system\nReasoning: ");
883        out.push_str(effort);
884        out.push_str("\n\n<|im_end|>\n");
885    }
886
887    // --- last_query_index: the index of the LAST `user` turn that is a real query, i.e. whose
888    // content is not itself a `<tool_response>…</tool_response>` wrapper (a client replaying tool
889    // output as a user turn must not reset the reasoning boundary). Default len-1 when there is
890    // no such turn, exactly as the jinja's namespace initializer does.
891    let last_query_index = turns
892        .iter()
893        .enumerate()
894        .rev()
895        .find(|(_, t)| {
896            t.role == "user"
897                && !(t.content.starts_with("<tool_response>")
898                    && t.content.ends_with("</tool_response>"))
899        })
900        .map(|(i, _)| i)
901        .unwrap_or(turns.len().saturating_sub(1));
902
903    for (i, turn) in turns.iter().enumerate() {
904        let content = &turn.content; // NOT trimmed: this template applies no `|trim`
905        match turn.role.as_str() {
906            // the leading system turn lives in the header above; later ones are body turns.
907            "system" if i == 0 => {}
908            "system" | "user" => {
909                out.push_str("<|im_start|>");
910                out.push_str(&turn.role);
911                out.push('\n');
912                out.push_str(content);
913                out.push_str("<|im_end|>\n");
914            }
915            "assistant" => {
916                // Split an inline `<think>…</think>` out of content, mirroring the jinja's
917                // string surgery exactly: reasoning = text before the FIRST `</think>`, with
918                // trailing newlines stripped, then everything after the LAST `<think>` in that
919                // prefix, with leading newlines stripped; body = after the LAST `</think>`,
920                // leading newlines stripped.
921                let (reasoning, body): (String, &str) = match content.find("</think>") {
922                    Some(first) => {
923                        let pre = content[..first].trim_end_matches('\n');
924                        let pre = match pre.rfind("<think>") {
925                            Some(o) => &pre[o + "<think>".len()..],
926                            None => pre,
927                        };
928                        let last = content.rfind("</think>").unwrap();
929                        (
930                            pre.trim_start_matches('\n').to_string(),
931                            content[last + "</think>".len()..].trim_start_matches('\n'),
932                        )
933                    }
934                    None => (String::new(), content.as_str()),
935                };
936                out.push_str("<|im_start|>assistant\n");
937                if i > last_query_index {
938                    out.push_str("<think>\n");
939                    out.push_str(&reasoning);
940                    out.push_str("\n</think>\n");
941                }
942                out.push_str(body);
943                // NO separator before or between calls (the qwen arm's `\n\n`/`\n` would corrupt).
944                for call in &turn.tool_calls {
945                    out.push_str("<tool_call>\n<function=");
946                    out.push_str(&call.name);
947                    out.push_str(">\n");
948                    for (key, value) in &call.params {
949                        out.push_str("<parameter=");
950                        out.push_str(key);
951                        out.push_str(">\n");
952                        out.push_str(value);
953                        out.push_str("\n</parameter>\n");
954                    }
955                    out.push_str("</function>\n</tool_call>");
956                }
957                out.push_str("<|im_end|>\n");
958            }
959            "tool" => {
960                // own role, and consecutive tool turns share ONE `tool_response` turn.
961                if i == 0 || turns[i - 1].role != "tool" {
962                    out.push_str("<|im_start|>tool_response\n");
963                }
964                out.push_str("<tool_response>");
965                out.push_str(content);
966                out.push_str("</tool_response>");
967                if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
968                    out.push_str("<|im_end|>\n");
969                }
970            }
971            other => {
972                // the jinja drops this turn entirely; see the divergence note above.
973                out.push_str("<|im_start|>");
974                out.push_str(other);
975                out.push('\n');
976                out.push_str(content);
977                out.push_str("<|im_end|>\n");
978            }
979        }
980    }
981
982    if add_generation_prompt {
983        out.push_str("<|im_start|>assistant\n<think>\n");
984    }
985    out
986}
987
988/// Text-only compatibility entry for the Hy3 `chat_template.jinja`.
989/// `effort` is the template's own `reasoning_effort` input — `"no_think"` / `"low"` /
990/// `"high"`, its full accepted set (the jinja `raise_exception`s on anything else; undefined
991/// defaults to `'no_think'`, so callers with no opinion pass `"no_think"`):
992///   - `{bos}{system…}<|reasoning_mode:opensource|>reasoning_effort:{effort}` header
993///     (system turns concatenate into the header, before any user turn);
994///   - `user`      -> `<|hy_User:opensource|>{content}`
995///   - `assistant` -> `<|hy_Assistant:opensource|><think:opensource></think:opensource>{content}<|hy_eos:opensource|>`
996///     (non-last turns; history turns render CLOSED think at every effort — the template
997///     opens only turns past `last_user_index`, and OpenAI history carries no reasoning);
998///   - generation prompt: `<|hy_Assistant:opensource|><think:opensource></think:opensource>`
999///     at no_think, `…<think:opensource>` (OPEN think) at low/high.
1000///     Content is NOT trimmed (the Hy3 template applies no `|trim`). Goldens: rendered from the
1001///     pinned tencent/Hy3 template (sha 7fc351fe…, snapshot 716aa724) by
1002///     `research/step-sku-20260807/render-thinking-goldens.py`.
1003fn apply_hy3_template(
1004    messages: &[(&str, &str)],
1005    add_generation_prompt: bool,
1006    effort: &str,
1007) -> String {
1008    let turns: Vec<Turn> = messages
1009        .iter()
1010        .map(|(role, content)| Turn {
1011            role: (*role).to_string(),
1012            content: (*content).to_string(),
1013            ..Default::default()
1014        })
1015        .collect();
1016    apply_hy3_template_tools(&turns, add_generation_prompt, &[], effort)
1017}
1018
1019/// Exact text/tools reproduction of Tencent HY3's pinned shipping template
1020/// (`chat_template.jinja` SHA-256 7fc351fe...). `tools_json` entries are the request's
1021/// function objects serialized by the HTTP layer in client order, matching jinja `tojson`.
1022fn apply_hy3_template_tools(
1023    turns: &[Turn],
1024    add_generation_prompt: bool,
1025    tools_json: &[String],
1026    effort: &str,
1027) -> String {
1028    const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
1029    const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
1030    const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
1031    const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
1032    const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
1033    const THINK_BEGIN: &str = "<think:opensource>";
1034    const THINK_END: &str = "</think:opensource>";
1035    const TOOLCALLS_BEGIN: &str = "<tool_calls:opensource>";
1036    const TOOLCALLS_END: &str = "</tool_calls:opensource>";
1037    const TOOLCALL_BEGIN: &str = "<tool_call:opensource>";
1038    const TOOLCALL_END: &str = "</tool_call:opensource>";
1039    const TOOL_SEP: &str = "<tool_sep:opensource>";
1040    const ARGKEY_BEGIN: &str = "<arg_key:opensource>";
1041    const ARGKEY_END: &str = "</arg_key:opensource>";
1042    const ARGVALUE_BEGIN: &str = "<arg_value:opensource>";
1043    const ARGVALUE_END: &str = "</arg_value:opensource>";
1044    const TOOLRESPONSES_BEGIN: &str = "<tool_responses:opensource>";
1045    const TOOLRESPONSES_END: &str = "</tool_responses:opensource>";
1046    const TOOLRESPONSE_BEGIN: &str = "<tool_response:opensource>";
1047    const TOOLRESPONSE_END: &str = "</tool_response:opensource>";
1048
1049    debug_assert!(
1050        matches!(effort, "no_think" | "low" | "high"),
1051        "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
1052    );
1053    let mut out = String::from(BOS);
1054    let mut system_prompt = String::new();
1055    for turn in turns.iter().filter(|turn| turn.role == "system") {
1056        system_prompt.push_str(&turn.content);
1057    }
1058    out.push_str(&system_prompt);
1059    if tools_json.is_empty() {
1060        out.push_str(REASONING);
1061        out.push_str("reasoning_effort:");
1062        out.push_str(effort);
1063    } else {
1064        if !system_prompt.is_empty() {
1065            out.push_str(
1066                "\n\n# Tools\n\nYou may call one or more functions to assist with the user query.",
1067            );
1068        } else {
1069            out.push_str(
1070                "# Tools\n\nYou may call one or more functions to assist with the user query.",
1071            );
1072        }
1073        out.push_str(
1074            "\n\nYou are provided with function signatures within <tools></tools> XML tags:",
1075        );
1076        out.push_str("\n<tools>\n");
1077        for (index, tool) in tools_json.iter().enumerate() {
1078            if index > 0 {
1079                out.push('\n');
1080            }
1081            out.push_str(tool);
1082        }
1083        out.push_str("\n</tools>\n\n");
1084        out.push_str("For function call returns, you should first print ");
1085        out.push_str(TOOLCALLS_BEGIN);
1086        out.push('\n');
1087        out.push_str("For each function call, you should return object like:\n");
1088        out.push_str(TOOLCALL_BEGIN);
1089        out.push_str("{function-name}");
1090        out.push_str(TOOL_SEP);
1091        out.push('\n');
1092        out.push_str(ARGKEY_BEGIN);
1093        out.push_str("{arg-key-1}");
1094        out.push_str(ARGKEY_END);
1095        out.push('\n');
1096        out.push_str(ARGVALUE_BEGIN);
1097        out.push_str("{arg-value-1}");
1098        out.push_str(ARGVALUE_END);
1099        out.push('\n');
1100        out.push_str(ARGKEY_BEGIN);
1101        out.push_str("{arg-key-2}");
1102        out.push_str(ARGKEY_END);
1103        out.push('\n');
1104        out.push_str(ARGVALUE_BEGIN);
1105        out.push_str("{arg-value-2}");
1106        out.push_str(ARGVALUE_END);
1107        out.push_str("\n...\n");
1108        out.push_str(TOOLCALL_END);
1109        out.push('\n');
1110        out.push_str("At the end of function call returns, you should print ");
1111        out.push_str(TOOLCALLS_END);
1112        out.push_str(REASONING);
1113        out.push_str("reasoning_effort:");
1114        out.push_str(effort);
1115    }
1116
1117    let last_user = turns.iter().rposition(|turn| turn.role == "user");
1118    let preserve_thinking = !tools_json.is_empty();
1119    let mut previous_is_tool = false;
1120    let mut tool_run_first = true;
1121    for (index, turn) in turns.iter().enumerate() {
1122        match turn.role.as_str() {
1123            "user" => {
1124                if previous_is_tool {
1125                    out.push_str(TOOLRESPONSES_END);
1126                }
1127                out.push_str(USER);
1128                out.push_str(&turn.content);
1129                previous_is_tool = false;
1130            }
1131            "assistant" => {
1132                if previous_is_tool {
1133                    out.push_str(TOOLRESPONSES_END);
1134                }
1135                let keep_reasoning = preserve_thinking || last_user.is_none_or(|last| index > last);
1136                out.push_str(ASSISTANT);
1137                out.push_str(THINK_BEGIN);
1138                if keep_reasoning && let Some(reasoning) = turn.reasoning.as_deref() {
1139                    out.push_str(reasoning);
1140                }
1141                out.push_str(THINK_END);
1142                out.push_str(&turn.content);
1143                if turn.tool_calls.is_empty() {
1144                    if index + 1 < turns.len() {
1145                        out.push_str(EOS);
1146                    }
1147                } else {
1148                    tool_run_first = true;
1149                    out.push_str(TOOLCALLS_BEGIN);
1150                    out.push('\n');
1151                    for call in &turn.tool_calls {
1152                        out.push_str(TOOLCALL_BEGIN);
1153                        out.push_str(&call.name);
1154                        out.push_str(TOOL_SEP);
1155                        out.push('\n');
1156                        for (key, value) in &call.params {
1157                            out.push_str(ARGKEY_BEGIN);
1158                            out.push_str(key);
1159                            out.push_str(ARGKEY_END);
1160                            out.push('\n');
1161                            out.push_str(ARGVALUE_BEGIN);
1162                            out.push_str(value);
1163                            out.push_str(ARGVALUE_END);
1164                            out.push('\n');
1165                        }
1166                        out.push_str(TOOLCALL_END);
1167                        out.push('\n');
1168                    }
1169                    out.push_str(TOOLCALLS_END);
1170                    out.push_str(EOS);
1171                }
1172                previous_is_tool = false;
1173            }
1174            "tool" => {
1175                previous_is_tool = true;
1176                if tool_run_first {
1177                    out.push_str(TOOLRESPONSES_BEGIN);
1178                    out.push('\n');
1179                    tool_run_first = false;
1180                }
1181                out.push_str(TOOLRESPONSE_BEGIN);
1182                out.push('\n');
1183                out.push_str(&turn.content);
1184                out.push('\n');
1185                out.push_str(TOOLRESPONSE_END);
1186                out.push('\n');
1187            }
1188            _ => {} // system handled in the header; unknown roles are ignored by the template
1189        }
1190    }
1191    if previous_is_tool {
1192        out.push_str(TOOLRESPONSES_END);
1193    }
1194    let last_is_assistant = turns.last().is_some_and(|turn| turn.role == "assistant");
1195    if add_generation_prompt && !last_is_assistant {
1196        out.push_str(ASSISTANT);
1197        out.push_str(THINK_BEGIN);
1198        if effort == "no_think" {
1199            out.push_str(THINK_END); // low/high leave the think channel OPEN (the golden)
1200        }
1201    }
1202    out
1203}
1204
1205/// gemma4 turn dialect (text-only path of the GGUF template, verified against the dumped
1206/// jinja — sha 36e3a42e…, goldens `research/step-sku-20260807/raw/thinking-goldens.txt`):
1207/// roles map assistant->model; each turn = `<|turn>{role}\n{content|trim}<turn|>\n`.
1208///
1209/// THINKING is `enable_thinking`, and its default is OFF (`enable_thinking | default(false)`)
1210/// — the inverse of the qwen class:
1211///   - thinking OFF (default): generation prompt = `<|turn>model\n<|channel>thought\n<channel|>`
1212///     (the CLOSED thought channel — the model may not think);
1213///   - thinking ON: a `<|think|>\n` token is injected at the very top of the FIRST system
1214///     turn (a system turn is CREATED if the request has none), and the generation prompt is
1215///     the bare `<|turn>model\n` — the thought channel is left to the model.
1216fn apply_gemma4_template(
1217    messages: &[(&str, &str)],
1218    add_generation_prompt: bool,
1219    thinking: bool,
1220) -> String {
1221    let mut out = String::new();
1222    let mut msgs = messages;
1223    // System header block: fires when thinking is on OR a leading system turn exists.
1224    let leading_system = msgs.first().filter(|(r, _)| *r == "system");
1225    if thinking || leading_system.is_some() {
1226        out.push_str("<|turn>system\n");
1227        if thinking {
1228            out.push_str("<|think|>\n");
1229        }
1230        if let Some((_, content)) = leading_system {
1231            out.push_str(content.trim());
1232            msgs = &msgs[1..];
1233        }
1234        out.push_str("<turn|>\n");
1235    }
1236    for (role, content) in msgs {
1237        let role = if *role == "assistant" { "model" } else { role };
1238        out.push_str("<|turn>");
1239        out.push_str(role);
1240        out.push('\n');
1241        out.push_str(content.trim());
1242        out.push_str("<turn|>\n");
1243    }
1244    if add_generation_prompt {
1245        out.push_str("<|turn>model\n");
1246        if !thinking {
1247            out.push_str("<|channel>thought\n<channel|>");
1248        }
1249    }
1250    out
1251}
1252
1253// ---- GLM-5.3-Flash (`glm5_next`) dialect ---------------------------------------------------
1254// A port of the checkpoint's own chat_template.jinja, banked byte-identical at
1255// research/glm53-flash-bringup-20260827/chat_template.jinja. The jinja is the LAW; byte parity
1256// is pinned by research/glm53-flash-bringup-20260827/surface-fixtures (the
1257// `glm5_fixtures_match_the_vendor_jinja` test in memra-server renders the vendor jinja under
1258// jinja2 and asserts equality, the same oracle discipline the gemma4/dsv4 arms carry).
1259//
1260// NOTHING about this dialect is ChatML. Before this arm existed, the template's `<think>` +
1261// `add_generation_prompt` markers matched the qwen detector and every GLM chat request rendered
1262// `<|im_start|>` turns — tokens that are not in this checkpoint's special vocabulary at all
1263// (its extra_special_tokens are `[gMASK] <sop> <|system|> <|user|> <|assistant|>
1264// <|observation|>` …), so the frame tokenized as ordinary text and the prompt was off the
1265// model's distribution end to end. It "worked" only because GLM follows the qwen tool-format
1266// instruction it was handed in-context: the GGUF-template-mint failure mode exactly — fluent,
1267// and invisible without a byte oracle.
1268
1269/// GLM-5.3-Flash template detector: the `[gMASK]<sop>` sequence head AND the `<|observation|>`
1270/// tool-result turn prefix. Both are unique to the GLM dialect among every committed template
1271/// (`rg` over research/**/*.jinja finds them only in the glm53 lane), and neither can appear in
1272/// a ChatML/gemma/hy3/dsv4 template by accident. Shared by the renderer dispatch, the tools
1273/// probe and the worker caps, so one law keys all three.
1274pub fn template_is_glm5(t: &str) -> bool {
1275    t.contains("[gMASK]<sop>") && t.contains("<|observation|>")
1276}
1277
1278/// The GLM-5.3-Flash REASONING-EFFORT ladder, resolved exactly as the template resolves it:
1279///
1280/// ```jinja
1281/// {%- set effective_reasoning_effort = reasoning_effort
1282///        if reasoning_effort is defined and reasoning_effort in ['low', 'high']
1283///        else 'max' -%}
1284/// <|system|>Reasoning Effort: {{ effective_reasoning_effort | capitalize }}
1285/// ```
1286///
1287/// So the model's own rungs are **low < high < max**, `max` is its DEFAULT, the line is always
1288/// rendered, and there is no off switch anywhere in the template (which is why an explicit
1289/// client off-request 400s upstream — `ModelCaps::qwen_think && !think_switch`).
1290///
1291/// The canonical serve levels map onto those three rungs:
1292///
1293/// | client `reasoning_effort` | rendered line |
1294/// |---|---|
1295/// | (absent)                  | `Reasoning Effort: Max` (the template's own default) |
1296/// | `low`                     | `Reasoning Effort: Low` |
1297/// | `medium`                  | `Reasoning Effort: High` (see below) |
1298/// | `high`                    | `Reasoning Effort: High` |
1299/// | `xhigh` / `max` / `ultra` | `Reasoning Effort: Max` (the real tier above high) |
1300///
1301/// `medium` maps UP to `high`: the middle ask onto the middle rung (owner ruling
1302/// 2026-09-02, issue #75, superseding the 2026-08-27 clamp-down to `low`). This
1303/// ladder has no medium rung, and `high` is the closest one to what a medium ask
1304/// means. The law the mapping must keep is that a sub-max ask NEVER falls through
1305/// the template's `else` arm to `max`: its `else` is the *unset* default, not a
1306/// medium rung, and routing "reason less" there would answer with the model's
1307/// deepest setting. (`max` stays reachable by name: `xhigh`/`max`/`ultra`
1308/// canonicalize to it below.) hy3 still clamps `medium` -> `low`; its ladder has
1309/// no rung above high, so the closest rung there is down, and that model's
1310/// mapping is its own call, not a precedent either way.
1311fn glm5_effort_level(reasoning_effort: Option<&str>) -> Result<&'static str, String> {
1312    match reasoning_effort {
1313        None => Ok("Max"),
1314        Some("low") => Ok("Low"),
1315        Some("medium") => Ok("High"),
1316        Some("high") => Ok("High"),
1317        Some("max") | Some("xhigh") | Some("ultra") => Ok("Max"),
1318        // `none`/`minimal` never arrive here: the serve path refuses an explicit off-request on
1319        // this template (no enable_thinking) and maps a deployment default of none/minimal to
1320        // `ThinkMode::NoThink` + level "low", which lands on the Low rung above. Anything else
1321        // is a level this model was never trained on, and the template's own `else` would have
1322        // silently rendered Max for it — the accepted-and-ignored shape this arm exists to end.
1323        Some(other) => Err(format!(
1324            "reasoning effort {other:?} is not a level this chat template defines \
1325             (low|medium|high|max; the template's own ladder is low|high|max with max the \
1326             default)"
1327        )),
1328    }
1329}
1330
1331/// One tool DECLARATION, rendered as the template's `tool_to_json` macro renders it: the
1332/// unwrapped `function` object as `json.dumps(ensure_ascii=False)` in INSERTION key order, with
1333/// the two client-side-only keys `defer_loading` and `strict` dropped. (`strict` is the one that
1334/// actually shows up: stock OpenAI-shaped clients put it inside `function`.)
1335fn glm5_tool_json(func: &Val) -> String {
1336    let mut out = String::new();
1337    let Some(obj) = as_obj(func) else {
1338        py_json(func, &mut out);
1339        return out;
1340    };
1341    out.push('{');
1342    let mut first = true;
1343    for (k, v) in obj {
1344        if k == "defer_loading" || k == "strict" {
1345            continue;
1346        }
1347        if !first {
1348            out.push_str(", ");
1349        }
1350        first = false;
1351        out.push('"');
1352        py_json_escape(k, &mut out);
1353        out.push_str("\": ");
1354        py_json(v, &mut out);
1355    }
1356    out.push('}');
1357    out
1358}
1359
1360/// The message id a tool-result / tool-call turn is keyed by — the template's `id_of` macro
1361/// (`obj.tool_call_id` first, then `obj.id`). An empty string is jinja-falsey, so it is `None`
1362/// here too.
1363fn glm5_id_of(id: Option<&str>) -> Option<&str> {
1364    id.filter(|s| !s.is_empty())
1365}
1366
1367/// Can this run of consecutive `tool` turns be re-ordered onto the preceding assistant turn's
1368/// `tool_calls` order? The template's `can_sort` predicate, reproduced in its own order:
1369/// the run must be immediately preceded by an assistant turn WITH tool_calls; every result in
1370/// the run must carry an id that is unique within the run and present among those calls; and
1371/// every call must carry an id, unique among the calls. Any miss renders the run in message
1372/// order instead.
1373fn glm5_can_sort(results: &[&Turn], calls: &[ToolCall]) -> bool {
1374    if calls.is_empty() {
1375        return false;
1376    }
1377    for (i, r) in results.iter().enumerate() {
1378        let Some(id) = glm5_id_of(r.tool_call_id.as_deref()) else {
1379            return false;
1380        };
1381        if results
1382            .iter()
1383            .enumerate()
1384            .any(|(j, o)| j != i && glm5_id_of(o.tool_call_id.as_deref()) == Some(id))
1385        {
1386            return false;
1387        }
1388        if !calls
1389            .iter()
1390            .any(|c| glm5_id_of(c.id.as_deref()) == Some(id))
1391        {
1392            return false;
1393        }
1394    }
1395    for (i, c) in calls.iter().enumerate() {
1396        let Some(id) = glm5_id_of(c.id.as_deref()) else {
1397            return false;
1398        };
1399        if calls
1400            .iter()
1401            .enumerate()
1402            .any(|(j, o)| j != i && glm5_id_of(o.id.as_deref()) == Some(id))
1403        {
1404            return false;
1405        }
1406    }
1407    true
1408}
1409
1410/// GLM-5.3-Flash (`glm5_next`) chat template — the vendor jinja, reproduced.
1411///
1412/// Shape, in the template's own order:
1413///
1414/// ```text
1415/// [gMASK]<sop>
1416/// <|system|>Reasoning Effort: {Low|High|Max}                      (always, no off switch)
1417/// <|system|>\n# Tools\n\n…<tools>\n\n{json}\n\n\n</tools>\n\n…     (only when tools present)
1418/// <|user|>{content}                                               (content NOT trimmed)
1419/// <|system|>{content}                                             (anywhere, NOT trimmed)
1420/// <|assistant|><think>{reasoning}</think>{content.strip()}
1421///     \n<tool_call>NAME<arg_key>k</arg_key><arg_value>v</arg_value></tool_call>…\n
1422/// <|observation|><tool_response>{r1}</tool_response><tool_response>{r2}</tool_response>
1423/// <|assistant|><think>                                            (add_generation_prompt)
1424/// ```
1425///
1426/// Load-bearing details, each measured against the jinja rather than assumed:
1427///
1428/// - **BOS is the template's own literal.** `[gMASK]<sop>` is emitted here; the checkpoint's
1429///   tokenizer_config declares no `bos_token` and no `add_bos_token`, so `encode(add_special)`
1430///   prepends nothing and there is no double-BOS trap (the one the gemma4/step35 arms document).
1431/// - **The reasoning-effort system line is unconditional** — `effective_reasoning_effort` is
1432///   always a string, so the `is not none` guard is always true. There is no prompt shape of
1433///   this model without it.
1434/// - **`<think>` is ALWAYS replayed on assistant history**, empty when the turn carries no
1435///   reasoning (`<think></think>`), because `clear_thinking` defaults false and the guard is
1436///   `(not clear_thinking or …)`. Reasoning comes from the turn's own `reasoning` field, else
1437///   from an inline `<think>…</think>` span inside the content, which is then stripped out of
1438///   the content — the same split the template performs.
1439/// - **Assistant content is `.strip()`ed; user/system content is NOT.** (The qwen arm trims
1440///   every role; copying that here would have been a silent byte divergence.)
1441/// - **Tool calls carry no separators**: one `\n` before the first, none between, one after
1442///   the last. Argument values are strings raw, everything else `json.dumps` — which is exactly
1443///   the pre-rendering `ToolCall::params` already carries.
1444/// - **A run of consecutive `tool` turns renders as ONE `<|observation|>` block**, re-ordered
1445///   onto the preceding assistant turn's `tool_calls` order when every id resolves uniquely
1446///   (`glm5_can_sort`), in message order otherwise.
1447///
1448/// NOT reproduced, because `Turn` cannot express them and no OpenAI/Anthropic/Responses request
1449/// can produce them: the native `tool_reference` content type (`<tool_response><tools>…`), the
1450/// list-of-outputs tool message shape (`m.content[i].output`), and the image/video/audio
1451/// `visible_text` arms (this server serves this model text-only). ONE deliberate divergence,
1452/// matching the step35 arm's: the vendor's body loop has no `else`, so a role outside
1453/// {user, assistant, tool, system} renders as NOTHING — the turn silently vanishes. A dropped
1454/// turn is the worse failure, so an unknown role renders here as a `<|user|>` turn. It cannot
1455/// fire from the serve surface, whose roles are exactly system/user/assistant/tool (`developer`
1456/// is normalized to `system` upstream).
1457fn apply_glm5_template(
1458    turns: &[Turn],
1459    add_generation_prompt: bool,
1460    tools_struct: &[Val],
1461    reasoning_effort: Option<&str>,
1462) -> Result<String, String> {
1463    let mut out = String::new();
1464    out.push_str("[gMASK]<sop>");
1465    out.push_str("<|system|>Reasoning Effort: ");
1466    out.push_str(glm5_effort_level(reasoning_effort)?);
1467    if !tools_struct.is_empty() {
1468        out.push_str(
1469            "<|system|>\n# Tools\n\nYou may call one or more functions to assist with the \
1470             user query.\n\nYou are provided with function signatures within <tools></tools> \
1471             XML tags:\n<tools>\n",
1472        );
1473        for f in tools_struct {
1474            out.push('\n');
1475            out.push_str(&glm5_tool_json(f));
1476            out.push_str("\n\n");
1477        }
1478        out.push_str(
1479            "\n</tools>\n\nFor each function call, output the function name and arguments \
1480             within the following XML format:\n<tool_call>{function-name}<arg_key>{arg-key-1}\
1481             </arg_key><arg_value>{arg-value-1}</arg_value><arg_key>{arg-key-2}</arg_key>\
1482             <arg_value>{arg-value-2}</arg_value>...</tool_call>",
1483        );
1484    }
1485    for (i, turn) in turns.iter().enumerate() {
1486        match turn.role.as_str() {
1487            "assistant" => {
1488                out.push_str("<|assistant|>");
1489                // `m.reasoning_content is string` first, then the inline `</think>` split —
1490                // the template's own order.
1491                let (reasoning, content) = match turn.reasoning.as_deref() {
1492                    Some(r) => (Some(r), turn.content.as_str()),
1493                    None => match turn.content.split_once("</think>") {
1494                        Some((head, tail)) => (
1495                            Some(head.rsplit("<think>").next().unwrap_or(head)),
1496                            // jinja `split('</think>')[-1]`: the LAST segment, so a second
1497                            // `</think>` inside the reply keeps only what follows it.
1498                            turn.content.rsplit("</think>").next().unwrap_or(tail),
1499                        ),
1500                        None => (None, turn.content.as_str()),
1501                    },
1502                };
1503                out.push_str("<think>");
1504                out.push_str(reasoning.unwrap_or(""));
1505                out.push_str("</think>");
1506                out.push_str(content.trim());
1507                if !turn.tool_calls.is_empty() {
1508                    out.push('\n');
1509                    for call in &turn.tool_calls {
1510                        out.push_str("<tool_call>");
1511                        out.push_str(&call.name);
1512                        for (key, value) in &call.params {
1513                            out.push_str("<arg_key>");
1514                            out.push_str(key);
1515                            out.push_str("</arg_key><arg_value>");
1516                            out.push_str(value);
1517                            out.push_str("</arg_value>");
1518                        }
1519                        out.push_str("</tool_call>");
1520                    }
1521                    out.push('\n');
1522                }
1523            }
1524            "tool" => {
1525                // Only the FIRST turn of a run emits: it renders the whole `<|observation|>`
1526                // block. The vendor's `if loop.first or previous.role != 'tool'` has no else,
1527                // so every following turn of the run renders nothing at all.
1528                if i > 0 && turns[i - 1].role == "tool" {
1529                    continue;
1530                }
1531                let end = turns[i..].iter().take_while(|t| t.role == "tool").count();
1532                let run: Vec<&Turn> = turns[i..i + end].iter().collect();
1533                let calls: &[ToolCall] = if i > 0 && turns[i - 1].role == "assistant" {
1534                    &turns[i - 1].tool_calls
1535                } else {
1536                    &[]
1537                };
1538                out.push_str("<|observation|>");
1539                if glm5_can_sort(&run, calls) {
1540                    for call in calls {
1541                        for r in &run {
1542                            if glm5_id_of(r.tool_call_id.as_deref())
1543                                == glm5_id_of(call.id.as_deref())
1544                            {
1545                                out.push_str("<tool_response>");
1546                                out.push_str(&r.content);
1547                                out.push_str("</tool_response>");
1548                            }
1549                        }
1550                    }
1551                } else {
1552                    for r in &run {
1553                        out.push_str("<tool_response>");
1554                        out.push_str(&r.content);
1555                        out.push_str("</tool_response>");
1556                    }
1557                }
1558            }
1559            "system" => {
1560                out.push_str("<|system|>");
1561                out.push_str(&turn.content);
1562            }
1563            // `user`, and the documented unknown-role divergence.
1564            _ => {
1565                out.push_str("<|user|>");
1566                out.push_str(&turn.content);
1567            }
1568        }
1569    }
1570    if add_generation_prompt {
1571        out.push_str("<|assistant|><think>");
1572    }
1573    Ok(out)
1574}
1575
1576/// A template carries a tools branch iff it has the qwen/step/HY3 `<tools>` block, the gemma4
1577/// tooluse dialect (both the `<|turn>` turn framing and the `<|tool>` declaration marker), the
1578/// dsv4 protocol, or the glm5 `<tool_call>` grammar. Shared by the renderer dispatch and the
1579/// worker caps probe.
1580pub fn template_has_tools_branch(t: &str) -> bool {
1581    template_is_dsv4(t)
1582        || template_is_glm5(t)
1583        || t.contains("<tools>")
1584        || (t.contains("<|turn>") && t.contains("<|tool>"))
1585}
1586
1587/// deepseek-v4 (`encoding_dsv4`) template detector: the `<|Assistant|>` turn prefix AND the
1588/// `|DSML|` tool-call markup token. Both are unique to the DeepSeek-V4 chat dialect (`|`
1589/// is U+FF5C, `<think>` alone would be ambiguous with the qwen class). Shared by the renderer
1590/// dispatch, the tools-branch probe, and the worker caps.
1591pub fn template_is_dsv4(t: &str) -> bool {
1592    t.contains("<\u{ff5c}Assistant\u{ff5c}>") && t.contains("\u{ff5c}DSML\u{ff5c}")
1593}
1594
1595// ---- gemma4 tooluse dialect ---------------------------------------------------------------
1596// A faithful port of research/gemma4-tools-20260817/official-tooluse-template.jinja (extracted
1597// byte-identical from the official Q8_0-MTP GGUF — the served trunk). The jinja is the LAW;
1598// byte parity is pinned by research/gemma4-tools-20260817/fixtures (the `gemma4_tools_fixtures`
1599// test in memra-server renders the official jinja under jinja2 and asserts equality). Deviation
1600// from the jinja: an unresolved tool-response name falls back to "unknown" instead of crashing
1601// on `str + None` (the jinja's `.get('name') | default('unknown')` renders None, then the
1602// concat raises) — unreachable from OpenAI histories, where the id always resolves.
1603
1604/// jinja `| dictsort`: case-insensitive by key, STABLE (ties keep insertion order).
1605fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
1606    let mut v: Vec<&(String, Val)> = pairs.iter().collect();
1607    v.sort_by_key(|a| a.0.to_lowercase());
1608    v
1609}
1610
1611/// jinja `format_argument(argument, escape_keys)`: strings wrapped in `<|"|>`, bools `true`/
1612/// `false`, mappings `{k:v,...}` (keys bare unless `escape_keys`, dictsorted, recursive),
1613/// sequences `[v,...]`, null -> `None` (jinja `{{ none }}`), numbers bare.
1614fn format_argument(v: &Val, escape_keys: bool) -> String {
1615    match v {
1616        Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
1617        Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
1618        Val::Obj(pairs) => {
1619            let mut out = String::from("{");
1620            for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1621                if i > 0 {
1622                    out.push(',');
1623                }
1624                if escape_keys {
1625                    out.push_str(&format!("<|\"|>{k}<|\"|>"));
1626                } else {
1627                    out.push_str(k);
1628                }
1629                out.push(':');
1630                out.push_str(&format_argument(val, escape_keys));
1631            }
1632            out.push('}');
1633            out
1634        }
1635        Val::Arr(items) => {
1636            let mut out = String::from("[");
1637            for (i, item) in items.iter().enumerate() {
1638                if i > 0 {
1639                    out.push(',');
1640                }
1641                out.push_str(&format_argument(item, escape_keys));
1642            }
1643            out.push(']');
1644            out
1645        }
1646        Val::Null => "None".to_string(),
1647        Val::Num(s) => s.clone(),
1648    }
1649}
1650
1651/// jinja `strip_thinking(text)`: drop every `<|channel>...<channel|>` span, then `| trim`.
1652/// Split on `<channel|>`; for each part, keep everything before a `<|channel>` (dropping the
1653/// channel body), else keep the whole part.
1654fn strip_thinking(text: &str) -> String {
1655    let mut result = String::new();
1656    for part in text.split("<channel|>") {
1657        match part.find("<|channel>") {
1658            Some(o) => result.push_str(&part[..o]),
1659            None => result.push_str(part),
1660        }
1661    }
1662    result.trim().to_string()
1663}
1664
1665fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
1666    obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1667}
1668fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
1669    match v {
1670        Val::Obj(p) => Some(p),
1671        _ => None,
1672    }
1673}
1674fn as_str(v: &Val) -> Option<&str> {
1675    match v {
1676        Val::Str(s) => Some(s),
1677        _ => None,
1678    }
1679}
1680/// jinja truthiness for `if value[...]`: None/false/""/[]/{} are falsy.
1681fn truthy(v: &Val) -> bool {
1682    match v {
1683        Val::Null => false,
1684        Val::Bool(b) => *b,
1685        Val::Str(s) => !s.is_empty(),
1686        Val::Num(s) => s != "0" && s != "0.0",
1687        Val::Arr(a) => !a.is_empty(),
1688        Val::Obj(o) => !o.is_empty(),
1689    }
1690}
1691
1692/// jinja comma helper: emit ',' iff a prior element was written in THIS property object, then
1693/// mark that at least one has been written.
1694fn comma(out: &mut String, add: &mut bool) {
1695    if *add {
1696        out.push(',');
1697    } else {
1698        *add = true;
1699    }
1700}
1701
1702/// jinja `format_parameters(properties, _required_unused, filter_keys)`. The second jinja arg
1703/// (`required`) is never referenced in the macro body, so it is dropped here.
1704fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
1705    const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
1706    let mut found_first = false;
1707    for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
1708        if filter_keys && STANDARD.contains(&key.as_str()) {
1709            continue;
1710        }
1711        if found_first {
1712            out.push(',');
1713        }
1714        found_first = true;
1715        out.push_str(key);
1716        out.push_str(":{");
1717        let vobj = as_obj(value);
1718        let mut add = false;
1719        // description
1720        if let Some(d) = vobj
1721            .and_then(|o| val_get(o, "description"))
1722            .filter(|d| truthy(d))
1723        {
1724            out.push_str("description:<|\"|>");
1725            out.push_str(as_str(d).unwrap_or(""));
1726            out.push_str("<|\"|>");
1727            add = true;
1728        }
1729        let ty_up = vobj
1730            .and_then(|o| val_get(o, "type"))
1731            .and_then(as_str)
1732            .map(|s| s.to_uppercase());
1733        match ty_up.as_deref() {
1734            Some("STRING") => {
1735                if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
1736                    comma(out, &mut add);
1737                    out.push_str("enum:");
1738                    out.push_str(&format_argument(en, true));
1739                }
1740            }
1741            Some("ARRAY") => {
1742                if let Some(items) = vobj
1743                    .and_then(|o| val_get(o, "items"))
1744                    .filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
1745                {
1746                    comma(out, &mut add);
1747                    out.push_str("items:{");
1748                    format_items(out, as_obj(items).unwrap());
1749                    out.push('}');
1750                }
1751            }
1752            _ => {}
1753        }
1754        // nullable
1755        if vobj
1756            .and_then(|o| val_get(o, "nullable"))
1757            .is_some_and(truthy)
1758        {
1759            comma(out, &mut add);
1760            out.push_str("nullable:true");
1761        }
1762        // OBJECT: nested properties + required
1763        if ty_up.as_deref() == Some("OBJECT") {
1764            if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
1765                comma(out, &mut add);
1766                out.push_str("properties:{");
1767                format_parameters(out, sub, false);
1768                out.push('}');
1769            } else if let Some(o) = vobj {
1770                // no explicit `properties`: treat the value's own keys as sub-properties,
1771                // filtering the standard schema keys (jinja `filter_keys=true` branch).
1772                comma(out, &mut add);
1773                out.push_str("properties:{");
1774                format_parameters(out, o, true);
1775                out.push('}');
1776            }
1777            if let Some(req) = vobj
1778                .and_then(|o| val_get(o, "required"))
1779                .filter(|r| truthy(r))
1780            {
1781                comma(out, &mut add);
1782                out.push_str("required:[");
1783                push_str_list(out, req);
1784                out.push(']');
1785            }
1786        }
1787        // closing `type:<|"|>UPPER<|"|>}` (always) — carries a leading comma iff anything above.
1788        comma(out, &mut add);
1789        out.push_str("type:<|\"|>");
1790        out.push_str(ty_up.as_deref().unwrap_or(""));
1791        out.push_str("<|\"|>}");
1792    }
1793}
1794
1795/// The ARRAY `items` mapping loop: dictsorts item keys, skips None values, and renders
1796/// properties/required/type specially, else generic `key:format_argument(value)`.
1797fn format_items(out: &mut String, items: &[(String, Val)]) {
1798    let mut found_first = false;
1799    for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
1800        if matches!(v, Val::Null) {
1801            continue;
1802        }
1803        if found_first {
1804            out.push(',');
1805        }
1806        found_first = true;
1807        match k.as_str() {
1808            "properties" => {
1809                out.push_str("properties:{");
1810                if let Some(o) = as_obj(v) {
1811                    format_parameters(out, o, false);
1812                }
1813                out.push('}');
1814            }
1815            "required" => {
1816                out.push_str("required:[");
1817                push_str_list(out, v);
1818                out.push(']');
1819            }
1820            "type" => {
1821                out.push_str("type:");
1822                match v {
1823                    Val::Str(s) => {
1824                        out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
1825                    }
1826                    Val::Arr(a) => {
1827                        let upper: Vec<Val> = a
1828                            .iter()
1829                            .map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
1830                            .collect();
1831                        out.push_str(&format_argument(&Val::Arr(upper), true));
1832                    }
1833                    other => out.push_str(&format_argument(other, true)),
1834                }
1835            }
1836            _ => {
1837                out.push_str(k);
1838                out.push(':');
1839                out.push_str(&format_argument(v, true));
1840            }
1841        }
1842    }
1843}
1844
1845/// `[<|"|>a<|"|>,<|"|>b<|"|>]` body (without the brackets) from a Val::Arr of strings.
1846fn push_str_list(out: &mut String, v: &Val) {
1847    if let Val::Arr(items) = v {
1848        for (i, item) in items.iter().enumerate() {
1849            if i > 0 {
1850                out.push(',');
1851            }
1852            out.push_str("<|\"|>");
1853            out.push_str(as_str(item).unwrap_or(""));
1854            out.push_str("<|\"|>");
1855        }
1856    }
1857}
1858
1859/// jinja `format_function_declaration(tool_data)` — `func` is the tool's `function` object.
1860fn format_function_declaration(func: &[(String, Val)]) -> String {
1861    let mut out = String::new();
1862    out.push_str("declaration:");
1863    out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
1864    out.push_str("{description:<|\"|>");
1865    out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
1866    out.push_str("<|\"|>");
1867    if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
1868        let pobj = as_obj(params);
1869        out.push_str(",parameters:{");
1870        if let Some(props) = pobj
1871            .and_then(|o| val_get(o, "properties"))
1872            .filter(|p| truthy(p))
1873            .and_then(as_obj)
1874        {
1875            out.push_str("properties:{");
1876            format_parameters(&mut out, props, false);
1877            out.push_str("},");
1878        }
1879        if let Some(req) = pobj
1880            .and_then(|o| val_get(o, "required"))
1881            .filter(|r| truthy(r))
1882        {
1883            out.push_str("required:[");
1884            push_str_list(&mut out, req);
1885            out.push_str("],");
1886        }
1887        if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
1888            out.push_str("type:<|\"|>");
1889            out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
1890            out.push_str("<|\"|>}");
1891        }
1892    }
1893    if let Some(resp) = val_get(func, "response").and_then(as_obj) {
1894        out.push_str(",response:{");
1895        if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
1896            out.push_str("description:<|\"|>");
1897            out.push_str(as_str(d).unwrap_or(""));
1898            out.push_str("<|\"|>,");
1899        }
1900        if val_get(resp, "type")
1901            .and_then(as_str)
1902            .map(|s| s.to_uppercase())
1903            == Some("OBJECT".into())
1904        {
1905            out.push_str("type:<|\"|>OBJECT<|\"|>}");
1906        }
1907    }
1908    out.push('}');
1909    out
1910}
1911
1912/// jinja `format_tool_response_block(tool_name, response)`.
1913fn format_tool_response_block(name: &str, response: &Val) -> String {
1914    let mut out = String::from("<|tool_response>");
1915    match response {
1916        Val::Obj(pairs) => {
1917            out.push_str("response:");
1918            out.push_str(name);
1919            out.push('{');
1920            for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1921                if i > 0 {
1922                    out.push(',');
1923                }
1924                out.push_str(k);
1925                out.push(':');
1926                out.push_str(&format_argument(v, false));
1927            }
1928            out.push('}');
1929        }
1930        other => {
1931            out.push_str("response:");
1932            out.push_str(name);
1933            out.push_str("{value:");
1934            out.push_str(&format_argument(other, false));
1935            out.push('}');
1936        }
1937    }
1938    out.push_str("<tool_response|>");
1939    out
1940}
1941
1942/// gemma4 tooluse renderer. `tools` are the tool `function` objects; `thinking` = jinja
1943/// `enable_thinking`; `closed_tail` = the QAT-trunk variant that emits a closed thought
1944/// channel on the thinking-off generation prompt (the official served trunk does not). BOS is
1945/// NOT emitted (encode(add_special) supplies it — the jinja's `{{ bos_token }}` is dropped).
1946fn apply_gemma4_tools_template(
1947    turns: &[Turn],
1948    add_generation_prompt: bool,
1949    tools: &[Val],
1950    thinking: bool,
1951    closed_tail: bool,
1952) -> String {
1953    let mut out = String::new();
1954    let mut prev: Option<&str> = None;
1955    let mut msgs = turns;
1956    let is_sys = |r: &str| r == "system" || r == "developer";
1957
1958    let leading_system = msgs.first().filter(|t| is_sys(&t.role));
1959    if thinking || !tools.is_empty() || leading_system.is_some() {
1960        out.push_str("<|turn>system\n");
1961        if thinking {
1962            out.push_str("<|think|>\n");
1963            prev = Some("think");
1964        }
1965        if let Some(sys) = leading_system {
1966            out.push_str(sys.content.trim());
1967            msgs = &msgs[1..];
1968        }
1969        for tool in tools {
1970            out.push_str("<|tool>");
1971            if let Some(func) = as_obj(tool) {
1972                out.push_str(format_function_declaration(func).trim());
1973            }
1974            out.push_str("<tool|>");
1975        }
1976        if !tools.is_empty() {
1977            prev = Some("tool");
1978        }
1979        out.push_str("<turn|>\n");
1980    }
1981
1982    let last_user_idx: isize = msgs
1983        .iter()
1984        .enumerate()
1985        .rev()
1986        .find(|(_, t)| t.role == "user")
1987        .map(|(i, _)| i as isize)
1988        .unwrap_or(-1);
1989
1990    for (i, m) in msgs.iter().enumerate() {
1991        if m.role == "tool" {
1992            continue; // consumed by a preceding assistant's forward-scan
1993        }
1994        prev = None;
1995        let role = if m.role == "assistant" {
1996            "model"
1997        } else {
1998            m.role.as_str()
1999        };
2000        let prev_nt_role = (0..i)
2001            .rev()
2002            .map(|j| &msgs[j])
2003            .find(|t| t.role != "tool")
2004            .map(|t| t.role.as_str());
2005        let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
2006        if !continue_same_model_turn {
2007            out.push_str("<|turn>");
2008            out.push_str(role);
2009            out.push('\n');
2010        }
2011
2012        // reasoning re-render (tool_calls-carrying assistant after the last user turn)
2013        if let Some(rt) = m.reasoning.as_deref()
2014            && !rt.is_empty()
2015            && (i as isize) > last_user_idx
2016            && !m.tool_calls.is_empty()
2017        {
2018            out.push_str("<|channel>thought\n");
2019            out.push_str(rt);
2020            out.push_str("\n<channel|>");
2021        }
2022
2023        // tool_calls
2024        if !m.tool_calls.is_empty() {
2025            for tc in &m.tool_calls {
2026                out.push_str("<|tool_call>call:");
2027                out.push_str(&tc.name);
2028                out.push('{');
2029                for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
2030                    if j > 0 {
2031                        out.push(',');
2032                    }
2033                    out.push_str(k);
2034                    out.push(':');
2035                    out.push_str(&format_argument(v, false));
2036                }
2037                out.push_str("}<tool_call|>");
2038            }
2039            prev = Some("tool_call");
2040        }
2041
2042        // tool responses: native (Google) on the assistant, else OpenAI role:"tool" forward-scan
2043        let mut tr_flag = false;
2044        if !m.tool_responses.is_empty() {
2045            for (name, resp) in &m.tool_responses {
2046                out.push_str(&format_tool_response_block(name, resp));
2047                tr_flag = true;
2048                prev = Some("tool_response");
2049            }
2050        } else if !m.tool_calls.is_empty() {
2051            #[allow(clippy::needless_range_loop)]
2052            // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
2053            for k in (i + 1)..msgs.len() {
2054                let follow = &msgs[k];
2055                if follow.role != "tool" {
2056                    break;
2057                }
2058                let mut name = follow
2059                    .tool_name
2060                    .clone()
2061                    .unwrap_or_else(|| "unknown".to_string());
2062                if let Some(fid) = follow.tool_call_id.as_deref() {
2063                    for tc in &m.tool_calls {
2064                        if tc.id.as_deref() == Some(fid) {
2065                            name = tc.name.clone();
2066                        }
2067                    }
2068                }
2069                out.push_str(&format_tool_response_block(
2070                    &name,
2071                    &Val::Str(follow.content.clone()),
2072                ));
2073                tr_flag = true;
2074                prev = Some("tool_response");
2075            }
2076        }
2077
2078        // content (model content strips thought channels; other roles trim)
2079        let captured = if role == "model" {
2080            strip_thinking(&m.content)
2081        } else {
2082            m.content.trim().to_string()
2083        };
2084        out.push_str(&captured);
2085        let has_content = !captured.trim().is_empty();
2086
2087        if prev == Some("tool_call") && !tr_flag {
2088            out.push_str("<|tool_response>"); // dangling open: calls with no responses yet
2089        } else if !(tr_flag && !has_content) {
2090            out.push_str("<turn|>\n");
2091        }
2092    }
2093
2094    if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
2095        out.push_str("<|turn>model\n");
2096        if closed_tail && !thinking {
2097            out.push_str("<|channel>thought\n<channel|>");
2098        }
2099    }
2100    out
2101}
2102
2103// ---- deepseek-v4 (encoding_dsv4) dialect --------------------------------------------------
2104// A faithful port of encoding_dsv4.py in BOTH shipped revisions: the preview oracle
2105// (research/dsv4-template-20260818/ref/encoding/encoding_dsv4.py, sha256 bdbd57c1…) and the
2106// 0731 oracle (…/ref-0731/encoding/encoding_dsv4.py, sha256 abc0d261…), which differ ONLY in
2107// the reasoning-effort ladder (full behavioral diff: ENCODING-DIFF.md; selection law:
2108// `Dsv4Encoding`). The python IS the law; byte parity is pinned by
2109// research/dsv4-template-20260818/fixtures (preview matrix) + fixtures-0731 (0731 matrix)
2110// plus the artifact's authoritative encoding/tests/test_output_{1..4} (byte-identical across
2111// both revisions). See TEMPLATE-SEMANTICS.md for the census + banked ambiguities. Deviation
2112// from the python: none in the renderer (the parser deviates on malformed spans per house
2113// policy — see toolcall.rs).
2114
2115// U+FF5C is the fullwidth vertical line `|` in every DeepSeek special token; U+2581 the ▁.
2116const DS_BOS: &str = "<\u{ff5c}begin\u{2581}of\u{2581}sentence\u{ff5c}>";
2117const DS_EOS: &str = "<\u{ff5c}end\u{2581}of\u{2581}sentence\u{ff5c}>";
2118const DS_USER: &str = "<\u{ff5c}User\u{ff5c}>";
2119const DS_ASSISTANT: &str = "<\u{ff5c}Assistant\u{ff5c}>";
2120const DS_REMINDER: &str = "<\u{ff5c}latest_reminder\u{ff5c}>";
2121const DS_THINK_START: &str = "<think>";
2122const DS_THINK_END: &str = "</think>";
2123const DS_DSML: &str = "\u{ff5c}DSML\u{ff5c}";
2124// preview encoding_dsv4 REASONING_EFFORT_MAX (E:64-68) == 0731 REASONING_EFFORT_PROMPTS["high"]
2125// (0731 E:64-77 — same bytes, one ladder rung lower). Ends with "\n\n".
2126const 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";
2127// 0731 REASONING_EFFORT_PROMPTS["max"] (0731 E:70-75) — the new, stronger top rung. The dash
2128// is U+2014 EM DASH in the source; ends with "\n\n". Not present in the preview encoding.
2129const 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";
2130
2131/// The reasoning-effort prompt prefix for one render (encoding_dsv4 preview E:260-263 /
2132/// 0731 E:270-277). `Ok("")` = no prefix. Errs ONLY on the ambiguous cell: an effort level
2133/// whose bytes differ between the two encodings (`"high"`/`"max"` in thinking mode) with no
2134/// encoding revision supplied — every other input renders identically under both revisions,
2135/// so it stays infallible there (the legacy no-effort dispatch relies on that).
2136///
2137/// Levels outside the encoding's accepted set (e.g. OpenAI "medium", which neither revision
2138/// defines) render as the default level, i.e. no prefix — the renderer never corrupts a
2139/// prompt over a knob the template does not consume (hy3 medium-clamp precedent).
2140fn dsv4_effort_prefix(
2141    thinking: bool,
2142    effort: Option<&str>,
2143    encoding: Option<Dsv4Encoding>,
2144) -> Result<&'static str, String> {
2145    if !thinking {
2146        // chat mode: no prefix under either encoding (preview E:262 / 0731 E:275 both gate
2147        // on thinking_mode == "thinking").
2148        return Ok("");
2149    }
2150    match effort {
2151        // None: preview renders nothing; 0731 defaults None -> "low" -> "" (E:271, E:66).
2152        // "low": 0731 default rung (no prefix); the preview oracle rejects the string, and
2153        // rendering no prefix is the only never-corrupt reading (banked, ENCODING-DIFF.md).
2154        None | Some("low") => Ok(""),
2155        Some("high") => match encoding {
2156            Some(Dsv4Encoding::Preview) => Ok(""), // preview law: "high" == None (E:261-263)
2157            Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_ABSOLUTE_MAX),
2158            None => Err(
2159                "dsv4 reasoning_effort \"high\" renders differently on the preview vs 0731 \
2160                 encoding and this artifact's encoding revision is unknown (config.json \
2161                 dspark_* census unavailable) — refusing rather than guessing"
2162                    .into(),
2163            ),
2164        },
2165        Some("max") => match encoding {
2166            Some(Dsv4Encoding::Preview) => Ok(DS_EFFORT_ABSOLUTE_MAX),
2167            Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_BEYOND_MAX),
2168            None => Err(
2169                "dsv4 reasoning_effort \"max\" renders differently on the preview vs 0731 \
2170                 encoding and this artifact's encoding revision is unknown (config.json \
2171                 dspark_* census unavailable) — refusing rather than guessing"
2172                    .into(),
2173            ),
2174        },
2175        Some(_) => Ok(""),
2176    }
2177}
2178
2179/// encoding_dsv4 DS_TASK_SP_TOKENS (E:28-35). The task token for a quick-instruction head.
2180fn ds_task_token(task: &str) -> Option<&'static str> {
2181    match task {
2182        "action" => Some("<\u{ff5c}action\u{ff5c}>"),
2183        "query" => Some("<\u{ff5c}query\u{ff5c}>"),
2184        "authority" => Some("<\u{ff5c}authority\u{ff5c}>"),
2185        "domain" => Some("<\u{ff5c}domain\u{ff5c}>"),
2186        "title" => Some("<\u{ff5c}title\u{ff5c}>"),
2187        "read_url" => Some("<\u{ff5c}read_url\u{ff5c}>"),
2188        _ => None,
2189    }
2190}
2191
2192/// python `json.dumps(v, ensure_ascii=False)` over a `Val` (encoding_dsv4 `to_json`, E:101-106):
2193/// default separators `", "` / `": "`, insertion key order, non-ASCII raw, `Num` exact text.
2194/// serde-free (this crate ships no serde) — the escaper below matches json.dumps exactly.
2195///
2196/// SHARED by the dsv4 arm (which named it) and the GLM-5.3-Flash arm: both templates render
2197/// their tool JSON through jinja's `tojson`, which `transformers` binds to exactly this call.
2198fn py_json(v: &Val, out: &mut String) {
2199    match v {
2200        Val::Null => out.push_str("null"),
2201        Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2202        Val::Num(s) => out.push_str(s),
2203        Val::Str(s) => {
2204            out.push('"');
2205            py_json_escape(s, out);
2206            out.push('"');
2207        }
2208        Val::Arr(a) => {
2209            out.push('[');
2210            for (i, x) in a.iter().enumerate() {
2211                if i > 0 {
2212                    out.push_str(", ");
2213                }
2214                py_json(x, out);
2215            }
2216            out.push(']');
2217        }
2218        Val::Obj(o) => {
2219            out.push('{');
2220            for (i, (k, val)) in o.iter().enumerate() {
2221                if i > 0 {
2222                    out.push_str(", ");
2223                }
2224                out.push('"');
2225                py_json_escape(k, out);
2226                out.push_str("\": ");
2227                py_json(val, out);
2228            }
2229            out.push('}');
2230        }
2231    }
2232}
2233
2234/// JSON string escaping matching python `json.dumps(ensure_ascii=False)`: `"` `\` and the
2235/// C0 escapes; other control chars < 0x20 become `\u00xx`; everything else (incl. non-ASCII)
2236/// passes through raw. json.dumps does NOT escape `/` or DEL.
2237fn py_json_escape(s: &str, out: &mut String) {
2238    for c in s.chars() {
2239        match c {
2240            '"' => out.push_str("\\\""),
2241            '\\' => out.push_str("\\\\"),
2242            '\n' => out.push_str("\\n"),
2243            '\r' => out.push_str("\\r"),
2244            '\t' => out.push_str("\\t"),
2245            '\u{8}' => out.push_str("\\b"),
2246            '\u{c}' => out.push_str("\\f"),
2247            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
2248            c => out.push(c),
2249        }
2250    }
2251}
2252
2253/// encoding_dsv4 `render_tools` (E:189-206) + TOOLS_TEMPLATE (E:70-95): the tool-declaration
2254/// block appended to a system/developer turn. `funcs` are the tool `function` objects
2255/// (encoding_dsv4 `tools_from_openai_format`). Ends with a trailing `\n`.
2256fn dsv4_render_tools(funcs: &[Val]) -> String {
2257    let mut schemas = String::new();
2258    for (i, f) in funcs.iter().enumerate() {
2259        if i > 0 {
2260            schemas.push('\n');
2261        }
2262        py_json(f, &mut schemas);
2263    }
2264    format!(
2265        "## Tools\n\nYou have access to a set of tools to help answer the user's question. \
2266You can invoke tools by writing a \"<{d}tool_calls>\" block like the following:\n\n\
2267<{d}tool_calls>\n<{d}invoke name=\"$TOOL_NAME\">\n\
2268<{d}parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</{d}parameter>\n\
2269...\n</{d}invoke>\n<{d}invoke name=\"$TOOL_NAME2\">\n...\n</{d}invoke>\n</{d}tool_calls>\n\n\
2270String parameters should be specified as is and set `string=\"true\"`. For all other types \
2271(numbers, booleans, arrays, objects), pass the value in JSON format and set `string=\"false\"`.\
2272\n\nIf thinking_mode is enabled (triggered by {ts}), you MUST output your complete reasoning \
2273inside {ts}...{te} BEFORE any tool calls or final response.\n\nOtherwise, output directly \
2274after {te} with tool calls or final response.\n\n### Available Tool Schemas\n\n{schemas}\n\n\
2275You MUST strictly follow the above defined tool name and parameter schemas to invoke tool \
2276calls.\n",
2277        d = DS_DSML,
2278        ts = DS_THINK_START,
2279        te = DS_THINK_END,
2280        schemas = schemas,
2281    )
2282}
2283
2284/// One assistant tool_calls block (encoding_dsv4 E:52-58, E:139-166, E:323-336): the `\n\n`
2285/// prefix + `<|DSML|tool_calls>` wrapper + one `<|DSML|invoke>` per call, each argument a
2286/// `<|DSML|parameter>` line (string values raw with `string="true"`, everything else
2287/// json.dumps'd with `string="false"`). Argument order = insertion order (NO dictsort).
2288fn dsv4_render_tool_calls(calls: &[ToolCall]) -> String {
2289    let mut invokes = String::new();
2290    for (i, call) in calls.iter().enumerate() {
2291        if i > 0 {
2292            invokes.push('\n');
2293        }
2294        invokes.push_str(&format!(
2295            "<{d}invoke name=\"{n}\">\n",
2296            d = DS_DSML,
2297            n = call.name
2298        ));
2299        for (j, (k, v)) in call.args.iter().enumerate() {
2300            if j > 0 {
2301                invokes.push('\n');
2302            }
2303            let is_str = matches!(v, Val::Str(_));
2304            invokes.push_str(&format!(
2305                "<{d}parameter name=\"{k}\" string=\"{b}\">",
2306                d = DS_DSML,
2307                k = k,
2308                b = if is_str { "true" } else { "false" },
2309            ));
2310            match v {
2311                Val::Str(s) => invokes.push_str(s),
2312                other => py_json(other, &mut invokes),
2313            }
2314            invokes.push_str(&format!("</{d}parameter>", d = DS_DSML));
2315        }
2316        invokes.push_str(&format!("\n</{d}invoke>", d = DS_DSML));
2317    }
2318    format!(
2319        "\n\n<{d}tool_calls>\n{invokes}\n</{d}tool_calls>",
2320        d = DS_DSML,
2321        invokes = invokes
2322    )
2323}
2324
2325/// One merged content block on a user turn (encoding_dsv4 content_blocks, E:289-309).
2326enum DsBlock {
2327    Text(String),
2328    ToolResult {
2329        content: String,
2330        tool_use_id: String,
2331    },
2332}
2333
2334/// One preprocessed message (post merge_tool_messages / sort). `blocks` is Some for user
2335/// turns (a merged run of user text + tool results); other roles carry `content`.
2336struct DsMsg {
2337    role: String,
2338    content: String,
2339    blocks: Option<Vec<DsBlock>>,
2340    reasoning: String,
2341    tool_calls: Vec<ToolCall>,
2342    tools: Vec<Val>,
2343    task: Option<String>,
2344}
2345
2346/// encoding_dsv4 `merge_tool_messages` (E:401-457): fold role:"tool" turns and consecutive
2347/// user turns into single `<|User|>` turns carrying `content_blocks`. `req_tools` are the
2348/// request-level tool `function` objects attached to the LEADING system turn (matching the
2349/// serve surface; a synthetic empty system turn is created when tools exist with no system
2350/// turn — the oracle's render of {"role":"system","content":"","tools":[...]}). A turn's own
2351/// `tools` (fixture harness, e.g. tools on a developer message) take precedence.
2352fn dsv4_merge(turns: &[Turn], req_tools: &[Val]) -> Vec<DsMsg> {
2353    let mut merged: Vec<DsMsg> = Vec::new();
2354    let any_turn_tools = turns.iter().any(|t| !t.tools.is_empty());
2355    // Serve surface: request-level tools ride the leading system turn (or a synthetic one).
2356    let mut leading_tools_pending = !req_tools.is_empty() && !any_turn_tools;
2357    if leading_tools_pending && !turns.first().map(|t| t.role == "system").unwrap_or(false) {
2358        merged.push(DsMsg {
2359            role: "system".into(),
2360            content: String::new(),
2361            blocks: None,
2362            reasoning: String::new(),
2363            tool_calls: Vec::new(),
2364            tools: req_tools.to_vec(),
2365            task: None,
2366        });
2367        leading_tools_pending = false;
2368    }
2369    for turn in turns {
2370        match turn.role.as_str() {
2371            "tool" => {
2372                let block = DsBlock::ToolResult {
2373                    content: turn.content.clone(),
2374                    tool_use_id: turn.tool_call_id.clone().unwrap_or_default(),
2375                };
2376                match merged.last_mut() {
2377                    Some(m) if m.role == "user" && m.blocks.is_some() => {
2378                        m.blocks.as_mut().unwrap().push(block);
2379                    }
2380                    _ => merged.push(DsMsg {
2381                        role: "user".into(),
2382                        content: String::new(),
2383                        blocks: Some(vec![block]),
2384                        reasoning: String::new(),
2385                        tool_calls: Vec::new(),
2386                        tools: Vec::new(),
2387                        task: None,
2388                    }),
2389                }
2390            }
2391            "user" => {
2392                let text = DsBlock::Text(turn.content.clone());
2393                match merged.last_mut() {
2394                    Some(m) if m.role == "user" && m.blocks.is_some() && m.task.is_none() => {
2395                        m.blocks.as_mut().unwrap().push(text);
2396                    }
2397                    _ => merged.push(DsMsg {
2398                        role: "user".into(),
2399                        content: turn.content.clone(),
2400                        blocks: Some(vec![text]),
2401                        reasoning: String::new(),
2402                        tool_calls: Vec::new(),
2403                        tools: turn.tools.clone(),
2404                        task: turn.task.clone(),
2405                    }),
2406                }
2407            }
2408            role => {
2409                let mut tools = turn.tools.clone();
2410                if role == "system" && leading_tools_pending && merged.is_empty() {
2411                    tools = req_tools.to_vec();
2412                    leading_tools_pending = false;
2413                }
2414                merged.push(DsMsg {
2415                    role: role.to_string(),
2416                    content: turn.content.clone(),
2417                    blocks: None,
2418                    reasoning: turn.reasoning.clone().unwrap_or_default(),
2419                    tool_calls: turn.tool_calls.clone(),
2420                    tools,
2421                    task: turn.task.clone(),
2422                });
2423            }
2424        }
2425    }
2426    merged
2427}
2428
2429/// encoding_dsv4 `sort_tool_results_by_call_order` (E:460-499): within a user turn holding
2430/// more than one tool_result block, order those blocks by the preceding assistant's
2431/// tool_calls id order (stable; an unknown id sorts as 0). Non-tool block positions are kept.
2432#[allow(clippy::needless_range_loop)] // indexed: reads earlier turns' order, mutates msgs[i]
2433fn dsv4_sort_tool_results(msgs: &mut [DsMsg]) {
2434    let mut order: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2435    // walk without holding an immutable borrow across the mutable block edit.
2436    for i in 0..msgs.len() {
2437        if msgs[i].role == "assistant" && !msgs[i].tool_calls.is_empty() {
2438            order.clear();
2439            for (idx, tc) in msgs[i].tool_calls.iter().enumerate() {
2440                if let Some(id) = tc.id.as_deref()
2441                    && !id.is_empty()
2442                {
2443                    order.insert(id.to_string(), idx);
2444                }
2445            }
2446        } else if msgs[i].role == "user" {
2447            let n_tool = msgs[i]
2448                .blocks
2449                .as_ref()
2450                .map(|b| {
2451                    b.iter()
2452                        .filter(|x| matches!(x, DsBlock::ToolResult { .. }))
2453                        .count()
2454                })
2455                .unwrap_or(0);
2456            if n_tool > 1 && !order.is_empty() {
2457                let blocks = msgs[i].blocks.take().unwrap();
2458                // stable sort the tool_result blocks by call order; keep others in place.
2459                let mut tool_blocks: Vec<DsBlock> = Vec::new();
2460                let mut positions: Vec<bool> = Vec::new(); // true = tool_result slot
2461                let mut others: Vec<DsBlock> = Vec::new();
2462                for b in blocks {
2463                    match b {
2464                        DsBlock::ToolResult { .. } => {
2465                            positions.push(true);
2466                            tool_blocks.push(b);
2467                        }
2468                        other => {
2469                            positions.push(false);
2470                            others.push(other);
2471                        }
2472                    }
2473                }
2474                tool_blocks.sort_by_key(|b| match b {
2475                    DsBlock::ToolResult { tool_use_id, .. } => {
2476                        *order.get(tool_use_id).unwrap_or(&0)
2477                    }
2478                    _ => 0,
2479                });
2480                let mut ti = tool_blocks.into_iter();
2481                let mut oi = others.into_iter();
2482                let rebuilt: Vec<DsBlock> = positions
2483                    .into_iter()
2484                    .map(|is_tool| {
2485                        if is_tool {
2486                            ti.next().unwrap()
2487                        } else {
2488                            oi.next().unwrap()
2489                        }
2490                    })
2491                    .collect();
2492                msgs[i].blocks = Some(rebuilt);
2493            }
2494        }
2495    }
2496}
2497
2498/// index of the last user/developer message (encoding_dsv4 `find_last_user_index`, E:209-216).
2499fn dsv4_last_user_idx(msgs: &[DsMsg]) -> isize {
2500    for i in (0..msgs.len()).rev() {
2501        if msgs[i].role == "user" || msgs[i].role == "developer" {
2502            return i as isize;
2503        }
2504    }
2505    -1
2506}
2507
2508/// encoding_dsv4 `_drop_thinking_messages` (E:575-599): keep user/system/latest_reminder and
2509/// everything at/after the last user; strip reasoning from earlier assistants; drop earlier
2510/// developer (and other) turns entirely. Runs only in thinking mode with no tools declared.
2511fn dsv4_drop_thinking(msgs: Vec<DsMsg>) -> Vec<DsMsg> {
2512    let last = dsv4_last_user_idx(&msgs);
2513    let mut out = Vec::with_capacity(msgs.len());
2514    for (i, mut m) in msgs.into_iter().enumerate() {
2515        let keep_role = matches!(
2516            m.role.as_str(),
2517            "user" | "system" | "latest_reminder" | "direct_search_results"
2518        );
2519        if keep_role || (i as isize) >= last {
2520            out.push(m);
2521        } else if m.role == "assistant" {
2522            m.reasoning.clear();
2523            out.push(m);
2524        }
2525        // developer + others before the last user are dropped.
2526    }
2527    out
2528}
2529
2530/// Full port of encoding_dsv4 `encode_messages` (E:506-572) + `render_message` (E:223-394),
2531/// covering BOTH shipped encoding revisions (they differ only in the effort ladder — see
2532/// `Dsv4Encoding`).
2533///
2534/// ThinkMode maps onto encoding_dsv4's (thinking_mode, reasoning_effort):
2535///
2536///   - `Default` → thinking (the model has no template-own default; thinking_mode is a
2537///     REQUIRED arg and the README example + the model's agentic positioning make thinking
2538///     the honest default — see TEMPLATE-SEMANTICS.md finding #1);
2539///   - `Think`   → thinking;
2540///   - `NoThink` → chat (the DeepSeek "Non-think" mode: `<|Assistant|></think>`).
2541///
2542/// The `reasoning_effort` string resolves through `dsv4_effort_prefix` per the artifact's
2543/// `encoding` revision (preview: "max" prefix only, "high" a documented no-op; 0731:
2544/// low/high/max ladder). `Err` ONLY when the requested (thinking, effort) cell renders
2545/// differently across revisions and `encoding` is `None` — the refuse-on-ambiguity law.
2546/// On the serve path the encoding rides the `Tokenizer` (config.json dspark_* census at
2547/// `from_hf_dir`); the HTTP layer forwards the OpenAI level for dsv4 models
2548/// (`ModelCaps::dsv4`), so "high" now reaches the 0731 ladder for real.
2549///
2550/// `req_tools` are the request-level tool `function` objects (attached to the leading system
2551/// turn); `add_generation_prompt` gates ONLY the final-message generation-prompt transition
2552/// (mid-conversation continuation transitions are always emitted, matching the python's
2553/// unconditional transition law).
2554fn apply_dsv4_template(
2555    turns: &[Turn],
2556    add_generation_prompt: bool,
2557    req_tools: &[Val],
2558    think: ThinkMode,
2559    reasoning_effort: Option<&str>,
2560    encoding: Option<Dsv4Encoding>,
2561) -> Result<String, String> {
2562    let thinking = think != ThinkMode::NoThink; // Default + Think -> thinking; NoThink -> chat
2563    let effort_prefix = dsv4_effort_prefix(thinking, reasoning_effort, encoding)?;
2564
2565    let mut msgs = dsv4_merge(turns, req_tools);
2566    dsv4_sort_tool_results(&mut msgs);
2567    // effective drop_thinking: default True, auto-disabled when any message declares tools.
2568    let any_tools = msgs.iter().any(|m| !m.tools.is_empty());
2569    let effective_drop = !any_tools;
2570    if thinking && effective_drop {
2571        msgs = dsv4_drop_thinking(msgs);
2572    }
2573    let last_user = dsv4_last_user_idx(&msgs);
2574    let n = msgs.len();
2575
2576    let mut out = String::from(DS_BOS);
2577    for idx in 0..n {
2578        let m = &msgs[idx];
2579        if idx == 0 {
2580            // effort prefix before the first rendered message (preview E:262-263 / 0731
2581            // E:275-277); "" when no prefix applies, so this is a no-op push then.
2582            out.push_str(effort_prefix);
2583        }
2584        match m.role.as_str() {
2585            "system" => {
2586                out.push_str(&m.content);
2587                if !m.tools.is_empty() {
2588                    out.push_str("\n\n");
2589                    out.push_str(&dsv4_render_tools(&m.tools));
2590                }
2591            }
2592            "developer" => {
2593                out.push_str(DS_USER);
2594                out.push_str(&m.content);
2595                if !m.tools.is_empty() {
2596                    out.push_str("\n\n");
2597                    out.push_str(&dsv4_render_tools(&m.tools));
2598                }
2599            }
2600            "user" => {
2601                out.push_str(DS_USER);
2602                if let Some(blocks) = &m.blocks {
2603                    for (i, b) in blocks.iter().enumerate() {
2604                        if i > 0 {
2605                            out.push_str("\n\n");
2606                        }
2607                        match b {
2608                            DsBlock::Text(t) => out.push_str(t),
2609                            DsBlock::ToolResult { content, .. } => {
2610                                out.push_str("<tool_result>");
2611                                out.push_str(content);
2612                                out.push_str("</tool_result>");
2613                            }
2614                        }
2615                    }
2616                } else {
2617                    out.push_str(&m.content);
2618                }
2619            }
2620            "latest_reminder" => {
2621                out.push_str(DS_REMINDER);
2622                out.push_str(&m.content);
2623            }
2624            "assistant" => {
2625                let prev_has_task = idx > 0 && msgs[idx - 1].task.is_some();
2626                let mut thinking_part = String::new();
2627                if thinking && !prev_has_task && (!effective_drop || (idx as isize) > last_user) {
2628                    thinking_part.push_str(&m.reasoning);
2629                    thinking_part.push_str(DS_THINK_END);
2630                }
2631                out.push_str(&thinking_part);
2632                out.push_str(&m.content);
2633                if !m.tool_calls.is_empty() {
2634                    out.push_str(&dsv4_render_tool_calls(&m.tool_calls));
2635                }
2636                out.push_str(DS_EOS);
2637            }
2638            _ => {} // direct_search_results and unknown roles never render (E:362-363).
2639        }
2640
2641        // --- transition tokens (E:365-394) ---
2642        // Early-out: a non-final message whose next turn is NOT assistant/latest_reminder gets
2643        // no transition (the python's E:366 guard).
2644        if idx + 1 < n {
2645            let next = msgs[idx + 1].role.as_str();
2646            if next != "assistant" && next != "latest_reminder" {
2647                continue;
2648            }
2649        }
2650        let is_last = idx + 1 >= n;
2651        if let Some(task) = m.task.as_deref() {
2652            // generation-prompt-shaped: a task on the final message is gated on the gen prompt.
2653            if is_last && !add_generation_prompt {
2654                continue;
2655            }
2656            if let Some(tok) = ds_task_token(task) {
2657                if task != "action" {
2658                    out.push_str(tok);
2659                } else {
2660                    out.push_str(DS_ASSISTANT);
2661                    out.push_str(if thinking {
2662                        DS_THINK_START
2663                    } else {
2664                        DS_THINK_END
2665                    });
2666                    out.push_str(tok);
2667                }
2668            }
2669        } else if m.role == "user" || m.role == "developer" {
2670            if is_last && !add_generation_prompt {
2671                continue;
2672            }
2673            out.push_str(DS_ASSISTANT);
2674            // E:387-392: thinking opens `<think>` when drop_thinking is OFF (tools present)
2675            // OR (drop on) at/after the last user turn; else it closes `</think>`. chat mode
2676            // (thinking=false) always closes.
2677            if thinking && (!effective_drop || (idx as isize) >= last_user) {
2678                out.push_str(DS_THINK_START);
2679            } else {
2680                out.push_str(DS_THINK_END);
2681            }
2682        }
2683    }
2684    Ok(out)
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689    use super::*;
2690
2691    /// ds4f rung-3 regression (the first real serve 400): the REAL dsv4 artifacts
2692    /// ship NO chat_template string — dispatch and the tools branch must key on the
2693    /// detected encoding revision, or a fully-defined dialect 400s at the door.
2694    #[test]
2695    fn templateless_dsv4_artifact_dispatches_on_encoding() {
2696        let s =
2697            apply_chat_template_enc(None, &[("user", "Hello")], true, Some(Dsv4Encoding::V0731))
2698                .unwrap();
2699        assert!(
2700            s.contains("<\u{ff5c}User\u{ff5c}>") && s.contains("<\u{ff5c}Assistant\u{ff5c}>"),
2701            "encoding dispatch did not reach the dsv4 renderer: {s:?}"
2702        );
2703        assert!(!s.contains("<|im_start|>"), "fell back to ChatML: {s:?}");
2704        let legacy = apply_chat_template_enc(None, &[("user", "Hello")], true, None).unwrap();
2705        assert_eq!(
2706            legacy,
2707            apply_chat_template_str(None, &[("user", "Hello")], true)
2708        );
2709
2710        let turns = vec![Turn {
2711            role: "user".into(),
2712            content: "What is the weather in Paris? Use the tool.".into(),
2713            ..Default::default()
2714        }];
2715        let tj = vec![
2716            r#"{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}"#.to_string(),
2717        ];
2718        // the dsv4 renderer consumes the typed tree (tools_struct), like the gemma dialect
2719        let tv = vec![Val::Obj(vec![
2720            ("name".into(), Val::Str("get_weather".into())),
2721            (
2722                "description".into(),
2723                Val::Str("Get weather for a city".into()),
2724            ),
2725            (
2726                "parameters".into(),
2727                Val::Obj(vec![
2728                    ("type".into(), Val::Str("object".into())),
2729                    (
2730                        "properties".into(),
2731                        Val::Obj(vec![(
2732                            "city".into(),
2733                            Val::Obj(vec![("type".into(), Val::Str("string".into()))]),
2734                        )]),
2735                    ),
2736                ]),
2737            ),
2738        ])];
2739        let out = apply_chat_template_tools_ex(
2740            None,
2741            &turns,
2742            true,
2743            &tj,
2744            &tv,
2745            ThinkMode::Default,
2746            None,
2747            Some(Dsv4Encoding::V0731),
2748        )
2749        .expect("templateless dsv4 artifact must render tools (DSML is its protocol)");
2750        assert!(
2751            out.contains("\u{ff5c}DSML\u{ff5c}") || out.contains("get_weather"),
2752            "tools block missing from the DSML render: {out:?}"
2753        );
2754    }
2755
2756    #[test]
2757    fn plain_chatml() {
2758        let s = apply_chat_template_str(None, &[("user", "Hello")], true);
2759        assert_eq!(
2760            s,
2761            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
2762        );
2763    }
2764
2765    /// A template stand-in carrying every marker the real qwen3.5/3.6 dumps carry
2766    /// (tools branch + think tail + enable_thinking switch).
2767    const QWEN_TOOLS_TMPL: &str =
2768        "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
2769
2770    /// Isolation contract: the tools renderer on a PLAIN request (no tools, no tool turns,
2771    /// Default think) is byte-identical to the legacy renderer, across the message shapes
2772    /// the serve path sees.
2773    #[test]
2774    fn tools_renderer_matches_legacy_when_plain() {
2775        let batteries: &[&[(&str, &str)]] = &[
2776            &[("user", "Hello")],
2777            &[("system", "You are helpful."), ("user", "Hi")],
2778            &[
2779                ("system", "rules"),
2780                ("user", "task"),
2781                ("assistant", "work"),
2782                ("user", "more"),
2783            ],
2784            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
2785        ];
2786        for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
2787            for msgs in batteries {
2788                let legacy = apply_chat_template_str(tmpl, msgs, true);
2789                let turns: Vec<Turn> = msgs
2790                    .iter()
2791                    .map(|(r, c)| Turn {
2792                        role: r.to_string(),
2793                        content: c.to_string(),
2794                        tool_calls: Vec::new(),
2795                        ..Default::default()
2796                    })
2797                    .collect();
2798                let ext =
2799                    apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2800                        .unwrap();
2801                assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
2802            }
2803        }
2804    }
2805
2806    #[test]
2807    fn tools_header_and_tool_response_render_per_template_law() {
2808        let tools =
2809            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2810        let turns = vec![
2811            Turn {
2812                role: "system".into(),
2813                content: "Be terse.".into(),
2814                tool_calls: Vec::new(),
2815                ..Default::default()
2816            },
2817            Turn {
2818                role: "user".into(),
2819                content: "Weather in Paris?".into(),
2820                tool_calls: Vec::new(),
2821                ..Default::default()
2822            },
2823            Turn {
2824                role: "assistant".into(),
2825                content: "".into(),
2826                tool_calls: vec![ToolCall {
2827                    name: "get_weather".into(),
2828                    params: vec![("city".into(), "Paris".into())],
2829                    ..Default::default()
2830                }],
2831                ..Default::default()
2832            },
2833            Turn {
2834                role: "tool".into(),
2835                content: "{\"temp_c\": 21}".into(),
2836                tool_calls: Vec::new(),
2837                ..Default::default()
2838            },
2839        ];
2840        let s = apply_chat_template_tools(
2841            Some(QWEN_TOOLS_TMPL),
2842            &turns,
2843            true,
2844            &tools,
2845            ThinkMode::Default,
2846            None,
2847        )
2848        .unwrap();
2849        let expected = concat!(
2850            "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
2851            "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
2852            "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2853            "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2854            "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2855            "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2856            "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2857            "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
2858            "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
2859            "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
2860            "no function call available, answer the question like normal with your current knowledge ",
2861            "and do not tell the user about function calls\n</IMPORTANT>",
2862            "\n\nBe terse.<|im_end|>\n",
2863            "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2864            "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
2865            "</parameter>\n</function>\n</tool_call><|im_end|>\n",
2866            "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
2867            "<|im_start|>assistant\n<think>\n",
2868        );
2869        assert_eq!(s, expected);
2870    }
2871
2872    #[test]
2873    fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
2874        let turns = vec![
2875            Turn {
2876                role: "user".into(),
2877                content: "both".into(),
2878                tool_calls: Vec::new(),
2879                ..Default::default()
2880            },
2881            Turn {
2882                role: "assistant".into(),
2883                content: "checking".into(),
2884                tool_calls: vec![
2885                    ToolCall {
2886                        name: "a".into(),
2887                        params: vec![("x".into(), "1".into())],
2888                        ..Default::default()
2889                    },
2890                    ToolCall {
2891                        name: "b".into(),
2892                        params: Vec::new(),
2893                        ..Default::default()
2894                    },
2895                ],
2896                ..Default::default()
2897            },
2898            Turn {
2899                role: "tool".into(),
2900                content: "r1".into(),
2901                tool_calls: Vec::new(),
2902                ..Default::default()
2903            },
2904            Turn {
2905                role: "tool".into(),
2906                content: "r2".into(),
2907                tool_calls: Vec::new(),
2908                ..Default::default()
2909            },
2910        ];
2911        let s = apply_chat_template_tools(
2912            Some(QWEN_TOOLS_TMPL),
2913            &turns,
2914            false,
2915            &[],
2916            ThinkMode::Default,
2917            None,
2918        )
2919        .unwrap();
2920        assert_eq!(
2921            s,
2922            concat!(
2923                "<|im_start|>user\nboth<|im_end|>\n",
2924                "<|im_start|>assistant\nchecking\n\n",
2925                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
2926                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2927                "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
2928                "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
2929            )
2930        );
2931    }
2932
2933    #[test]
2934    fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
2935        let turns = vec![Turn {
2936            role: "user".into(),
2937            content: "hi".into(),
2938            tool_calls: Vec::new(),
2939            ..Default::default()
2940        }];
2941        // switch present: NoThink renders the closed think block.
2942        let s = apply_chat_template_tools(
2943            Some(QWEN_TOOLS_TMPL),
2944            &turns,
2945            true,
2946            &[],
2947            ThinkMode::NoThink,
2948            None,
2949        )
2950        .unwrap();
2951        assert!(
2952            s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
2953            "{s:?}"
2954        );
2955        // no enable_thinking switch: NoThink is ignored (template default stands).
2956        let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
2957        let s = apply_chat_template_tools(
2958            Some(tmpl_no_switch),
2959            &turns,
2960            true,
2961            &[],
2962            ThinkMode::NoThink,
2963            None,
2964        )
2965        .unwrap();
2966        assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
2967        // no template at all: plain ChatML, no tail either way.
2968        let s =
2969            apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
2970        assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
2971    }
2972
2973    #[test]
2974    fn tools_on_templates_without_tools_branch_error() {
2975        let turns = vec![Turn {
2976            role: "user".into(),
2977            content: "hi".into(),
2978            tool_calls: Vec::new(),
2979            ..Default::default()
2980        }];
2981        let tools = vec!["{}".to_string()];
2982        for tmpl in [None, Some("... <|turn> ...")] {
2983            let err =
2984                apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
2985            assert!(err.is_err(), "template={tmpl:?}");
2986        }
2987        // tool-role turns need the branch too.
2988        let tool_turns = vec![Turn {
2989            role: "tool".into(),
2990            content: "r".into(),
2991            tool_calls: Vec::new(),
2992            ..Default::default()
2993        }];
2994        assert!(
2995            apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
2996                .is_err()
2997        );
2998    }
2999
3000    // ---- per-arch thinking control (owner directive 2026-08-07) -------------------------
3001    // Every `expected` below is the EXACT string the arch's REAL shipped template renders,
3002    // from research/step-sku-20260807/raw/thinking-goldens.txt (render-thinking-goldens.py:
3003    // jinja2 trim_blocks/lstrip_blocks over the pinned template dumps — gemma4 sha 36e3a42e
3004    // from the local QAT GGUF header, hy3 sha 7fc351fe from the pinned tencent/Hy3 snapshot).
3005
3006    fn one_user() -> Vec<Turn> {
3007        vec![turn("user", "Hi")]
3008    }
3009
3010    #[test]
3011    fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
3012        let g = |think: ThinkMode| {
3013            apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
3014                .unwrap()
3015        };
3016        // Default AND NoThink = the template's own default(false): closed thought channel.
3017        // Byte-identical to the legacy renderer (no silent behavior change).
3018        let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
3019        assert_eq!(g(ThinkMode::Default), closed);
3020        assert_eq!(g(ThinkMode::NoThink), closed);
3021        assert_eq!(
3022            apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
3023            closed,
3024            "legacy renderer = the default arm"
3025        );
3026        // Think = enable_thinking=true: <|think|> injected into a CREATED system turn and
3027        // the generation turn left open (golden: gemma4 enable_thinking=true, no system).
3028        assert_eq!(
3029            g(ThinkMode::Think),
3030            "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
3031        );
3032        // with a client system turn the token lands at the very top of it (golden).
3033        let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
3034        let s = apply_chat_template_tools(
3035            Some("... <|turn> ..."),
3036            &turns,
3037            true,
3038            &[],
3039            ThinkMode::Think,
3040            None,
3041        )
3042        .unwrap();
3043        assert_eq!(
3044            s,
3045            "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
3046                       <|turn>user\nHi<turn|>\n<|turn>model\n"
3047        );
3048    }
3049
3050    /// A QAT-tooluse stand-in: carries `<|turn>` + `<|tool>` (engages the gemma4 tools arm)
3051    /// AND the closed-tail literal (the QAT trunk's thinking-off generation tail). The
3052    /// official served trunk omits that literal, so its tools arm emits the bare `<|turn>model`
3053    /// on thinking-off — the fixtures cover that side.
3054    const GEMMA_TOOLUSE_QAT_TMPL: &str =
3055        "... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
3056
3057    #[test]
3058    fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
3059        // REGRESSION (deliverable 6): a NO-tools request through the gemma4 tools arm renders
3060        // byte-identically to the standalone gemma4 renderer, across think modes and message
3061        // shapes — the tool path never perturbs plain gemma traffic on the tooluse trunk.
3062        let batteries: &[&[(&str, &str)]] = &[
3063            &[("user", "Hi")],
3064            &[("system", "Be terse."), ("user", "Weather?")],
3065            &[
3066                ("system", "rules"),
3067                ("user", "task"),
3068                ("assistant", "work"),
3069                ("user", "more"),
3070            ],
3071            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
3072        ];
3073        for msgs in batteries {
3074            let turns: Vec<Turn> = msgs
3075                .iter()
3076                .map(|(r, c)| Turn {
3077                    role: r.to_string(),
3078                    content: c.to_string(),
3079                    ..Default::default()
3080                })
3081                .collect();
3082            for (mode, thinking) in [
3083                (ThinkMode::Default, false),
3084                (ThinkMode::NoThink, false),
3085                (ThinkMode::Think, true),
3086            ] {
3087                let legacy = apply_gemma4_template(msgs, true, thinking);
3088                let arm = apply_chat_template_tools(
3089                    Some(GEMMA_TOOLUSE_QAT_TMPL),
3090                    &turns,
3091                    true,
3092                    &[],
3093                    mode,
3094                    None,
3095                )
3096                .unwrap();
3097                assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
3098            }
3099        }
3100    }
3101
3102    #[test]
3103    fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
3104        // a `<|turn>` template WITHOUT `<|tool>` keeps rejecting tool features with the clear
3105        // error (no committed tools reference for that trunk).
3106        let turns = vec![turn("user", "Weather?")];
3107        let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
3108        let err = apply_chat_template_tools(
3109            Some("... <|turn> ..."),
3110            &turns,
3111            true,
3112            &tools,
3113            ThinkMode::Default,
3114            None,
3115        );
3116        assert!(err.is_err());
3117    }
3118
3119    #[test]
3120    fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
3121        const HY_TMPL: Option<&str> = Some("... hy_User ...");
3122        let h = |think: ThinkMode, effort: Option<&str>| {
3123            apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
3124        };
3125        // Default AND NoThink = the template's own default: no_think header + CLOSED think.
3126        // Byte-identical to the legacy renderer.
3127        let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
3128                      <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
3129                      <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
3130                      <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
3131                      <think:opensource></think:opensource>";
3132        assert_eq!(h(ThinkMode::Default, None), closed);
3133        assert_eq!(
3134            h(ThinkMode::NoThink, Some("low")),
3135            closed,
3136            "NoThink wins over a level: thinking off IS no_think"
3137        );
3138        assert_eq!(
3139            apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
3140            closed,
3141            "legacy renderer = the default arm"
3142        );
3143        // Think at low/high = the template's own open-think levels (goldens: header carries
3144        // the level, generation prompt ends with an OPEN <think:opensource>).
3145        let low = h(ThinkMode::Think, Some("low"));
3146        assert!(low.contains("reasoning_effort:low"), "{low:?}");
3147        assert!(low.ends_with("<think:opensource>"), "{low:?}");
3148        let high = h(ThinkMode::Think, Some("high"));
3149        assert!(high.contains("reasoning_effort:high"), "{high:?}");
3150        assert!(high.ends_with("<think:opensource>"), "{high:?}");
3151        // medium clamps to low (hy3's accepted set is exactly no_think|low|high — the jinja
3152        // raise_exceptions on anything else); Think with no level also lands at low.
3153        assert_eq!(h(ThinkMode::Think, Some("medium")), low);
3154        assert_eq!(h(ThinkMode::Think, None), low);
3155        // History assistant turns stay CLOSED-think at every effort (the template opens only
3156        // turns past last_user_index; golden: "hy3 assistant history stays closed-think").
3157        let turns = vec![
3158            turn("user", "q"),
3159            turn("assistant", "a"),
3160            turn("user", "more"),
3161        ];
3162        let s =
3163            apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
3164                .unwrap();
3165        assert_eq!(
3166            s,
3167            "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
3168                       <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
3169                       <\u{ff5c}hy_User:opensource\u{ff5c}>q\
3170                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
3171                       <think:opensource></think:opensource>a\
3172                       <\u{ff5c}hy_eos:opensource\u{ff5c}>\
3173                       <\u{ff5c}hy_User:opensource\u{ff5c}>more\
3174                       <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
3175        );
3176    }
3177
3178    fn hy3_tools_header(tool: &str, effort: &str) -> String {
3179        [
3180            "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>You are concise.\n\n# Tools\n\n",
3181            "You may call one or more functions to assist with the user query.\n\n",
3182            "You are provided with function signatures within <tools></tools> XML tags:\n",
3183            "<tools>\n",
3184            tool,
3185            "\n</tools>\n\nFor function call returns, you should first print ",
3186            "<tool_calls:opensource>\nFor each function call, you should return object like:\n",
3187            "<tool_call:opensource>{function-name}<tool_sep:opensource>\n",
3188            "<arg_key:opensource>{arg-key-1}</arg_key:opensource>\n",
3189            "<arg_value:opensource>{arg-value-1}</arg_value:opensource>\n",
3190            "<arg_key:opensource>{arg-key-2}</arg_key:opensource>\n",
3191            "<arg_value:opensource>{arg-value-2}</arg_value:opensource>\n...\n",
3192            "</tool_call:opensource>\nAt the end of function call returns, you should print ",
3193            "</tool_calls:opensource><\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:",
3194            effort,
3195        ]
3196        .concat()
3197    }
3198
3199    #[test]
3200    fn hy3_tools_definitions_match_the_pinned_jinja() {
3201        const TEMPLATE: &str = "... hy_User ... <tools> ... <tool_calls{}> ...";
3202        let tool = r#"{"type": "function", "function": {"name": "get_weather", "description": "Get weather.", "parameters": {"type": "object", "properties": {"city": {"type": "string"}, "days": {"type": "integer"}}, "required": ["city"]}}}"#;
3203        let turns = vec![turn("system", "You are concise."), turn("user", "Weather?")];
3204        let got = apply_chat_template_tools(
3205            Some(TEMPLATE),
3206            &turns,
3207            true,
3208            &[tool.to_string()],
3209            ThinkMode::Default,
3210            None,
3211        )
3212        .unwrap();
3213        let expected = [
3214            &hy3_tools_header(tool, "no_think"),
3215            "<\u{ff5c}hy_User:opensource\u{ff5c}>Weather?",
3216            "<\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource></think:opensource>",
3217        ]
3218        .concat();
3219        assert_eq!(got, expected);
3220        assert!(template_has_tools_branch(TEMPLATE));
3221    }
3222
3223    #[test]
3224    fn hy3_tool_call_and_response_history_match_the_pinned_jinja() {
3225        const TEMPLATE: &str = "... hy_User ... <tools> ... <tool_calls{}> ...";
3226        let tool = r#"{"type": "function", "function": {"name": "get_weather", "description": "Get weather.", "parameters": {"type": "object", "properties": {"city": {"type": "string"}, "days": {"type": "integer"}}, "required": ["city"]}}}"#;
3227        let turns = vec![
3228            turn("system", "You are concise."),
3229            turn("user", "Weather?"),
3230            Turn {
3231                role: "assistant".into(),
3232                reasoning: Some("Need weather.".into()),
3233                tool_calls: vec![ToolCall {
3234                    name: "get_weather".into(),
3235                    params: vec![("city".into(), "Paris".into()), ("days".into(), "2".into())],
3236                    ..Default::default()
3237                }],
3238                ..Default::default()
3239            },
3240            turn("tool", "sunny"),
3241            turn("user", "Summarize."),
3242        ];
3243        let got = apply_chat_template_tools(
3244            Some(TEMPLATE),
3245            &turns,
3246            true,
3247            &[tool.to_string()],
3248            ThinkMode::Think,
3249            Some("high"),
3250        )
3251        .unwrap();
3252        let expected = [
3253            &hy3_tools_header(tool, "high"),
3254            "<\u{ff5c}hy_User:opensource\u{ff5c}>Weather?",
3255            "<\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>Need weather.</think:opensource>",
3256            "<tool_calls:opensource>\n<tool_call:opensource>get_weather<tool_sep:opensource>\n",
3257            "<arg_key:opensource>city</arg_key:opensource>\n<arg_value:opensource>Paris</arg_value:opensource>\n",
3258            "<arg_key:opensource>days</arg_key:opensource>\n<arg_value:opensource>2</arg_value:opensource>\n",
3259            "</tool_call:opensource>\n</tool_calls:opensource><\u{ff5c}hy_eos:opensource\u{ff5c}>",
3260            "<tool_responses:opensource>\n<tool_response:opensource>\nsunny\n",
3261            "</tool_response:opensource>\n</tool_responses:opensource>",
3262            "<\u{ff5c}hy_User:opensource\u{ff5c}>Summarize.",
3263            "<\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>",
3264        ]
3265        .concat();
3266        assert_eq!(got, expected);
3267    }
3268
3269    #[test]
3270    fn qwen_think_mode_covers_all_three_directions() {
3271        let q = |think: ThinkMode| {
3272            apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
3273                .unwrap()
3274        };
3275        // qwen's template default IS thinking-on, so Default and Think render identically.
3276        assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
3277        assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
3278        assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
3279    }
3280
3281    // ---- StepFun Step-3.7-Flash (arch step35) -------------------------------------------
3282    // Every `expected` below is the EXACT string the shipped jinja renders, taken from
3283    // research/step37-p2-20260806/raw/step35-template-goldens.txt (generated by
3284    // render_step35_template.py under jinja2 with trim_blocks/lstrip_blocks — the settings HF
3285    // transformers and llama.cpp's minja use). `{{bos_token}}` renders as "" there because
3286    // encode(add_special) supplies BOS.
3287
3288    /// A step35 template stand-in: the real one is 5723 chars, and the detector keys on
3289    /// `render_message_content` (the macro no other committed template defines). The other
3290    /// markers are present to prove the step35 arm WINS the dispatch — a qwen-marker template
3291    /// carrying `<tools>`/`<think>`/`add_generation_prompt` would otherwise take the qwen arm.
3292    const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
3293
3294    fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
3295        apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
3296    }
3297
3298    fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
3299        apply_chat_template_tools(
3300            Some(STEP35_TMPL),
3301            &turns,
3302            genp,
3303            tools,
3304            ThinkMode::Default,
3305            None,
3306        )
3307        .unwrap()
3308    }
3309
3310    fn turn(role: &str, content: &str) -> Turn {
3311        Turn {
3312            role: role.into(),
3313            content: content.into(),
3314            tool_calls: Vec::new(),
3315            ..Default::default()
3316        }
3317    }
3318
3319    #[test]
3320    fn step35_plain_paths_match_the_shipped_jinja() {
3321        assert_eq!(
3322            s35(&[("user", "Hello")], true),
3323            "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
3324        );
3325        assert_eq!(
3326            s35(&[("user", "Hello")], false),
3327            "<|im_start|>user\nHello<|im_end|>\n"
3328        );
3329        assert_eq!(
3330            s35(&[("system", "You are helpful."), ("user", "Hi")], true),
3331            "<|im_start|>system\nYou are helpful.<|im_end|>\n\
3332                    <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
3333        );
3334        // multi-turn: the prior assistant is BEFORE the last user query, so it carries NO
3335        // think block — the reasoning boundary the qwen arms have no concept of.
3336        assert_eq!(
3337            s35(
3338                &[
3339                    ("system", "rules"),
3340                    ("user", "task"),
3341                    ("assistant", "work"),
3342                    ("user", "more")
3343                ],
3344                true
3345            ),
3346            "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
3347             <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
3348             <|im_start|>assistant\n<think>\n"
3349        );
3350        // content is NOT trimmed (this template applies no `|trim`) — the qwen arms trim.
3351        assert_eq!(
3352            s35(&[("user", "  padded  ")], true),
3353            "<|im_start|>user\n  padded  <|im_end|>\n<|im_start|>assistant\n<think>\n"
3354        );
3355    }
3356
3357    #[test]
3358    fn step35_dispatch_beats_the_qwen_marker_arm() {
3359        // The step35 template carries every qwen marker. If the dispatch order regressed, the
3360        // think tail would still be right and the BODY would be wrong (trimmed content, wrong
3361        // tools header) — so assert a body-shaped difference, not the tail.
3362        let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
3363        let step = s35(&[("user", " pad ")], true);
3364        assert_eq!(
3365            qwen,
3366            "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
3367        );
3368        assert_eq!(
3369            step,
3370            "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
3371        );
3372        assert_ne!(qwen, step);
3373    }
3374
3375    #[test]
3376    fn step35_reasoning_effort_renders_in_the_system_turn() {
3377        assert_eq!(
3378            apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
3379            "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
3380             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
3381        );
3382        assert_eq!(
3383            apply_step35_template(
3384                &[turn("system", "Be terse."), turn("user", "Hi")],
3385                true,
3386                &[],
3387                Some("low")
3388            ),
3389            "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
3390             <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
3391        );
3392        // with tools the order flips: Reasoning, then the system content, then `# Tools`.
3393        let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
3394        let s = apply_step35_template(
3395            &[turn("system", "Be terse."), turn("user", "q")],
3396            true,
3397            &tools,
3398            Some("medium"),
3399        );
3400        assert!(
3401            s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
3402            "{s:?}"
3403        );
3404    }
3405
3406    #[test]
3407    fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
3408        // The serve path enters via apply_chat_template_tools: the level must land in the
3409        // rendered system turn on the step35 dialect...
3410        let turns = vec![turn("user", "Hi")];
3411        let s = apply_chat_template_tools(
3412            Some(STEP35_TMPL),
3413            &turns,
3414            true,
3415            &[],
3416            ThinkMode::Default,
3417            Some("high"),
3418        )
3419        .unwrap();
3420        assert!(
3421            s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
3422            "{s:?}"
3423        );
3424        // ...None keeps the template's own default (no Reasoning: line at all)...
3425        let s = apply_chat_template_tools(
3426            Some(STEP35_TMPL),
3427            &turns,
3428            true,
3429            &[],
3430            ThinkMode::Default,
3431            None,
3432        )
3433        .unwrap();
3434        assert!(!s.contains("Reasoning:"), "{s:?}");
3435        // ...and every non-step35 dialect ignores the parameter (their templates have no
3436        // reasoning_effort input) — byte-identical with and without it.
3437        for tmpl in [
3438            None,
3439            Some(QWEN_TOOLS_TMPL),
3440            Some("... hy_User ..."),
3441            Some("... <|turn> ..."),
3442        ] {
3443            let with = apply_chat_template_tools(
3444                tmpl,
3445                &turns,
3446                true,
3447                &[],
3448                ThinkMode::Default,
3449                Some("high"),
3450            )
3451            .unwrap();
3452            let without =
3453                apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
3454                    .unwrap();
3455            assert_eq!(with, without, "template={tmpl:?}");
3456        }
3457    }
3458
3459    #[test]
3460    fn step35_tools_header_is_not_the_qwen_header() {
3461        let tools = vec![
3462            r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
3463            r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
3464        ];
3465        let s = s35_turns(
3466            vec![
3467                turn("system", "Be terse."),
3468                turn("user", "Weather in Paris?"),
3469            ],
3470            true,
3471            &tools,
3472        );
3473        assert_eq!(
3474            s,
3475            concat!(
3476                // leading system folds in BEFORE `# Tools` (the qwen arm appends it AFTER the
3477                // instruction block), and the header says "in JSONSchema format".
3478                "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
3479                "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
3480                "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
3481                "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
3482                "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
3483                "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
3484                "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
3485                "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
3486                // the nesting reminder carries literal \n...\n INSIDE the example tags, and the
3487                // Reminder list stops after 2 bullets (the qwen block has 4).
3488                "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
3489                "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
3490                "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
3491                "<|im_end|>\n",
3492                "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
3493                "<|im_start|>assistant\n<think>\n",
3494            )
3495        );
3496        // and it is NOT the qwen instruction block.
3497        assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
3498    }
3499
3500    #[test]
3501    fn step35_tool_results_take_their_own_role_and_group() {
3502        let tools =
3503            vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
3504        let turns = vec![
3505            turn("user", "both"),
3506            Turn {
3507                role: "assistant".into(),
3508                content: "checking".into(),
3509                tool_calls: vec![
3510                    ToolCall {
3511                        name: "a".into(),
3512                        params: vec![("x".into(), "1".into())],
3513                        ..Default::default()
3514                    },
3515                    ToolCall {
3516                        name: "b".into(),
3517                        params: Vec::new(),
3518                        ..Default::default()
3519                    },
3520                ],
3521                ..Default::default()
3522            },
3523            turn("tool", "r1"),
3524            turn("tool", "r2"),
3525        ];
3526        let s = s35_turns(turns, true, &tools);
3527        let body = s
3528            .split("<|im_end|>\n")
3529            .skip(1)
3530            .collect::<Vec<_>>()
3531            .join("<|im_end|>\n");
3532        assert_eq!(
3533            body,
3534            concat!(
3535                "<|im_start|>user\nboth<|im_end|>\n",
3536                // the assistant is AFTER the last user query, so it carries a think block — empty,
3537                // because its content has no `</think>` marker.
3538                "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
3539                // NO separator before the first call and NONE between calls.
3540                "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
3541                "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
3542                // own `tool_response` ROLE (not a user turn), and NO newlines inside the wrappers.
3543                "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
3544                "<tool_response>r2</tool_response><|im_end|>\n",
3545                "<|im_start|>assistant\n<think>\n",
3546            )
3547        );
3548    }
3549
3550    #[test]
3551    fn step35_assistant_think_split_and_the_reasoning_boundary() {
3552        // inline <think>…</think> in content splits into the reasoning block + body.
3553        assert_eq!(
3554            s35(
3555                &[
3556                    ("user", "q"),
3557                    ("assistant", "<think>\nreasoned\n</think>\nanswer")
3558                ],
3559                false
3560            ),
3561            "<|im_start|>user\nq<|im_end|>\n\
3562             <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
3563        );
3564        // no markers, but still after the last query -> an EMPTY reasoning block is emitted.
3565        assert_eq!(
3566            s35(&[("user", "q"), ("assistant", "plain")], false),
3567            "<|im_start|>user\nq<|im_end|>\n\
3568             <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
3569        );
3570        // a user turn that IS a <tool_response> wrapper does NOT move the boundary: the
3571        // assistant before it still counts as after-the-last-real-query.
3572        assert_eq!(
3573            s35(
3574                &[
3575                    ("user", "real question"),
3576                    ("assistant", "thinking about it"),
3577                    ("user", "<tool_response>r</tool_response>")
3578                ],
3579                true
3580            ),
3581            "<|im_start|>user\nreal question<|im_end|>\n\
3582             <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
3583             <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
3584             <|im_start|>assistant\n<think>\n"
3585        );
3586    }
3587
3588    #[test]
3589    fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
3590        // No `enable_thinking` in this template, so ThinkMode::NoThink cannot close the tail —
3591        // the same graceful-no-op contract the other switchless templates get. A NoThink that
3592        // silently emitted `<think>\n\n</think>\n\n` would be a prompt the model never saw.
3593        let turns = vec![turn("user", "hi")];
3594        for mode in [ThinkMode::Default, ThinkMode::NoThink] {
3595            let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
3596                .unwrap();
3597            assert!(
3598                s.ends_with("<|im_start|>assistant\n<think>\n"),
3599                "mode={mode:?} {s:?}"
3600            );
3601        }
3602    }
3603
3604    #[test]
3605    fn step35_plain_path_is_identical_through_both_renderers() {
3606        // same isolation contract the qwen arms hold: a plain request renders byte-identically
3607        // whether it enters via apply_chat_template_str or apply_chat_template_tools.
3608        let batteries: &[&[(&str, &str)]] = &[
3609            &[("user", "Hello")],
3610            &[("system", "You are helpful."), ("user", "Hi")],
3611            &[
3612                ("system", "rules"),
3613                ("user", "task"),
3614                ("assistant", "work"),
3615                ("user", "more"),
3616            ],
3617            &[("user", "  padded  "), ("assistant", "reply\nwith lines")],
3618        ];
3619        for msgs in batteries {
3620            let legacy = s35(msgs, true);
3621            let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
3622            assert_eq!(legacy, ext, "msgs={msgs:?}");
3623        }
3624    }
3625
3626    #[test]
3627    fn qwen_think_tail() {
3628        // a template string containing both markers triggers the <think> tail.
3629        let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
3630        let s = apply_chat_template_str(
3631            Some(tmpl),
3632            &[("system", "You are helpful."), ("user", "Hi")],
3633            true,
3634        );
3635        assert_eq!(
3636            s,
3637            "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
3638        );
3639    }
3640
3641    /// The dsv4 effort-prefix law across BOTH encoding revisions (0731 re-gate,
3642    /// ENCODING-DIFF.md): the exact (thinking, effort, encoding) -> prefix table, including
3643    /// the refuse-on-ambiguity cells (unknown revision where the two encodings' bytes
3644    /// differ) and the never-corrupt clamps ("low"/"medium"/unknown levels -> no prefix).
3645    #[test]
3646    fn dsv4_effort_prefix_law() {
3647        use Dsv4Encoding::{Preview, V0731};
3648        let p = dsv4_effort_prefix;
3649        // chat mode: never a prefix, under any encoding or level (incl. unknown revision).
3650        for enc in [None, Some(Preview), Some(V0731)] {
3651            for eff in [None, Some("low"), Some("high"), Some("max")] {
3652                assert_eq!(p(false, eff, enc), Ok(""), "chat eff={eff:?} enc={enc:?}");
3653            }
3654        }
3655        // encoding-independent thinking cells: None/"low"/foreign levels -> no prefix.
3656        for enc in [None, Some(Preview), Some(V0731)] {
3657            assert_eq!(p(true, None, enc), Ok(""));
3658            assert_eq!(p(true, Some("low"), enc), Ok(""));
3659            assert_eq!(p(true, Some("medium"), enc), Ok(""));
3660        }
3661        // preview law: "high" == None (documented no-op), "max" -> the absolute text.
3662        assert_eq!(p(true, Some("high"), Some(Preview)), Ok(""));
3663        assert_eq!(
3664            p(true, Some("max"), Some(Preview)),
3665            Ok(DS_EFFORT_ABSOLUTE_MAX)
3666        );
3667        // 0731 law: "high" -> the absolute text (the OLD max), "max" -> the new beyond text.
3668        assert_eq!(
3669            p(true, Some("high"), Some(V0731)),
3670            Ok(DS_EFFORT_ABSOLUTE_MAX)
3671        );
3672        assert_eq!(p(true, Some("max"), Some(V0731)), Ok(DS_EFFORT_BEYOND_MAX));
3673        // ambiguity refusal: exactly the two cells whose bytes differ across revisions.
3674        assert!(p(true, Some("high"), None).is_err());
3675        assert!(p(true, Some("max"), None).is_err());
3676        // prefix text invariants pinned against the oracle constants: both end "\n\n",
3677        // both open with the ladder header, and they are distinct rungs.
3678        assert!(DS_EFFORT_ABSOLUTE_MAX.starts_with("Reasoning Effort: Absolute maximum"));
3679        assert!(DS_EFFORT_BEYOND_MAX.starts_with("Reasoning Effort: Beyond maximum \u{2014}"));
3680        assert!(DS_EFFORT_ABSOLUTE_MAX.ends_with("\n\n"));
3681        assert!(DS_EFFORT_BEYOND_MAX.ends_with("\n\n"));
3682        assert_ne!(DS_EFFORT_ABSOLUTE_MAX, DS_EFFORT_BEYOND_MAX);
3683    }
3684
3685    /// End-to-end through the dispatch: the same request renders per-revision prefixes, and
3686    /// an unknown revision refuses ONLY when the requested cell is ambiguous.
3687    #[test]
3688    fn dsv4_effort_renders_per_encoding_through_dispatch() {
3689        const DSV4_TMPL: &str = "<\u{ff5c}Assistant\u{ff5c}> \u{ff5c}DSML\u{ff5c}";
3690        let turns = vec![Turn {
3691            role: "user".into(),
3692            content: "Hi".into(),
3693            ..Default::default()
3694        }];
3695        let render = |effort: Option<&str>, enc: Option<Dsv4Encoding>| {
3696            apply_chat_template_tools_ex(
3697                Some(DSV4_TMPL),
3698                &turns,
3699                true,
3700                &[],
3701                &[],
3702                ThinkMode::Think,
3703                effort,
3704                enc,
3705            )
3706        };
3707        let base = render(None, None).unwrap();
3708        // preview: high is a no-op; max prefixes the absolute text right after BOS.
3709        assert_eq!(
3710            render(Some("high"), Some(Dsv4Encoding::Preview)).unwrap(),
3711            base
3712        );
3713        let pv_max = render(Some("max"), Some(Dsv4Encoding::Preview)).unwrap();
3714        assert_eq!(
3715            pv_max,
3716            format!("{DS_BOS}{DS_EFFORT_ABSOLUTE_MAX}{}", &base[DS_BOS.len()..])
3717        );
3718        // 0731: low == default; high == the preview's max bytes; max is the new text.
3719        let v_low = render(Some("low"), Some(Dsv4Encoding::V0731)).unwrap();
3720        assert_eq!(v_low, base);
3721        let v_high = render(Some("high"), Some(Dsv4Encoding::V0731)).unwrap();
3722        assert_eq!(v_high, pv_max);
3723        let v_max = render(Some("max"), Some(Dsv4Encoding::V0731)).unwrap();
3724        assert_eq!(
3725            v_max,
3726            format!("{DS_BOS}{DS_EFFORT_BEYOND_MAX}{}", &base[DS_BOS.len()..])
3727        );
3728        // unknown revision: unambiguous cells render, ambiguous cells refuse.
3729        assert_eq!(render(Some("low"), None).unwrap(), base);
3730        assert!(render(Some("high"), None).is_err());
3731        assert!(render(Some("max"), None).is_err());
3732    }
3733
3734    // ================= QWEN3.8 REASONING-EFFORT LADDER (lane/reasoning-schema-20260823) ======
3735    //
3736    // THE DEFECT: `reasoning_effort: low|medium|high` was accepted-and-ignored on every qwen3.8
3737    // request. The `effort_levels` cap probed for the substring `reasoning_effort is defined`,
3738    // and this template spells its input `reasoning_effort|default('xhigh')` — so the level was
3739    // parsed, validated, then dropped before the render, and the template's own `xhigh` default
3740    // never rendered either.
3741    //
3742    // THE GATE: memra's Rust renderer must reproduce the VENDOR's jinja byte-for-byte. The
3743    // template and the goldens are both committed; the goldens come from
3744    // `research/reasoning-schema-20260823/render_qwen38_goldens.py`, which renders the real
3745    // template under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF transformers
3746    // and llama.cpp's minja both use, so the goldens are what the DEPLOYED template does.
3747    //
3748    // Lab authority (owner ruling 2026-08-23, "use the lab of the model, not a guess"): the
3749    // three rungs and both instruction sentences are Qwen's own — Qwen/Qwen3.8-27B's card
3750    // documents `reasoning_effort` as xhigh (default) | medium | low, and the sentences here are
3751    // that template's verbatim strings. `medium` injecting NOTHING is the vendor's choice, not a
3752    // gap. The served mint adds one thing the open-weights jinja lacks — a `high` -> `xhigh`
3753    // alias — which reproduces Qwen's own documented hosted-API mapping (high/max -> xhigh,
3754    // minimal -> low, none -> enable_thinking=False), so it is vendor semantics rather than ours.
3755    const Q38_TMPL: &str =
3756        include_str!("../../../research/reasoning-schema-20260823/qwen38-27b.chat_template.jinja");
3757
3758    fn q38(turns: &[Turn], think: ThinkMode, effort: Option<&str>, tools: &[String]) -> String {
3759        apply_chat_template_tools(Some(Q38_TMPL), turns, true, tools, think, effort)
3760            .expect("q38 render")
3761    }
3762
3763    #[test]
3764    fn qwen38_effort_ladder_reproduces_the_vendor_jinja_byte_for_byte() {
3765        let plain = [turn("user", "hi")];
3766        let with_system = [turn("system", "You are terse."), turn("user", "hi")];
3767        let empty_system = [turn("system", ""), turn("user", "hi")];
3768        // TWO leading system turns: the vendor MERGES the run into one turn joined by `\n`. This
3769        // server produces the shape itself (it normalizes `developer` to `system`), and the
3770        // historical per-turn emission diverged from the template here.
3771        let two_system = [
3772            turn("system", "rules"),
3773            turn("system", "dev rules"),
3774            turn("user", "hi"),
3775        ];
3776        let multiturn = [
3777            turn("user", "hi"),
3778            turn("assistant", "hello there"),
3779            turn("user", "again"),
3780        ];
3781        let multiturn_reasoned = [
3782            turn("user", "hi"),
3783            Turn {
3784                reasoning: Some("the user greets; greet back".into()),
3785                ..turn("assistant", "hello there")
3786            },
3787            turn("user", "again"),
3788        ];
3789        // (golden name, turns, think, effort) -> the jinja's own output.
3790        let cases: &[(&str, &[Turn], ThinkMode, Option<&str>)] = &[
3791            // THE LADDER, thinking on. `None` is the template's `default('xhigh')`.
3792            ("plain_default", &plain, ThinkMode::Default, None),
3793            ("plain_xhigh", &plain, ThinkMode::Think, Some("high")),
3794            ("plain_medium", &plain, ThinkMode::Think, Some("medium")),
3795            ("plain_low", &plain, ThinkMode::Think, Some("low")),
3796            // a leading system turn: the sentence PREPENDS it across a blank line.
3797            ("system_default", &with_system, ThinkMode::Default, None),
3798            ("system_xhigh", &with_system, ThinkMode::Think, Some("high")),
3799            (
3800                "system_medium",
3801                &with_system,
3802                ThinkMode::Think,
3803                Some("medium"),
3804            ),
3805            ("system_low", &with_system, ThinkMode::Think, Some("low")),
3806            // A system turn with NO content: the sentence renders ALONE. An unconditional
3807            // separator would leave a stray blank line before `<|im_end|>`.
3808            (
3809                "empty_system_low",
3810                &empty_system,
3811                ThinkMode::Think,
3812                Some("low"),
3813            ),
3814            (
3815                "two_system_xhigh",
3816                &two_system,
3817                ThinkMode::Think,
3818                Some("high"),
3819            ),
3820            ("two_system_off", &two_system, ThinkMode::NoThink, None),
3821            // THE BINARY AXIS: thinking off carries NO effort sentence, even with a level
3822            // named, because the template wraps the whole block in `enable_thinking is true`.
3823            ("plain_off", &plain, ThinkMode::NoThink, None),
3824            (
3825                "plain_off_with_level",
3826                &plain,
3827                ThinkMode::NoThink,
3828                Some("low"),
3829            ),
3830            ("system_off", &with_system, ThinkMode::NoThink, None),
3831            // MULTI-TURN: the template's preserve_thinking DEFAULT replays every prior
3832            // assistant turn's <think> block — empty when the client sent no reasoning,
3833            // the client's reasoning_content|trim when it did. These are the bytes the
3834            // reuse pools' text tier matches a parked stream against
3835            // (lane/dflash2-session-reuse).
3836            ("multiturn_off", &multiturn, ThinkMode::NoThink, None),
3837            ("multiturn_default", &multiturn, ThinkMode::Default, None),
3838            (
3839                "multiturn_reasoned_off",
3840                &multiturn_reasoned,
3841                ThinkMode::NoThink,
3842                None,
3843            ),
3844        ];
3845        for (name, turns, think, effort) in cases {
3846            let golden = golden(name);
3847            let got = q38(turns, *think, *effort, &[]);
3848            assert_eq!(
3849                got, golden,
3850                "{name}: memra's render diverges from the vendor's own jinja.\n\
3851                 got:    {got:?}\nwanted: {golden:?}"
3852            );
3853        }
3854    }
3855
3856    #[test]
3857    fn qwen38_effort_ladder_holds_on_the_tools_branch_too() {
3858        // The sentence goes BEFORE the `# Tools` header inside the one system turn. A separate
3859        // arm because the tools branch builds that turn on a different code path, and an effort
3860        // control honoured only on plain requests is the same defect wearing a different hat.
3861        let plain = [turn("user", "hi")];
3862        let tools = vec![
3863            concat!(
3864                r#"{"type": "function", "function": {"name": "get_weather", "#,
3865                r#""description": "Get the weather", "parameters": {"type": "object", "#,
3866                r#""properties": {"city": {"type": "string"}}, "required": ["city"]}}}"#
3867            )
3868            .to_string(),
3869        ];
3870        let two_system = [
3871            turn("system", "rules"),
3872            turn("system", "dev rules"),
3873            turn("user", "hi"),
3874        ];
3875        for (name, turns, think, effort) in [
3876            ("tools_default", &plain[..], ThinkMode::Default, None),
3877            ("tools_xhigh", &plain[..], ThinkMode::Think, Some("high")),
3878            ("tools_medium", &plain[..], ThinkMode::Think, Some("medium")),
3879            ("tools_low", &plain[..], ThinkMode::Think, Some("low")),
3880            // the leading system RUN folds into the tools header, merged — not leaked out as a
3881            // second body system turn.
3882            (
3883                "two_system_tools_low",
3884                &two_system[..],
3885                ThinkMode::Think,
3886                Some("low"),
3887            ),
3888        ] {
3889            let golden = golden(name);
3890            let got = q38(turns, think, effort, &tools);
3891            assert_eq!(
3892                got, golden,
3893                "{name}: tools-branch render diverges from the vendor's own jinja.\n\
3894                 got:    {got:?}\nwanted: {golden:?}"
3895            );
3896        }
3897    }
3898
3899    #[test]
3900    fn qwen38_ladder_rungs_are_distinct_prompts_and_medium_is_the_neutral_one() {
3901        // The owner's standard: a level that returns 200 must have an EFFECT, and any gradation
3902        // must be real. Effect here is measured the only way that cannot lie — prompt bytes.
3903        let plain = [turn("user", "hi")];
3904        let r = |effort: Option<&str>| q38(&plain, ThinkMode::Think, effort, &[]);
3905        let xhigh = r(Some("high"));
3906        let medium = r(Some("medium"));
3907        let low = r(Some("low"));
3908        assert_ne!(
3909            xhigh, medium,
3910            "xhigh and medium must not render the same prompt"
3911        );
3912        assert_ne!(xhigh, low, "xhigh and low must not render the same prompt");
3913        assert_ne!(
3914            medium, low,
3915            "medium and low must not render the same prompt"
3916        );
3917        // `medium` is the vendor's zero-steering rung: no sentence at all, so it renders exactly
3918        // what a bare ChatML request renders. THIS is what memra produced for EVERY q38 request
3919        // before this lane, at every effort level and at the default — which is why landing the
3920        // fix changes the default prompt (an operator `default_reasoning_effort: "medium"` keeps
3921        // it byte-identical to that history, and that is the documented no-op migration).
3922        assert!(
3923            !medium.contains("Reasoning effort is set to"),
3924            "medium must inject no sentence: {medium:?}"
3925        );
3926        assert_eq!(
3927            medium, "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n<think>\n",
3928            "medium is the pre-lane byte history for q38"
3929        );
3930        // The default is NOT medium — it is the vendor's xhigh. The serving-behaviour change.
3931        assert_eq!(
3932            r(None),
3933            xhigh,
3934            "an unset level is the template's own xhigh default"
3935        );
3936        assert!(
3937            xhigh.len() > medium.len() + 200,
3938            "xhigh adds a real instruction"
3939        );
3940    }
3941
3942    #[test]
3943    fn a_qwen_template_without_the_ladder_is_byte_identical_at_every_level() {
3944        // ORNITH, and the construction fact behind the server's TRANSLATION rule. Ornith AI
3945        // documents no graded effort anywhere (zero `reasoning_effort` occurrences across every
3946        // card in the org, both generations, all sizes; the entire control surface is one
3947        // `enable_thinking` guard). So the level has nothing to land on, and low/medium/high
3948        // render the SAME BYTES as an unset request. The server therefore folds a graded level
3949        // onto the binary axis as reasoning ON (coordinator ruling 2026-08-23 — stock codex and
3950        // Claude Code send `xhigh` on every request, and a caller who asked for reasoning and
3951        // gets reasoning has their promise kept); this test pins the byte-identity that makes
3952        // that translation honest rather than decorative.
3953        const ORNITH_TMPL: &str = include_str!(
3954            "../../../research/reasoning-schema-20260823/ornith15.chat_template.jinja"
3955        );
3956        let plain = [turn("user", "hi")];
3957        let r = |think: ThinkMode, effort: Option<&str>| {
3958            apply_chat_template_tools(Some(ORNITH_TMPL), &plain, true, &[], think, effort)
3959                .expect("ornith render")
3960        };
3961        let base = r(ThinkMode::Default, None);
3962        for level in ["low", "medium", "high"] {
3963            assert_eq!(
3964                r(ThinkMode::Think, Some(level)),
3965                base,
3966                "{level} must be byte-identical on a ladder-less template — the fact that makes \
3967                 the graded->ON translation exact rather than approximate"
3968            );
3969        }
3970        // The BINARY axis is real here, and it is the one control ornith's lab defines.
3971        assert!(base.ends_with("<think>\n"), "{base:?}");
3972        assert!(
3973            r(ThinkMode::NoThink, None).ends_with("<think>\n\n</think>\n\n"),
3974            "ornith honours reasoning-off through its enable_thinking guard"
3975        );
3976        assert!(
3977            !base.contains("Reasoning effort is set to"),
3978            "the qwen3.8 sentence must NEVER leak onto a template that does not define it"
3979        );
3980    }
3981
3982    fn golden(name: &str) -> String {
3983        // Goldens live next to the generator that made them, so a reviewer can regenerate and
3984        // diff. `include_str!` rather than a runtime read: the path is checked at compile time,
3985        // so moving the fixture breaks the build instead of silently skipping the gate.
3986        macro_rules! g {
3987            ($($n:literal),* $(,)?) => {
3988                match name {
3989                    $($n => include_str!(concat!(
3990                        "../../../research/reasoning-schema-20260823/goldens/", $n, ".txt"
3991                    )).to_string(),)*
3992                    other => panic!("no golden named {other}"),
3993                }
3994            };
3995        }
3996        g!(
3997            "plain_default",
3998            "plain_xhigh",
3999            "plain_medium",
4000            "plain_low",
4001            "plain_off",
4002            "plain_off_with_level",
4003            "system_default",
4004            "system_xhigh",
4005            "system_medium",
4006            "system_low",
4007            "system_off",
4008            "empty_system_low",
4009            "two_system_xhigh",
4010            "two_system_off",
4011            "two_system_tools_low",
4012            "multiturn_off",
4013            "multiturn_default",
4014            "multiturn_reasoned_off",
4015            "tools_default",
4016            "tools_xhigh",
4017            "tools_medium",
4018            "tools_low",
4019        )
4020    }
4021}