1use anyhow::{Context, Result};
15use minijinja::{Environment, Error, ErrorKind, State, Value as JinjaValue};
16use serde_json::{Map, Value};
17
18fn 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
48fn 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
103pub struct LocalRenderer {
105 environment: Environment<'static>,
106 context: Map<String, Value>,
107}
108
109impl LocalRenderer {
110 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 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 let rendered = render(
198 "{{ 'abc'.startswith('ab') }}|{{ 'abc'.endswith('bc') }}",
199 json!({}),
200 json!({}),
201 );
202 assert_eq!(rendered, "True|True");
203
204 let rendered = render("{{ 'a</think>b'.split('</think>')[-1] }}", json!({}), json!({}));
206 assert_eq!(rendered, "b");
207
208 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 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}