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