1use anyhow::{Context, Result, ensure};
4use minijinja::{Environment, Error, ErrorKind};
5use serde_json::{Map, Value, json};
6use std::{io::ErrorKind as IoErrorKind, path::Path};
7
8pub(crate) struct Prompt {
9 pub text: String,
10 pub boundaries: [usize; 2],
12}
13
14impl Prompt {
15 pub(crate) fn raw(text: String) -> Self {
16 Self {
17 text,
18 boundaries: [0, 0],
19 }
20 }
21}
22
23pub(crate) struct ChatTemplate {
24 env: Environment<'static>,
25}
26
27impl ChatTemplate {
28 pub(crate) fn load(model_dir: &Path) -> Result<Option<Self>> {
29 let path = model_dir.join("chat_template.jinja");
30 let source = match std::fs::read_to_string(&path) {
31 Ok(source) => Some(source),
32 Err(error) if error.kind() == IoErrorKind::NotFound => embedded_template(model_dir)?,
33 Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
34 };
35
36 source.map(Self::new).transpose()
37 }
38
39 fn new(source: String) -> Result<Self> {
40 let mut env = Environment::new();
41
42 env.set_trim_blocks(true);
43 env.set_lstrip_blocks(true);
44 env.set_recursion_limit(64);
45 env.set_fuel(Some(1_000_000));
46 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
47 env.add_function(
48 "raise_exception",
49 |message: String| -> Result<String, Error> {
50 Err(Error::new(ErrorKind::InvalidOperation, message))
51 },
52 );
53 env.add_template_owned("chat", source)
54 .context("compiling checkpoint chat template")?;
55
56 Ok(Self { env })
57 }
58
59 pub(crate) fn user(&self, content: &str) -> Result<Prompt> {
60 self.chat(&[json!({"role": "user", "content": content})], None)
61 }
62
63 pub(crate) fn chat(&self, messages: &[Value], tools: Option<&[Value]>) -> Result<Prompt> {
66 let mut messages = text_messages(messages)?;
67 let tools = tools.filter(|t| !t.is_empty());
68 let text = self.render(&messages, true, tools)?;
69 let message_end = common_prefix(&text, &self.render(&messages, false, tools)?);
70 let mut stable_end = 0;
74
75 if let Some(last_user) = messages.iter().rposition(|m| m["role"] == "user") {
76 messages.truncate(last_user + 1);
77
78 messages[last_user]["content"] = json!("");
79
80 if let Ok(prefix) = self.render(&messages, false, tools) {
81 stable_end = common_prefix(&text, &prefix);
82 }
83 }
84
85 Ok(Prompt {
86 text,
87 boundaries: [stable_end, message_end],
88 })
89 }
90
91 fn render(
92 &self,
93 messages: &[Value],
94 add_generation_prompt: bool,
95 tools: Option<&[Value]>,
96 ) -> Result<String> {
97 let mut context = json!({
100 "messages": messages,
101 "add_generation_prompt": add_generation_prompt,
102 "enable_thinking": false,
103 });
104
105 if let Some(tools) = tools {
107 context["tools"] = json!(tools.to_vec());
108 }
109
110 self.render_context(&context)
111 }
112
113 fn render_context(&self, context: &Value) -> Result<String> {
114 self.env
115 .get_template("chat")?
116 .render(context)
117 .map_err(|error| anyhow::anyhow!("chat template: {error}"))
118 }
119}
120
121fn embedded_template(model_dir: &Path) -> Result<Option<String>> {
122 let path = model_dir.join("tokenizer_config.json");
123 let bytes = match std::fs::read(&path) {
124 Ok(bytes) => bytes,
125 Err(error) if error.kind() == IoErrorKind::NotFound => return Ok(None),
126 Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
127 };
128 let config: Value =
129 serde_json::from_slice(&bytes).context("reading tokenizer configuration")?;
130 let template = &config["chat_template"];
131
132 if let Some(source) = template.as_str() {
133 return Ok(Some(source.to_owned()));
134 }
135
136 Ok(template.as_array().and_then(|templates| {
138 templates
139 .iter()
140 .find(|t| t["name"] == "default")?
141 .get("template")?
142 .as_str()
143 .map(str::to_owned)
144 }))
145}
146
147pub(crate) fn valid_function_name(name: &str) -> bool {
150 (1..=64).contains(&name.len())
151 && name
152 .bytes()
153 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
154}
155
156fn normalize_content(message: &mut Value) -> Result<()> {
160 for part in message
161 .get_mut("content")
162 .and_then(Value::as_array_mut)
163 .into_iter()
164 .flatten()
165 {
166 let obj = if part.is_string() {
167 anyhow::bail!("content parts must be typed objects")
168 } else {
169 part.as_object()
170 .ok_or_else(|| anyhow::anyhow!("content parts must be typed objects"))?
171 };
172 let typ = obj.get("type").and_then(|v| v.as_str());
173
174 match typ {
175 Some("text") => {
176 let text = obj
177 .get("text")
178 .and_then(|v| v.as_str())
179 .context("text content part requires a string 'text'")?;
180
181 *part = json!({"type": "text", "text": text});
182 }
183 Some("image") | Some("image_url") => {
184 anyhow::bail!("image input is not supported")
185 }
186 Some("video") => anyhow::bail!("video input is not supported"),
187 Some(other) => anyhow::bail!("content part type '{other}' is not supported"),
188 None => anyhow::bail!("content parts must be typed objects"),
189 }
190 }
191
192 Ok(())
193}
194
195fn text_messages(messages: &[Value]) -> Result<Vec<Value>> {
196 ensure!(!messages.is_empty(), "messages must not be empty");
197
198 let mut messages = messages.to_vec();
199
200 for (index, message) in messages.iter_mut().enumerate() {
201 normalize_content(message)?;
202
203 let role = message["role"]
204 .as_str()
205 .context("message role must be a string")?
206 .to_owned();
207
208 ensure!(
209 ["system", "developer", "user", "assistant", "tool"].contains(&role.as_str()),
210 "unsupported role {role}"
211 );
212
213 match role.as_str() {
214 "assistant" => validate_assistant_message(message, index)?,
215 "tool" => validate_tool_message(message, index)?,
216 _ => validate_plain_message(message)?,
217 }
218
219 if role == "developer" {
220 message["role"] = json!("system");
221 }
222 }
223
224 Ok(messages)
225}
226
227fn validate_plain_message(message: &Value) -> Result<()> {
228 ensure!(
229 message["content"].is_string() || message["content"].is_array(),
230 "message content must be text"
231 );
232 ensure!(
233 message["tool_calls"].is_null() || message["tool_calls"] == json!([]),
234 "tool_calls are only valid on assistant messages"
235 );
236 ensure!(
237 message["function_call"].is_null(),
238 "function_call is only valid on assistant messages"
239 );
240 ensure!(
241 message["tool_call_id"].is_null(),
242 "tool_call_id is only valid on tool messages"
243 );
244 ensure_reasoning_content_absent(message)?;
245
246 Ok(())
247}
248
249fn validate_tool_message(message: &Value, index: usize) -> Result<()> {
250 ensure!(
251 message["content"].is_string() || message["content"].is_array(),
252 "tool message {index} content must be text"
253 );
254 ensure!(
255 message["tool_calls"].is_null() || message["tool_calls"] == json!([]),
256 "tool messages cannot contain tool_calls"
257 );
258 ensure!(
259 message["function_call"].is_null(),
260 "function_call is only valid on assistant messages"
261 );
262
263 if !message["tool_call_id"].is_null() {
264 message["tool_call_id"]
265 .as_str()
266 .context("tool_call_id must be a string")?;
267 }
268
269 ensure_reasoning_content_absent(message)?;
270
271 Ok(())
272}
273
274fn ensure_reasoning_content_absent(message: &Value) -> Result<()> {
275 if !message["reasoning_content"].is_null() {
276 let reasoning = message["reasoning_content"]
277 .as_str()
278 .context("reasoning_content must be a string")?;
279
280 ensure!(
281 reasoning.is_empty(),
282 "reasoning_content is only valid on assistant messages"
283 );
284 }
285
286 Ok(())
287}
288
289fn validate_assistant_message(message: &mut Value, index: usize) -> Result<()> {
290 ensure!(
291 message["content"].is_string()
292 || message["content"].is_array()
293 || message["content"].is_null(),
294 "assistant message {index} content must be text or null"
295 );
296 ensure!(
297 message["tool_call_id"].is_null(),
298 "message {index} tool_call_id is only valid on tool messages"
299 );
300
301 if !message["reasoning_content"].is_null() {
302 message["reasoning_content"]
303 .as_str()
304 .context("assistant reasoning_content must be a string")?;
305 }
306
307 let mut calls: Vec<Value> = Vec::new();
309
310 if !message["function_call"].is_null() {
311 let legacy = message["function_call"]
312 .as_object()
313 .context("function_call must be an object")?;
314 let (name, arguments) = function_call_name_and_arguments(legacy)?;
315
316 calls.push(tool_call_value(String::new(), name, arguments));
317 message.as_object_mut().unwrap().remove("function_call");
318 }
319
320 if !message["tool_calls"].is_null() {
321 for call in message["tool_calls"]
322 .as_array()
323 .context("tool_calls must be an array")?
324 {
325 if !call.is_object() {
326 anyhow::bail!("message {index} tool_calls entries must be objects");
327 }
328
329 if !call["id"].is_string() {
330 anyhow::bail!("message {index} tool_calls entries must contain a string id");
331 }
332
333 ensure!(
334 call["type"] == json!("function"),
335 "only function tool_calls are supported"
336 );
337
338 let function = call["function"]
339 .as_object()
340 .context("tool_calls entries must contain a function object")?;
341 let (name, arguments) = function_call_name_and_arguments(function)?;
342
343 calls.push(tool_call_value(
344 call["id"].as_str().unwrap().to_owned(),
345 name,
346 arguments,
347 ));
348 }
349 }
350
351 if !calls.is_empty() {
352 message["tool_calls"] = Value::Array(calls);
353 }
354
355 Ok(())
356}
357
358fn function_call_name_and_arguments(function: &Map<String, Value>) -> Result<(String, Value)> {
362 let name = function
363 .get("name")
364 .and_then(Value::as_str)
365 .context("function name must be a string")?;
366
367 ensure!(
368 valid_function_name(name),
369 "function name must match [A-Za-z0-9_-]{{1,64}}"
370 );
371
372 let arguments = function
373 .get("arguments")
374 .context("function arguments are required")?;
375
376 let decoded = match arguments {
377 Value::String(encoded) if encoded.trim().is_empty() => Value::Object(Map::new()),
378 Value::String(encoded) => {
379 serde_json::from_str(encoded).context("function arguments must be valid JSON")?
380 }
381 Value::Object(_) => arguments.clone(),
382 other => anyhow::bail!("function arguments must be a JSON string or object, not {other:?}"),
383 };
384
385 ensure!(
386 decoded.is_object(),
387 "function arguments must decode to a JSON object"
388 );
389
390 Ok((name.to_owned(), decoded))
391}
392
393fn tool_call_value(id: String, name: String, arguments: Value) -> Value {
394 json!({
395 "id": id,
396 "type": "function",
397 "function": {
398 "name": name,
399 "arguments": arguments,
400 },
401 })
402}
403
404fn common_prefix(a: &str, b: &str) -> usize {
405 a.chars()
406 .zip(b.chars())
407 .take_while(|(a, b)| a == b)
408 .map(|(c, _)| c.len_utf8())
409 .sum()
410}
411
412#[cfg(test)]
413pub(crate) fn fixture_template() -> ChatTemplate {
414 ChatTemplate::new(include_str!("../tests/fixtures/prompt/chat_template.jinja").to_owned())
415 .expect("checkpoint template fixture")
416}
417
418#[cfg(test)]
419#[path = "../tests/unit/prompt.rs"]
420mod tests;