Skip to main content

jev_bridge/
render.rs

1//! Local chat-template rendering.
2//!
3//! The serving runtime's own template is the preferred source (`/apply-template`
4//! on llama.cpp), but runtimes such as vLLM expose no such endpoint. This module
5//! renders the same Jinja template locally with minijinja.
6//!
7//! llama.cpp renders with the C++ minijinja plus Python-style string methods,
8//! which the Rust crate does not implement. Those methods are supplied through
9//! [`Environment::set_unknown_method_callback`] so a template written for
10//! transformers still renders here. `python_string_methods` mirrors CPython
11//! semantics, including treating the argument of `strip`/`lstrip`/`rstrip` as a
12//! set of characters rather than a prefix.
13
14use anyhow::{Context, Result};
15use minijinja::{Environment, Error, ErrorKind, State, Value as JinjaValue};
16use serde_json::{Map, Value};
17
18/// `strip` / `lstrip` / `rstrip` with CPython's character-set semantics.
19fn strip_chars(text: &str, chars: Option<&str>, left: bool, right: bool) -> String {
20    let mut result = text;
21    if left {
22        result = result.trim_start_matches(|character: char| match chars {
23            Some(set) => set.contains(character),
24            None => character.is_whitespace(),
25        });
26    }
27    if right {
28        result = result.trim_end_matches(|character: char| match chars {
29            Some(set) => set.contains(character),
30            None => character.is_whitespace(),
31        });
32    }
33    result.to_string()
34}
35
36fn string_argument(method: &str, args: &[JinjaValue], index: usize) -> Result<String, Error> {
37    args.get(index)
38        .and_then(|value| value.as_str())
39        .map(str::to_string)
40        .ok_or_else(|| {
41            Error::new(
42                ErrorKind::InvalidOperation,
43                format!("{method}() expects a string argument at position {index}"),
44            )
45        })
46}
47
48/// Methods a transformers chat template may call that the Rust minijinja lacks.
49fn python_string_methods(
50    _state: &State,
51    value: &JinjaValue,
52    method: &str,
53    args: &[JinjaValue],
54) -> Result<JinjaValue, Error> {
55    if let Some(text) = value.as_str() {
56        return match method {
57            "startswith" => Ok(JinjaValue::from(text.starts_with(&string_argument(method, args, 0)?))),
58            "endswith" => Ok(JinjaValue::from(text.ends_with(&string_argument(method, args, 0)?))),
59            "replace" => Ok(JinjaValue::from(text.replace(
60                &string_argument(method, args, 0)?,
61                &string_argument(method, args, 1)?,
62            ))),
63            "split" => {
64                let separator = string_argument(method, args, 0)?;
65                let parts: Vec<String> = text.split(&separator).map(str::to_string).collect();
66                Ok(JinjaValue::from(parts))
67            }
68            "strip" | "lstrip" | "rstrip" => {
69                let chars = if args.is_empty() {
70                    None
71                } else {
72                    Some(string_argument(method, args, 0)?)
73                };
74                Ok(JinjaValue::from(strip_chars(
75                    text,
76                    chars.as_deref(),
77                    method != "rstrip",
78                    method != "lstrip",
79                )))
80            }
81            _ => Err(Error::new(
82                ErrorKind::UnknownMethod,
83                format!("string has no method named {method}"),
84            )),
85        };
86    }
87
88    if method == "items" && value.as_object().is_some() {
89        let mut items = Vec::new();
90        for key in value.try_iter()? {
91            let entry = value.get_item(&key)?;
92            items.push(JinjaValue::from(vec![key, entry]));
93        }
94        return Ok(JinjaValue::from(items));
95    }
96
97    Err(Error::new(
98        ErrorKind::UnknownMethod,
99        format!("value has no method named {method}"),
100    ))
101}
102
103/// A chat template rendered in-process.
104pub struct LocalRenderer {
105    environment: Environment<'static>,
106    context: Map<String, Value>,
107}
108
109impl LocalRenderer {
110    /// Compile `template`, adding `context` to every render.
111    ///
112    /// The context carries the variables transformers supplies besides the
113    /// messages, such as `bos_token` and `eos_token`; a template that starts
114    /// with `{{- bos_token }}` needs them.
115    pub fn new(template: String, context: Map<String, Value>) -> Result<Self> {
116        let mut environment = Environment::new();
117        environment.set_unknown_method_callback(python_string_methods);
118        environment
119            .add_template_owned("chat", template)
120            .context("the chat template failed to compile")?;
121        Ok(Self {
122            environment,
123            context,
124        })
125    }
126
127    /// Render `messages`, with `chat_template_kwargs` taking precedence over the
128    /// configured context.
129    ///
130    /// `messages` is generic over `Serialize` so this module stays independent
131    /// of the prompt layer: any chat-shaped value renders.
132    pub fn render<M: serde::Serialize + ?Sized>(
133        &self,
134        messages: &M,
135        chat_template_kwargs: &Value,
136    ) -> Result<String> {
137        let template = self
138            .environment
139            .get_template("chat")
140            .context("the chat template disappeared from the environment")?;
141        let mut context = self.context.clone();
142        context.insert(
143            "messages".to_string(),
144            serde_json::to_value(messages).context("messages are not serialisable")?,
145        );
146        context.insert("add_generation_prompt".to_string(), Value::Bool(true));
147        if let Some(extra) = chat_template_kwargs.as_object() {
148            for (key, value) in extra {
149                context.insert(key.clone(), value.clone());
150            }
151        }
152        template
153            .render(Value::Object(context))
154            .context("the chat template failed to render")
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use serde_json::json;
162
163    fn render(template: &str, context: Value, kwargs: Value) -> String {
164        let renderer = LocalRenderer::new(template.to_string(), context.as_object().unwrap().clone())
165            .unwrap();
166        renderer
167            .render(&json!([{"role": "user", "content": "hi"}]), &kwargs)
168            .unwrap()
169    }
170
171    #[test]
172    fn renders_messages_and_generation_prompt() {
173        let template = "{% for m in messages %}{{ m.role }}:{{ m.content }}\n{% endfor %}{% if add_generation_prompt %}assistant:{% endif %}";
174        let rendered = render(template, json!({}), json!({}));
175        assert_eq!(rendered, "user:hi\nassistant:");
176    }
177
178    #[test]
179    fn context_variables_are_available() {
180        let rendered = render("{{- bos_token }}x", json!({"bos_token": "<s>"}), json!({}));
181        assert_eq!(rendered, "<s>x");
182    }
183
184    #[test]
185    fn kwargs_override_context() {
186        let rendered = render(
187            "{{ bos_token }}|{{ enable_thinking }}",
188            json!({"bos_token": "<s>", "enable_thinking": true}),
189            json!({"enable_thinking": false}),
190        );
191        assert_eq!(rendered, "<s>|False");
192    }
193
194    #[test]
195    fn python_string_methods_match_cpython() {
196        // startswith / endswith, as used to detect tool responses.
197        let rendered = render(
198            "{{ 'abc'.startswith('ab') }}|{{ 'abc'.endswith('bc') }}",
199            json!({}),
200            json!({}),
201        );
202        assert_eq!(rendered, "True|True");
203
204        // split returns an indexable list.
205        let rendered = render("{{ 'a</think>b'.split('</think>')[-1] }}", json!({}), json!({}));
206        assert_eq!(rendered, "b");
207
208        // strip with a character set, not a prefix: '\n\nx\n\n'.strip('\n') == 'x'
209        let rendered = render(
210            "{{ '\n\nx\n\n'.strip('\n') }}|{{ '  y  '.lstrip() }}|{{ '  y  '.rstrip() }}",
211            json!({}),
212            json!({}),
213        );
214        assert_eq!(rendered, "x|y  |  y");
215
216        // replace substitutes every occurrence.
217        let rendered = render("{{ 'a-b-c'.replace('-', '+') }}", json!({}), json!({}));
218        assert_eq!(rendered, "a+b+c");
219    }
220
221    #[test]
222    fn dict_items_are_iterable() {
223        let rendered = render(
224            "{% for k, v in data.items() %}{{ k }}={{ v }};{% endfor %}",
225            json!({"data": {"a": 1, "b": 2}}),
226            json!({}),
227        );
228        assert_eq!(rendered, "a=1;b=2;");
229    }
230
231    #[test]
232    fn unknown_methods_still_fail_loudly() {
233        let renderer =
234            LocalRenderer::new("{{ 'x'.frobnicate() }}".to_string(), Map::new()).unwrap();
235        let error = renderer.render(&json!([]), &json!({})).unwrap_err();
236        assert!(
237            format!("{error:#}").contains("no method named frobnicate"),
238            "{error:#}"
239        );
240    }
241
242    #[test]
243    fn a_broken_template_fails_at_construction() {
244        assert!(LocalRenderer::new("{% for x in %}".to_string(), Map::new()).is_err());
245    }
246}