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}