Skip to main content

llama_cpp_4/
chat.rs

1//! Chat templates, tool calling, and JSON-Schema-driven grammars.
2//!
3//! This wraps llama.cpp's `common/chat.h` — the layer its own server uses to
4//! turn an OpenAI-shaped request into a prompt, a GBNF grammar, and a parser
5//! that can read tool calls back out of whatever the model emits.
6//!
7//! The value over hand-rolling it is [`ChatParams::grammar`] plus
8//! [`ChatParams::grammar_triggers`]. Naive grammar forcing breaks models that
9//! emit reasoning before a tool call: constrain from token zero and the model
10//! can never open its `<think>` block. A *lazy* grammar stays dormant until a
11//! trigger fires — usually the `<tool_call>` marker — so reasoning flows
12//! unconstrained and only the call itself is forced to be well-formed.
13//!
14//! # Example
15//!
16//! ```no_run
17//! # use llama_cpp_4::chat::{ChatTemplates, ChatApplyParams, ToolChoice};
18//! # fn f(model: &llama_cpp_4::model::LlamaModel) -> Result<(), Box<dyn std::error::Error>> {
19//! let templates = ChatTemplates::from_model(model, None)?;
20//!
21//! let applied = templates.apply(
22//!     &ChatApplyParams::new(r#"[{"role":"user","content":"Weather in Tokyo?"}]"#)
23//!         .with_tools(r#"[{"type":"function","function":{"name":"get_weather",
24//!             "parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]"#)
25//!         .with_tool_choice(ToolChoice::Required),
26//! )?;
27//!
28//! // Feed `applied.prompt` to the model, constrain with `applied.grammar`,
29//! // then read the tool calls back out:
30//! let msg = applied.parse("<tool_call>{\"name\":\"get_weather\"}</tool_call>", false)?;
31//! # Ok(())
32//! # }
33//! ```
34
35use std::ffi::{c_char, CString};
36use std::ptr::NonNull;
37use std::sync::Arc;
38
39use llama_cpp_sys_4 as sys;
40
41use crate::model::LlamaModel;
42
43/// Errors from the chat layer.
44///
45/// An alias for [`crate::ShimError`] — every shim-backed
46/// module shares one error type, since they share one status enum and one error
47/// buffer.
48pub type ChatError = crate::shim::ShimError;
49
50use crate::shim::{check_status, last_error, read_string};
51
52/// What the model is allowed to do with the supplied tools.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum ToolChoice {
55    /// The model decides whether to call a tool.
56    #[default]
57    Auto,
58    /// The model must call a tool. This is enforced by grammar, not by prompt.
59    Required,
60    /// Tools are visible to the template but must not be called.
61    None,
62}
63
64impl ToolChoice {
65    // The C enum is unsigned (no negative discriminants) but the struct field
66    // it feeds is `int32_t`, so the two need bridging in both directions.
67    #[allow(clippy::cast_possible_wrap)]
68    fn as_raw(self) -> i32 {
69        let raw = match self {
70            Self::Auto => sys::CHAT_SHIM_TOOL_CHOICE_AUTO,
71            Self::Required => sys::CHAT_SHIM_TOOL_CHOICE_REQUIRED,
72            Self::None => sys::CHAT_SHIM_TOOL_CHOICE_NONE,
73        };
74        raw as i32
75    }
76
77    #[allow(clippy::cast_possible_wrap)]
78    fn from_raw(raw: i32) -> Option<Self> {
79        if raw == sys::CHAT_SHIM_TOOL_CHOICE_AUTO as i32 {
80            Some(Self::Auto)
81        } else if raw == sys::CHAT_SHIM_TOOL_CHOICE_REQUIRED as i32 {
82            Some(Self::Required)
83        } else if raw == sys::CHAT_SHIM_TOOL_CHOICE_NONE as i32 {
84            Some(Self::None)
85        } else {
86            None
87        }
88    }
89
90    /// Parse an `OpenAI` `tool_choice` value: `"auto"`, `"required"` or
91    /// `"none"`.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`ChatError::Failed`] if llama.cpp does not recognise `value`.
96    pub fn parse_oaicompat(value: &str) -> Result<Self, ChatError> {
97        let c_value = CString::new(value)?;
98        let mut raw: i32 = 0;
99        let status = unsafe {
100            sys::chat_shim_tool_choice_parse_oaicompat(c_value.as_ptr(), &raw mut raw)
101        };
102        check_status(status)?;
103        Self::from_raw(raw).ok_or_else(|| ChatError::Failed(format!("unknown tool_choice {raw}")))
104    }
105}
106
107/// How reasoning (`<think>` blocks) should be handled when parsing output.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum ReasoningFormat {
110    /// Do not treat reasoning specially.
111    #[default]
112    None,
113    /// Split reasoning into `reasoning_content`. The usual choice.
114    Auto,
115    /// Like [`Self::Auto`], but leaves reasoning inline when streaming.
116    DeepSeekLegacy,
117    /// Split reasoning out, including in streaming deltas.
118    DeepSeek,
119}
120
121impl ReasoningFormat {
122    #[allow(clippy::cast_possible_wrap)]
123    fn as_raw(self) -> i32 {
124        let raw = match self {
125            Self::None => sys::CHAT_SHIM_REASONING_NONE,
126            Self::Auto => sys::CHAT_SHIM_REASONING_AUTO,
127            Self::DeepSeekLegacy => sys::CHAT_SHIM_REASONING_DEEPSEEK_LEGACY,
128            Self::DeepSeek => sys::CHAT_SHIM_REASONING_DEEPSEEK,
129        };
130        raw as i32
131    }
132}
133
134/// Convert a JSON Schema into a GBNF grammar.
135///
136/// The output goes straight to
137/// [`LlamaSampler::grammar`](crate::sampling::LlamaSampler::grammar), which is
138/// how you implement `OpenAI`'s `response_format: json_schema` — the model then
139/// *cannot* emit anything the schema rejects, rather than being asked nicely.
140///
141/// `force_gbnf` mirrors upstream's flag: leave it `false` and the converter may
142/// pick a more compact representation for schemas it recognises.
143///
144/// ```
145/// # use llama_cpp_4::chat::json_schema_to_grammar;
146/// let gbnf = json_schema_to_grammar(r#"{"type":"integer"}"#, false).unwrap();
147/// assert!(gbnf.contains("root"));
148/// ```
149///
150/// # Errors
151///
152/// Returns [`ChatError::BadJson`] if `schema_json` is not valid JSON, or
153/// [`ChatError::Failed`] if it is not a schema llama.cpp can convert.
154pub fn json_schema_to_grammar(schema_json: &str, force_gbnf: bool) -> Result<String, ChatError> {
155    let c_schema = CString::new(schema_json)?;
156    read_string(|buf, len, expected| unsafe {
157        sys::common_json_schema_to_grammar_c(c_schema.as_ptr(), force_gbnf, buf, len, expected)
158    })
159}
160
161/// The chat templates a model ships, ready to apply.
162///
163/// Wraps `common_chat_templates`, which owns parsed Jinja programs — building
164/// it is not free, so hold one per model rather than one per request.
165#[derive(Debug)]
166pub struct ChatTemplates {
167    raw: NonNull<sys::chat_shim_templates>,
168}
169
170// SAFETY: the handle owns a `common_chat_templates_ptr` with no interior
171// mutability reachable through `&self` — `apply` and the getters only read it.
172// The shim's only mutable global is a `thread_local` error buffer.
173unsafe impl Send for ChatTemplates {}
174unsafe impl Sync for ChatTemplates {}
175
176impl Drop for ChatTemplates {
177    fn drop(&mut self) {
178        unsafe { sys::chat_shim_templates_free(self.raw.as_ptr()) }
179    }
180}
181
182impl ChatTemplates {
183    /// Build the template set for `model`.
184    ///
185    /// Pass `None` for `template_override` to use whatever the model ships, or
186    /// Jinja source to override it. Overriding is what lets you reach a
187    /// model's `tool_use` variant — fetch it with
188    /// [`LlamaModel::chat_template`] and hand it back here.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`ChatError::Init`] if the model has no usable template
193    /// or the override does not parse.
194    pub fn from_model(
195        model: &LlamaModel,
196        template_override: Option<&str>,
197    ) -> Result<Self, ChatError> {
198        let c_override = template_override.map(CString::new).transpose()?;
199        let override_ptr = c_override.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
200        let raw = unsafe { sys::chat_shim_templates_init(model.model.as_ptr(), override_ptr) };
201        NonNull::new(raw)
202            .map(|raw| Self { raw })
203            .ok_or_else(|| ChatError::Init(last_error()))
204    }
205
206    /// Where the active template came from, e.g. `"model"` or a builtin name.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`ChatError::Failed`] if llama.cpp could not report a source.
211    pub fn source(&self, variant: Option<&str>) -> Result<String, ChatError> {
212        let c_variant = variant.map(CString::new).transpose()?;
213        let variant_ptr = c_variant.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
214        read_string(|buf, len, expected| unsafe {
215            sys::chat_shim_templates_source(self.raw.as_ptr(), variant_ptr, buf, len, expected)
216        })
217    }
218
219    /// Whether the caller supplied the template rather than the model.
220    #[must_use]
221    pub fn was_explicit(&self) -> bool {
222        unsafe { sys::chat_shim_templates_was_explicit(self.raw.as_ptr()) }
223    }
224
225    /// Whether this template understands `enable_thinking`.
226    #[must_use]
227    pub fn supports_enable_thinking(&self) -> bool {
228        unsafe { sys::chat_shim_templates_support_enable_thinking(self.raw.as_ptr()) }
229    }
230
231    /// Template capabilities as a JSON object of `name -> bool`.
232    ///
233    /// This is what upstream's server reports on `/props`; it tells you whether
234    /// the template can handle tools, parallel calls, a system role, and so on
235    /// *before* you send a request it cannot render.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`ChatError::Failed`] if llama.cpp could not report caps.
240    pub fn caps_json(&self) -> Result<String, ChatError> {
241        read_string(|buf, len, expected| unsafe {
242            sys::chat_shim_templates_get_caps(self.raw.as_ptr(), buf, len, expected)
243        })
244    }
245
246    /// Render messages and tools into a prompt plus its sampling constraints.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`ChatError::BadJson`] for malformed `messages`/`tools` JSON, or
251    /// [`ChatError::Failed`] if the template cannot render the request.
252    pub fn apply(&self, params: &ChatApplyParams) -> Result<ChatParams, ChatError> {
253        let messages = CString::new(params.messages_json.as_str())?;
254        let tools = params.tools_json.as_deref().map(CString::new).transpose()?;
255        let grammar = params.grammar.as_deref().map(CString::new).transpose()?;
256        let schema = params.json_schema.as_deref().map(CString::new).transpose()?;
257        let kwargs = params
258            .template_kwargs_json
259            .as_deref()
260            .map(CString::new)
261            .transpose()?;
262
263        let raw_params = sys::chat_shim_apply_params {
264            messages_json: messages.as_ptr(),
265            tools_json: opt_ptr(tools.as_ref()),
266            grammar: opt_ptr(grammar.as_ref()),
267            json_schema: opt_ptr(schema.as_ref()),
268            template_kwargs_json: opt_ptr(kwargs.as_ref()),
269            tool_choice: params.tool_choice.as_raw(),
270            reasoning_format: params.reasoning_format.as_raw(),
271            add_generation_prompt: params.add_generation_prompt,
272            enable_thinking: params.enable_thinking,
273            parallel_tool_calls: params.parallel_tool_calls,
274            use_jinja: params.use_jinja,
275            add_bos: params.add_bos,
276            add_eos: params.add_eos,
277            force_pure_content: params.force_pure_content,
278        };
279
280        // Two-call protocol: size, then fill. The shim fills `result` on both
281        // calls, so the template is only rendered once per call — but we take
282        // the second call's offsets, which describe the buffer we actually got.
283        // bindgen derives no `Default` for this struct; zeroing is correct
284        // here because every field is a `usize` offset, an `i32`, a `bool`, or
285        // the parser pointer — for which zero is the NULL the shim also writes
286        // on a size query — and the shim overwrites all of them before
287        // reporting success.
288        let mut result: sys::chat_shim_apply_result = unsafe { std::mem::zeroed() };
289        let mut needed: usize = 0;
290        let status = unsafe {
291            sys::chat_shim_templates_apply(
292                self.raw.as_ptr(),
293                &raw const raw_params,
294                &raw mut result,
295                std::ptr::null_mut(),
296                0,
297                &raw mut needed,
298            )
299        };
300        if status != sys::LLAMA_SHIM_BUFFER_TOO_SMALL {
301            check_status(status)?;
302        }
303
304        let mut buf = vec![0u8; needed];
305        let status = unsafe {
306            sys::chat_shim_templates_apply(
307                self.raw.as_ptr(),
308                &raw const raw_params,
309                &raw mut result,
310                buf.as_mut_ptr().cast::<c_char>(),
311                buf.len(),
312                &raw mut needed,
313            )
314        };
315        check_status(status)?;
316
317        // Take ownership of the parser before anything else fallible runs, or
318        // a later error would leak it. The shim only sets it on success.
319        let parser = NonNull::new(result.parser).map(|raw| Arc::new(ChatParser { raw }));
320
321        ChatParams::from_packed(&buf, &result, parser)
322    }
323}
324
325/// A request to render: messages, optional tools, and how to constrain output.
326///
327/// `messages_json` and `tools_json` are `OpenAI`-shaped JSON. They are passed as
328/// text rather than typed structs so this crate need not take a JSON dependency
329/// or track upstream's message schema, which grows most releases.
330// Mirrors `common_chat_templates_inputs`, which is mostly independent flags;
331// grouping them into sub-structs would diverge from the C layout for no gain.
332#[allow(clippy::struct_excessive_bools)]
333#[derive(Debug, Clone)]
334pub struct ChatApplyParams {
335    messages_json: String,
336    tools_json: Option<String>,
337    grammar: Option<String>,
338    json_schema: Option<String>,
339    template_kwargs_json: Option<String>,
340    tool_choice: ToolChoice,
341    reasoning_format: ReasoningFormat,
342    add_generation_prompt: bool,
343    enable_thinking: bool,
344    parallel_tool_calls: bool,
345    use_jinja: bool,
346    add_bos: bool,
347    add_eos: bool,
348    force_pure_content: bool,
349}
350
351impl ChatApplyParams {
352    /// Start from an OpenAI-shaped `messages` JSON array.
353    #[must_use]
354    pub fn new(messages_json: impl Into<String>) -> Self {
355        Self {
356            messages_json: messages_json.into(),
357            tools_json: None,
358            grammar: None,
359            json_schema: None,
360            template_kwargs_json: None,
361            tool_choice: ToolChoice::Auto,
362            reasoning_format: ReasoningFormat::Auto,
363            add_generation_prompt: true,
364            enable_thinking: true,
365            parallel_tool_calls: false,
366            use_jinja: true,
367            add_bos: false,
368            add_eos: false,
369            force_pure_content: false,
370        }
371    }
372
373    /// Supply an OpenAI-shaped `tools` JSON array.
374    #[must_use]
375    pub fn with_tools(mut self, tools_json: impl Into<String>) -> Self {
376        self.tools_json = Some(tools_json.into());
377        self
378    }
379
380    /// Constrain output with a GBNF grammar directly.
381    #[must_use]
382    pub fn with_grammar(mut self, grammar: impl Into<String>) -> Self {
383        self.grammar = Some(grammar.into());
384        self
385    }
386
387    /// Constrain output with a JSON Schema. llama.cpp converts it to GBNF.
388    #[must_use]
389    pub fn with_json_schema(mut self, schema_json: impl Into<String>) -> Self {
390        self.json_schema = Some(schema_json.into());
391        self
392    }
393
394    /// Extra Jinja variables, as a JSON object.
395    #[must_use]
396    pub fn with_template_kwargs(mut self, kwargs_json: impl Into<String>) -> Self {
397        self.template_kwargs_json = Some(kwargs_json.into());
398        self
399    }
400
401    /// Render a content-only template: reasoning and tool calls are left
402    /// inline in `content` instead of being parsed out.
403    ///
404    /// This is the replacement for the per-parse `parse_tool_calls` /
405    /// `reasoning_in_content` flags llama.cpp dropped in #30210. Because the
406    /// parser is now built once by `apply`, the decision has to be made here
407    /// rather than at [`ChatParams::parse`] time.
408    #[must_use]
409    pub fn with_force_pure_content(mut self, force: bool) -> Self {
410        self.force_pure_content = force;
411        self
412    }
413
414    /// Set what the model may do with the tools. Defaults to
415    /// [`ToolChoice::Auto`].
416    #[must_use]
417    pub fn with_tool_choice(mut self, choice: ToolChoice) -> Self {
418        self.tool_choice = choice;
419        self
420    }
421
422    /// Set how reasoning is handled. Defaults to [`ReasoningFormat::Auto`].
423    #[must_use]
424    pub fn with_reasoning_format(mut self, format: ReasoningFormat) -> Self {
425        self.reasoning_format = format;
426        self
427    }
428
429    /// Append the assistant generation prompt. Defaults to `true`; set `false`
430    /// to render a transcript rather than a request.
431    #[must_use]
432    pub fn with_add_generation_prompt(mut self, add: bool) -> Self {
433        self.add_generation_prompt = add;
434        self
435    }
436
437    /// Let the model think before answering, on templates that support it.
438    /// Defaults to `true`.
439    #[must_use]
440    pub fn with_enable_thinking(mut self, enable: bool) -> Self {
441        self.enable_thinking = enable;
442        self
443    }
444
445    /// Allow several tool calls in one turn. Defaults to `false`.
446    #[must_use]
447    pub fn with_parallel_tool_calls(mut self, parallel: bool) -> Self {
448        self.parallel_tool_calls = parallel;
449        self
450    }
451
452    /// Render with the Jinja engine. Defaults to `true`; `false` selects the
453    /// legacy `llama_chat_apply_template` path, which ignores tools.
454    #[must_use]
455    pub fn with_use_jinja(mut self, use_jinja: bool) -> Self {
456        self.use_jinja = use_jinja;
457        self
458    }
459
460    /// Prepend BOS / append EOS to the rendered prompt. Both default to
461    /// `false`, since tokenization usually adds BOS itself.
462    // The two parameter names mirror the upstream fields; renaming either to
463    // satisfy `similar_names` would obscure which C field it sets.
464    #[allow(clippy::similar_names)]
465    #[must_use]
466    pub fn with_add_bos_eos(mut self, add_bos: bool, add_eos: bool) -> Self {
467        self.add_bos = add_bos;
468        self.add_eos = add_eos;
469        self
470    }
471}
472
473/// The PEG parser a template produces, owned on the llama.cpp side.
474///
475/// `common_chat_templates_apply` builds a parser arena alongside the prompt,
476/// and [`ChatParams::parse`] needs it to read tool calls back out of model
477/// output. It used to round-trip through a string, but llama.cpp #30210 removed
478/// the arena's serialization, so it is held by handle instead and shared
479/// between clones of a [`ChatParams`].
480struct ChatParser {
481    raw: NonNull<sys::chat_shim_parser>,
482}
483
484// SAFETY: the arena is immutable once `apply` has built it, and upstream parses
485// through a `const common_peg_arena &` with all mutable state in a per-call
486// context. Nothing reachable through `&self` mutates it.
487unsafe impl Send for ChatParser {}
488unsafe impl Sync for ChatParser {}
489
490impl std::fmt::Debug for ChatParser {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        f.write_str("ChatParser(<opaque>)")
493    }
494}
495
496impl Drop for ChatParser {
497    fn drop(&mut self) {
498        unsafe { sys::chat_shim_parser_free(self.raw.as_ptr()) }
499    }
500}
501
502/// A lazy-grammar trigger: what has to appear before the grammar engages.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct GrammarTrigger {
505    /// `"token"`, `"word"`, `"pattern"` or `"pattern_full"`.
506    pub kind: String,
507    /// The literal, word, or regex the trigger matches.
508    pub value: String,
509    /// Token id for `"token"` triggers; `-1` otherwise.
510    pub token: i32,
511}
512
513/// The rendered prompt and everything needed to constrain and parse generation.
514#[derive(Debug, Clone)]
515pub struct ChatParams {
516    /// The prompt to feed the model.
517    pub prompt: String,
518    /// GBNF grammar constraining output. Empty when unconstrained.
519    pub grammar: String,
520    /// When true, `grammar` must not be applied until a trigger in
521    /// [`Self::grammar_triggers`] fires. Applying it from token zero is what
522    /// stops thinking models emitting their reasoning prefix.
523    pub grammar_lazy: bool,
524    /// Triggers that activate a lazy grammar. Raw JSON, and parsed into
525    /// [`Self::grammar_triggers`].
526    pub grammar_triggers_json: String,
527    /// Parsed form of [`Self::grammar_triggers_json`].
528    pub grammar_triggers: Vec<GrammarTrigger>,
529    /// JSON array of strings to keep verbatim while sampling.
530    pub preserved_tokens_json: String,
531    /// JSON array of extra stop strings this format needs.
532    pub additional_stops_json: String,
533    /// Whether this template supports reasoning.
534    pub supports_thinking: bool,
535    /// Opening reasoning tag, e.g. `"<think>"`. Empty when unsupported.
536    pub thinking_start_tag: String,
537    /// JSON array of closing reasoning tags.
538    pub thinking_end_tags_json: String,
539    /// `common_chat_format` discriminant, needed to parse output back.
540    pub format: i32,
541    /// PEG parser produced alongside the prompt, or `None` when the template
542    /// produced none — [`Self::parse`] then falls back to plain content.
543    parser: Option<Arc<ChatParser>>,
544    /// Prefix the parser expects to see before generated text.
545    generation_prompt: String,
546}
547
548impl ChatParams {
549    fn from_packed(
550        buf: &[u8],
551        result: &sys::chat_shim_apply_result,
552        parser: Option<Arc<ChatParser>>,
553    ) -> Result<Self, ChatError> {
554        let at = |off: usize| -> Result<String, ChatError> {
555            let rest = buf.get(off..).ok_or(ChatError::CorruptResult)?;
556            let end = rest
557                .iter()
558                .position(|b| *b == 0)
559                .ok_or(ChatError::CorruptResult)?;
560            String::from_utf8(rest[..end].to_vec()).map_err(ChatError::from)
561        };
562
563        let grammar_triggers_json = at(result.grammar_triggers_off)?;
564        let grammar_triggers = parse_triggers(&grammar_triggers_json);
565
566        Ok(Self {
567            prompt: at(result.prompt_off)?,
568            grammar: at(result.grammar_off)?,
569            grammar_lazy: result.grammar_lazy,
570            grammar_triggers,
571            grammar_triggers_json,
572            preserved_tokens_json: at(result.preserved_tokens_off)?,
573            additional_stops_json: at(result.additional_stops_off)?,
574            supports_thinking: result.supports_thinking,
575            thinking_start_tag: at(result.thinking_start_tag_off)?,
576            thinking_end_tags_json: at(result.thinking_end_tags_off)?,
577            format: result.format,
578            parser,
579            generation_prompt: at(result.generation_prompt_off)?,
580        })
581    }
582
583    /// Human-readable name of this chat format, e.g. `"Hermes 2 Pro"`.
584    ///
585    /// # Errors
586    ///
587    /// Returns [`ChatError::Failed`] if llama.cpp cannot name the format.
588    pub fn format_name(&self) -> Result<String, ChatError> {
589        format_name(self.format)
590    }
591
592    /// Convert [`Self::grammar_triggers`] into the `(patterns, tokens)` pair
593    /// [`LlamaSampler::grammar_lazy_patterns`](crate::sampling::LlamaSampler::grammar_lazy_patterns)
594    /// expects.
595    ///
596    /// The four trigger kinds do not map onto the sampler one-for-one, and
597    /// getting the translation wrong silently produces a grammar that never
598    /// activates:
599    ///
600    /// - `word` is a **literal**, so it is regex-escaped before becoming a
601    ///   pattern. A raw `<tool_call>` would otherwise be a character class.
602    /// - `pattern` passes through unchanged.
603    /// - `pattern_full` is anchored with `^`/`$` unless it already is.
604    /// - `token` becomes a trigger token rather than a pattern.
605    ///
606    /// This mirrors `common/sampling.cpp`, so callers do not have to.
607    #[must_use]
608    pub fn sampler_triggers(&self) -> (Vec<String>, Vec<crate::token::LlamaToken>) {
609        let mut patterns = Vec::new();
610        let mut tokens = Vec::new();
611        for trigger in &self.grammar_triggers {
612            match trigger.kind.as_str() {
613                "word" => patterns.push(regex_escape(&trigger.value)),
614                "pattern" => patterns.push(trigger.value.clone()),
615                "pattern_full" => patterns.push(anchor_pattern(&trigger.value)),
616                "token" => tokens.push(crate::token::LlamaToken(trigger.token)),
617                // An unknown kind from a newer llama.cpp: dropping it is safer
618                // than guessing, and the grammar simply stays dormant for it.
619                _ => {}
620            }
621        }
622        (patterns, tokens)
623    }
624
625    /// The prefix the template already placed at the end of the prompt, e.g.
626    /// `"<|im_start|>assistant\n"`.
627    ///
628    /// This is not part of the model's output, but the grammar and the parser
629    /// are both written as if it were — see [`Self::grammar_sampler`].
630    #[must_use]
631    pub fn generation_prompt(&self) -> &str {
632        &self.generation_prompt
633    }
634
635    /// Build the grammar sampler this render needs, or `None` when
636    /// unconstrained.
637    ///
638    /// Prefer this over constructing the sampler yourself: it handles three
639    /// things that are each silently wrong if missed.
640    ///
641    /// - **Lazy vs eager.** A lazy grammar must be built with its triggers, or
642    ///   it never activates. One built eagerly from a lazy grammar constrains
643    ///   from token zero and blocks a thinking model's reasoning prefix.
644    /// - **Trigger translation.** Literal, regex and token triggers map onto
645    ///   the sampler differently — see [`Self::sampler_triggers`].
646    /// - **Generation-prompt prefill.** llama.cpp writes tool-call grammars to
647    ///   match `generation_prompt + output`, because that is what the parser
648    ///   later sees. The sampler only sees `output`, so without advancing the
649    ///   grammar past that prefix it forces the model to *re-emit*
650    ///   `<|im_start|>assistant` as generated text. Prefill applies only to
651    ///   non-lazy grammars — a lazy one has not started matching yet.
652    ///
653    /// # Panics
654    ///
655    /// Panics if llama.cpp cannot parse the grammar it just produced, or if
656    /// that grammar contains an interior NUL.
657    #[must_use]
658    pub fn grammar_sampler(&self, model: &LlamaModel) -> Option<crate::sampling::LlamaSampler> {
659        use crate::sampling::LlamaSampler;
660
661        if self.grammar.is_empty() {
662            return None;
663        }
664
665        let (patterns, tokens) = self.sampler_triggers();
666        let lazy = self.grammar_lazy && !(patterns.is_empty() && tokens.is_empty());
667
668        let mut sampler = if lazy {
669            let refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
670            LlamaSampler::grammar_lazy_patterns(model, &self.grammar, "root", &refs, &tokens)
671        } else {
672            LlamaSampler::grammar(model, &self.grammar, "root")
673        };
674
675        if !lazy {
676            for token in self.generation_prompt_tokens(model) {
677                sampler.accept(token);
678            }
679        }
680        Some(sampler)
681    }
682
683    /// Tokenize [`Self::generation_prompt`] the way llama.cpp does when
684    /// prefilling a grammar.
685    ///
686    /// Some tokenizers prepend a space to the first token; upstream drops it
687    /// when the prompt itself does not start with whitespace, since that space
688    /// is an artefact rather than something the template emitted.
689    fn generation_prompt_tokens(&self, model: &LlamaModel) -> Vec<crate::token::LlamaToken> {
690        if self.generation_prompt.is_empty() {
691            return Vec::new();
692        }
693        let Ok(tokens) = model.str_to_token(&self.generation_prompt, crate::model::AddBos::Never)
694        else {
695            return Vec::new();
696        };
697        let starts_with_space = self
698            .generation_prompt
699            .starts_with(char::is_whitespace);
700        let mut out = Vec::with_capacity(tokens.len());
701        for (i, token) in tokens.into_iter().enumerate() {
702            if i == 0 && !starts_with_space {
703                if let Ok(piece) = model.token_to_str(token, crate::model::Special::Tokenize) {
704                    if piece.starts_with(char::is_whitespace) {
705                        continue;
706                    }
707                }
708            }
709            out.push(token);
710        }
711        out
712    }
713
714    /// Parse model output back into an OpenAI-shaped message JSON object with
715    /// `role`, `content`, `reasoning_content` and `tool_calls`.
716    ///
717    /// This uses the parser the template produced, so it understands that
718    /// model family's tool-call syntax rather than scraping for a fixed marker.
719    ///
720    /// Set `is_partial` while streaming: the parser then tolerates a truncated
721    /// tail instead of rejecting the buffer.
722    ///
723    /// # Errors
724    ///
725    /// Returns [`ChatError::Failed`] if the output cannot be parsed.
726    pub fn parse(&self, text: &str, is_partial: bool) -> Result<String, ChatError> {
727        let c_text = CString::new(text)?;
728        let c_gen_prompt = CString::new(self.generation_prompt.as_str())?;
729        let params = sys::chat_shim_parse_params {
730            text: c_text.as_ptr(),
731            parser: self
732                .parser
733                .as_ref()
734                .map_or(std::ptr::null(), |p| p.raw.as_ptr().cast_const()),
735            generation_prompt: c_gen_prompt.as_ptr(),
736            format: self.format,
737            is_partial,
738        };
739        read_string(|buf, len, expected| unsafe {
740            sys::chat_shim_parse(&raw const params, buf, len, expected)
741        })
742    }
743}
744
745/// Human-readable name of a `common_chat_format` discriminant.
746///
747/// # Errors
748///
749/// Returns [`ChatError::Failed`] if llama.cpp cannot name the format.
750pub fn format_name(format: i32) -> Result<String, ChatError> {
751    read_string(|buf, len, expected| unsafe {
752        sys::chat_shim_format_name(format, buf, len, expected)
753    })
754}
755
756/// Validate and normalise an OpenAI-shaped `messages` array.
757///
758/// Expands the shorthands llama.cpp accepts — typed content parts, legacy
759/// `function_call` — into the canonical form, and rejects malformed input
760/// before it reaches a template.
761///
762/// # Errors
763///
764/// Returns [`ChatError::BadJson`] if the input is not valid JSON, or
765/// [`ChatError::Failed`] if it is not a valid message array.
766pub fn parse_messages_oaicompat(messages_json: &str) -> Result<String, ChatError> {
767    let c_messages = CString::new(messages_json)?;
768    read_string(|buf, len, expected| unsafe {
769        sys::chat_shim_msgs_parse_oaicompat(c_messages.as_ptr(), buf, len, expected)
770    })
771}
772
773/// Validate and normalise an OpenAI-shaped `tools` array into a JSON array of
774/// `{"name", "description", "parameters"}`.
775///
776/// # Errors
777///
778/// Returns [`ChatError::BadJson`] if the input is not valid JSON, or
779/// [`ChatError::Failed`] if it is not a valid tool array.
780pub fn parse_tools_oaicompat(tools_json: &str) -> Result<String, ChatError> {
781    let c_tools = CString::new(tools_json)?;
782    read_string(|buf, len, expected| unsafe {
783        sys::chat_shim_tools_parse_oaicompat(c_tools.as_ptr(), buf, len, expected)
784    })
785}
786
787// ── plumbing ────────────────────────────────────────────────────────────────
788
789/// Escape the characters `std::regex` treats as special, matching
790/// `regex_escape` in `common/common.cpp`.
791fn regex_escape(s: &str) -> String {
792    const SPECIAL: &[char] = &[
793        '.', '^', '$', '|', '(', ')', '*', '+', '?', '[', ']', '{', '}', '\\',
794    ];
795    let mut out = String::with_capacity(s.len());
796    for c in s.chars() {
797        if SPECIAL.contains(&c) {
798            out.push('\\');
799        }
800        out.push(c);
801    }
802    out
803}
804
805/// Anchor a `pattern_full` trigger, matching `common/sampling.cpp`. An empty
806/// pattern becomes `^$` — matching only the empty string — rather than `^^$$`.
807fn anchor_pattern(pattern: &str) -> String {
808    if pattern.is_empty() {
809        return "^$".to_owned();
810    }
811    let mut out = String::with_capacity(pattern.len() + 2);
812    if !pattern.starts_with('^') {
813        out.push('^');
814    }
815    out.push_str(pattern);
816    if !pattern.ends_with('$') {
817        out.push('$');
818    }
819    out
820}
821
822fn opt_ptr(s: Option<&CString>) -> *const c_char {
823    s.map_or(std::ptr::null(), |c| c.as_ptr())
824}
825
826/// Pull the fields out of the shim's trigger JSON without taking a JSON
827/// dependency. The shape is fixed by `chat_shim.cpp`, so a hand parser is
828/// enough — anything unexpected yields no triggers, and
829/// [`ChatParams::grammar_triggers_json`] still carries the raw text.
830fn parse_triggers(json: &str) -> Vec<GrammarTrigger> {
831    let mut out = Vec::new();
832    for chunk in json.split('{').skip(1) {
833        let kind = json_str_field(chunk, "type");
834        let value = json_str_field(chunk, "value");
835        let token = json_int_field(chunk, "token");
836        if let (Some(kind), Some(value)) = (kind, value) {
837            out.push(GrammarTrigger {
838                kind,
839                value,
840                token: token.unwrap_or(-1),
841            });
842        }
843    }
844    out
845}
846
847fn json_str_field(chunk: &str, key: &str) -> Option<String> {
848    let needle = format!("\"{key}\":");
849    let rest = &chunk[chunk.find(&needle)? + needle.len()..];
850    let rest = rest.trim_start();
851    let mut chars = rest.strip_prefix('"')?.chars();
852    let mut value = String::new();
853    while let Some(c) = chars.next() {
854        match c {
855            '"' => return Some(value),
856            '\\' => match chars.next()? {
857                'n' => value.push('\n'),
858                'r' => value.push('\r'),
859                't' => value.push('\t'),
860                'u' => {
861                    let hex: String = chars.by_ref().take(4).collect();
862                    let code = u32::from_str_radix(&hex, 16).ok()?;
863                    value.push(char::from_u32(code)?);
864                }
865                other => value.push(other),
866            },
867            other => value.push(other),
868        }
869    }
870    None
871}
872
873fn json_int_field(chunk: &str, key: &str) -> Option<i32> {
874    let needle = format!("\"{key}\":");
875    let rest = &chunk[chunk.find(&needle)? + needle.len()..];
876    let rest = rest.trim_start();
877    let end = rest
878        .find(|c: char| !c.is_ascii_digit() && c != '-')
879        .unwrap_or(rest.len());
880    rest[..end].parse().ok()
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    /// The schema the `structured` example ships in its docs must convert and
888    /// be loadable by the sampler — an object schema with a required key is the
889    /// shape every `response_format: json_schema` caller sends.
890    #[test]
891    fn realistic_object_schema_converts() {
892        let gbnf = json_schema_to_grammar(
893            r#"{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}"#,
894            false,
895        )
896        .unwrap();
897        eprintln!("GBNF:\n{gbnf}");
898        assert!(gbnf.contains("root"));
899    }
900
901    #[test]
902    fn json_schema_to_grammar_produces_a_root_rule() {
903        let gbnf = json_schema_to_grammar(r#"{"type":"integer"}"#, false).unwrap();
904        assert!(gbnf.contains("root"), "no root rule in: {gbnf}");
905    }
906
907    /// An object schema must constrain its keys, otherwise the grammar is not
908    /// actually enforcing the schema.
909    #[test]
910    fn json_schema_to_grammar_constrains_object_keys() {
911        let gbnf = json_schema_to_grammar(
912            r#"{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}"#,
913            false,
914        )
915        .unwrap();
916        assert!(gbnf.contains("city"), "key not in grammar: {gbnf}");
917    }
918
919    /// Invalid JSON must be reported as such rather than crossing the FFI
920    /// boundary as an exception.
921    #[test]
922    fn json_schema_to_grammar_rejects_invalid_json() {
923        let err = json_schema_to_grammar("{not json", false).unwrap_err();
924        assert!(
925            matches!(err, ChatError::BadJson(_)),
926            "expected BadJson, got {err:?}"
927        );
928    }
929
930    #[test]
931    fn json_schema_to_grammar_rejects_empty_input() {
932        assert!(json_schema_to_grammar("", false).is_err());
933    }
934
935    /// Interior NULs must be caught in Rust — a `CString` cannot hold one, and
936    /// truncating silently would change the schema.
937    #[test]
938    fn json_schema_to_grammar_rejects_interior_nul() {
939        let err = json_schema_to_grammar("{\"type\":\"in\0teger\"}", false).unwrap_err();
940        assert!(matches!(err, ChatError::Nul(_)), "got {err:?}");
941    }
942
943    #[test]
944    fn tool_choice_parses_openai_values() {
945        assert_eq!(ToolChoice::parse_oaicompat("auto").unwrap(), ToolChoice::Auto);
946        assert_eq!(
947            ToolChoice::parse_oaicompat("required").unwrap(),
948            ToolChoice::Required
949        );
950        assert_eq!(ToolChoice::parse_oaicompat("none").unwrap(), ToolChoice::None);
951    }
952
953    #[test]
954    fn tool_choice_rejects_unknown_value() {
955        assert!(ToolChoice::parse_oaicompat("sometimes").is_err());
956    }
957
958    #[test]
959    fn tools_parse_oaicompat_extracts_name_and_parameters() {
960        let normalised = parse_tools_oaicompat(
961            r#"[{"type":"function","function":{"name":"get_weather",
962                "description":"Get weather","parameters":{"type":"object"}}}]"#,
963        )
964        .unwrap();
965        assert!(normalised.contains("get_weather"), "got {normalised}");
966    }
967
968    #[test]
969    fn tools_parse_oaicompat_rejects_garbage() {
970        assert!(parse_tools_oaicompat("[[[").is_err());
971    }
972
973    #[test]
974    fn messages_parse_oaicompat_roundtrips_a_simple_turn() {
975        let normalised =
976            parse_messages_oaicompat(r#"[{"role":"user","content":"hi"}]"#).unwrap();
977        assert!(normalised.contains("user"), "got {normalised}");
978        assert!(normalised.contains("hi"), "got {normalised}");
979    }
980
981    #[test]
982    fn messages_parse_oaicompat_rejects_non_array() {
983        assert!(parse_messages_oaicompat(r#"{"role":"user"}"#).is_err());
984    }
985
986    /// The trigger parser is hand-rolled, so pin it against the exact shape
987    /// `chat_shim.cpp` emits, including escapes.
988    #[test]
989    fn trigger_parser_reads_shim_output() {
990        let json = r#"[{"type":"word","value":"<tool_call>","token":-1},
991                       {"type":"token","value":"a\nb","token":42}]"#;
992        let triggers = parse_triggers(json);
993        assert_eq!(triggers.len(), 2);
994        assert_eq!(triggers[0].kind, "word");
995        assert_eq!(triggers[0].value, "<tool_call>");
996        assert_eq!(triggers[0].token, -1);
997        assert_eq!(triggers[1].kind, "token");
998        assert_eq!(triggers[1].value, "a\nb");
999        assert_eq!(triggers[1].token, 42);
1000    }
1001
1002    /// A literal trigger like `<tool_call>` must be escaped before it becomes
1003    /// a regex — unescaped, `[` and `]` would make it a character class and the
1004    /// grammar would never fire.
1005    #[test]
1006    fn word_triggers_are_regex_escaped() {
1007        let params = ChatParams {
1008            grammar_triggers: vec![GrammarTrigger {
1009                kind: "word".to_owned(),
1010                value: "a.b[c]".to_owned(),
1011                token: -1,
1012            }],
1013            ..stub_params()
1014        };
1015        let (patterns, tokens) = params.sampler_triggers();
1016        assert_eq!(patterns, vec![r"a\.b\[c\]".to_owned()]);
1017        assert!(tokens.is_empty());
1018    }
1019
1020    #[test]
1021    fn pattern_triggers_pass_through_unescaped() {
1022        let params = ChatParams {
1023            grammar_triggers: vec![GrammarTrigger {
1024                kind: "pattern".to_owned(),
1025                value: "a.b".to_owned(),
1026                token: -1,
1027            }],
1028            ..stub_params()
1029        };
1030        assert_eq!(params.sampler_triggers().0, vec!["a.b".to_owned()]);
1031    }
1032
1033    #[test]
1034    fn pattern_full_triggers_are_anchored_once() {
1035        let cases = [
1036            ("abc", "^abc$"),
1037            ("^abc", "^abc$"),
1038            ("abc$", "^abc$"),
1039            ("^abc$", "^abc$"),
1040            ("", "^$"),
1041        ];
1042        for (input, want) in cases {
1043            let params = ChatParams {
1044                grammar_triggers: vec![GrammarTrigger {
1045                    kind: "pattern_full".to_owned(),
1046                    value: input.to_owned(),
1047                    token: -1,
1048                }],
1049                ..stub_params()
1050            };
1051            assert_eq!(
1052                params.sampler_triggers().0,
1053                vec![want.to_owned()],
1054                "anchoring {input:?}"
1055            );
1056        }
1057    }
1058
1059    #[test]
1060    fn token_triggers_become_tokens_not_patterns() {
1061        let params = ChatParams {
1062            grammar_triggers: vec![GrammarTrigger {
1063                kind: "token".to_owned(),
1064                value: String::new(),
1065                token: 42,
1066            }],
1067            ..stub_params()
1068        };
1069        let (patterns, tokens) = params.sampler_triggers();
1070        assert!(patterns.is_empty());
1071        assert_eq!(tokens, vec![crate::token::LlamaToken(42)]);
1072    }
1073
1074    /// A kind from a newer llama.cpp must be dropped rather than guessed at.
1075    #[test]
1076    fn unknown_trigger_kinds_are_dropped() {
1077        let params = ChatParams {
1078            grammar_triggers: vec![GrammarTrigger {
1079                kind: "something_new".to_owned(),
1080                value: "x".to_owned(),
1081                token: -1,
1082            }],
1083            ..stub_params()
1084        };
1085        let (patterns, tokens) = params.sampler_triggers();
1086        assert!(patterns.is_empty());
1087        assert!(tokens.is_empty());
1088    }
1089
1090    fn stub_params() -> ChatParams {
1091        ChatParams {
1092            prompt: String::new(),
1093            grammar: String::new(),
1094            grammar_lazy: false,
1095            grammar_triggers_json: String::new(),
1096            grammar_triggers: Vec::new(),
1097            preserved_tokens_json: String::new(),
1098            additional_stops_json: String::new(),
1099            supports_thinking: false,
1100            thinking_start_tag: String::new(),
1101            thinking_end_tags_json: String::new(),
1102            format: 0,
1103            parser: None,
1104            generation_prompt: String::new(),
1105        }
1106    }
1107
1108    #[test]
1109    fn trigger_parser_handles_empty_array() {
1110        assert!(parse_triggers("[]").is_empty());
1111    }
1112}