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;
39pub mod inkling;
40mod template;
41
42pub use template::{
43 ChatTemplate, ChatTemplateValue, ContextMixins, deepseek_formatter_for, may_be_fix_tool_schema,
44 native_formatter_for,
45};
46
47/// Selects which context-mixin behaviors a template renders with.
48///
49/// Carried on the model deployment card (`prompt_context`) and consumed by the
50/// chat-template renderer via [`ContextMixins`].
51#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
52#[serde(rename_all = "snake_case")]
53pub enum PromptContextMixin {
54 /// Support OAI Chat Messages and Tools
55 OaiChat,
56
57 /// Enables templates with `{{datetime}}` to be rendered with the current date and time.
58 Llama3DateTime,
59}
60
61/// Shared helper: extract a boolean thinking toggle from `chat_template_args`.
62///
63/// Reads the two equivalent keys (`thinking`, `enable_thinking` — vLLM's
64/// canonical kwarg) in order and returns the first bool value found, or `None`
65/// if neither key is present (or neither carries a bool). Used by the V4
66/// formatter's `resolve_thinking_mode` and by reasoning-parser gating in
67/// consumers so both paths agree on the signal interpretation.
68pub fn thinking_bool_from_args(args: Option<&HashMap<String, serde_json::Value>>) -> Option<bool> {
69 let args = args?;
70 for key in ["thinking", "enable_thinking"] {
71 if let Some(v) = args.get(key).and_then(|x| x.as_bool()) {
72 return Some(v);
73 }
74 }
75 None
76}
77
78#[derive(Debug)]
79pub enum TokenInput {
80 Single(Vec<u32>),
81 Batch(Vec<Vec<u32>>),
82}
83
84#[derive(Debug)]
85pub enum TextInput {
86 Single(String),
87 Batch(Vec<String>),
88}
89
90#[derive(Debug)]
91pub enum PromptInput {
92 Tokens(TokenInput),
93 Text(TextInput),
94}
95
96/// Trait that defines a request that can map to an OpenAI-like request.
97///
98/// Implement this for your request type to render it through a
99/// [`PromptFormatter`]. Media/multimodal IO config is intentionally *not* part
100/// of this trait — it is a preprocessing concern owned by the consumer, kept
101/// off the rendering surface so this crate stays runtime-free.
102pub trait OAIChatLikeRequest {
103 fn model(&self) -> String;
104 fn messages(&self) -> Value;
105 fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
106 None
107 }
108 fn tools(&self) -> Option<Value> {
109 None
110 }
111 fn tool_choice(&self) -> Option<Value> {
112 None
113 }
114 fn response_format(&self) -> Option<Value> {
115 None
116 }
117
118 /// OpenAI-compatible reasoning-effort control, when the request type
119 /// exposes it as a top-level field.
120 fn reasoning_effort(&self) -> Option<Value> {
121 None
122 }
123
124 fn should_add_generation_prompt(&self) -> bool;
125
126 /// Optional additional args to merge into the chat template context
127 fn chat_template_args(&self) -> Option<&HashMap<String, serde_json::Value>> {
128 None
129 }
130
131 /// Returns the type of input for the prompt. Default is Text.
132 fn prompt_input_type(&self) -> PromptInput {
133 PromptInput::Text(TextInput::Single(String::new()))
134 }
135
136 /// Extract tokens if the input is pre-tokenized
137 fn extract_tokens(&self) -> Option<TokenInput> {
138 None
139 }
140
141 fn extract_text(&self) -> Option<TextInput> {
142 None
143 }
144
145 fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
146 None
147 }
148}
149
150pub trait OAIPromptFormatter: Send + Sync + 'static {
151 fn supports_add_generation_prompt(&self) -> bool;
152 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String>;
153
154 /// Per-family image-placeholder template used when the chat template
155 /// requires string content and the request contains images. `{n}` in
156 /// the template is the 1-based image index. `None` when the
157 /// formatter has no flatten strategy — MM-aware routing falls back
158 /// to text-prefix routing for those families.
159 fn image_placeholder_template(&self) -> Option<&'static str> {
160 None
161 }
162}
163
164#[derive(Clone)]
165pub enum PromptFormatter {
166 OAI(Arc<dyn OAIPromptFormatter>),
167}
168
169// No-op formatter: used for models without chat_template
170#[derive(Debug, Default)]
171pub struct NoOpFormatter;
172
173impl OAIPromptFormatter for NoOpFormatter {
174 fn supports_add_generation_prompt(&self) -> bool {
175 false
176 }
177
178 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
179 let messages = req.messages();
180
181 let first_message = messages
182 .get_item_by_index(0)
183 .map_err(|_| anyhow::Error::msg("No message at index 0 or messages array is empty"))?;
184
185 let content = first_message
186 .get_attr("content")
187 .map_err(|_| anyhow::Error::msg("First message has no 'content' field"))?;
188
189 let content_str = content
190 .as_str()
191 .ok_or_else(|| anyhow::Error::msg("Message content is not a string"))?
192 .to_string();
193 Ok(content_str)
194 }
195}
196
197impl PromptFormatter {
198 pub fn no_op() -> Self {
199 Self::OAI(Arc::new(NoOpFormatter))
200 }
201}