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}