1use superfluid_abi::Status;
4use minijinja::{context, Environment, Value};
5
6use crate::codec::{
7 learn_delims, Delims, BundleCodec, Channelizer, Segmenter, TextCodec, ToolEnvelope, S_K1, S_K2, S_NAME, S_V1, S_V2,
8};
9use crate::wal::channel;
10
11enum ToolWire {
12 Envelope(ToolEnvelope),
13 Delimited(Delims),
14 Raw,
15}
16
17impl ToolWire {
18 fn learn(codec: &TemplateCodec, frame: Option<(&str, &str)>, gemma_frame: bool) -> ToolWire {
19 if gemma_frame {
20 return ToolWire::Envelope(ToolEnvelope::Gemma);
21 }
22 ToolWire::learn_from(
23 |args: &str| -> Option<String> {
24 let user = crate::codec::ChatMessage::new(crate::wal::role::USER, "hi");
25 let mut asst = crate::codec::ChatMessage::new(crate::wal::role::ASSISTANT, "");
26 asst.tool_calls.push(crate::codec::ToolCallMsg {
27 id: String::new(),
28 name: S_NAME.to_string(),
29 arguments: args.to_string(),
30 });
31 let msgs = vec![codec.message_value(&user), codec.message_value(&asst)];
32 codec.try_render_values(msgs, &[], false, &serde_json::Map::new()).ok()
33 },
34 frame,
35 )
36 }
37
38 fn learn_from(render: impl Fn(&str) -> Option<String>, frame: Option<(&str, &str)>) -> ToolWire {
39 let two = match render(&format!("{{\"{S_K1}\":\"{S_V1}\",\"{S_K2}\":\"{S_V2}\"}}")) {
40 Some(t) => t,
41 None => return ToolWire::Raw,
42 };
43 if two.contains(&format!("\"{S_K1}\"")) && two.contains(&format!("\"{S_NAME}\"")) {
44 return ToolWire::Envelope(ToolEnvelope::Json);
45 }
46 let packed: String = two.chars().filter(|c| !c.is_whitespace()).collect();
47 if packed.contains(&format!("{S_NAME}<arg_key>{S_K1}</arg_key><arg_value>{S_V1}</arg_value>")) {
48 return ToolWire::Envelope(ToolEnvelope::Glm);
49 }
50 let zero = render("{}");
51 match learn_delims(&two, zero.as_deref(), frame) {
52 Some(d) => ToolWire::Delimited(d),
53 None => ToolWire::Raw,
54 }
55 }
56
57 pub(crate) fn parse(&self, raw: &str, schemas: Option<&crate::codec::ToolSchemas>) -> Option<(String, String)> {
58 match self {
59 ToolWire::Envelope(e) => e.parse_call(raw, schemas),
60 ToolWire::Delimited(d) => d.parse(raw, schemas).or_else(|| crate::codec::parse_json_tool_call(raw)),
61 ToolWire::Raw => crate::codec::parse_json_tool_call(raw),
62 }
63 }
64}
65
66pub struct TemplateCodec {
67 inner: BundleCodec,
68 env: Environment<'static>,
69 bos: String,
70 eos: String,
71 markers: Vec<(u32, u32, u32)>,
72 channel_names: Vec<(u32, Vec<u32>)>,
73 terminators: Vec<u32>,
74 tool_wire: ToolWire,
75 verified: bool,
76 template_digest: u64,
77 mentions_reasoning_effort: bool,
78 harmony: Option<crate::harmony::HarmonyIds>,
79 effort_ladders: Vec<(EffortContext, Option<Vec<&'static str>>)>,
80 thinking_switch: bool,
81}
82
83impl TemplateCodec {
84 #[cfg(feature = "basert")]
85 pub fn load(model_path: &std::path::Path) -> Result<TemplateCodec, Status> {
86 let tok = superfluid_engine_ffi::TokenizerHandle::load(model_path).map_err(|_| Status::Fatal)?;
87 Self::from_tokenizer(std::sync::Arc::new(tok))
88 }
89
90 pub fn from_tokenizer(
91 tok: std::sync::Arc<dyn superfluid_engine::Tokenizer>,
92 ) -> Result<TemplateCodec, Status> {
93 let inner = BundleCodec::from_tokenizer(tok);
94 let tok = inner.tokenizer();
95 let source = tok.chat_template_jinja();
96 if source.trim().is_empty() {
97 return Err(Status::Unsupported);
98 }
99 let tool_source = tok.chat_template_named("tool_use").filter(|t| !t.trim().is_empty());
100 let specials = tok.special_tokens();
101
102 let by_id = |id: u32| specials.iter().find(|(_, i)| *i == id).map(|(s, _)| s.clone());
103 let eos = by_id(tok.eos_token()).unwrap_or_default();
104 let bos = tok.bos_token().and_then(by_id).unwrap_or_default();
105
106 let harmony = crate::harmony::HarmonyIds::discover(&specials).filter(|_| source.contains("<|channel|>"));
107
108 let find = |name: &str| specials.iter().find(|(s, _)| s == name).map(|(_, i)| *i);
109 let mut markers = Vec::new();
110 if let (Some(o), Some(c)) = (find("<think>"), find("</think>")) {
111 markers.push((o, c, channel::REASONING));
112 }
113 let mut channel_names = Vec::new();
114 if source.contains("<|channel>thought") {
115 if let (Some(o), Some(c)) = (find("<|channel>"), find("<channel|>")) {
116 markers.push((o, c, channel::REASONING));
117 let mut name = tok.encode("thought\n");
118 if tok.bos_token().is_some_and(|bos| name.first() == Some(&bos)) {
119 name.remove(0);
120 }
121 channel_names.push((o, name));
122 }
123 }
124 let mut gemma_frame = false;
125 if let (Some(o), Some(c)) = (find("<tool_call>"), find("</tool_call>")) {
126 markers.push((o, c, channel::TOOL_CALL));
127 } else if let (Some(o), Some(c)) = (
128 find(crate::codec::GEMMA_TOOL_OPEN),
129 find(crate::codec::GEMMA_TOOL_CLOSE),
130 ) {
131 markers.push((o, c, channel::TOOL_CALL));
132 gemma_frame = true;
133 }
134
135 let mut terminators = Vec::new();
136 if let Some(h) = harmony {
137 terminators.push(h.ret);
138 terminators.push(h.call);
139 }
140 if let (Some(o), Some(c)) = (
141 find(crate::codec::GEMMA_TOOL_RESPONSE_OPEN),
142 find(crate::codec::GEMMA_TOOL_RESPONSE_CLOSE),
143 ) {
144 markers.push((o, c, channel::REASONING));
145 terminators.push(o);
146 }
147
148 let pinned_date = today_utc();
149 let mut template_digest = superfluid_fingerprint::fnv1a64(source.as_bytes(), 0xCBF2_9CE4_8422_2325);
150 if let Some(t) = &tool_source {
151 template_digest = superfluid_fingerprint::fnv1a64(t.as_bytes(), template_digest);
152 }
153 let mentions_reasoning_effort = source.contains("reasoning_effort")
154 || tool_source.as_ref().is_some_and(|t| t.contains("reasoning_effort"));
155 let mut env = Environment::new();
156 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
157 env.add_function("raise_exception", |m: String| -> Result<Value, minijinja::Error> {
158 Err(minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, m))
159 });
160 env.add_function("strftime_now", move |_fmt: String| pinned_date.clone());
161 env.add_filter("tojson", hf_tojson);
162 env.add_template_owned("chat", source)
163 .map_err(|_| Status::Unsupported)?;
164 if let Some(t) = tool_source {
165 env.add_template_owned("chat_tools", t)
166 .map_err(|_| Status::Unsupported)?;
167 }
168
169 let frame = markers
170 .iter()
171 .find(|(_, _, ch)| *ch == channel::TOOL_CALL)
172 .map(|&(o, c, _)| (inner.marker_literal(o), inner.marker_literal(c)));
173
174 let mut codec = TemplateCodec {
175 inner,
176 env,
177 bos,
178 eos,
179 markers,
180 channel_names,
181 terminators,
182 tool_wire: ToolWire::Raw,
183 verified: false,
184 template_digest,
185 mentions_reasoning_effort,
186 harmony,
187 effort_ladders: Vec::new(),
188 thinking_switch: false,
189 };
190 codec.tool_wire = if codec.harmony.is_some() {
191 ToolWire::Envelope(ToolEnvelope::Harmony)
192 } else {
193 ToolWire::learn(&codec, frame.as_ref().map(|(o, c)| (o.as_str(), c.as_str())), gemma_frame)
194 };
195 match codec.try_render(&[("user", "probe")], &[], true) {
196 Ok(t) if !t.trim().is_empty() => {}
197 _ => return Err(Status::Unsupported),
198 }
199 codec.thinking_switch = codec.probe_thinking_switch();
200 if codec.supports_reasoning_effort() {
201 let ladders = learn_effort_ladders(|msgs, tools, generation, kw| {
202 let msgs = msgs.iter().map(|m| codec.message_value(m)).collect();
203 codec.try_render_values(msgs, tools, generation, kw)
204 });
205 if ladders.iter().any(|(_, l)| l.as_ref().is_none_or(|l| !l.is_empty())) {
206 tracing::info!("TemplateCodec: reasoning_effort levels by request shape {:?}", ladders);
207 }
208 codec.effort_ladders = ladders;
209 }
210 codec.verified = codec.validate_append_stability();
211 if !codec.verified {
212 tracing::info!(
213 "TemplateCodec: chat template is not append-stable — each turn renders the whole conversation afresh"
214 );
215 }
216 Ok(codec)
217 }
218
219 fn probe_thinking_switch(&self) -> bool {
220 crate::codec::thinking_switch_changes(|msgs, tools, on| {
221 let mut kw = serde_json::Map::new();
222 kw.insert("enable_thinking".into(), serde_json::Value::Bool(on));
223 let msgs = msgs.iter().map(|m| self.message_value(m)).collect();
224 self.try_render_values(msgs, tools, true, &kw).ok()
225 })
226 }
227
228 pub fn verified(&self) -> bool {
229 self.verified
230 }
231
232 pub fn tool_wire_kind(&self) -> &'static str {
233 match &self.tool_wire {
234 ToolWire::Envelope(e) => e.name(),
235 ToolWire::Delimited(_) => "delimited",
236 ToolWire::Raw => "raw",
237 }
238 }
239
240 pub fn tool_body_is_json(&self) -> bool {
241 matches!(self.tool_wire, ToolWire::Envelope(ToolEnvelope::Json))
242 }
243
244 pub fn template_digest(&self) -> u64 {
245 self.template_digest
246 }
247
248 pub fn render_identity(&self) -> String {
249 let ladders: Vec<Option<&[&str]>> = self.effort_ladders.iter().map(|(_, l)| l.as_deref()).collect();
250 render_identity_of(self.template_digest, &ladders)
251 }
252
253 pub fn render_turns_structured(
254 &self,
255 messages: &[crate::codec::ChatMessage],
256 tools: &[String],
257 ) -> Option<Vec<u32>> {
258 self.render_turns_structured_with(messages, tools, &serde_json::Map::new())
259 }
260
261 pub fn render_turns_structured_with(
262 &self,
263 messages: &[crate::codec::ChatMessage],
264 tools: &[String],
265 kwargs: &serde_json::Map<String, serde_json::Value>,
266 ) -> Option<Vec<u32>> {
267 self.try_render_turns_structured_with(messages, tools, kwargs).ok()
268 }
269
270 pub fn try_render_turns_structured_with(
271 &self,
272 messages: &[crate::codec::ChatMessage],
273 tools: &[String],
274 kwargs: &serde_json::Map<String, serde_json::Value>,
275 ) -> Result<Vec<u32>, String> {
276 self.try_render_prompt_protected(messages, tools, kwargs, false)
277 .map_err(|e| format!("{e:#}"))
278 }
279
280 fn message_value(&self, m: &crate::codec::ChatMessage) -> Value {
281 self.message_value_with(m, &mut None)
282 }
283
284 fn message_value_with(
285 &self,
286 m: &crate::codec::ChatMessage,
287 subs: &mut Option<Vec<(String, String)>>,
288 ) -> Value {
289 fn stand_in(subs: &mut Option<Vec<(String, String)>>, text: &str) -> String {
290 match subs {
291 Some(s) => {
292 let key = crate::codec::stand_in_key(s.len());
293 s.push((key.clone(), text.to_string()));
294 key
295 }
296 None => text.to_string(),
297 }
298 }
299 let protect = m.role != crate::wal::role::ASSISTANT;
300 let role = role_name(m.role);
301 let mut fields: Vec<(&str, Value)> = vec![("role", Value::from(role))];
302 if !m.parts.is_empty() {
303 let parts: Vec<serde_json::Value> = m
304 .parts
305 .iter()
306 .map(|p| match p {
307 crate::codec::ContentPart::Text(t) => {
308 let text = if protect { stand_in(subs, t) } else { t.clone() };
309 serde_json::json!({"type": "text", "text": text})
310 }
311 crate::codec::ContentPart::Image { .. } => serde_json::json!({"type": "image"}),
312 crate::codec::ContentPart::Audio { .. } => serde_json::json!({"type": "audio"}),
313 })
314 .collect();
315 fields.push(("content", Value::from_serialize(&parts)));
316 } else {
317 let content = if protect { stand_in(subs, &m.content) } else { m.content.clone() };
318 fields.push(("content", Value::from(content)));
319 }
320 if let Some(r) = &m.reasoning {
321 fields.push(("reasoning_content", Value::from(r.clone())));
322 fields.push(("thinking", Value::from(r.clone())));
323 }
324 let calls: Vec<Value> = m
325 .tool_calls
326 .iter()
327 .map(|c| {
328 let args = match serde_json::from_str::<serde_json::Value>(&c.arguments) {
329 Ok(serde_json::Value::Object(o)) => Value::from_serialize(&o),
330 Ok(serde_json::Value::Null) => Value::from_serialize(serde_json::Map::new()),
331 Ok(other) => Value::from_serialize(serde_json::json!({ "value": other })),
332 Err(_) if c.arguments.trim().is_empty() => {
333 Value::from_serialize(serde_json::Map::new())
334 }
335 Err(_) => Value::from_serialize(serde_json::json!({ "value": c.arguments })),
336 };
337 Value::from_iter([
338 ("id", Value::from(c.id.clone())),
339 ("type", Value::from("function")),
340 ("function", context! { name => c.name.clone(), arguments => args }),
341 ])
342 })
343 .collect();
344 if !calls.is_empty() {
345 fields.push(("tool_calls", Value::from(calls)));
346 }
347 if let Some(id) = &m.tool_call_id {
348 fields.push(("tool_call_id", Value::from(id.clone())));
349 }
350 if let Some(name) = &m.name {
351 fields.push(("name", Value::from(name.clone())));
352 }
353 Value::from_iter(fields)
354 }
355
356 fn try_render_prompt_protected(
357 &self,
358 messages: &[crate::codec::ChatMessage],
359 tools: &[String],
360 kwargs: &serde_json::Map<String, serde_json::Value>,
361 add_generation_prompt: bool,
362 ) -> Result<Vec<u32>, minijinja::Error> {
363 let reference = {
364 let msgs: Vec<Value> = messages.iter().map(|m| self.message_value(m)).collect();
365 self.try_render_values(msgs, tools, add_generation_prompt, kwargs)?
366 };
367 let mut subs = Some(Vec::new());
368 let msgs: Vec<Value> = messages
369 .iter()
370 .map(|m| self.message_value_with(m, &mut subs))
371 .collect();
372 let subs = subs.unwrap_or_default();
373 let with_stand_ins = self.try_render_values(msgs, tools, add_generation_prompt, kwargs)?;
374 match self.encode_rendered(&with_stand_ins, &subs, &reference) {
375 Some(toks) => Ok(toks),
376 None => {
377 tracing::debug!(
378 "chat template does not carry every message verbatim; \
379 the prompt is encoded as framing"
380 );
381 Ok(self.encode_framing(&reference))
382 }
383 }
384 }
385
386 fn encode_rendered(
387 &self,
388 text: &str,
389 subs: &[(String, String)],
390 reference: &str,
391 ) -> Option<Vec<u32>> {
392 let owned =
393 crate::codec::split_around_stand_ins(text, subs, reference, crate::codec::Placement::KnownFormsOnly)?;
394 let pieces: Vec<(&str, bool)> = owned.iter().map(|(t, c)| (t.as_str(), *c)).collect();
395 Some(self.inner.encode_pieces(&pieces))
396 }
397
398 fn try_render_values(
399 &self,
400 msgs: Vec<Value>,
401 tools: &[String],
402 add_generation_prompt: bool,
403 kwargs: &serde_json::Map<String, serde_json::Value>,
404 ) -> Result<String, minijinja::Error> {
405 let tool_vals: Vec<Value> = tools
406 .iter()
407 .filter_map(|t| serde_json::from_str::<serde_json::Value>(t).ok())
408 .map(|j| Value::from_serialize(&j))
409 .collect();
410 let t = if !tool_vals.is_empty() {
411 self.env.get_template("chat_tools").or_else(|_| self.env.get_template("chat"))?
412 } else {
413 self.env.get_template("chat")?
414 };
415 let remapped;
416 let ladder = EffortContext::of(&msgs, tools, add_generation_prompt, kwargs).best(&self.effort_ladders);
417 let kwargs = match kwargs
418 .get("reasoning_effort")
419 .and_then(|v| v.as_str())
420 .and_then(|r| nearest_effort(ladder, r).map(|to| (r, to)))
421 {
422 Some((from, to)) => {
423 tracing::debug!("reasoning_effort {from:?} -> {to:?} (this request shape's levels: {ladder:?})");
424 let mut m = kwargs.clone();
425 m.insert("reasoning_effort".into(), serde_json::Value::String(to.into()));
426 remapped = m;
427 &remapped
428 }
429 None => kwargs,
430 };
431 let extra = Value::from_serialize(kwargs);
432 t.render(context! {
433 messages => Value::from(msgs),
434 tools => if tool_vals.is_empty() { Value::from(()) } else { Value::from(tool_vals) },
435 add_generation_prompt => add_generation_prompt,
436 bos_token => self.bos.clone(),
437 eos_token => self.eos.clone(),
438 ..extra
439 })
440 }
441
442 fn try_render(
443 &self,
444 messages: &[(&str, &str)],
445 tools: &[String],
446 add_generation_prompt: bool,
447 ) -> Result<String, minijinja::Error> {
448 let msgs: Vec<Value> = messages
449 .iter()
450 .map(|(role, content)| context! { role => *role, content => *content })
451 .collect();
452 self.try_render_values(msgs, tools, add_generation_prompt, &serde_json::Map::new())
453 }
454
455 pub fn render_conversation(
456 &self,
457 messages: &[(&str, &str)],
458 tools: &[String],
459 add_generation_prompt: bool,
460 ) -> String {
461 self.try_render(messages, tools, add_generation_prompt).unwrap_or_default()
462 }
463
464 pub fn render_conversation_tokens(
465 &self,
466 messages: &[(&str, &str)],
467 tools: &[String],
468 add_generation_prompt: bool,
469 ) -> Option<Vec<u32>> {
470 let text = self.try_render(messages, tools, add_generation_prompt).ok()?;
471 Some(self.encode_framing(&text))
472 }
473
474 fn validate_append_stability(&self) -> bool {
475 let convo = [
476 ("system", "You are a helpful assistant."),
477 ("user", "First question about geography."),
478 ("assistant", "A helpful answer."),
479 ("user", "A follow-up question."),
480 ];
481 let mut prev: Vec<u32> = Vec::new();
482 for n in 1..=convo.len() {
483 let Ok(text) = self.try_render(&convo[..n], &[], false) else {
484 return false;
485 };
486 let toks = self.encode_framing(&text);
487 if !toks.starts_with(&prev) {
488 return false;
489 }
490 prev = toks;
491 }
492 true
493 }
494}
495
496impl TemplateCodec {
497 fn encode_framing(&self, text: &str) -> Vec<u32> {
498 self.inner.encode_pieces(&[(text, false)])
499 }
500}
501
502impl TextCodec for TemplateCodec {
503 fn encode(&self, text: &str) -> Vec<u32> {
504 self.inner.encode(text)
505 }
506 fn encode_content(&self, text: &str) -> Vec<u32> {
507 self.inner.encode_content(text)
508 }
509 fn encode_pieces(&self, pieces: &[(&str, bool)]) -> Vec<u32> {
510 self.inner.encode_pieces(pieces)
511 }
512 fn chat_template_source(&self) -> Option<String> {
513 self.inner.chat_template_source()
514 }
515 fn token_bytes(&self, token: u32) -> Vec<u8> {
516 self.inner.token_bytes(token)
517 }
518 fn marker_literal(&self, token: u32) -> String {
519 self.inner.marker_literal(token)
520 }
521
522 fn render_fim(&self, prefix: &str, suffix: &str, mode: u8) -> Option<Vec<u32>> {
523 self.inner.render_fim(prefix, suffix, mode)
524 }
525 fn behavior_fingerprint(&self) -> Option<superfluid_fingerprint::BehaviorFingerprint> {
526 Some(self.inner.fingerprint(&format!("template/{}", self.render_identity()), &self.markers))
527 }
528
529 fn renders_per_message(&self) -> bool {
530 false
531 }
532
533 fn render_prompt_structured(
534 &self,
535 messages: &[crate::codec::ChatMessage],
536 tools: &[String],
537 ) -> Option<Vec<u32>> {
538 self.try_render_prompt_protected(messages, tools, &serde_json::Map::new(), true)
539 .ok()
540 }
541
542 fn supports_reasoning_effort(&self) -> bool {
543 if self.mentions_reasoning_effort {
544 return true;
545 }
546 let probe = [
547 crate::codec::ChatMessage::new(crate::wal::role::SYSTEM, ""),
548 crate::codec::ChatMessage::new(crate::wal::role::USER, "x"),
549 ];
550 let Some(plain) = self.render_turns_structured(&probe, &[]) else {
551 return false;
552 };
553 let mut kw = serde_json::Map::new();
554 kw.insert(
555 "reasoning_effort".into(),
556 serde_json::Value::String("__probe__".into()),
557 );
558 match self.try_render_turns_structured_with(&probe, &[], &kw) {
559 Err(_) => true,
560 Ok(toks) => toks != plain,
561 }
562 }
563
564 fn supports_enable_thinking(&self) -> bool {
565 self.thinking_switch
566 }
567
568 fn reasoning_effort_levels(&self) -> Vec<&'static str> {
569 common_levels(self.effort_ladders.iter().map(|(_, l)| l.as_deref()))
570 }
571
572 fn validate_template_kwargs(
573 &self,
574 kwargs: &serde_json::Map<String, serde_json::Value>,
575 ) -> Result<(), String> {
576 if kwargs.is_empty() {
577 return Ok(());
578 }
579 let probe = [
580 crate::codec::ChatMessage::new(crate::wal::role::SYSTEM, ""),
581 crate::codec::ChatMessage::new(crate::wal::role::USER, "x"),
582 ];
583 if self
584 .try_render_turns_structured_with(&probe, &[], &serde_json::Map::new())
585 .is_err()
586 {
587 return Ok(());
588 }
589 self.try_render_turns_structured_with(&probe, &[], kwargs)
590 .map(|_| ())
591 }
592
593 fn render_prompt_structured_with(
594 &self,
595 messages: &[crate::codec::ChatMessage],
596 tools: &[String],
597 kwargs: &serde_json::Map<String, serde_json::Value>,
598 ) -> Option<Vec<u32>> {
599 match self.try_render_prompt_protected(messages, tools, kwargs, true) {
600 Ok(toks) => Some(toks),
601 Err(e) => {
602 tracing::warn!("chat template render failed: {e:#}");
603 None
604 }
605 }
606 }
607
608 fn trailing_generation_prompt(
609 &self,
610 messages: &[crate::codec::ChatMessage],
611 tools: &[String],
612 kwargs: &serde_json::Map<String, serde_json::Value>,
613 span: &[u32],
614 ) -> usize {
615 let Ok(without) = self.try_render_prompt_protected(messages, tools, kwargs, false) else {
616 return 0;
617 };
618 if without.is_empty() || span.len() <= without.len() {
619 return 0;
620 }
621 if span.starts_with(&without) {
622 return span.len() - without.len();
623 }
624 let bytes_of = |toks: &[u32]| -> Vec<u8> { toks.iter().flat_map(|&t| self.inner.token_bytes(t)).collect() };
625 let without_bytes = bytes_of(&without);
626 if !bytes_of(span).starts_with(&without_bytes) {
627 tracing::debug!(
628 "generation prompt not derived: the render without it is no text prefix of the render with it"
629 );
630 return 0;
631 }
632 let boundary = without_bytes.len();
633 let mut cum = 0usize;
634 for (k, &t) in span.iter().enumerate() {
635 if cum >= boundary {
636 return span.len() - k;
637 }
638 cum += self.inner.token_bytes(t).len();
639 }
640 0
641 }
642
643 fn template_refusal(
644 &self,
645 messages: &[crate::codec::ChatMessage],
646 tools: &[String],
647 kwargs: &serde_json::Map<String, serde_json::Value>,
648 ) -> Option<String> {
649 let msgs: Vec<Value> = messages.iter().map(|m| self.message_value(m)).collect();
650 let e = self.try_render_values(msgs, tools, true, kwargs).err()?;
651 Some(e.detail().map(str::to_string).unwrap_or_else(|| e.to_string()))
652 }
653
654 fn render_prompt(&self, messages: &[(u32, String)], tools: &[String]) -> Option<Vec<u32>> {
655 let msgs: Vec<crate::codec::ChatMessage> = messages
656 .iter()
657 .map(|(role, text)| crate::codec::ChatMessage::new(*role, text.clone()))
658 .collect();
659 self.try_render_prompt_protected(&msgs, tools, &serde_json::Map::new(), true)
660 .ok()
661 }
662
663 fn channel_markers(&self) -> Vec<(u32, u32, u32)> {
664 self.markers.clone()
665 }
666
667 fn channelizer(&self) -> Box<dyn Channelizer> {
668 match self.harmony {
669 Some(ids) => Box::new(crate::harmony::HarmonyChannelizer::new(self.inner.tokenizer(), ids)),
670 None => Box::new(Segmenter::new(self.markers.clone()).with_names(self.channel_names.clone())),
671 }
672 }
673
674 fn parse_tool_call_with(
675 &self,
676 raw: &str,
677 schemas: Option<&crate::codec::ToolSchemas>,
678 ) -> Option<(String, String)> {
679 self.tool_wire.parse(raw, schemas)
680 }
681
682 fn named_tool_call(&self, raw: &str) -> Option<(String, String)> {
683 match &self.tool_wire {
684 ToolWire::Envelope(e) => e.named_call(raw),
685 ToolWire::Delimited(_) | ToolWire::Raw => None,
686 }
687 }
688
689 fn call_grammar(&self, tool_jsons: &[String]) -> Option<String> {
690 match &self.tool_wire {
691 ToolWire::Envelope(e) => e.call_grammar(tool_jsons),
692 ToolWire::Delimited(_) | ToolWire::Raw => None,
693 }
694 }
695
696 fn structural_tag(&self, tool_jsons: &[String], at_least_one: bool) -> Option<String> {
697 if let Some(ids) = self.harmony {
698 return if at_least_one {
699 crate::harmony::forced_call_tag(ids, tool_jsons)
700 } else {
701 crate::harmony::auto_call_tag(ids, tool_jsons)
702 };
703 }
704 match &self.tool_wire {
705 ToolWire::Envelope(e) => {
706 let (begin, end) = self.tool_call_delimiters()?;
707 e.structural_tag(tool_jsons, &begin, &end, at_least_one)
708 }
709 ToolWire::Delimited(d) => {
710 let (begin, end) = self.tool_call_delimiters()?;
711 d.structural_tag(tool_jsons, &begin, &end, at_least_one)
712 }
713 ToolWire::Raw => None,
714 }
715 }
716
717 fn response_format_tag(&self, json_schema: &str) -> Option<String> {
718 crate::harmony::final_json_tag(self.harmony?, json_schema)
719 }
720
721 fn turn_terminators(&self) -> Vec<u32> {
722 self.terminators.clone()
723 }
724
725 fn tool_envelope(&self) -> crate::codec::ToolEnvelope {
726 match &self.tool_wire {
727 ToolWire::Envelope(e) => *e,
728 ToolWire::Delimited(_) | ToolWire::Raw => crate::codec::ToolEnvelope::Json,
729 }
730 }
731
732 fn tool_fragment_mode(&self) -> crate::tool_fragment::ToolFragmentMode {
733 use crate::tool_fragment::ToolFragmentMode;
734 match &self.tool_wire {
735 ToolWire::Delimited(d) => ToolFragmentMode::Delimited(d.clone()),
736 ToolWire::Envelope(e) => match e {
737 crate::codec::ToolEnvelope::Json => ToolFragmentMode::Json,
738 _ => ToolFragmentMode::None,
739 },
740 ToolWire::Raw => ToolFragmentMode::Json,
741 }
742 }
743}
744
745fn role_name(role: u32) -> &'static str {
746 match role {
747 crate::wal::role::SYSTEM => "system",
748 crate::wal::role::ASSISTANT => "assistant",
749 crate::wal::role::TOOL => "tool",
750 _ => "user",
751 }
752}
753
754fn today_utc() -> String {
755 let secs = std::time::SystemTime::now()
756 .duration_since(std::time::UNIX_EPOCH)
757 .map(|d| d.as_secs() as i64)
758 .unwrap_or(0);
759 civil_date(secs.div_euclid(86_400))
760}
761
762fn civil_date(days: i64) -> String {
763 let z = days + 719_468;
764 let era = z.div_euclid(146_097);
765 let doe = z.rem_euclid(146_097);
766 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
767 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
768 let mp = (5 * doy + 2) / 153;
769 let d = doy - (153 * mp + 2) / 5 + 1;
770 let m = if mp < 10 { mp + 3 } else { mp - 9 };
771 let y = yoe + era * 400 + i64::from(m <= 2);
772 format!("{y:04}-{m:02}-{d:02}")
773}
774
775const EFFORT_SCALE: [&str; 7] = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
776
777const EFFORT_NORMALIZATION_VERSION: u32 = 1;
778
779fn render_identity_of(digest: u64, ladders: &[Option<&[&str]>]) -> String {
780 if ladders.iter().all(|l| l.is_some_and(|l| l.is_empty())) {
781 return format!("{digest:016x}");
782 }
783 let mut parts: Vec<String> = ladders.iter().map(|l| l.map_or_else(|| "!".to_string(), |l| l.join(","))).collect();
784 parts.dedup();
785 format!("{digest:016x}/effort{EFFORT_NORMALIZATION_VERSION}:{}", parts.join("|"))
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789struct EffortContext {
790 generation: bool,
791 system: bool,
792 tools: bool,
793 thinking: Option<bool>,
794}
795
796impl EffortContext {
797 fn of(
798 msgs: &[Value],
799 tools: &[String],
800 generation: bool,
801 kwargs: &serde_json::Map<String, serde_json::Value>,
802 ) -> EffortContext {
803 EffortContext {
804 generation,
805 system: msgs.iter().any(|m| m.get_attr("role").ok().and_then(|r| r.as_str().map(|r| r == "system")) == Some(true)),
806 tools: !tools.is_empty(),
807 thinking: kwargs.get("enable_thinking").and_then(|v| v.as_bool()),
808 }
809 }
810
811 fn best<'a>(&self, ladders: &'a [(EffortContext, Option<Vec<&'static str>>)]) -> &'a [&'static str] {
812 ladders
813 .iter()
814 .rev()
815 .max_by_key(|(c, _)| {
816 (c.generation == self.generation, c.tools == self.tools, c.system == self.system, c.thinking == self.thinking)
817 })
818 .and_then(|(_, l)| l.as_deref())
819 .unwrap_or(&[])
820 }
821}
822
823#[allow(clippy::type_complexity)]
824fn learn_effort_ladders<E>(
825 render: impl Fn(&[crate::codec::ChatMessage], &[String], bool, &serde_json::Map<String, serde_json::Value>) -> Result<String, E>,
826) -> Vec<(EffortContext, Option<Vec<&'static str>>)> {
827 let mut shapes = crate::codec::thinking_probe_contexts();
828 if let Some(tools) = shapes.iter().map(|(_, t)| t.clone()).find(|t| !t.is_empty()) {
829 shapes.push((vec![crate::codec::ChatMessage::new(crate::wal::role::USER, "probe")], tools));
830 }
831 let mut ladders = Vec::new();
832 for (msgs, tools) in &shapes {
833 for generation in [true, false] {
834 for on in [None, Some(true), Some(false)] {
835 let ctx = EffortContext {
836 generation,
837 system: msgs.iter().any(|m| m.role == crate::wal::role::SYSTEM),
838 tools: !tools.is_empty(),
839 thinking: on,
840 };
841 let with = |effort: Option<&str>| {
842 let mut kw = serde_json::Map::new();
843 if let Some(on) = on {
844 kw.insert("enable_thinking".into(), serde_json::Value::Bool(on));
845 }
846 if let Some(e) = effort {
847 kw.insert("reasoning_effort".into(), serde_json::Value::String(e.into()));
848 }
849 render(msgs, tools, generation, &kw)
850 };
851 let unavailable = with(None).is_ok() && EFFORT_SCALE.iter().all(|e| with(Some(*e)).is_err());
852 let ladder = (!unavailable).then(|| learn_effort_ladder(with));
853 ladders.push((ctx, ladder));
854 }
855 }
856 }
857 ladders
858}
859
860fn common_levels<'a>(ladders: impl IntoIterator<Item = Option<&'a [&'static str]>>) -> Vec<&'static str> {
861 let ladders: Option<Vec<&[&'static str]>> = ladders.into_iter().collect();
862 let Some(ladders) = ladders else { return Vec::new() };
863 let mut it = ladders.into_iter().filter(|l| !l.is_empty());
864 let Some(first) = it.next() else { return Vec::new() };
865 let mut out = first.to_vec();
866 for l in it {
867 out.retain(|n| l.contains(n));
868 }
869 out
870}
871
872fn effort_rank(name: &str) -> Option<usize> {
873 EFFORT_SCALE.iter().position(|n| *n == name)
874}
875
876fn contains_word(text: &str, word: &str) -> bool {
877 let b = text.as_bytes();
878 text.match_indices(word).any(|(i, _)| {
879 let before = i == 0 || !b[i - 1].is_ascii_alphanumeric();
880 let j = i + word.len();
881 let after = j >= b.len() || !b[j].is_ascii_alphanumeric();
882 before && after
883 })
884}
885
886fn learn_effort_ladder<E>(render: impl Fn(Option<&str>) -> Result<String, E>) -> Vec<&'static str> {
887 let mut groups: Vec<(String, String, Vec<&'static str>)> = Vec::new();
888 let mut refused = 0;
889 for &name in EFFORT_SCALE.iter() {
890 let Ok(out) = render(Some(name)) else {
891 refused += 1;
892 continue;
893 };
894 match groups.iter_mut().find(|(o, _, _)| *o == out) {
895 Some((_, _, names)) => names.push(name),
896 None => {
897 let lower = out.to_lowercase();
898 groups.push((out, lower, vec![name]));
899 }
900 }
901 }
902 if groups.len() < 2 {
903 return match (groups.pop(), refused) {
904 (Some((_, _, names)), 1..) => names,
905 _ => Vec::new(),
906 };
907 }
908 let mut ladder: Vec<&'static str> = Vec::new();
909 for (i, (_, lower, names)) in groups.iter().enumerate() {
910 let spelled: Vec<&'static str> = names
911 .iter()
912 .copied()
913 .filter(|n| {
914 contains_word(lower, n)
915 && groups.iter().enumerate().all(|(j, (_, l, _))| j == i || !contains_word(l, n))
916 })
917 .collect();
918 match spelled.as_slice() {
919 [one] => ladder.push(*one),
920 _ => ladder.extend(names.iter().copied()),
921 }
922 }
923 ladder.sort_by_key(|n| effort_rank(n));
924 ladder
925}
926
927fn nearest_effort(ladder: &[&'static str], requested: &str) -> Option<&'static str> {
928 let req = requested.trim().to_ascii_lowercase();
929 if ladder.is_empty() {
930 return None;
931 }
932 if let Some(l) = ladder.iter().find(|l| **l == req) {
933 return (requested != *l).then_some(*l);
934 }
935 let r = effort_rank(&req)? as i64;
936 ladder.iter().copied().min_by_key(|l| {
937 let d = effort_rank(l).unwrap_or(0) as i64 - r;
938 (d.abs(), d < 0)
939 })
940}
941
942#[cfg(test)]
943mod effort_ladder_tests {
944 use super::*;
945
946 fn ladder_of(src: &str) -> Vec<&'static str> {
947 let mut env = Environment::new();
948 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
949 env.add_function("raise_exception", |m: String| -> Result<Value, minijinja::Error> {
950 Err(minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, m))
951 });
952 env.add_function("strftime_now", |_f: String| "2026-10-04".to_string());
953 env.add_filter("tojson", hf_tojson);
954 env.add_template_owned("chat", src.to_string()).expect("template compiles");
955 let t = env.get_template("chat").unwrap();
956 learn_effort_ladder(|e| {
957 let msgs = vec![context! { role => "user", content => "probe" }];
958 match e {
959 Some(e) => t.render(context! { messages => msgs, add_generation_prompt => true, reasoning_effort => e }),
960 None => t.render(context! { messages => msgs, add_generation_prompt => true }),
961 }
962 })
963 }
964
965 #[test]
966 fn glm_5_3_learns_low_high_max_and_medium_goes_to_high() {
967 let l = ladder_of(include_str!("../../../tests/fixtures/chat_templates/glm_5_3.jinja"));
968 assert_eq!(l, ["low", "high", "max"]);
969 assert_eq!(nearest_effort(&l, "medium"), Some("high"));
970 assert_eq!(nearest_effort(&l, "minimal"), Some("low"));
971 assert_eq!(nearest_effort(&l, "none"), Some("low"), "the bottom of the scale, not the top");
972 assert_eq!(nearest_effort(&l, "xhigh"), Some("max"));
973 for level in ["low", "high", "max"] {
974 assert_eq!(nearest_effort(&l, level), None, "{level} is native: untouched");
975 }
976 assert_eq!(nearest_effort(&l, "High"), Some("high"));
977 assert_eq!(nearest_effort(&l, " low "), Some("low"));
978 }
979
980 #[test]
981 fn glm_5_2_knows_only_high_and_max() {
982 let l = ladder_of(include_str!("../../../tests/fixtures/chat_templates/glm_5_2.jinja"));
983 assert_eq!(l, ["high", "max"]);
984 assert_eq!(nearest_effort(&l, "low"), Some("high"), "low used to mean MAX here");
985 assert_eq!(nearest_effort(&l, "medium"), Some("high"));
986 }
987
988 #[test]
989 fn gpt_oss_passes_every_value_through() {
990 let l = ladder_of(include_str!("../../../tests/fixtures/chat_templates/gpt_oss.jinja"));
991 assert_eq!(l, EFFORT_SCALE);
992 for v in EFFORT_SCALE {
993 assert_eq!(nearest_effort(&l, v), None);
994 }
995 }
996
997 #[test]
998 fn a_validating_template_gets_a_value_it_accepts() {
999 let src = "{% if reasoning_effort is defined and reasoning_effort not in ['low', 'medium', 'xhigh'] %}\
1000 {{ raise_exception('reasoning_effort must be low, medium or xhigh') }}{% endif %}\
1001 effort={{ reasoning_effort | default('medium') }}{% for m in messages %}{{ m.content }}{% endfor %}";
1002 let l = ladder_of(src);
1003 assert_eq!(l, ["low", "medium", "xhigh"]);
1004 assert_eq!(nearest_effort(&l, "high"), Some("xhigh"), "high used to 400");
1005 assert_eq!(nearest_effort(&l, "max"), Some("xhigh"));
1006 assert_eq!(nearest_effort(&l, "minimal"), Some("low"));
1007 }
1008
1009 #[test]
1010 fn templates_without_the_knob_and_unknown_words_are_left_alone() {
1011 let l = ladder_of(include_str!("../../../tests/fixtures/chat_templates/nemotron_3_nano.jinja"));
1012 assert!(l.is_empty(), "{l:?}");
1013 assert_eq!(nearest_effort(&l, "high"), None);
1014 let glm = ["low", "high", "max"];
1015 assert_eq!(nearest_effort(&glm, "auto"), None, "not a scale word: the template decides");
1016 }
1017
1018 #[test]
1019 fn a_distinct_level_without_a_label_keeps_its_names() {
1020 let src = "{% if reasoning_effort in ['minimal', 'low'] %}short{% elif reasoning_effort == 'high' %}effort high\
1021 {% else %}effort max{% endif %}\n{% for m in messages %}{{ m.content }}{% endfor %}";
1022 let l = ladder_of(src);
1023 assert_eq!(l, ["minimal", "low", "high", "max"]);
1024 for native in ["minimal", "low", "high", "max"] {
1025 assert_eq!(nearest_effort(&l, native), None, "{native} is a level the template renders");
1026 }
1027 assert_eq!(nearest_effort(&l, "medium"), Some("high"));
1028 assert_eq!(nearest_effort(&l, "none"), Some("minimal"));
1029 }
1030
1031 #[test]
1032 fn case_only_differences_are_distinct_levels() {
1033 let src = "{% if reasoning_effort == 'high' %}Effort: HIGH{% elif reasoning_effort == 'max' %}Effort: high\
1034 {% else %}Effort: low{% endif %}\n{% for m in messages %}{{ m.content }}{% endfor %}";
1035 let l = ladder_of(src);
1036 assert!(l.contains(&"high") && l.contains(&"max"), "{l:?}");
1037 assert_eq!(nearest_effort(&l, "max"), None, "max renders its own prompt; never folded into high");
1038 assert_eq!(nearest_effort(&l, "high"), None);
1039 }
1040
1041 #[test]
1042 fn normalization_is_part_of_the_render_identity() {
1043 let d = 0x1234_5678_9abc_def0;
1044 assert_eq!(render_identity_of(d, &[]), "123456789abcdef0", "no ladder: the digest alone, as before");
1045 assert_eq!(render_identity_of(d, &[Some(&[]), Some(&[])]), "123456789abcdef0");
1046 let lhm: Option<&[&str]> = Some(&["low", "high", "max"]);
1047 let glm = render_identity_of(d, &[lhm, lhm, lhm]);
1048 assert_eq!(glm, format!("123456789abcdef0/effort{EFFORT_NORMALIZATION_VERSION}:low,high,max"));
1049 assert_ne!(glm, render_identity_of(d, &[Some(&["high", "max"])]));
1050 assert_ne!(glm, render_identity_of(d, &[lhm, Some(&["low", "high"])]), "a context's ladder is identity too");
1051 assert_ne!(glm, render_identity_of(d, &[lhm, None]), "so is a shape that refuses every value");
1052 }
1053
1054 #[test]
1055 fn a_template_that_accepts_one_value_gets_it() {
1056 let src = "{% if reasoning_effort is defined and reasoning_effort != 'high' %}\
1057 {{ raise_exception('only high') }}{% endif %}effort={{ reasoning_effort }}\n\
1058 {% for m in messages %}{{ m.content }}{% endfor %}";
1059 let l = ladder_of(src);
1060 assert_eq!(l, ["high"]);
1061 assert_eq!(nearest_effort(&l, "low"), Some("high"));
1062 assert!(ladder_of("{% for m in messages %}{{ m.content }}{% endfor %}").is_empty());
1063 }
1064
1065 #[test]
1066 fn each_request_shape_is_normalized_against_its_own_ladder() {
1067 let src = "{% if tools %}{% set ok = ['low', 'medium', 'high'] %}{% else %}{% set ok = ['low', 'high'] %}{% endif %}\
1068 Effort: {{ reasoning_effort if reasoning_effort in ok else 'high' }}\n\
1069 {% for m in messages %}{{ m.content }}{% endfor %}";
1070 let mut env = Environment::new();
1071 env.add_template_owned("chat", src.to_string()).unwrap();
1072 let t = env.get_template("chat").unwrap();
1073 let learn = |tools: bool| {
1074 learn_effort_ladder(|e| {
1075 let msgs = vec![context! { role => "user", content => "probe" }];
1076 let tools: Vec<Value> = if tools { vec![Value::from("probe")] } else { Vec::new() };
1077 t.render(context! { messages => msgs, tools => tools, reasoning_effort => e })
1078 })
1079 };
1080 assert_eq!(learn(false), ["low", "high"]);
1081 assert_eq!(learn(true), ["low", "medium", "high"]);
1082 let ctx = |system, tools, thinking| EffortContext { generation: true, system, tools, thinking };
1083 let ladders = vec![
1084 (ctx(false, false, None), Some(learn(false))),
1085 (ctx(true, false, None), Some(learn(false))),
1086 (ctx(true, true, None), Some(learn(true))),
1087 ];
1088 let with_tools = ctx(false, true, None).best(&ladders);
1089 let without = ctx(false, false, None).best(&ladders);
1090 assert_eq!(nearest_effort(with_tools, "medium"), None, "native with tools: never remapped");
1091 assert_eq!(nearest_effort(without, "medium"), Some("high"), "not a level without tools");
1092 assert_eq!(nearest_effort(without, "max"), Some("high"));
1093 let levels = common_levels(ladders.iter().map(|(_, l)| l.as_deref()));
1094 assert_eq!(levels, ["low", "high"], "advertised: what every shape has");
1095 let msgs = vec![context! { role => "system", content => "s" }, context! { role => "user", content => "u" }];
1096 let mut kw = serde_json::Map::new();
1097 kw.insert("enable_thinking".into(), serde_json::Value::Bool(false));
1098 assert_eq!(EffortContext::of(&msgs, &["{}".to_string()], true, &kw), ctx(true, true, Some(false)));
1099 assert_eq!(EffortContext::of(&msgs[1..], &[], true, &serde_json::Map::new()), ctx(false, false, None));
1100 let ladders = vec![
1101 (ctx(true, true, None), Some(vec!["high", "max"])),
1102 (ctx(false, true, None), Some(vec!["low", "high"])),
1103 ];
1104 assert_eq!(ctx(false, true, None).best(&ladders), ["low", "high"]);
1105 assert_eq!(ctx(true, true, None).best(&ladders), ["high", "max"]);
1106 }
1107
1108 #[test]
1109 fn effort_read_only_under_thinking_is_learned_there() {
1110 let src = "{% if enable_thinking is defined and enable_thinking and reasoning_effort is defined %}\
1111 {% if reasoning_effort not in ['low', 'high'] %}{{ raise_exception('bad effort') }}{% endif %}\
1112 effort={{ reasoning_effort }}\n{% endif %}{% for m in messages %}{{ m.content }}{% endfor %}";
1113 let mut env = Environment::new();
1114 env.add_function("raise_exception", |m: String| -> Result<Value, minijinja::Error> {
1115 Err(minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, m))
1116 });
1117 env.add_template_owned("chat", src.to_string()).unwrap();
1118 let t = env.get_template("chat").unwrap();
1119 let ladders = learn_effort_ladders(|msgs, tools, _generation, kw| {
1120 let msgs: Vec<Value> = msgs.iter().map(|m| context! { content => m.content.clone() }).collect();
1121 let tools: Vec<Value> = tools.iter().map(|t| Value::from(t.clone())).collect();
1122 t.render(context! { messages => msgs, tools => tools, ..Value::from_serialize(kw) })
1123 });
1124 assert_eq!(ladders.len(), 24, "4 conversations x 2 render modes x 3 thinking values");
1125 let ctx = |system, tools, thinking| EffortContext { generation: true, system, tools, thinking };
1126 assert_eq!(ctx(false, true, Some(true)).best(&ladders), ["low", "high"]);
1127 assert!(ctx(false, true, Some(false)).best(&ladders).is_empty(), "ignored when thinking is off");
1128 assert_eq!(nearest_effort(ctx(false, false, Some(true)).best(&ladders), "max"), Some("high"));
1129 assert!(ladders.iter().any(|(c, _)| *c == ctx(false, true, None)), "tools without a system turn");
1130 }
1131
1132 #[test]
1133 fn render_modes_and_refusing_shapes() {
1134 let src = "{% if reasoning_effort is defined %}{% if not add_generation_prompt %}{{ raise_exception('no effort here') }}\
1135 {% elif reasoning_effort not in ['low', 'high'] %}{{ raise_exception('bad') }}{% endif %}\
1136 effort={{ reasoning_effort }}\n{% endif %}{% for m in messages %}{{ m.content }}{% endfor %}";
1137 let mut env = Environment::new();
1138 env.add_function("raise_exception", |m: String| -> Result<Value, minijinja::Error> {
1139 Err(minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, m))
1140 });
1141 env.add_template_owned("chat", src.to_string()).unwrap();
1142 let t = env.get_template("chat").unwrap();
1143 let ladders = learn_effort_ladders(|msgs, _tools, generation, kw| {
1144 let msgs: Vec<Value> = msgs.iter().map(|m| context! { content => m.content.clone() }).collect();
1145 t.render(context! { messages => msgs, add_generation_prompt => generation, ..Value::from_serialize(kw) })
1146 });
1147 let shape = |generation| EffortContext { generation, system: false, tools: false, thinking: None };
1148 assert_eq!(shape(true).best(&ladders), ["low", "high"]);
1149 assert!(ladders.iter().any(|(c, l)| *c == shape(false) && l.is_none()), "refused, not ignored");
1150 assert!(shape(false).best(&ladders).is_empty(), "nothing to map to: passes through");
1151 assert_eq!(
1152 common_levels(ladders.iter().map(|(_, l)| l.as_deref())),
1153 Vec::<&str>::new(),
1154 "no level works in every shape"
1155 );
1156 assert_eq!(common_levels([Some(&["low", "high"][..]), Some(&[][..])]), ["low", "high"]);
1157 }
1158
1159 #[test]
1160 fn whole_words_only() {
1161 assert!(contains_word("effort: xhigh", "xhigh"));
1162 assert!(!contains_word("effort: xhigh", "high"));
1163 assert!(contains_word("reasoning effort: high", "high"));
1164 }
1165}
1166
1167fn hf_tojson(
1170 value: &Value,
1171 indent: Option<Value>,
1172 args: minijinja::value::Kwargs,
1173) -> Result<Value, minijinja::Error> {
1174 let ensure_ascii: Option<bool> = args.get("ensure_ascii")?;
1175 let indent = match indent {
1176 Some(i) => Some(i),
1177 None => args.get("indent")?,
1178 };
1179 args.assert_all_used()?;
1180 let indent = match indent {
1181 None => None,
1182 Some(v) => match bool::try_from(v.clone()).ok() {
1183 Some(true) => Some(2),
1184 Some(false) => None,
1185 None => Some(usize::try_from(v)?),
1186 },
1187 };
1188 let mut out = Vec::<u8>::new();
1189 let written = match indent {
1190 Some(n) => {
1191 let pad = " ".repeat(n);
1192 let formatter = serde_json::ser::PrettyFormatter::with_indent(pad.as_bytes());
1193 serde::Serialize::serialize(value, &mut serde_json::Serializer::with_formatter(&mut out, formatter))
1194 }
1195 None => serde::Serialize::serialize(value, &mut serde_json::Serializer::with_formatter(&mut out, PyJsonFormatter)),
1196 };
1197 let unwritable = |e: &dyn std::fmt::Display| {
1198 minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, format!("cannot serialize to JSON: {e}"))
1199 };
1200 written.map_err(|e| unwritable(&e))?;
1201 let s = String::from_utf8(out).map_err(|e| unwritable(&e))?;
1202 if !ensure_ascii.unwrap_or(false) {
1203 return Ok(Value::from_safe_string(s));
1204 }
1205 let mut ascii = String::with_capacity(s.len());
1206 for c in s.chars() {
1207 if c.is_ascii() {
1208 ascii.push(c);
1209 } else {
1210 let mut buf = [0u16; 2];
1211 for unit in c.encode_utf16(&mut buf) {
1212 ascii.push_str(&format!("\\u{unit:04x}"));
1213 }
1214 }
1215 }
1216 Ok(Value::from_safe_string(ascii))
1217}
1218
1219struct PyJsonFormatter;
1221
1222impl serde_json::ser::Formatter for PyJsonFormatter {
1223 fn begin_array_value<W: ?Sized + std::io::Write>(&mut self, w: &mut W, first: bool) -> std::io::Result<()> {
1224 if first {
1225 Ok(())
1226 } else {
1227 w.write_all(b", ")
1228 }
1229 }
1230
1231 fn begin_object_key<W: ?Sized + std::io::Write>(&mut self, w: &mut W, first: bool) -> std::io::Result<()> {
1232 if first {
1233 Ok(())
1234 } else {
1235 w.write_all(b", ")
1236 }
1237 }
1238
1239 fn begin_object_value<W: ?Sized + std::io::Write>(&mut self, w: &mut W) -> std::io::Result<()> {
1240 w.write_all(b": ")
1241 }
1242}
1243
1244#[cfg(test)]
1245mod tojson_tests {
1246 use super::*;
1247
1248 fn render(src: &str, v: serde_json::Value) -> Result<String, minijinja::Error> {
1249 let mut env = Environment::new();
1250 env.add_filter("tojson", hf_tojson);
1251 env.add_template("t", src)?;
1252 env.get_template("t")?.render(context! { v => Value::from_serialize(&v) })
1253 }
1254
1255 #[test]
1256 fn hf_keywords_render_and_match_the_builtin() {
1257 let v = serde_json::json!({"name": "get_weather", "desc": "Météo <now> & 'later'", "n": [1, 2]});
1258 let plain = render("{{ v | tojson }}", v.clone()).unwrap();
1259 assert_eq!(render("{{ v | tojson(ensure_ascii=False) }}", v.clone()).unwrap(), plain);
1260 assert_eq!(plain, r#"{"name": "get_weather", "desc": "Météo <now> & 'later'", "n": [1, 2]}"#, "json.dumps's layout");
1261 let ascii = render("{{ v | tojson(ensure_ascii=True) }}", v.clone()).unwrap();
1262 assert_eq!(ascii, plain.replace('é', "\\u00e9"));
1263 let emoji = render("{{ v | tojson(ensure_ascii=True) }}", serde_json::json!("🙂")).unwrap();
1264 assert_eq!(emoji, "\"\\ud83d\\ude42\"");
1265 assert_eq!(
1266 render("{{ v | tojson(2) }}", v.clone()).unwrap(),
1267 render("{{ v | tojson(indent=2, ensure_ascii=False) }}", v.clone()).unwrap()
1268 );
1269 }
1270
1271 #[test]
1272 fn loop_controls_compile_and_run() {
1273 let mut env = Environment::new();
1274 env.add_template("t", "{% for x in v %}{% if x > 2 %}{% break %}{% endif %}{% if x == 1 %}{% continue %}{% endif %}{{ x }}{% endfor %}")
1275 .expect("break/continue compile");
1276 let out = env.get_template("t").unwrap().render(context! { v => vec![0, 1, 2, 3, 4] }).unwrap();
1277 assert_eq!(out, "02");
1278 }
1279
1280 #[test]
1281 fn layout_changing_keywords_are_still_refused() {
1282 let v = serde_json::json!({"b": 1, "a": 2});
1283 assert!(render("{{ v | tojson(sort_keys=True) }}", v.clone()).is_err());
1284 assert!(render("{{ v | tojson(separators=[',', ':']) }}", v).is_err());
1285 }
1286}
1287
1288#[cfg(test)]
1289mod date_tests {
1290 use super::*;
1291
1292 #[test]
1293 fn civil_dates_are_right_around_the_awkward_days() {
1294 assert_eq!(civil_date(0), "1970-01-01");
1295 assert_eq!(civil_date(10_956), "1999-12-31");
1296 assert_eq!(civil_date(11_016), "2000-02-29");
1297 assert_eq!(civil_date(20_724), "2026-09-28");
1298 assert_eq!(civil_date(-1), "1969-12-31");
1299 }
1300
1301 #[test]
1302 fn today_is_a_date_after_the_old_constant() {
1303 let t = today_utc();
1304 assert_eq!(t.len(), 10);
1305 assert!(t.as_str() > "2024-01-01", "{t}");
1306 }
1307}
1308
1309#[cfg(test)]
1310mod glm_wire_tests {
1311 use super::*;
1312 use crate::codec::ToolSchemas;
1313
1314 const GLM_5_2: &str = include_str!("../../../tests/fixtures/chat_templates/glm_5_2.jinja");
1315 const GLM_5_3: &str = include_str!("../../../tests/fixtures/chat_templates/glm_5_3.jinja");
1316 const GLM_4_5_SHAPE: &str = "{%- for m in messages %}{%- if m.tool_calls %}{%- for tc in m.tool_calls %}\
1317 {%- set tc = tc.function %}<tool_call>{{ tc.name }}\n\
1318 {% for k, v in tc.arguments.items() %}<arg_key>{{ k }}</arg_key>\n\
1319 <arg_value>{{ v | tojson(ensure_ascii=False) if v is not string else v }}</arg_value>\n\
1320 {% endfor %}</tool_call>{% endfor %}{% else %}<|{{ m.role }}|>{{ m.content }}{% endif %}{% endfor %}";
1321
1322 pub(super) fn env_for(src: &str) -> Environment<'static> {
1323 let mut env = Environment::new();
1324 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
1325 env.add_function("raise_exception", |m: String| -> Result<Value, minijinja::Error> {
1326 Err(minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, m))
1327 });
1328 env.add_function("strftime_now", |_f: String| "2026-10-04".to_string());
1329 env.add_filter("tojson", hf_tojson);
1330 env.add_template_owned("chat", src.to_string()).expect("template compiles");
1331 env
1332 }
1333
1334 pub(super) fn render_call(env: &Environment<'static>, name: &str, args: &serde_json::Value) -> String {
1335 let msgs = serde_json::json!([
1336 {"role": "user", "content": "hi"},
1337 {"role": "assistant", "content": "", "tool_calls": [
1338 {"id": "call_0", "type": "function", "function": {"name": name, "arguments": args}}
1339 ]}
1340 ]);
1341 env.get_template("chat")
1342 .unwrap()
1343 .render(context! { messages => Value::from_serialize(&msgs), add_generation_prompt => false })
1344 .expect("renders a call")
1345 }
1346
1347 pub(super) fn call_body(rendered: &str) -> &str {
1348 let open = rendered.rfind("<tool_call>").expect("a call block") + "<tool_call>".len();
1349 let close = open + rendered[open..].find("</tool_call>").expect("closed");
1350 &rendered[open..close]
1351 }
1352
1353 #[test]
1354 fn glm_templates_learn_the_glm_wire() {
1355 for (label, src) in [("GLM-5.2", GLM_5_2), ("GLM-5.3", GLM_5_3), ("GLM-4.5 shape", GLM_4_5_SHAPE)] {
1356 let env = env_for(src);
1357 let wire = ToolWire::learn_from(
1358 |args| {
1359 let a: serde_json::Value = serde_json::from_str(args).ok()?;
1360 Some(render_call(&env, S_NAME, &a))
1361 },
1362 Some(("<tool_call>", "</tool_call>")),
1363 );
1364 assert!(matches!(wire, ToolWire::Envelope(ToolEnvelope::Glm)), "{label}");
1365 }
1366 }
1367
1368 #[test]
1369 fn calls_the_real_templates_render_read_back_exactly() {
1370 let tool = serde_json::json!({"type": "function", "function": {"name": "edit_file", "parameters": {
1371 "type": "object",
1372 "properties": {
1373 "path": {"type": "string"}, "line": {"type": "string"}, "flag": {"type": "string"},
1374 "nothing": {"type": "string"}, "content": {"type": "string"},
1375 "count": {"type": "integer"}, "ratio": {"type": "number"}, "dry_run": {"type": "boolean"},
1376 "tags": {"type": "array", "items": {"type": "string"}},
1377 "opts": {"type": "object"}, "maybe": {"type": ["string", "null"]}
1378 }
1379 }}});
1380 let args = serde_json::json!({
1381 "path": "/tmp/naïve café.txt", "line": "3", "flag": "true", "nothing": "null",
1382 "content": "\n def f():\n return '<x>' & 1\n",
1383 "count": 3, "ratio": 0.5, "dry_run": false, "tags": ["a", "b c"],
1384 "opts": {"k": [1, 2], "s": "v"}, "maybe": null
1385 });
1386 let schemas = ToolSchemas::from_values([&tool]);
1387 for (label, src) in [("GLM-5.2", GLM_5_2), ("GLM-5.3", GLM_5_3), ("GLM-4.5 shape", GLM_4_5_SHAPE)] {
1388 let env = env_for(src);
1389 let rendered = render_call(&env, "edit_file", &args);
1390 let body = call_body(&rendered);
1391 let (name, got) = ToolEnvelope::Glm.parse_call(body, Some(&schemas)).expect(label);
1392 assert_eq!(name, "edit_file", "{label}");
1393 let got: serde_json::Value = serde_json::from_str(&got).unwrap();
1394 assert_eq!(got, args, "{label}: rendered body {body:?}");
1395 let (_, blind) = ToolEnvelope::Glm.parse_call(body, None).unwrap();
1396 let blind: serde_json::Value = serde_json::from_str(&blind).unwrap();
1397 assert_eq!(blind["line"], 3, "{label}");
1398 }
1399 }
1400
1401 #[test]
1402 fn zero_argument_calls_render_and_read_as_a_bare_name() {
1403 for src in [GLM_5_2, GLM_5_3] {
1404 let env = env_for(src);
1405 let rendered = render_call(&env, "list_files", &serde_json::json!({}));
1406 assert_eq!(call_body(&rendered), "list_files");
1407 let parsed = ToolEnvelope::Glm.parse_call(call_body(&rendered), None);
1408 assert_eq!(parsed, Some(("list_files".to_string(), "{}".to_string())));
1409 }
1410 }
1411}
1412
1413#[cfg(test)]
1414mod qwen_wire_tests {
1415 use super::glm_wire_tests::{call_body, env_for, render_call};
1416 use super::*;
1417 use crate::codec::ToolSchemas;
1418
1419 const QWEN_3_6: &str = include_str!("../../../tests/fixtures/chat_templates/qwen3_6.jinja");
1420 const QWEN_3_5: &str = include_str!("../../../tests/fixtures/chat_templates/qwen3_5.jinja");
1421
1422 fn learned(src: &str) -> (Environment<'static>, ToolWire) {
1423 let env = env_for(src);
1424 let wire = ToolWire::learn_from(
1425 |args| {
1426 let a: serde_json::Value = serde_json::from_str(args).ok()?;
1427 Some(render_call(&env, S_NAME, &a))
1428 },
1429 Some(("<tool_call>", "</tool_call>")),
1430 );
1431 (env, wire)
1432 }
1433
1434 #[test]
1435 fn qwen_templates_render_the_wire_the_parser_reads() {
1436 let tool = serde_json::json!({"type": "function", "function": {"name": "edit_file", "parameters": {
1437 "type": "object",
1438 "properties": {
1439 "path": {"type": "string"}, "port": {"type": "string"}, "flag": {"type": "string"},
1440 "nothing": {"type": "string"}, "content": {"type": "string"}, "blank": {"type": "string"},
1441 "id": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
1442 "count": {"type": "integer"}, "ratio": {"type": "number"}, "dry_run": {"type": "boolean"},
1443 "tags": {"type": "array", "items": {"type": "string"}},
1444 "opts": {"type": "object"}
1445 }
1446 }}});
1447 let args = serde_json::json!({
1448 "path": "/tmp/naïve café.txt", "port": "8080", "flag": "true", "nothing": "null",
1449 "content": "\n def f():\n return '<x>' & 1\n", "blank": "",
1450 "id": "42",
1451 "count": 3, "ratio": 0.5, "dry_run": false, "tags": ["a", "b c"],
1452 "opts": {"k": [1, 2], "s": "v"}
1453 });
1454 let schemas = ToolSchemas::from_values([&tool]);
1455 for (label, src) in [("Qwen3.6", QWEN_3_6), ("Qwen3.5", QWEN_3_5)] {
1456 let (env, wire) = learned(src);
1457 assert!(matches!(wire, ToolWire::Delimited(_)), "{label}: learns the XML wire");
1458 let rendered = render_call(&env, "edit_file", &args);
1459 let body = call_body(&rendered);
1460 let (name, got) = wire.parse(body, Some(&schemas)).expect(label);
1461 assert_eq!(name, "edit_file", "{label}");
1462 let got: serde_json::Value = serde_json::from_str(&got).unwrap();
1463 assert_eq!(got, args, "{label}: rendered body {body:?}");
1464 let (_, blind) = wire.parse(body, None).unwrap();
1465 let blind: serde_json::Value = serde_json::from_str(&blind).unwrap();
1466 assert_eq!(blind["port"], 8080, "{label}");
1467 assert_eq!(blind["content"], args["content"], "{label}: payload whitespace never depended on the schema");
1468 }
1469 }
1470
1471 #[test]
1472 fn a_string_port_and_a_trailing_newline_survive_the_real_template() {
1473 let (env, wire) = learned(QWEN_3_6);
1474 let tool = serde_json::json!({"type": "function", "function": {"name": "write", "parameters": {
1475 "type": "object",
1476 "properties": {"port": {"type": "string"}, "content": {"type": "string"}},
1477 "required": ["port", "content"]
1478 }}});
1479 let args = serde_json::json!({"port": "8080", "content": "<!DOCTYPE html>\n<p>hi</p>\n"});
1480 let rendered = render_call(&env, "write", &args);
1481 let body = call_body(&rendered);
1482 assert!(body.contains("<parameter=port>\n8080\n</parameter>\n"), "{body:?}");
1483 let (_, got) = wire.parse(body, Some(&ToolSchemas::from_values([&tool]))).unwrap();
1484 assert_eq!(serde_json::from_str::<serde_json::Value>(&got).unwrap(), args);
1485 }
1486}