Skip to main content

cherenkov/
prompt.rs

1//! Checkpoint-owned Jinja formatting, compiled once when the tokenizer loads.
2
3use 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    /// Safe byte prefixes, checked against the complete rendered prompt.
11    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    /// Render a chat conversation; `tools` are the shaped function definitions
64    /// for the template's tools system block (omitted when empty).
65    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        // Prefix-only renders can be invalid (the template requires a user query).
71        // Keep the last user with empty content as a cache probe, and trust only
72        // bytes that also occur at the beginning of the full rendered prompt.
73        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        // Preserve the engine's direct-answer default. Other formatting and
98        // reasoning-history defaults come from the checkpoint's template.
99        let mut context = json!({
100            "messages": messages,
101            "add_generation_prompt": add_generation_prompt,
102            "enable_thinking": false,
103        });
104
105        // The template renders a tools system block only for a non-empty list.
106        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    // Transformers also saves named templates; chat without tools uses "default".
137    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
147/// Names accepted on the wire; the tokenizer side uses a longer bound for
148/// model-emitted names.
149pub(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
156/// Validate typed parts and keep only the fields used by their declared type.
157/// The checkpoint template checks media keys before text, so extra fields
158/// must not reach it and change a text part into an image or video placeholder.
159fn 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    // Keep the wire order: a legacy function_call precedes tool_calls.
308    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
358/// Validate a function reference and decode its `arguments` into the object
359/// the checkpoint template iterates. Fresh requests carry the wire form
360/// (a JSON string); retained session history carries the decoded object.
361fn 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;