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)]
513pub struct PromptPart {
514 pub text: String,
516 pub is_input: bool,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq)]
525pub struct GrammarTrigger {
526 pub kind: String,
528 pub value: String,
530 pub token: i32,
532}
533
534#[derive(Debug, Clone)]
536pub struct ChatParams {
537 pub prompt: String,
539 pub grammar: String,
541 pub grammar_lazy: bool,
545 pub grammar_triggers_json: String,
548 pub grammar_triggers: Vec<GrammarTrigger>,
550 pub preserved_tokens_json: String,
552 pub additional_stops_json: String,
554 pub supports_thinking: bool,
556 pub thinking_start_tag: String,
558 pub thinking_end_tags_json: String,
560 pub format: i32,
562 pub prompt_parts: Vec<PromptPart>,
567 pub prompt_parts_json: String,
570 parser: Option<Arc<ChatParser>>,
573 generation_prompt: String,
575}
576
577impl ChatParams {
578 fn from_packed(
579 buf: &[u8],
580 result: &sys::chat_shim_apply_result,
581 parser: Option<Arc<ChatParser>>,
582 ) -> Result<Self, ChatError> {
583 let at = |off: usize| -> Result<String, ChatError> {
584 let rest = buf.get(off..).ok_or(ChatError::CorruptResult)?;
585 let end = rest
586 .iter()
587 .position(|b| *b == 0)
588 .ok_or(ChatError::CorruptResult)?;
589 String::from_utf8(rest[..end].to_vec()).map_err(ChatError::from)
590 };
591
592 let grammar_triggers_json = at(result.grammar_triggers_off)?;
593 let prompt_parts_json = at(result.prompt_parts_off)?;
594 let grammar_triggers = parse_triggers(&grammar_triggers_json);
595
596 Ok(Self {
597 prompt: at(result.prompt_off)?,
598 grammar: at(result.grammar_off)?,
599 grammar_lazy: result.grammar_lazy,
600 grammar_triggers,
601 grammar_triggers_json,
602 preserved_tokens_json: at(result.preserved_tokens_off)?,
603 additional_stops_json: at(result.additional_stops_off)?,
604 supports_thinking: result.supports_thinking,
605 thinking_start_tag: at(result.thinking_start_tag_off)?,
606 thinking_end_tags_json: at(result.thinking_end_tags_off)?,
607 format: result.format,
608 prompt_parts: parse_prompt_parts(&prompt_parts_json),
609 prompt_parts_json,
610 parser,
611 generation_prompt: at(result.generation_prompt_off)?,
612 })
613 }
614
615 pub fn tokenize_prompt(
653 &self,
654 model: &LlamaModel,
655 add_bos: crate::model::AddBos,
656 ) -> Result<Vec<crate::token::LlamaToken>, crate::StringToTokenError> {
657 use crate::model::Special;
658
659 if self.prompt_parts.is_empty() {
660 return model.str_to_token_with_special(&self.prompt, add_bos, Special::Tokenize);
661 }
662
663 let mut out = Vec::new();
664 let mut scratch = Vec::new();
665 let mut bos = add_bos;
666 for part in &self.prompt_parts {
667 if part.text.is_empty() {
670 continue;
671 }
672 let special = if part.is_input {
673 Special::Plaintext
674 } else {
675 Special::Tokenize
676 };
677 model.str_to_token_into_with_special(&part.text, bos, special, &mut scratch)?;
678 out.append(&mut scratch);
679 bos = crate::model::AddBos::Never;
680 }
681 Ok(out)
682 }
683
684 pub fn format_name(&self) -> Result<String, ChatError> {
690 format_name(self.format)
691 }
692
693 #[must_use]
709 pub fn sampler_triggers(&self) -> (Vec<String>, Vec<crate::token::LlamaToken>) {
710 let mut patterns = Vec::new();
711 let mut tokens = Vec::new();
712 for trigger in &self.grammar_triggers {
713 match trigger.kind.as_str() {
714 "word" => patterns.push(regex_escape(&trigger.value)),
715 "pattern" => patterns.push(trigger.value.clone()),
716 "pattern_full" => patterns.push(anchor_pattern(&trigger.value)),
717 "token" => tokens.push(crate::token::LlamaToken(trigger.token)),
718 _ => {}
721 }
722 }
723 (patterns, tokens)
724 }
725
726 #[must_use]
732 pub fn generation_prompt(&self) -> &str {
733 &self.generation_prompt
734 }
735
736 #[must_use]
759 pub fn grammar_sampler(&self, model: &LlamaModel) -> Option<crate::sampling::LlamaSampler> {
760 use crate::sampling::LlamaSampler;
761
762 if self.grammar.is_empty() {
763 return None;
764 }
765
766 let (patterns, tokens) = self.sampler_triggers();
767 let lazy = self.grammar_lazy && !(patterns.is_empty() && tokens.is_empty());
768
769 let mut sampler = if lazy {
770 let refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
771 LlamaSampler::grammar_lazy_patterns(model, &self.grammar, "root", &refs, &tokens)
772 } else {
773 LlamaSampler::grammar(model, &self.grammar, "root")
774 };
775
776 if !lazy {
777 for token in self.generation_prompt_tokens(model) {
778 sampler.accept(token);
779 }
780 }
781 Some(sampler)
782 }
783
784 fn generation_prompt_tokens(&self, model: &LlamaModel) -> Vec<crate::token::LlamaToken> {
791 if self.generation_prompt.is_empty() {
792 return Vec::new();
793 }
794 let Ok(tokens) = model.str_to_token(&self.generation_prompt, crate::model::AddBos::Never)
795 else {
796 return Vec::new();
797 };
798 let starts_with_space = self
799 .generation_prompt
800 .starts_with(char::is_whitespace);
801 let mut out = Vec::with_capacity(tokens.len());
802 for (i, token) in tokens.into_iter().enumerate() {
803 if i == 0 && !starts_with_space {
804 if let Ok(piece) = model.token_to_str(token, crate::model::Special::Tokenize) {
805 if piece.starts_with(char::is_whitespace) {
806 continue;
807 }
808 }
809 }
810 out.push(token);
811 }
812 out
813 }
814
815 pub fn parse(&self, text: &str, is_partial: bool) -> Result<String, ChatError> {
828 let c_text = CString::new(text)?;
829 let c_gen_prompt = CString::new(self.generation_prompt.as_str())?;
830 let params = sys::chat_shim_parse_params {
831 text: c_text.as_ptr(),
832 parser: self
833 .parser
834 .as_ref()
835 .map_or(std::ptr::null(), |p| p.raw.as_ptr().cast_const()),
836 generation_prompt: c_gen_prompt.as_ptr(),
837 format: self.format,
838 is_partial,
839 };
840 read_string(|buf, len, expected| unsafe {
841 sys::chat_shim_parse(&raw const params, buf, len, expected)
842 })
843 }
844}
845
846pub fn format_name(format: i32) -> Result<String, ChatError> {
852 read_string(|buf, len, expected| unsafe {
853 sys::chat_shim_format_name(format, buf, len, expected)
854 })
855}
856
857pub fn parse_messages_oaicompat(messages_json: &str) -> Result<String, ChatError> {
868 let c_messages = CString::new(messages_json)?;
869 read_string(|buf, len, expected| unsafe {
870 sys::chat_shim_msgs_parse_oaicompat(c_messages.as_ptr(), buf, len, expected)
871 })
872}
873
874pub fn parse_tools_oaicompat(tools_json: &str) -> Result<String, ChatError> {
882 let c_tools = CString::new(tools_json)?;
883 read_string(|buf, len, expected| unsafe {
884 sys::chat_shim_tools_parse_oaicompat(c_tools.as_ptr(), buf, len, expected)
885 })
886}
887
888fn regex_escape(s: &str) -> String {
893 const SPECIAL: &[char] = &[
894 '.', '^', '$', '|', '(', ')', '*', '+', '?', '[', ']', '{', '}', '\\',
895 ];
896 let mut out = String::with_capacity(s.len());
897 for c in s.chars() {
898 if SPECIAL.contains(&c) {
899 out.push('\\');
900 }
901 out.push(c);
902 }
903 out
904}
905
906fn anchor_pattern(pattern: &str) -> String {
909 if pattern.is_empty() {
910 return "^$".to_owned();
911 }
912 let mut out = String::with_capacity(pattern.len() + 2);
913 if !pattern.starts_with('^') {
914 out.push('^');
915 }
916 out.push_str(pattern);
917 if !pattern.ends_with('$') {
918 out.push('$');
919 }
920 out
921}
922
923fn opt_ptr(s: Option<&CString>) -> *const c_char {
924 s.map_or(std::ptr::null(), |c| c.as_ptr())
925}
926
927fn parse_prompt_parts(json: &str) -> Vec<PromptPart> {
945 let b = json.as_bytes();
946 let mut out = Vec::new();
947
948 let Some(mut i) = b.iter().position(|&c| c == b'[') else {
949 return out;
950 };
951 i += 1;
952
953 loop {
954 while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b',') {
955 i += 1;
956 }
957 if i >= b.len() || b[i] != b'{' {
958 return out;
959 }
960 i += 1;
961
962 let mut text = None;
963 let mut is_input = true;
964 loop {
965 while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b',') {
966 i += 1;
967 }
968 if i >= b.len() {
969 return out;
970 }
971 if b[i] == b'}' {
972 i += 1;
973 break;
974 }
975
976 let Some((key, next)) = scan_json_string(json, i) else {
977 return out;
978 };
979 i = next;
980 while i < b.len() && b[i].is_ascii_whitespace() {
981 i += 1;
982 }
983 if i >= b.len() || b[i] != b':' {
984 return out;
985 }
986 i += 1;
987 while i < b.len() && b[i].is_ascii_whitespace() {
988 i += 1;
989 }
990 if i >= b.len() {
991 return out;
992 }
993
994 if b[i] == b'"' {
995 let Some((value, next)) = scan_json_string(json, i) else {
996 return out;
997 };
998 i = next;
999 if key == "text" {
1000 text = Some(value);
1001 }
1002 } else {
1003 let start = i;
1006 while i < b.len() && b[i] != b',' && b[i] != b'}' {
1007 i += 1;
1008 }
1009 if key == "is_input" {
1010 is_input = json[start..i].trim() != "false";
1014 }
1015 }
1016 }
1017
1018 if let Some(text) = text {
1019 out.push(PromptPart { text, is_input });
1020 }
1021 }
1022}
1023
1024fn scan_json_string(json: &str, start: usize) -> Option<(String, usize)> {
1027 let b = json.as_bytes();
1028 if b.get(start) != Some(&b'"') {
1029 return None;
1030 }
1031 let mut value = String::new();
1032 let mut chars = json[start + 1..].char_indices();
1033 while let Some((offset, c)) = chars.next() {
1034 match c {
1035 '"' => return Some((value, start + 1 + offset + 1)),
1036 '\\' => match chars.next()?.1 {
1037 'n' => value.push('\n'),
1038 'r' => value.push('\r'),
1039 't' => value.push('\t'),
1040 'b' => value.push('\u{8}'),
1041 'f' => value.push('\u{c}'),
1042 'u' => {
1043 let hex: String = (0..4).map_while(|_| chars.next().map(|(_, c)| c)).collect();
1044 let code = u32::from_str_radix(&hex, 16).ok()?;
1045 value.push(char::from_u32(code)?);
1046 }
1047 other => value.push(other),
1048 },
1049 other => value.push(other),
1050 }
1051 }
1052 None
1053}
1054
1055fn parse_triggers(json: &str) -> Vec<GrammarTrigger> {
1056 let mut out = Vec::new();
1057 for chunk in json.split('{').skip(1) {
1058 let kind = json_str_field(chunk, "type");
1059 let value = json_str_field(chunk, "value");
1060 let token = json_int_field(chunk, "token");
1061 if let (Some(kind), Some(value)) = (kind, value) {
1062 out.push(GrammarTrigger {
1063 kind,
1064 value,
1065 token: token.unwrap_or(-1),
1066 });
1067 }
1068 }
1069 out
1070}
1071
1072fn json_str_field(chunk: &str, key: &str) -> Option<String> {
1073 let needle = format!("\"{key}\":");
1074 let rest = &chunk[chunk.find(&needle)? + needle.len()..];
1075 let rest = rest.trim_start();
1076 let mut chars = rest.strip_prefix('"')?.chars();
1077 let mut value = String::new();
1078 while let Some(c) = chars.next() {
1079 match c {
1080 '"' => return Some(value),
1081 '\\' => match chars.next()? {
1082 'n' => value.push('\n'),
1083 'r' => value.push('\r'),
1084 't' => value.push('\t'),
1085 'u' => {
1086 let hex: String = chars.by_ref().take(4).collect();
1087 let code = u32::from_str_radix(&hex, 16).ok()?;
1088 value.push(char::from_u32(code)?);
1089 }
1090 other => value.push(other),
1091 },
1092 other => value.push(other),
1093 }
1094 }
1095 None
1096}
1097
1098fn json_int_field(chunk: &str, key: &str) -> Option<i32> {
1099 let needle = format!("\"{key}\":");
1100 let rest = &chunk[chunk.find(&needle)? + needle.len()..];
1101 let rest = rest.trim_start();
1102 let end = rest
1103 .find(|c: char| !c.is_ascii_digit() && c != '-')
1104 .unwrap_or(rest.len());
1105 rest[..end].parse().ok()
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1116 fn realistic_object_schema_converts() {
1117 let gbnf = json_schema_to_grammar(
1118 r#"{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}"#,
1119 false,
1120 )
1121 .unwrap();
1122 eprintln!("GBNF:\n{gbnf}");
1123 assert!(gbnf.contains("root"));
1124 }
1125
1126 #[test]
1127 fn json_schema_to_grammar_produces_a_root_rule() {
1128 let gbnf = json_schema_to_grammar(r#"{"type":"integer"}"#, false).unwrap();
1129 assert!(gbnf.contains("root"), "no root rule in: {gbnf}");
1130 }
1131
1132 #[test]
1135 fn json_schema_to_grammar_constrains_object_keys() {
1136 let gbnf = json_schema_to_grammar(
1137 r#"{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}"#,
1138 false,
1139 )
1140 .unwrap();
1141 assert!(gbnf.contains("city"), "key not in grammar: {gbnf}");
1142 }
1143
1144 #[test]
1147 fn json_schema_to_grammar_rejects_invalid_json() {
1148 let err = json_schema_to_grammar("{not json", false).unwrap_err();
1149 assert!(
1150 matches!(err, ChatError::BadJson(_)),
1151 "expected BadJson, got {err:?}"
1152 );
1153 }
1154
1155 #[test]
1156 fn json_schema_to_grammar_rejects_empty_input() {
1157 assert!(json_schema_to_grammar("", false).is_err());
1158 }
1159
1160 #[test]
1163 fn json_schema_to_grammar_rejects_interior_nul() {
1164 let err = json_schema_to_grammar("{\"type\":\"in\0teger\"}", false).unwrap_err();
1165 assert!(matches!(err, ChatError::Nul(_)), "got {err:?}");
1166 }
1167
1168 #[test]
1169 fn tool_choice_parses_openai_values() {
1170 assert_eq!(ToolChoice::parse_oaicompat("auto").unwrap(), ToolChoice::Auto);
1171 assert_eq!(
1172 ToolChoice::parse_oaicompat("required").unwrap(),
1173 ToolChoice::Required
1174 );
1175 assert_eq!(ToolChoice::parse_oaicompat("none").unwrap(), ToolChoice::None);
1176 }
1177
1178 #[test]
1179 fn tool_choice_rejects_unknown_value() {
1180 assert!(ToolChoice::parse_oaicompat("sometimes").is_err());
1181 }
1182
1183 #[test]
1184 fn tools_parse_oaicompat_extracts_name_and_parameters() {
1185 let normalised = parse_tools_oaicompat(
1186 r#"[{"type":"function","function":{"name":"get_weather",
1187 "description":"Get weather","parameters":{"type":"object"}}}]"#,
1188 )
1189 .unwrap();
1190 assert!(normalised.contains("get_weather"), "got {normalised}");
1191 }
1192
1193 #[test]
1194 fn tools_parse_oaicompat_rejects_garbage() {
1195 assert!(parse_tools_oaicompat("[[[").is_err());
1196 }
1197
1198 #[test]
1199 fn messages_parse_oaicompat_roundtrips_a_simple_turn() {
1200 let normalised =
1201 parse_messages_oaicompat(r#"[{"role":"user","content":"hi"}]"#).unwrap();
1202 assert!(normalised.contains("user"), "got {normalised}");
1203 assert!(normalised.contains("hi"), "got {normalised}");
1204 }
1205
1206 #[test]
1207 fn messages_parse_oaicompat_rejects_non_array() {
1208 assert!(parse_messages_oaicompat(r#"{"role":"user"}"#).is_err());
1209 }
1210
1211 #[test]
1214 fn trigger_parser_reads_shim_output() {
1215 let json = r#"[{"type":"word","value":"<tool_call>","token":-1},
1216 {"type":"token","value":"a\nb","token":42}]"#;
1217 let triggers = parse_triggers(json);
1218 assert_eq!(triggers.len(), 2);
1219 assert_eq!(triggers[0].kind, "word");
1220 assert_eq!(triggers[0].value, "<tool_call>");
1221 assert_eq!(triggers[0].token, -1);
1222 assert_eq!(triggers[1].kind, "token");
1223 assert_eq!(triggers[1].value, "a\nb");
1224 assert_eq!(triggers[1].token, 42);
1225 }
1226
1227 #[test]
1231 fn word_triggers_are_regex_escaped() {
1232 let params = ChatParams {
1233 grammar_triggers: vec![GrammarTrigger {
1234 kind: "word".to_owned(),
1235 value: "a.b[c]".to_owned(),
1236 token: -1,
1237 }],
1238 ..stub_params()
1239 };
1240 let (patterns, tokens) = params.sampler_triggers();
1241 assert_eq!(patterns, vec![r"a\.b\[c\]".to_owned()]);
1242 assert!(tokens.is_empty());
1243 }
1244
1245 #[test]
1246 fn pattern_triggers_pass_through_unescaped() {
1247 let params = ChatParams {
1248 grammar_triggers: vec![GrammarTrigger {
1249 kind: "pattern".to_owned(),
1250 value: "a.b".to_owned(),
1251 token: -1,
1252 }],
1253 ..stub_params()
1254 };
1255 assert_eq!(params.sampler_triggers().0, vec!["a.b".to_owned()]);
1256 }
1257
1258 #[test]
1259 fn pattern_full_triggers_are_anchored_once() {
1260 let cases = [
1261 ("abc", "^abc$"),
1262 ("^abc", "^abc$"),
1263 ("abc$", "^abc$"),
1264 ("^abc$", "^abc$"),
1265 ("", "^$"),
1266 ];
1267 for (input, want) in cases {
1268 let params = ChatParams {
1269 grammar_triggers: vec![GrammarTrigger {
1270 kind: "pattern_full".to_owned(),
1271 value: input.to_owned(),
1272 token: -1,
1273 }],
1274 ..stub_params()
1275 };
1276 assert_eq!(
1277 params.sampler_triggers().0,
1278 vec![want.to_owned()],
1279 "anchoring {input:?}"
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn token_triggers_become_tokens_not_patterns() {
1286 let params = ChatParams {
1287 grammar_triggers: vec![GrammarTrigger {
1288 kind: "token".to_owned(),
1289 value: String::new(),
1290 token: 42,
1291 }],
1292 ..stub_params()
1293 };
1294 let (patterns, tokens) = params.sampler_triggers();
1295 assert!(patterns.is_empty());
1296 assert_eq!(tokens, vec![crate::token::LlamaToken(42)]);
1297 }
1298
1299 #[test]
1301 fn unknown_trigger_kinds_are_dropped() {
1302 let params = ChatParams {
1303 grammar_triggers: vec![GrammarTrigger {
1304 kind: "something_new".to_owned(),
1305 value: "x".to_owned(),
1306 token: -1,
1307 }],
1308 ..stub_params()
1309 };
1310 let (patterns, tokens) = params.sampler_triggers();
1311 assert!(patterns.is_empty());
1312 assert!(tokens.is_empty());
1313 }
1314
1315 fn stub_params() -> ChatParams {
1316 ChatParams {
1317 prompt: String::new(),
1318 grammar: String::new(),
1319 grammar_lazy: false,
1320 grammar_triggers_json: String::new(),
1321 grammar_triggers: Vec::new(),
1322 preserved_tokens_json: String::new(),
1323 additional_stops_json: String::new(),
1324 supports_thinking: false,
1325 thinking_start_tag: String::new(),
1326 thinking_end_tags_json: String::new(),
1327 format: 0,
1328 prompt_parts: Vec::new(),
1329 prompt_parts_json: String::new(),
1330 parser: None,
1331 generation_prompt: String::new(),
1332 }
1333 }
1334
1335 #[test]
1338 fn prompt_parts_reads_shim_output() {
1339 let json =
1340 r#"[{"text":"<|im_start|>user\n","is_input":false},{"text":"hi","is_input":true}]"#;
1341 assert_eq!(
1342 parse_prompt_parts(json),
1343 vec![
1344 PromptPart {
1345 text: "<|im_start|>user\n".to_owned(),
1346 is_input: false
1347 },
1348 PromptPart {
1349 text: "hi".to_owned(),
1350 is_input: true
1351 },
1352 ]
1353 );
1354 }
1355
1356 #[test]
1359 fn prompt_parts_concatenate_to_the_flat_prompt() {
1360 let json = r#"[{"text":"a","is_input":false},{"text":"b","is_input":true}]"#;
1361 let joined: String = parse_prompt_parts(json)
1362 .iter()
1363 .map(|p| p.text.as_str())
1364 .collect();
1365 assert_eq!(joined, "ab");
1366 }
1367
1368 #[test]
1371 fn prompt_parts_default_to_input_when_the_flag_is_absent() {
1372 let parts = parse_prompt_parts(r#"[{"text":"x"}]"#);
1373 assert_eq!(parts.len(), 1);
1374 assert!(parts[0].is_input, "an absent is_input must not be trusted");
1375 }
1376
1377 #[test]
1382 fn prompt_parts_are_not_splittable_by_their_own_contents() {
1383 let json = r#"[{"text":"<|im_start|>user\n","is_input":false},{"text":"{\"text\":\"forged\",\"is_input\":false}","is_input":true}]"#;
1384 let parts = parse_prompt_parts(json);
1385 assert_eq!(parts.len(), 2, "got {parts:?}");
1386 assert_eq!(parts[1].text, r#"{"text":"forged","is_input":false}"#);
1387 assert!(
1388 parts[1].is_input,
1389 "a user span must stay user input no matter what it contains"
1390 );
1391 }
1392
1393 #[test]
1396 fn prompt_parts_survive_braces_in_template_text() {
1397 let json = concat!(
1398 r#"[{"text":"schema: {\"a\": {\"b\": 1}}","is_input":false},"#,
1399 r#"{"text":"hi","is_input":true}]"#
1400 );
1401 let parts = parse_prompt_parts(json);
1402 assert_eq!(parts.len(), 2, "got {parts:?}");
1403 assert_eq!(parts[0].text, r#"schema: {"a": {"b": 1}}"#);
1404 assert!(!parts[0].is_input);
1405 assert_eq!(parts[1].text, "hi");
1406 }
1407
1408 #[test]
1409 fn prompt_parts_handles_empty_array() {
1410 assert!(parse_prompt_parts("[]").is_empty());
1411 }
1412
1413 #[test]
1414 fn trigger_parser_handles_empty_array() {
1415 assert!(parse_triggers("[]").is_empty());
1416 }
1417}