Skip to main content

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_v41(model_type_lower, display_name_lower) {
33        return Some(PromptFormatter::OAI(Arc::new(
34            super::deepseek::v41::DeepSeekV41Formatter,
35        )));
36    }
37    if is_deepseek_v4(model_type_lower, display_name_lower) {
38        tracing::info!(
39            model_type = ?model_type_lower,
40            display_name = %display_name_lower,
41            "Detected DeepSeek V4 model, using native Rust formatter",
42        );
43        return Some(PromptFormatter::OAI(Arc::new(
44            super::deepseek::v4::DeepSeekV4Formatter::new_thinking(),
45        )));
46    }
47    if is_deepseek_v3_2_non_exp(model_type_lower, display_name_lower) {
48        tracing::info!("Detected DeepSeek V3.2 model (non-Exp), using native Rust formatter");
49        return Some(PromptFormatter::OAI(Arc::new(
50            super::deepseek::v32::DeepSeekV32Formatter::new_thinking(),
51        )));
52    }
53    None
54}
55
56/// If the model is Kimi K3, return its native XTML formatter. K3 ships no
57/// Jinja chat template and must preserve special-vs-ordinary segment boundaries
58/// until tokenization.
59pub fn kimi_k3_formatter_for(
60    model_type_lower: &Option<String>,
61    display_name_lower: &str,
62    exclude_tools_when_tool_choice_none: bool,
63) -> Option<PromptFormatter> {
64    if !is_kimi_k3(model_type_lower, display_name_lower) {
65        return None;
66    }
67
68    tracing::info!(
69        model_type = ?model_type_lower,
70        display_name = %display_name_lower,
71        "Detected Kimi K3 model, using native Rust XTML formatter",
72    );
73    Some(PromptFormatter::OAI(Arc::new(
74        super::kimi_k3::KimiK3Formatter::new(exclude_tools_when_tool_choice_none),
75    )))
76}
77
78fn is_kimi_k3(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
79    match model_type_lower.as_deref() {
80        Some("kimi_k3") => true,
81        Some(_) => false,
82        None => ["kimi-k3", "kimi_k3", "kimik3"]
83            .iter()
84            .any(|needle| display_name_lower.contains(needle)),
85    }
86}
87
88/// Select a native formatter for model families that do not ship a usable HF
89/// `chat_template`.
90///
91/// Inkling is selected only from the authoritative `config.json` model type;
92/// unlike display-name substring matching, this remains stable under
93/// `--served-model-name` aliases. DeepSeek keeps its existing fallback for
94/// older model cards that do not publish `model_type`.
95pub fn native_formatter_for(
96    model_type_lower: &Option<String>,
97    display_name_lower: &str,
98) -> Option<PromptFormatter> {
99    if model_type_lower.as_deref() == Some("inkling_mm_model") {
100        tracing::info!(
101            model_type = ?model_type_lower,
102            "Detected Inkling model, using native Rust formatter",
103        );
104        return Some(PromptFormatter::OAI(Arc::new(
105            super::inkling::InklingFormatter,
106        )));
107    }
108
109    deepseek_formatter_for(model_type_lower, display_name_lower)
110}
111
112impl PromptFormatter {
113    pub fn from_parts(
114        config: ChatTemplate,
115        context: ContextMixins,
116        exclude_tools_when_tool_choice_none: bool,
117    ) -> Result<PromptFormatter> {
118        let formatter = HfTokenizerConfigJsonFormatter::with_options(
119            config,
120            context,
121            exclude_tools_when_tool_choice_none,
122        )?;
123        Ok(Self::OAI(Arc::new(formatter)))
124    }
125}
126
127/// Chat Template Jinja Renderer
128///
129/// Manages a Jinja environment with registered templates for chat formatting.
130/// Handles two types of ChatTemplateValue templates:
131///
132/// 1. String template: Registered as the 'default' template
133/// 2. Map template: Contains 'tool_use' and/or 'default' templates
134///    - tool_use: Template for tool-based interactions
135///    - default: Template for standard chat interactions
136///
137///   If the map contains both keys, the `tool_use` template is registered as the `tool_use` template
138///   and the `default` template is registered as the `default` template.
139struct JinjaEnvironment {
140    env: Environment<'static>,
141}
142
143/// Which message-shape restrictions one chat template enforces, probed once at
144/// load. Each rewrite in `normalize_system_messages` is gated on its own flag:
145/// the restrictions are independent, so a template that rejects a non-leading
146/// `system` (Qwen3.5) but accepts consecutive `user` turns keeps those turns
147/// separate instead of being reshaped for a rule it does not have.
148#[derive(Debug, Default, Clone, Copy)]
149struct SystemNormalization {
150    /// Template raises on a `system` turn that is not first.
151    demote_nonleading_system: bool,
152    /// Template raises on two adjacent `user` turns.
153    coalesce_consecutive_users: bool,
154}
155
156impl SystemNormalization {
157    fn is_required(&self) -> bool {
158        self.demote_nonleading_system || self.coalesce_consecutive_users
159    }
160}
161
162/// Formatter for HuggingFace tokenizer config JSON templates
163///
164/// Implements chat template rendering based on HuggingFace's tokenizer_config.json format.
165/// Supports:
166/// - Tool usage templates
167/// - Generation prompts
168/// - Context mixins for template customization
169#[derive(Debug)]
170struct HfTokenizerConfigJsonFormatter {
171    env: Environment<'static>,
172    config: ChatTemplate,
173    mixins: Arc<ContextMixins>,
174    supports_add_generation_prompt: bool,
175    requires_content_arrays: bool,
176    /// When true, strip tool definitions from the chat template when tool_choice is "none".
177    /// This prevents models from generating raw XML tool calls in the content field.
178    exclude_tools_when_tool_choice_none: bool,
179    /// True if the `default` template natively references `reasoning_content`.
180    /// When true and rendering through `default`, skip injection — the template
181    /// handles it. Tracked separately for `default` and `tool_use` because HF
182    /// configs may register different sources for each: Gemma4's `tool_use`
183    /// template is adapted by `normalize_chat_template_source` to read
184    /// `reasoning_content`, while its `default` template is not. A single global
185    /// flag would wrongly suppress injection on the untouched `default` path and
186    /// silently drop prior assistant reasoning on no-tool renders.
187    default_template_handles_reasoning: bool,
188    /// True if the `tool_use` template natively references `reasoning_content`.
189    /// See `default_template_handles_reasoning` for rationale.
190    tool_use_template_handles_reasoning: bool,
191    /// True if the `default` template renders a string `reasoning_content` but
192    /// not the segment array sent with interleaved tool calls (MiniMax-M2 and
193    /// Qwen3 read it only when it `is string`). When true and rendering through
194    /// `default`, join the segments into the string the template expects.
195    default_template_requires_reasoning_string: bool,
196    /// See `default_template_requires_reasoning_string` for rationale.
197    tool_use_template_requires_reasoning_string: bool,
198    /// Per-family placeholder template for image content parts when flattening
199    /// mixed text+image content arrays into a single string (`preserve_arrays`
200    /// = false path). `{n}` in the template is substituted with the 1-based
201    /// image index. `None` when the model's chat template handles content
202    /// arrays natively (Qwen-VL family) or when we have no flatten strategy
203    /// for it (no MM-aware routing benefit either way).
204    image_placeholder_template: Option<&'static str>,
205    /// True if the `default` template branches on `tool_call.arguments is string`
206    /// and renders string arguments verbatim (Qwen3, Hermes, etc.; templates that
207    /// only use the test to reject strings don't count). When true and rendering
208    /// through `default`, skip pre-parsing the JSON-string
209    /// `tool_calls[].function.arguments` into an object — the template wants the
210    /// raw string verbatim. Pre-parsing forces the `tojson`-with-object branch and
211    /// re-emits with minijinja's compact separators, which breaks append-only
212    /// prefix matching across multi-step tool-use turns. Tracked separately for
213    /// `default` and `tool_use` because HF configs may register different sources
214    /// for each, and because `arguments is string` is tool_calls-specific — legacy
215    /// `function_call.arguments` lives outside that branch and is still
216    /// normalized unconditionally.
217    default_template_handles_tool_calls_arguments_string: bool,
218    /// True if the `tool_use` template branches on `tool_call.arguments is string`.
219    /// See `default_template_handles_tool_calls_arguments_string` for rationale.
220    tool_use_template_handles_tool_calls_arguments_string: bool,
221    /// Message-shape restrictions the `default` template enforces.
222    default_system_normalization: SystemNormalization,
223    /// Message-shape restrictions the `tool_use` template enforces.
224    /// Kept separate because dict-form HF configs may register templates with
225    /// different constraints.
226    tool_use_system_normalization: SystemNormalization,
227}
228
229// /// OpenAI Standard Prompt Formatter
230// pub trait StandardPromptFormatter {
231//     fn render(&self, context: &impl StandardPromptContext) -> Result<String>;
232// }
233
234// pub trait StandardPromptContext {
235//     fn messages(&self) -> Value;
236//     fn tools(&self) -> Option<Value>;
237// }
238
239#[derive(Debug, Clone, Default)]
240pub struct ContextMixins {
241    context_mixins: HashSet<PromptContextMixin>,
242}
243
244fn is_deepseek_v41(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
245    match model_type_lower.as_deref() {
246        Some("deepseek_v41") => true,
247        Some(_) => false,
248        None => [
249            "deepseek-v4.1",
250            "deepseek_v4.1",
251            "deepseek.v4.1",
252            "deepseekv4.1",
253        ]
254        .iter()
255        .any(|prefix| {
256            display_name_lower
257                .strip_prefix(prefix)
258                .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with(['-', '_', '.']))
259        }),
260    }
261}
262
263/// Decides whether to activate the DeepSeek-V4 native formatter.
264///
265/// Primary signal: config.json `model_type`. DeepSeek-V4-Pro and V4-Flash both
266/// ship `"model_type": "deepseek_v4"`, set by the model author — this survives
267/// any `--served-model-name` rename.
268///
269/// Fallback: `display_name`, tight-matched against
270/// `^deepseek(?:[-_.])?v4(?:[-_.]|$)`. Only consulted when config.json is
271/// absent (tokenizer-only MDCs) or unreadable; a concrete config.json value
272/// that is *not* `deepseek_v4` is authoritative and suppresses the fallback.
273fn is_deepseek_v4(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
274    match model_type_lower.as_deref() {
275        Some("deepseek_v4") => true,
276        Some(_) => false, // config.json says something else — trust it
277        None => is_deepseek_v4_name(display_name_lower),
278    }
279}
280
281/// Decides whether to activate the DeepSeek-V3.2 (non-Exp) native formatter.
282/// Same config-primary / name-fallback rule as V4.
283fn is_deepseek_v3_2_non_exp(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
284    let name_match = display_name_lower.contains("deepseek")
285        && display_name_lower.contains("v3.2")
286        && !display_name_lower.contains("exp");
287    match model_type_lower.as_deref() {
288        // HF ships `deepseek_v32` (no underscore between 3 and 2); Dynamo's
289        // internal/tool-parser key is `deepseek_v3_2`. Accept both.
290        Some("deepseek_v3_2" | "deepseek_v32") => !display_name_lower.contains("exp"),
291        Some(_) => false,
292        None => name_match,
293    }
294}
295
296/// Tight, anchored match for DeepSeek-V4 display names. Equivalent to the
297/// regex `^deepseek(?:[-_.])?v4(?:[-_.]|$)` over an already-lowercased string.
298/// Written with string ops to avoid pulling in the `regex` crate.
299///
300/// Rejects composite names that previously short-circuited the V4 branch:
301/// - `deepseek-v3.2-v4-foo` (the `v3.2` variant is the real one)
302/// - `deepseek-v40` / `deepseek-v4pro` (no separator after `v4`)
303/// - `my-deepseek-v4` (prefix must be at the start)
304fn is_deepseek_v4_name(name_lower: &str) -> bool {
305    let Some(rest) = name_lower.strip_prefix("deepseek") else {
306        return false;
307    };
308    // Optional single separator between "deepseek" and "v4".
309    let rest = rest
310        .strip_prefix(|c: char| matches!(c, '-' | '_' | '.'))
311        .unwrap_or(rest);
312    let Some(after_v4) = rest.strip_prefix("v4") else {
313        return false;
314    };
315    // `v4` must end the name or be followed by a separator — anything else
316    // (e.g. `v40`, `v4pro`) is a different model family.
317    after_v4.is_empty() || after_v4.starts_with(['-', '_', '.'])
318}
319
320#[cfg(test)]
321mod detection_tests {
322    use super::{is_deepseek_v3_2_non_exp, is_deepseek_v4, is_deepseek_v4_name, is_kimi_k3};
323
324    #[test]
325    fn kimi_k3_detection_prefers_config_model_type() {
326        assert!(is_kimi_k3(&Some("kimi_k3".to_string()), "served-name"));
327        assert!(!is_kimi_k3(
328            &Some("kimi_k2".to_string()),
329            "moonshot-kimi-k3"
330        ));
331        assert!(is_kimi_k3(&None, "moonshot-kimi-k3"));
332        assert!(is_kimi_k3(&None, "kimi_k3-instruct"));
333        assert!(!is_kimi_k3(&None, "kimi-k2.5"));
334    }
335
336    #[test]
337    fn v4_name_matches_canonical_variants() {
338        for name in [
339            "deepseek-v4",
340            "deepseek_v4",
341            "deepseek.v4",
342            "deepseekv4",
343            "deepseek-v4-pro",
344            "deepseek-v4-flash",
345            "deepseek-v4-flash-2507",
346            "deepseek-v4.1",
347            "deepseek_v4_thinking",
348        ] {
349            assert!(is_deepseek_v4_name(name), "expected {name} to match V4");
350        }
351    }
352
353    #[test]
354    fn v4_name_rejects_non_v4() {
355        // Composite names that previously short-circuited to V4 before the
356        // V3.2 branch — now correctly rejected.
357        for name in [
358            "deepseek-v3.2-v4-foo",
359            "my-deepseek-v4",
360            "deepseek-v40",
361            "deepseek-v4pro",
362            "deepseekv40",
363            "deepseek-v3",
364            "deepseek-v3.2",
365            "deepseek-r1",
366            "qwen3-v4", // only deepseek-prefixed names qualify
367            "dsflash",
368            "",
369        ] {
370            assert!(
371                !is_deepseek_v4_name(name),
372                "expected {name} to NOT match V4",
373            );
374        }
375    }
376
377    #[test]
378    fn v4_detection_prefers_config_model_type() {
379        // config.json `model_type = "deepseek_v4"` wins regardless of what
380        // the operator calls the model via --served-model-name.
381        let v4 = Some("deepseek_v4".to_string());
382        for display in ["dsflash", "my-pet-model", "llama-3-8b", ""] {
383            assert!(
384                is_deepseek_v4(&v4, display),
385                "config says deepseek_v4, display {display:?} — expected V4",
386            );
387        }
388
389        // A concrete non-V4 config.json suppresses the display-name fallback.
390        // Even if the operator names the served model "deepseek-v4", a model
391        // with `model_type = "llama"` is NOT DeepSeek-V4.
392        let llama = Some("llama".to_string());
393        for display in ["deepseek-v4", "deepseek-v4-flash", "anything"] {
394            assert!(
395                !is_deepseek_v4(&llama, display),
396                "config says llama, display {display:?} — expected NOT V4",
397            );
398        }
399
400        // No config.json — fall back to display-name match.
401        assert!(is_deepseek_v4(&None, "deepseek-v4-flash"));
402        assert!(!is_deepseek_v4(&None, "dsflash"));
403
404        // A config.json with `"model_type": ""` is treated as "no signal" at
405        // the call site (normalized to None before is_deepseek_v4 is called),
406        // so the display-name fallback still runs — pin that contract.
407        let empty: Option<String> = None;
408        assert!(is_deepseek_v4(&empty, "deepseek-v4-flash"));
409        assert!(!is_deepseek_v4(&empty, "dsflash"));
410    }
411
412    #[test]
413    fn v3_2_detection_prefers_config_model_type() {
414        // config says deepseek_v3_2, any non-"exp" display name triggers.
415        let v3_2 = Some("deepseek_v3_2".to_string());
416        assert!(is_deepseek_v3_2_non_exp(&v3_2, "whatever"));
417        assert!(is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2"));
418        // V3.2-Exp is a separate model family; suppress even via config.
419        assert!(!is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2-exp"));
420
421        // The actual HF config.json spelling has no underscore between 3 and 2
422        // (`deepseek_v32`). It must trigger identically to the internal key.
423        let hf_real = Some("deepseek_v32".to_string());
424        assert!(is_deepseek_v3_2_non_exp(&hf_real, "whatever"));
425        assert!(is_deepseek_v3_2_non_exp(&hf_real, "deepseek-v3.2-nvfp4"));
426        assert!(!is_deepseek_v3_2_non_exp(&hf_real, "deepseek-v3.2-exp"));
427
428        // Other config types lose regardless of display name.
429        let other = Some("deepseek_v4".to_string());
430        assert!(!is_deepseek_v3_2_non_exp(&other, "deepseek-v3.2"));
431
432        // No config — fall back to the original display-name heuristic.
433        assert!(is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-pro"));
434        assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-exp"));
435        assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v4"));
436    }
437}