Skip to main content

superfluid_daemon/
template_codec.rs

1//! `TemplateCodec`.
2
3use 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
1167/// `tojson` as chat templates expect it: Python's `json.dumps` separators and indentation,
1168/// characters unescaped.
1169fn 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
1219/// `json.dumps`'s default separators: `", "` between items, `": "` after a key.
1220struct 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}