dynamo_renderer/template.rs
1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{collections::HashSet, sync::Arc};
5
6use anyhow::{Ok, Result};
7use minijinja::Environment;
8
9use super::PromptContextMixin;
10
11mod context;
12mod formatters;
13mod oai;
14mod tokcfg;
15
16use super::{OAIPromptFormatter, PromptFormatter};
17pub use oai::may_be_fix_tool_schema;
18pub use tokcfg::{ChatTemplate, ChatTemplateValue};
19
20/// If the model is a DeepSeek family whose HF repo doesn't ship a Jinja
21/// `chat_template`, return the native Rust formatter for it. Returns `None`
22/// for everything else (the caller then loads the HF `tokenizer_config.json`
23/// template via [`PromptFormatter::from_parts`]).
24///
25/// `model_type_lower` is the lowercased `config.json` `model_type` (authoritative,
26/// survives `--served-model-name` renames); `display_name_lower` is the
27/// lowercased served name, used only as a fallback when `model_type` is absent.
28pub fn deepseek_formatter_for(
29 model_type_lower: &Option<String>,
30 display_name_lower: &str,
31) -> Option<PromptFormatter> {
32 if is_deepseek_v4(model_type_lower, display_name_lower) {
33 tracing::info!(
34 model_type = ?model_type_lower,
35 display_name = %display_name_lower,
36 "Detected DeepSeek V4 model, using native Rust formatter",
37 );
38 return Some(PromptFormatter::OAI(Arc::new(
39 super::deepseek::v4::DeepSeekV4Formatter::new_thinking(),
40 )));
41 }
42 if is_deepseek_v3_2_non_exp(model_type_lower, display_name_lower) {
43 tracing::info!("Detected DeepSeek V3.2 model (non-Exp), using native Rust formatter");
44 return Some(PromptFormatter::OAI(Arc::new(
45 super::deepseek::v32::DeepSeekV32Formatter::new_thinking(),
46 )));
47 }
48 None
49}
50
51/// Select a native formatter for model families that do not ship a usable HF
52/// `chat_template`.
53///
54/// Inkling is selected only from the authoritative `config.json` model type;
55/// unlike display-name substring matching, this remains stable under
56/// `--served-model-name` aliases. DeepSeek keeps its existing fallback for
57/// older model cards that do not publish `model_type`.
58pub fn native_formatter_for(
59 model_type_lower: &Option<String>,
60 display_name_lower: &str,
61) -> Option<PromptFormatter> {
62 if model_type_lower.as_deref() == Some("inkling_mm_model") {
63 tracing::info!(
64 model_type = ?model_type_lower,
65 "Detected Inkling model, using native Rust formatter",
66 );
67 return Some(PromptFormatter::OAI(Arc::new(
68 super::inkling::InklingFormatter,
69 )));
70 }
71
72 deepseek_formatter_for(model_type_lower, display_name_lower)
73}
74
75impl PromptFormatter {
76 pub fn from_parts(
77 config: ChatTemplate,
78 context: ContextMixins,
79 exclude_tools_when_tool_choice_none: bool,
80 ) -> Result<PromptFormatter> {
81 let formatter = HfTokenizerConfigJsonFormatter::with_options(
82 config,
83 context,
84 exclude_tools_when_tool_choice_none,
85 )?;
86 Ok(Self::OAI(Arc::new(formatter)))
87 }
88}
89
90/// Chat Template Jinja Renderer
91///
92/// Manages a Jinja environment with registered templates for chat formatting.
93/// Handles two types of ChatTemplateValue templates:
94///
95/// 1. String template: Registered as the 'default' template
96/// 2. Map template: Contains 'tool_use' and/or 'default' templates
97/// - tool_use: Template for tool-based interactions
98/// - default: Template for standard chat interactions
99///
100/// If the map contains both keys, the `tool_use` template is registered as the `tool_use` template
101/// and the `default` template is registered as the `default` template.
102struct JinjaEnvironment {
103 env: Environment<'static>,
104}
105
106/// Formatter for HuggingFace tokenizer config JSON templates
107///
108/// Implements chat template rendering based on HuggingFace's tokenizer_config.json format.
109/// Supports:
110/// - Tool usage templates
111/// - Generation prompts
112/// - Context mixins for template customization
113#[derive(Debug)]
114struct HfTokenizerConfigJsonFormatter {
115 env: Environment<'static>,
116 config: ChatTemplate,
117 mixins: Arc<ContextMixins>,
118 supports_add_generation_prompt: bool,
119 requires_content_arrays: bool,
120 /// When true, strip tool definitions from the chat template when tool_choice is "none".
121 /// This prevents models from generating raw XML tool calls in the content field.
122 exclude_tools_when_tool_choice_none: bool,
123 /// True if the `default` template natively references `reasoning_content`.
124 /// When true and rendering through `default`, skip injection — the template
125 /// handles it. Tracked separately for `default` and `tool_use` because HF
126 /// configs may register different sources for each: Gemma4's `tool_use`
127 /// template is adapted by `normalize_chat_template_source` to read
128 /// `reasoning_content`, while its `default` template is not. A single global
129 /// flag would wrongly suppress injection on the untouched `default` path and
130 /// silently drop prior assistant reasoning on no-tool renders.
131 default_template_handles_reasoning: bool,
132 /// True if the `tool_use` template natively references `reasoning_content`.
133 /// See `default_template_handles_reasoning` for rationale.
134 tool_use_template_handles_reasoning: bool,
135 /// Per-family placeholder template for image content parts when flattening
136 /// mixed text+image content arrays into a single string (`preserve_arrays`
137 /// = false path). `{n}` in the template is substituted with the 1-based
138 /// image index. `None` when the model's chat template handles content
139 /// arrays natively (Qwen-VL family) or when we have no flatten strategy
140 /// for it (no MM-aware routing benefit either way).
141 image_placeholder_template: Option<&'static str>,
142 /// True if the `default` template branches on `tool_call.arguments is string`
143 /// (Qwen3, Hermes, etc.). When true and rendering through `default`, skip
144 /// pre-parsing the JSON-string `tool_calls[].function.arguments` into an
145 /// object — the template wants the raw string verbatim. Pre-parsing forces
146 /// the `tojson`-with-object branch and re-emits with minijinja's compact
147 /// separators, which breaks append-only prefix matching across multi-step
148 /// tool-use turns. Tracked separately for `default` and `tool_use` because
149 /// HF configs may register different sources for each, and because
150 /// `arguments is string` is tool_calls-specific — legacy
151 /// `function_call.arguments` lives outside that branch and is still
152 /// normalized unconditionally.
153 default_template_handles_tool_calls_arguments_string: bool,
154 /// True if the `tool_use` template branches on `tool_call.arguments is string`.
155 /// See `default_template_handles_tool_calls_arguments_string` for rationale.
156 tool_use_template_handles_tool_calls_arguments_string: bool,
157}
158
159// /// OpenAI Standard Prompt Formatter
160// pub trait StandardPromptFormatter {
161// fn render(&self, context: &impl StandardPromptContext) -> Result<String>;
162// }
163
164// pub trait StandardPromptContext {
165// fn messages(&self) -> Value;
166// fn tools(&self) -> Option<Value>;
167// }
168
169#[derive(Debug, Clone, Default)]
170pub struct ContextMixins {
171 context_mixins: HashSet<PromptContextMixin>,
172}
173
174/// Decides whether to activate the DeepSeek-V4 native formatter.
175///
176/// Primary signal: config.json `model_type`. DeepSeek-V4-Pro and V4-Flash both
177/// ship `"model_type": "deepseek_v4"`, set by the model author — this survives
178/// any `--served-model-name` rename.
179///
180/// Fallback: `display_name`, tight-matched against
181/// `^deepseek(?:[-_.])?v4(?:[-_.]|$)`. Only consulted when config.json is
182/// absent (tokenizer-only MDCs) or unreadable; a concrete config.json value
183/// that is *not* `deepseek_v4` is authoritative and suppresses the fallback.
184fn is_deepseek_v4(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
185 match model_type_lower.as_deref() {
186 Some("deepseek_v4") => true,
187 Some(_) => false, // config.json says something else — trust it
188 None => is_deepseek_v4_name(display_name_lower),
189 }
190}
191
192/// Decides whether to activate the DeepSeek-V3.2 (non-Exp) native formatter.
193/// Same config-primary / name-fallback rule as V4.
194fn is_deepseek_v3_2_non_exp(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
195 let name_match = display_name_lower.contains("deepseek")
196 && display_name_lower.contains("v3.2")
197 && !display_name_lower.contains("exp");
198 match model_type_lower.as_deref() {
199 // HF ships `deepseek_v32` (no underscore between 3 and 2); Dynamo's
200 // internal/tool-parser key is `deepseek_v3_2`. Accept both.
201 Some("deepseek_v3_2" | "deepseek_v32") => !display_name_lower.contains("exp"),
202 Some(_) => false,
203 None => name_match,
204 }
205}
206
207/// Tight, anchored match for DeepSeek-V4 display names. Equivalent to the
208/// regex `^deepseek(?:[-_.])?v4(?:[-_.]|$)` over an already-lowercased string.
209/// Written with string ops to avoid pulling in the `regex` crate.
210///
211/// Rejects composite names that previously short-circuited the V4 branch:
212/// - `deepseek-v3.2-v4-foo` (the `v3.2` variant is the real one)
213/// - `deepseek-v40` / `deepseek-v4pro` (no separator after `v4`)
214/// - `my-deepseek-v4` (prefix must be at the start)
215fn is_deepseek_v4_name(name_lower: &str) -> bool {
216 let Some(rest) = name_lower.strip_prefix("deepseek") else {
217 return false;
218 };
219 // Optional single separator between "deepseek" and "v4".
220 let rest = rest
221 .strip_prefix(|c: char| matches!(c, '-' | '_' | '.'))
222 .unwrap_or(rest);
223 let Some(after_v4) = rest.strip_prefix("v4") else {
224 return false;
225 };
226 // `v4` must end the name or be followed by a separator — anything else
227 // (e.g. `v40`, `v4pro`) is a different model family.
228 after_v4.is_empty() || after_v4.starts_with(['-', '_', '.'])
229}
230
231#[cfg(test)]
232mod detection_tests {
233 use super::{is_deepseek_v3_2_non_exp, is_deepseek_v4, is_deepseek_v4_name};
234
235 #[test]
236 fn v4_name_matches_canonical_variants() {
237 for name in [
238 "deepseek-v4",
239 "deepseek_v4",
240 "deepseek.v4",
241 "deepseekv4",
242 "deepseek-v4-pro",
243 "deepseek-v4-flash",
244 "deepseek-v4-flash-2507",
245 "deepseek-v4.1",
246 "deepseek_v4_thinking",
247 ] {
248 assert!(is_deepseek_v4_name(name), "expected {name} to match V4");
249 }
250 }
251
252 #[test]
253 fn v4_name_rejects_non_v4() {
254 // Composite names that previously short-circuited to V4 before the
255 // V3.2 branch — now correctly rejected.
256 for name in [
257 "deepseek-v3.2-v4-foo",
258 "my-deepseek-v4",
259 "deepseek-v40",
260 "deepseek-v4pro",
261 "deepseekv40",
262 "deepseek-v3",
263 "deepseek-v3.2",
264 "deepseek-r1",
265 "qwen3-v4", // only deepseek-prefixed names qualify
266 "dsflash",
267 "",
268 ] {
269 assert!(
270 !is_deepseek_v4_name(name),
271 "expected {name} to NOT match V4",
272 );
273 }
274 }
275
276 #[test]
277 fn v4_detection_prefers_config_model_type() {
278 // config.json `model_type = "deepseek_v4"` wins regardless of what
279 // the operator calls the model via --served-model-name.
280 let v4 = Some("deepseek_v4".to_string());
281 for display in ["dsflash", "my-pet-model", "llama-3-8b", ""] {
282 assert!(
283 is_deepseek_v4(&v4, display),
284 "config says deepseek_v4, display {display:?} — expected V4",
285 );
286 }
287
288 // A concrete non-V4 config.json suppresses the display-name fallback.
289 // Even if the operator names the served model "deepseek-v4", a model
290 // with `model_type = "llama"` is NOT DeepSeek-V4.
291 let llama = Some("llama".to_string());
292 for display in ["deepseek-v4", "deepseek-v4-flash", "anything"] {
293 assert!(
294 !is_deepseek_v4(&llama, display),
295 "config says llama, display {display:?} — expected NOT V4",
296 );
297 }
298
299 // No config.json — fall back to display-name match.
300 assert!(is_deepseek_v4(&None, "deepseek-v4-flash"));
301 assert!(!is_deepseek_v4(&None, "dsflash"));
302
303 // A config.json with `"model_type": ""` is treated as "no signal" at
304 // the call site (normalized to None before is_deepseek_v4 is called),
305 // so the display-name fallback still runs — pin that contract.
306 let empty: Option<String> = None;
307 assert!(is_deepseek_v4(&empty, "deepseek-v4-flash"));
308 assert!(!is_deepseek_v4(&empty, "dsflash"));
309 }
310
311 #[test]
312 fn v3_2_detection_prefers_config_model_type() {
313 // config says deepseek_v3_2, any non-"exp" display name triggers.
314 let v3_2 = Some("deepseek_v3_2".to_string());
315 assert!(is_deepseek_v3_2_non_exp(&v3_2, "whatever"));
316 assert!(is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2"));
317 // V3.2-Exp is a separate model family; suppress even via config.
318 assert!(!is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2-exp"));
319
320 // The actual HF config.json spelling has no underscore between 3 and 2
321 // (`deepseek_v32`). It must trigger identically to the internal key.
322 let hf_real = Some("deepseek_v32".to_string());
323 assert!(is_deepseek_v3_2_non_exp(&hf_real, "whatever"));
324 assert!(is_deepseek_v3_2_non_exp(&hf_real, "deepseek-v3.2-nvfp4"));
325 assert!(!is_deepseek_v3_2_non_exp(&hf_real, "deepseek-v3.2-exp"));
326
327 // Other config types lose regardless of display name.
328 let other = Some("deepseek_v4".to_string());
329 assert!(!is_deepseek_v3_2_non_exp(&other, "deepseek-v3.2"));
330
331 // No config — fall back to the original display-name heuristic.
332 assert!(is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-pro"));
333 assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-exp"));
334 assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v4"));
335 }
336}