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