1use 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
43pub type ChatError = crate::shim::ShimError;
49
50use crate::shim::{check_status, last_error, read_string};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum ToolChoice {
55 #[default]
57 Auto,
58 Required,
60 None,
62}
63
64impl ToolChoice {
65 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum ReasoningFormat {
110 #[default]
112 None,
113 Auto,
115 DeepSeekLegacy,
117 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
134pub 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#[derive(Debug)]
166pub struct ChatTemplates {
167 raw: NonNull<sys::chat_shim_templates>,
168}
169
170unsafe 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 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 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 #[must_use]
221 pub fn was_explicit(&self) -> bool {
222 unsafe { sys::chat_shim_templates_was_explicit(self.raw.as_ptr()) }
223 }
224
225 #[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 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 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 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 let parser = NonNull::new(result.parser).map(|raw| Arc::new(ChatParser { raw }));
320
321 ChatParams::from_packed(&buf, &result, parser)
322 }
323}
324
325#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
417 pub fn with_tool_choice(mut self, choice: ToolChoice) -> Self {
418 self.tool_choice = choice;
419 self
420 }
421
422 #[must_use]
424 pub fn with_reasoning_format(mut self, format: ReasoningFormat) -> Self {
425 self.reasoning_format = format;
426 self
427 }
428
429 #[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 #[must_use]
440 pub fn with_enable_thinking(mut self, enable: bool) -> Self {
441 self.enable_thinking = enable;
442 self
443 }
444
445 #[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 #[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 #[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
473struct ChatParser {
481 raw: NonNull<sys::chat_shim_parser>,
482}
483
484unsafe 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#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct GrammarTrigger {
505 pub kind: String,
507 pub value: String,
509 pub token: i32,
511}
512
513#[derive(Debug, Clone)]
515pub struct ChatParams {
516 pub prompt: String,
518 pub grammar: String,
520 pub grammar_lazy: bool,
524 pub grammar_triggers_json: String,
527 pub grammar_triggers: Vec<GrammarTrigger>,
529 pub preserved_tokens_json: String,
531 pub additional_stops_json: String,
533 pub supports_thinking: bool,
535 pub thinking_start_tag: String,
537 pub thinking_end_tags_json: String,
539 pub format: i32,
541 parser: Option<Arc<ChatParser>>,
544 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 pub fn format_name(&self) -> Result<String, ChatError> {
589 format_name(self.format)
590 }
591
592 #[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 _ => {}
620 }
621 }
622 (patterns, tokens)
623 }
624
625 #[must_use]
631 pub fn generation_prompt(&self) -> &str {
632 &self.generation_prompt
633 }
634
635 #[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 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 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
745pub 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
756pub 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
773pub 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
787fn 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
805fn 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
826fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}