Skip to main content

dynamo_renderer/
lib.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Prompt Formatting
5//!
6//! Standalone, runtime-free chat-template / prompt formatting for
7//! OpenAI-compatible inference frontends. Renders HuggingFace `chat_template`
8//! jinja2 (via `minijinja` + `minijinja-contrib` pycompat), handles tool
9//! usage formatting and generation-prompt handling.
10//!
11//! Consumers implement [`OAIChatLikeRequest`] for their request type (or use
12//! the ready-made impl for `dynamo-protocols`' OpenAI chat request) and render
13//! with a [`PromptFormatter`] built from a HuggingFace `tokenizer_config.json`
14//! ([`ChatTemplate`]).
15//!
16//! This crate is a *bridge* between OpenAI request types ([`dynamo_protocols`])
17//! and the HF chat-template engine ([`minijinja`]); it does not depend on
18//! tokenizer internals. `dynamo-tokenizers` is re-exported for convenience.
19
20// TODO:
21// 1. Query if `add_generation_prompt` is present in the prompt template
22// 2. Support for models with add_generation_prompt:
23//    - PALS (Prefix-Assisted Language Sampling)
24//    - Continuation - Detected on user turns, where we can return
25//      partial assistant responses without add_generation_prompt
26
27use anyhow::Result;
28use minijinja::value::Value;
29use std::collections::HashMap;
30use std::sync::Arc;
31
32/// Re-export of `dynamo-tokenizers` as a one-import convenience: consumers that
33/// want both tokenization and chat templating can reach the tokenizer types via
34/// `dynamo_renderer::dynamo_tokenizers::*` without adding a second dependency.
35/// This crate does not otherwise use the tokenizer internals.
36pub use dynamo_tokenizers;
37
38pub mod deepseek;
39mod template;
40
41pub use template::{
42    ChatTemplate, ChatTemplateValue, ContextMixins, deepseek_formatter_for, may_be_fix_tool_schema,
43};
44
45/// Selects which context-mixin behaviors a template renders with.
46///
47/// Carried on the model deployment card (`prompt_context`) and consumed by the
48/// chat-template renderer via [`ContextMixins`].
49#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
50#[serde(rename_all = "snake_case")]
51pub enum PromptContextMixin {
52    /// Support OAI Chat Messages and Tools
53    OaiChat,
54
55    /// Enables templates with `{{datetime}}` to be rendered with the current date and time.
56    Llama3DateTime,
57}
58
59/// Shared helper: extract a boolean thinking toggle from `chat_template_args`.
60///
61/// Reads the two equivalent keys (`thinking`, `enable_thinking` — vLLM's
62/// canonical kwarg) in order and returns the first bool value found, or `None`
63/// if neither key is present (or neither carries a bool). Used by the V4
64/// formatter's `resolve_thinking_mode` and by reasoning-parser gating in
65/// consumers so both paths agree on the signal interpretation.
66pub fn thinking_bool_from_args(args: Option<&HashMap<String, serde_json::Value>>) -> Option<bool> {
67    let args = args?;
68    for key in ["thinking", "enable_thinking"] {
69        if let Some(v) = args.get(key).and_then(|x| x.as_bool()) {
70            return Some(v);
71        }
72    }
73    None
74}
75
76#[derive(Debug)]
77pub enum TokenInput {
78    Single(Vec<u32>),
79    Batch(Vec<Vec<u32>>),
80}
81
82#[derive(Debug)]
83pub enum TextInput {
84    Single(String),
85    Batch(Vec<String>),
86}
87
88#[derive(Debug)]
89pub enum PromptInput {
90    Tokens(TokenInput),
91    Text(TextInput),
92}
93
94/// Trait that defines a request that can map to an OpenAI-like request.
95///
96/// Implement this for your request type to render it through a
97/// [`PromptFormatter`]. Media/multimodal IO config is intentionally *not* part
98/// of this trait — it is a preprocessing concern owned by the consumer, kept
99/// off the rendering surface so this crate stays runtime-free.
100pub trait OAIChatLikeRequest {
101    fn model(&self) -> String;
102    fn messages(&self) -> Value;
103    fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
104        None
105    }
106    fn tools(&self) -> Option<Value> {
107        None
108    }
109    fn tool_choice(&self) -> Option<Value> {
110        None
111    }
112    fn response_format(&self) -> Option<Value> {
113        None
114    }
115
116    fn should_add_generation_prompt(&self) -> bool;
117
118    /// Optional additional args to merge into the chat template context
119    fn chat_template_args(&self) -> Option<&HashMap<String, serde_json::Value>> {
120        None
121    }
122
123    /// Returns the type of input for the prompt. Default is Text.
124    fn prompt_input_type(&self) -> PromptInput {
125        PromptInput::Text(TextInput::Single(String::new()))
126    }
127
128    /// Extract tokens if the input is pre-tokenized
129    fn extract_tokens(&self) -> Option<TokenInput> {
130        None
131    }
132
133    fn extract_text(&self) -> Option<TextInput> {
134        None
135    }
136
137    fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
138        None
139    }
140}
141
142pub trait OAIPromptFormatter: Send + Sync + 'static {
143    fn supports_add_generation_prompt(&self) -> bool;
144    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String>;
145
146    /// Per-family image-placeholder template used when the chat template
147    /// requires string content and the request contains images. `{n}` in
148    /// the template is the 1-based image index. `None` when the
149    /// formatter has no flatten strategy — MM-aware routing falls back
150    /// to text-prefix routing for those families.
151    fn image_placeholder_template(&self) -> Option<&'static str> {
152        None
153    }
154}
155
156#[derive(Clone)]
157pub enum PromptFormatter {
158    OAI(Arc<dyn OAIPromptFormatter>),
159}
160
161// No-op formatter: used for models without chat_template
162#[derive(Debug, Default)]
163pub struct NoOpFormatter;
164
165impl OAIPromptFormatter for NoOpFormatter {
166    fn supports_add_generation_prompt(&self) -> bool {
167        false
168    }
169
170    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
171        let messages = req.messages();
172
173        let first_message = messages
174            .get_item_by_index(0)
175            .map_err(|_| anyhow::Error::msg("No message at index 0 or messages array is empty"))?;
176
177        let content = first_message
178            .get_attr("content")
179            .map_err(|_| anyhow::Error::msg("First message has no 'content' field"))?;
180
181        let content_str = content
182            .as_str()
183            .ok_or_else(|| anyhow::Error::msg("Message content is not a string"))?
184            .to_string();
185        Ok(content_str)
186    }
187}
188
189impl PromptFormatter {
190    pub fn no_op() -> Self {
191        Self::OAI(Arc::new(NoOpFormatter))
192    }
193}