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
51impl PromptFormatter {
52    pub fn from_parts(
53        config: ChatTemplate,
54        context: ContextMixins,
55        exclude_tools_when_tool_choice_none: bool,
56    ) -> Result<PromptFormatter> {
57        let formatter = HfTokenizerConfigJsonFormatter::with_options(
58            config,
59            context,
60            exclude_tools_when_tool_choice_none,
61        )?;
62        Ok(Self::OAI(Arc::new(formatter)))
63    }
64}
65
66/// Chat Template Jinja Renderer
67///
68/// Manages a Jinja environment with registered templates for chat formatting.
69/// Handles two types of ChatTemplateValue templates:
70///
71/// 1. String template: Registered as the 'default' template
72/// 2. Map template: Contains 'tool_use' and/or 'default' templates
73///    - tool_use: Template for tool-based interactions
74///    - default: Template for standard chat interactions
75///
76///   If the map contains both keys, the `tool_use` template is registered as the `tool_use` template
77///   and the `default` template is registered as the `default` template.
78struct JinjaEnvironment {
79    env: Environment<'static>,
80}
81
82/// Formatter for HuggingFace tokenizer config JSON templates
83///
84/// Implements chat template rendering based on HuggingFace's tokenizer_config.json format.
85/// Supports:
86/// - Tool usage templates
87/// - Generation prompts
88/// - Context mixins for template customization
89#[derive(Debug)]
90struct HfTokenizerConfigJsonFormatter {
91    env: Environment<'static>,
92    config: ChatTemplate,
93    mixins: Arc<ContextMixins>,
94    supports_add_generation_prompt: bool,
95    requires_content_arrays: bool,
96    /// When true, strip tool definitions from the chat template when tool_choice is "none".
97    /// This prevents models from generating raw XML tool calls in the content field.
98    exclude_tools_when_tool_choice_none: bool,
99    /// True if the chat template natively references `reasoning_content`.
100    /// When true, skip injection — the template handles it.
101    template_handles_reasoning: bool,
102    /// Per-family placeholder template for image content parts when flattening
103    /// mixed text+image content arrays into a single string (`preserve_arrays`
104    /// = false path). `{n}` in the template is substituted with the 1-based
105    /// image index. `None` when the model's chat template handles content
106    /// arrays natively (Qwen-VL family) or when we have no flatten strategy
107    /// for it (no MM-aware routing benefit either way).
108    image_placeholder_template: Option<&'static str>,
109    /// True if the `default` template branches on `tool_call.arguments is string`
110    /// (Qwen3, Hermes, etc.). When true and rendering through `default`, skip
111    /// pre-parsing the JSON-string `tool_calls[].function.arguments` into an
112    /// object — the template wants the raw string verbatim. Pre-parsing forces
113    /// the `tojson`-with-object branch and re-emits with minijinja's compact
114    /// separators, which breaks append-only prefix matching across multi-step
115    /// tool-use turns. Tracked separately for `default` and `tool_use` because
116    /// HF configs may register different sources for each, and because
117    /// `arguments is string` is tool_calls-specific — legacy
118    /// `function_call.arguments` lives outside that branch and is still
119    /// normalized unconditionally.
120    default_template_handles_tool_calls_arguments_string: bool,
121    /// True if the `tool_use` template branches on `tool_call.arguments is string`.
122    /// See `default_template_handles_tool_calls_arguments_string` for rationale.
123    tool_use_template_handles_tool_calls_arguments_string: bool,
124}
125
126// /// OpenAI Standard Prompt Formatter
127// pub trait StandardPromptFormatter {
128//     fn render(&self, context: &impl StandardPromptContext) -> Result<String>;
129// }
130
131// pub trait StandardPromptContext {
132//     fn messages(&self) -> Value;
133//     fn tools(&self) -> Option<Value>;
134// }
135
136#[derive(Debug, Clone, Default)]
137pub struct ContextMixins {
138    context_mixins: HashSet<PromptContextMixin>,
139}
140
141/// Decides whether to activate the DeepSeek-V4 native formatter.
142///
143/// Primary signal: config.json `model_type`. DeepSeek-V4-Pro and V4-Flash both
144/// ship `"model_type": "deepseek_v4"`, set by the model author — this survives
145/// any `--served-model-name` rename.
146///
147/// Fallback: `display_name`, tight-matched against
148/// `^deepseek(?:[-_.])?v4(?:[-_.]|$)`. Only consulted when config.json is
149/// absent (tokenizer-only MDCs) or unreadable; a concrete config.json value
150/// that is *not* `deepseek_v4` is authoritative and suppresses the fallback.
151fn is_deepseek_v4(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
152    match model_type_lower.as_deref() {
153        Some("deepseek_v4") => true,
154        Some(_) => false, // config.json says something else — trust it
155        None => is_deepseek_v4_name(display_name_lower),
156    }
157}
158
159/// Decides whether to activate the DeepSeek-V3.2 (non-Exp) native formatter.
160/// Same config-primary / name-fallback rule as V4.
161fn is_deepseek_v3_2_non_exp(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
162    let name_match = display_name_lower.contains("deepseek")
163        && display_name_lower.contains("v3.2")
164        && !display_name_lower.contains("exp");
165    match model_type_lower.as_deref() {
166        Some("deepseek_v3_2") => !display_name_lower.contains("exp"),
167        Some(_) => false,
168        None => name_match,
169    }
170}
171
172/// Tight, anchored match for DeepSeek-V4 display names. Equivalent to the
173/// regex `^deepseek(?:[-_.])?v4(?:[-_.]|$)` over an already-lowercased string.
174/// Written with string ops to avoid pulling in the `regex` crate.
175///
176/// Rejects composite names that previously short-circuited the V4 branch:
177/// - `deepseek-v3.2-v4-foo` (the `v3.2` variant is the real one)
178/// - `deepseek-v40` / `deepseek-v4pro` (no separator after `v4`)
179/// - `my-deepseek-v4` (prefix must be at the start)
180fn is_deepseek_v4_name(name_lower: &str) -> bool {
181    let Some(rest) = name_lower.strip_prefix("deepseek") else {
182        return false;
183    };
184    // Optional single separator between "deepseek" and "v4".
185    let rest = rest
186        .strip_prefix(|c: char| matches!(c, '-' | '_' | '.'))
187        .unwrap_or(rest);
188    let Some(after_v4) = rest.strip_prefix("v4") else {
189        return false;
190    };
191    // `v4` must end the name or be followed by a separator — anything else
192    // (e.g. `v40`, `v4pro`) is a different model family.
193    after_v4.is_empty() || after_v4.starts_with(['-', '_', '.'])
194}
195
196#[cfg(test)]
197mod detection_tests {
198    use super::{is_deepseek_v3_2_non_exp, is_deepseek_v4, is_deepseek_v4_name};
199
200    #[test]
201    fn v4_name_matches_canonical_variants() {
202        for name in [
203            "deepseek-v4",
204            "deepseek_v4",
205            "deepseek.v4",
206            "deepseekv4",
207            "deepseek-v4-pro",
208            "deepseek-v4-flash",
209            "deepseek-v4-flash-2507",
210            "deepseek-v4.1",
211            "deepseek_v4_thinking",
212        ] {
213            assert!(is_deepseek_v4_name(name), "expected {name} to match V4");
214        }
215    }
216
217    #[test]
218    fn v4_name_rejects_non_v4() {
219        // Composite names that previously short-circuited to V4 before the
220        // V3.2 branch — now correctly rejected.
221        for name in [
222            "deepseek-v3.2-v4-foo",
223            "my-deepseek-v4",
224            "deepseek-v40",
225            "deepseek-v4pro",
226            "deepseekv40",
227            "deepseek-v3",
228            "deepseek-v3.2",
229            "deepseek-r1",
230            "qwen3-v4", // only deepseek-prefixed names qualify
231            "dsflash",
232            "",
233        ] {
234            assert!(
235                !is_deepseek_v4_name(name),
236                "expected {name} to NOT match V4",
237            );
238        }
239    }
240
241    #[test]
242    fn v4_detection_prefers_config_model_type() {
243        // config.json `model_type = "deepseek_v4"` wins regardless of what
244        // the operator calls the model via --served-model-name.
245        let v4 = Some("deepseek_v4".to_string());
246        for display in ["dsflash", "my-pet-model", "llama-3-8b", ""] {
247            assert!(
248                is_deepseek_v4(&v4, display),
249                "config says deepseek_v4, display {display:?} — expected V4",
250            );
251        }
252
253        // A concrete non-V4 config.json suppresses the display-name fallback.
254        // Even if the operator names the served model "deepseek-v4", a model
255        // with `model_type = "llama"` is NOT DeepSeek-V4.
256        let llama = Some("llama".to_string());
257        for display in ["deepseek-v4", "deepseek-v4-flash", "anything"] {
258            assert!(
259                !is_deepseek_v4(&llama, display),
260                "config says llama, display {display:?} — expected NOT V4",
261            );
262        }
263
264        // No config.json — fall back to display-name match.
265        assert!(is_deepseek_v4(&None, "deepseek-v4-flash"));
266        assert!(!is_deepseek_v4(&None, "dsflash"));
267
268        // A config.json with `"model_type": ""` is treated as "no signal" at
269        // the call site (normalized to None before is_deepseek_v4 is called),
270        // so the display-name fallback still runs — pin that contract.
271        let empty: Option<String> = None;
272        assert!(is_deepseek_v4(&empty, "deepseek-v4-flash"));
273        assert!(!is_deepseek_v4(&empty, "dsflash"));
274    }
275
276    #[test]
277    fn v3_2_detection_prefers_config_model_type() {
278        // config says deepseek_v3_2, any non-"exp" display name triggers.
279        let v3_2 = Some("deepseek_v3_2".to_string());
280        assert!(is_deepseek_v3_2_non_exp(&v3_2, "whatever"));
281        assert!(is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2"));
282        // V3.2-Exp is a separate model family; suppress even via config.
283        assert!(!is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2-exp"));
284
285        // Other config types lose regardless of display name.
286        let other = Some("deepseek_v4".to_string());
287        assert!(!is_deepseek_v3_2_non_exp(&other, "deepseek-v3.2"));
288
289        // No config — fall back to the original display-name heuristic.
290        assert!(is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-pro"));
291        assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-exp"));
292        assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v4"));
293    }
294}