1use std::collections::{HashMap, HashSet};
51
52use async_trait::async_trait;
53use openai_protocol::common::Tool;
54use regex::Regex;
55use serde_json::{json, Value};
56
57use crate::{
58 errors::ParserResult,
59 traits::ToolParser,
60 types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
61};
62
63const TOOLS_OPEN: &str = "<|open|>tools<|sep|>";
64const TOOLS_CLOSE: &str = "<|close|>tools<|sep|>";
65const RESPONSE_OPEN: &str = "<|open|>response<|sep|>";
66const RESPONSE_CLOSE: &str = "<|close|>response<|sep|>";
67const THINK_CLOSE: &str = "<|close|>think<|sep|>";
68
69const TOOLS_SECTION_PREFIXES: [&str; 3] = ["", THINK_CLOSE, RESPONSE_CLOSE];
73
74const OPEN: &str = "<|open|>";
76const CLOSE: &str = "<|close|>";
77const SEP: &str = "<|sep|>";
78
79struct DecodedCall {
85 name: String,
86 arguments: String,
88}
89
90pub struct KimiK3Parser {
96 tools_open_re: Regex,
98 tools_close_re: Regex,
100 response_open_re: Regex,
102 response_close_re: Regex,
104 message_close_re: Regex,
106 call_re: Regex,
108 arg_re: Regex,
110 attr_re: Regex,
112 response_re: Regex,
114
115 buffer: String,
118 sent_content_idx: usize,
121 sent_tool_call_count: usize,
123}
124
125impl KimiK3Parser {
126 pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
176 let call_formats: Vec<Value> = tools
177 .iter()
178 .filter(|tool| !tool.function.name.is_empty())
179 .map(|tool| build_call_format(&tool.function.name, &tool.function.parameters))
180 .collect();
181
182 let call_choice = if call_formats.is_empty() {
183 permissive_call_format()
184 } else {
185 json!({ "type": "or", "elements": call_formats })
186 };
187
188 let content = json!({ "type": "plus", "content": call_choice });
189
190 let tags: Vec<Value> = TOOLS_SECTION_PREFIXES
192 .iter()
193 .map(|prefix| {
194 json!({
195 "begin": format!("{prefix}{TOOLS_OPEN}"),
196 "content": content,
197 "end": TOOLS_CLOSE,
198 })
199 })
200 .collect();
201
202 json!({
203 "format": {
204 "type": "triggered_tags",
205 "triggers": [TOOLS_OPEN, THINK_CLOSE, RESPONSE_CLOSE],
206 "tags": tags,
207 "at_least_one": at_least_one,
208 }
209 })
210 }
211
212 #[expect(
214 clippy::expect_used,
215 reason = "regex patterns are compile-time string literals"
216 )]
217 pub fn new() -> Self {
218 Self {
219 tools_open_re: Regex::new(r"<\|open\|>\s*tools\s*<\|sep\|>")
220 .expect("valid regex"),
221 tools_close_re: Regex::new(r"<\|close\|>\s*tools\s*<\|sep\|>")
222 .expect("valid regex"),
223 response_open_re: Regex::new(r"<\|open\|>\s*response\s*<\|sep\|>")
224 .expect("valid regex"),
225 response_close_re: Regex::new(r"<\|close\|>\s*response\s*<\|sep\|>")
226 .expect("valid regex"),
227 message_close_re: Regex::new(r"<\|close\|>\s*message\s*<\|sep\|>")
228 .expect("valid regex"),
229 call_re: Regex::new(
230 r"(?s)<\|open\|>\s*call\s+(?P<attrs>.*?)<\|sep\|>(?P<body>.*?)<\|close\|>\s*call\s*<\|sep\|>",
231 )
232 .expect("valid regex"),
233 arg_re: Regex::new(
234 r"(?s)<\|open\|>\s*argument\s+(?P<attrs>.*?)<\|sep\|>(?P<val>.*?)<\|close\|>\s*argument\s*<\|sep\|>",
235 )
236 .expect("valid regex"),
237 attr_re: Regex::new(r#"(?P<k>\w+)="(?P<v>[^"]*)""#).expect("valid regex"),
238 response_re: Regex::new(
239 r"(?s)<\|open\|>\s*response\s*<\|sep\|>(?P<c>.*?)<\|close\|>\s*response\s*<\|sep\|>",
240 )
241 .expect("valid regex"),
242 buffer: String::new(),
243 sent_content_idx: 0,
244 sent_tool_call_count: 0,
245 }
246 }
247
248 fn attrs(&self, s: &str) -> HashMap<String, String> {
251 self.attr_re
252 .captures_iter(s)
253 .map(|m| {
254 let key = m.name("k").map_or("", |g| g.as_str()).to_string();
255 let value = m
256 .name("v")
257 .map_or("", |g| g.as_str())
258 .replace(""", "\"")
259 .replace("&", "&");
260 (key, value)
261 })
262 .collect()
263 }
264
265 fn decode_call(&self, attrs: &str, body: &str) -> Option<DecodedCall> {
271 let call_attrs = self.attrs(attrs);
272 let tool_name = call_attrs.get("tool").cloned().unwrap_or_default();
273 if tool_name.is_empty() {
274 return None;
275 }
276
277 let mut arguments = serde_json::Map::new();
278 for arg_match in self.arg_re.captures_iter(body) {
279 let arg_attrs_text = arg_match.name("attrs").map_or("", |g| g.as_str());
280 let arg_attrs = self.attrs(arg_attrs_text);
281 let key = arg_attrs.get("key").cloned().unwrap_or_default();
282 let arg_type = arg_attrs.get("type").map_or("string", String::as_str);
283 let raw_value = arg_match.name("val").map_or("", |g| g.as_str());
284
285 let value = if arg_type == "string" {
286 Value::String(raw_value.to_string())
287 } else {
288 serde_json::from_str::<Value>(raw_value)
289 .unwrap_or_else(|_| Value::String(raw_value.to_string()))
290 };
291 arguments.insert(key, value);
292 }
293
294 let arguments_str = serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".to_string());
295
296 Some(DecodedCall {
297 name: tool_name,
298 arguments: arguments_str,
299 })
300 }
301
302 fn strip_response_content(&self, text: &str) -> Option<String> {
310 let stripped = if let Some(m_open) = self.response_open_re.find(text) {
311 if let Some(m_close) = self.response_close_re.find_at(text, m_open.end()) {
312 text[m_open.end()..m_close.start()].to_string()
313 } else {
314 text[m_open.end()..].to_string()
315 }
316 } else {
317 self.response_close_re.replace_all(text, "").into_owned()
318 };
319 let stripped = self
320 .message_close_re
321 .replace_all(&stripped, "")
322 .into_owned();
323 if stripped.is_empty() {
324 None
325 } else {
326 Some(stripped)
327 }
328 }
329
330 fn content(&self, model_output: &str, before: &str) -> Option<String> {
335 if let Some(m) = self.response_re.captures(model_output) {
336 let c = m.name("c").map_or("", |g| g.as_str());
337 return if c.is_empty() {
338 None
339 } else {
340 Some(c.to_string())
341 };
342 }
343 self.strip_response_content(before)
344 }
345
346 fn extract_response_content(&mut self, current_text: &str) -> Option<String> {
352 let m_open = self.response_open_re.find(current_text);
353 let body_start = m_open.map_or(0, |m| m.end());
354
355 let tools_start = self
356 .tools_open_re
357 .find_at(current_text, body_start)
358 .map(|m| m.start());
359 let response_end = self
360 .response_close_re
361 .find_at(current_text, body_start)
362 .map(|m| m.start());
363
364 let sendable_idx = match (tools_start, response_end) {
365 (Some(a), Some(b)) => a.min(b),
366 (Some(a), None) | (None, Some(a)) => a,
367 (None, None) => {
368 let overlap = partial_tag_overlap(current_text, RESPONSE_OPEN)
369 .max(partial_tag_overlap(current_text, RESPONSE_CLOSE))
370 .max(partial_tag_overlap(current_text, TOOLS_OPEN));
371 current_text.len() - overlap
372 }
373 };
374
375 if sendable_idx <= body_start {
376 return None;
377 }
378 if self.sent_content_idx < body_start {
379 self.sent_content_idx = body_start;
380 }
381 if sendable_idx <= self.sent_content_idx {
382 return None;
383 }
384
385 let content = current_text[self.sent_content_idx..sendable_idx].to_string();
386 self.sent_content_idx = sendable_idx;
387 if content.is_empty() {
388 None
389 } else {
390 Some(content)
391 }
392 }
393
394 fn decode_calls_in_section(&self, section: &str) -> Vec<DecodedCall> {
398 let mut calls = Vec::new();
399 for m in self.call_re.captures_iter(section) {
400 let attrs_text = m.name("attrs").map_or("", |g| g.as_str());
401 let body = m.name("body").map_or("", |g| g.as_str());
402 if let Some(decoded) = self.decode_call(attrs_text, body) {
403 calls.push(decoded);
404 }
405 }
406 calls
407 }
408}
409
410enum ArgShape {
416 Fixed {
418 type_attr: &'static str,
419 value: Value,
420 },
421 Permissive,
424}
425
426fn escape_attr(value: &str) -> String {
429 value.replace('&', "&").replace('"', """)
430}
431
432fn build_call_format(name: &str, params: &Value) -> Value {
435 let esc_name = escape_attr(name);
436 json!({
437 "type": "sequence",
438 "elements": [
439 { "type": "const_string", "value": format!("{OPEN}call tool=\"{esc_name}\" index=\"") },
440 { "type": "regex", "pattern": "[1-9][0-9]*" },
441 { "type": "const_string", "value": format!("\"{SEP}") },
442 build_args_format(params),
443 { "type": "const_string", "value": format!("{CLOSE}call{SEP}") },
444 ]
445 })
446}
447
448fn build_args_format(params: &Value) -> Value {
454 let Some(properties) = params.get("properties").and_then(Value::as_object) else {
455 return permissive_args_format();
456 };
457 if properties.is_empty() {
458 return permissive_args_format();
459 }
460
461 let required: HashSet<&str> = params
462 .get("required")
463 .and_then(Value::as_array)
464 .map(|values| values.iter().filter_map(Value::as_str).collect())
465 .unwrap_or_default();
466
467 let elements: Vec<Value> = properties
468 .iter()
469 .map(|(key, subschema)| {
470 let argument = build_argument_format(key, subschema);
471 if required.contains(key.as_str()) {
472 argument
473 } else {
474 json!({ "type": "optional", "content": argument })
475 }
476 })
477 .collect();
478
479 json!({ "type": "sequence", "elements": elements })
480}
481
482fn build_argument_format(key: &str, subschema: &Value) -> Value {
485 let esc_key = escape_attr(key);
486 match classify_arg(subschema) {
487 ArgShape::Fixed { type_attr, value } => json!({
488 "type": "sequence",
489 "elements": [
490 { "type": "const_string", "value": format!("{OPEN}argument key=\"{esc_key}\" type=\"{type_attr}\"{SEP}") },
491 value,
492 { "type": "const_string", "value": format!("{CLOSE}argument{SEP}") },
493 ]
494 }),
495 ArgShape::Permissive => json!({
496 "type": "sequence",
497 "elements": [
498 { "type": "const_string", "value": format!("{OPEN}argument key=\"{esc_key}\" type=\"") },
499 { "type": "any_text", "excludes": ["\"", "<|"] },
500 { "type": "const_string", "value": format!("\"{SEP}") },
501 { "type": "any_text", "excludes": [CLOSE, OPEN, SEP] },
502 { "type": "const_string", "value": format!("{CLOSE}argument{SEP}") },
503 ]
504 }),
505 }
506}
507
508fn classify_arg(subschema: &Value) -> ArgShape {
513 if let Some(values) = subschema.get("enum").and_then(Value::as_array) {
515 if !values.is_empty() && values.iter().all(Value::is_string) {
516 let options: Vec<Value> = values
517 .iter()
518 .filter_map(Value::as_str)
519 .map(|v| json!({ "type": "const_string", "value": v }))
520 .collect();
521 return ArgShape::Fixed {
522 type_attr: "string",
523 value: json!({ "type": "or", "elements": options }),
524 };
525 }
526 }
527
528 match subschema.get("type").and_then(Value::as_str) {
529 Some("string") => ArgShape::Fixed {
531 type_attr: "string",
532 value: json!({ "type": "any_text", "excludes": [CLOSE, OPEN, SEP] }),
533 },
534 Some("integer") | Some("number") => ArgShape::Fixed {
535 type_attr: "number",
536 value: json!({ "type": "json_schema", "json_schema": subschema }),
537 },
538 Some("boolean") => ArgShape::Fixed {
539 type_attr: "boolean",
540 value: json!({ "type": "json_schema", "json_schema": { "type": "boolean" } }),
541 },
542 Some("object") => ArgShape::Fixed {
543 type_attr: "object",
544 value: json!({ "type": "json_schema", "json_schema": subschema }),
545 },
546 Some("array") => ArgShape::Fixed {
547 type_attr: "array",
548 value: json!({ "type": "json_schema", "json_schema": subschema }),
549 },
550 Some("null") => ArgShape::Fixed {
551 type_attr: "null",
552 value: json!({ "type": "const_string", "value": "null" }),
553 },
554 _ => ArgShape::Permissive,
555 }
556}
557
558fn permissive_args_format() -> Value {
561 json!({ "type": "star", "content": permissive_argument_format() })
562}
563
564fn permissive_argument_format() -> Value {
573 json!({
574 "type": "sequence",
575 "elements": [
576 { "type": "const_string", "value": format!("{OPEN}argument key=\"") },
577 { "type": "any_text", "excludes": ["\"", "<|"] },
578 { "type": "const_string", "value": "\" type=\"" },
579 { "type": "any_text", "excludes": ["\"", "<|"] },
580 { "type": "const_string", "value": format!("\"{SEP}") },
581 { "type": "any_text", "excludes": [CLOSE, OPEN, SEP] },
582 { "type": "const_string", "value": format!("{CLOSE}argument{SEP}") },
583 ]
584 })
585}
586
587fn permissive_call_format() -> Value {
589 json!({
590 "type": "sequence",
591 "elements": [
592 { "type": "const_string", "value": format!("{OPEN}call tool=\"") },
593 { "type": "any_text", "excludes": ["\"", "<|"] },
594 { "type": "const_string", "value": "\" index=\"" },
595 { "type": "regex", "pattern": "[1-9][0-9]*" },
596 { "type": "const_string", "value": format!("\"{SEP}") },
597 permissive_args_format(),
598 { "type": "const_string", "value": format!("{CLOSE}call{SEP}") },
599 ]
600 })
601}
602
603fn partial_tag_overlap(text: &str, tag: &str) -> usize {
608 let max_len = text.len().min(tag.len().saturating_sub(1));
609 for n in (1..=max_len).rev() {
610 if text.ends_with(&tag[..n]) {
611 return n;
612 }
613 }
614 0
615}
616
617impl Default for KimiK3Parser {
618 fn default() -> Self {
619 Self::new()
620 }
621}
622
623#[async_trait]
624impl ToolParser for KimiK3Parser {
625 async fn parse_complete(&self, output: &str) -> ParserResult<(String, Vec<ToolCall>)> {
626 let Some(m_open) = self.tools_open_re.find(output) else {
627 let content = self.content(output, output);
628 return Ok((content.unwrap_or_default(), vec![]));
629 };
630
631 let before = &output[..m_open.start()];
632 let start = m_open.end();
633 let section_end = self
634 .tools_close_re
635 .find_at(output, start)
636 .map_or(output.len(), |m| m.start());
637 let section = &output[start..section_end];
638
639 let calls = self
640 .decode_calls_in_section(section)
641 .into_iter()
642 .map(|decoded| ToolCall {
643 function: FunctionCall {
644 name: decoded.name,
645 arguments: decoded.arguments,
646 },
647 })
648 .collect();
649
650 let content = self.content(output, before);
651 Ok((content.unwrap_or_default(), calls))
652 }
653
654 async fn parse_incremental(
655 &mut self,
656 chunk: &str,
657 _tools: &[Tool],
658 ) -> ParserResult<StreamingParseResult> {
659 self.buffer.push_str(chunk);
660 let current_text = self.buffer.clone();
661
662 let content = self.extract_response_content(¤t_text);
663
664 let Some(m_tools) = self.tools_open_re.find(¤t_text) else {
665 return Ok(StreamingParseResult {
666 normal_text: content.unwrap_or_default(),
667 calls: vec![],
668 });
669 };
670
671 let section = ¤t_text[m_tools.end()..];
672 let decoded_calls = self.decode_calls_in_section(section);
673
674 if decoded_calls.len() <= self.sent_tool_call_count {
675 return Ok(StreamingParseResult {
676 normal_text: content.unwrap_or_default(),
677 calls: vec![],
678 });
679 }
680
681 let calls = decoded_calls
682 .iter()
683 .skip(self.sent_tool_call_count)
684 .enumerate()
685 .map(|(i, decoded)| ToolCallItem {
686 tool_index: self.sent_tool_call_count + i,
689 name: Some(decoded.name.clone()),
690 parameters: decoded.arguments.clone(),
691 })
692 .collect();
693 self.sent_tool_call_count = decoded_calls.len();
694
695 Ok(StreamingParseResult {
696 normal_text: content.unwrap_or_default(),
697 calls,
698 })
699 }
700
701 fn has_tool_markers(&self, text: &str) -> bool {
702 self.tools_open_re.is_match(text)
703 }
704
705 fn reset(&mut self) {
706 self.buffer.clear();
707 self.sent_content_idx = 0;
708 self.sent_tool_call_count = 0;
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use serde_json::Value;
715
716 use super::*;
717
718 fn _arg(key: &str, typ: &str, value: &str) -> String {
719 format!(r#"{OPEN}argument key="{key}" type="{typ}"{SEP}{value}{CLOSE}argument{SEP}"#)
720 }
721
722 fn _call(tool: &str, index: i32, args: &[String]) -> String {
723 let body: String = args.concat();
724 format!(r#"{OPEN}call tool="{tool}" index="{index}"{SEP}{body}{CLOSE}call{SEP}"#)
725 }
726
727 fn _response(content: &str) -> String {
728 format!("{OPEN}response{SEP}{content}{CLOSE}response{SEP}")
729 }
730
731 fn _tools(calls: &[String]) -> String {
732 let body: String = calls.concat();
733 format!("{OPEN}tools{SEP}{body}{CLOSE}tools{SEP}")
734 }
735
736 #[tokio::test]
737 async fn test_parse_complete_response_and_typed_arguments() {
738 let parser = KimiK3Parser::new();
739 let input = _response("answer")
740 + &_tools(&[_call(
741 "calc",
742 1,
743 &[
744 _arg("x", "number", "1"),
745 _arg("flag", "boolean", "true"),
746 _arg("text", "string", "raw"),
747 ],
748 )]);
749
750 let (content, calls) = parser.parse_complete(&input).await.unwrap();
751 assert_eq!(content, "answer");
752 assert_eq!(calls.len(), 1);
753 assert_eq!(calls[0].function.name, "calc");
754 let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
755 assert_eq!(
756 args,
757 serde_json::json!({"x": 1, "flag": true, "text": "raw"})
758 );
759 }
760
761 #[tokio::test]
762 async fn test_parse_complete_unescapes_attributes() {
763 let parser = KimiK3Parser::new();
764 let input = _tools(&[_call(
765 "a&b"c",
766 1,
767 &[_arg("k&q", "string", "v")],
768 )]);
769
770 let (_content, calls) = parser.parse_complete(&input).await.unwrap();
771 assert_eq!(calls.len(), 1);
772 assert_eq!(calls[0].function.name, "a&b\"c");
773 let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
774 assert_eq!(args, serde_json::json!({"k&q": "v"}));
775 }
776
777 #[tokio::test]
778 async fn test_parse_complete_allows_less_than_in_attributes() {
779 let parser = KimiK3Parser::new();
780 let input = _tools(&[_call("calc<beta", 1, &[_arg("foo<bar", "string", "raw")])]);
781
782 let (_content, calls) = parser.parse_complete(&input).await.unwrap();
783 assert_eq!(calls.len(), 1);
784 assert_eq!(calls[0].function.name, "calc<beta");
785 let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
786 assert_eq!(args, serde_json::json!({"foo<bar": "raw"}));
787 }
788
789 #[tokio::test]
790 async fn test_parse_complete_no_tools_channel() {
791 let parser = KimiK3Parser::new();
792 let input = _response("hi");
793
794 let (content, calls) = parser.parse_complete(&input).await.unwrap();
795 assert_eq!(content, "hi");
796 assert_eq!(calls.len(), 0);
797 }
798
799 #[tokio::test]
800 async fn test_parse_complete_whitespace_degraded_markers() {
801 let parser = KimiK3Parser::new();
802 let input = format!("{OPEN} response {SEP}answer{CLOSE} response {SEP}");
803
804 let (content, calls) = parser.parse_complete(&input).await.unwrap();
805 assert_eq!(content, "answer");
806 assert_eq!(calls.len(), 0);
807 }
808
809 #[tokio::test]
810 async fn test_parse_complete_malformed_json_argument_falls_back_to_raw() {
811 let parser = KimiK3Parser::new();
812 let input = _tools(&[_call("calc", 1, &[_arg("x", "number", "not-json")])]);
813
814 let (_content, calls) = parser.parse_complete(&input).await.unwrap();
815 assert_eq!(calls.len(), 1);
816 let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
817 assert_eq!(args, serde_json::json!({"x": "not-json"}));
818 }
819
820 #[tokio::test]
821 async fn test_parse_complete_multiple_tool_calls() {
822 let parser = KimiK3Parser::new();
823 let input = _tools(&[
824 _call("first", 1, &[_arg("a", "number", "1")]),
825 _call("second", 2, &[_arg("b", "number", "2")]),
826 ]);
827
828 let (_content, calls) = parser.parse_complete(&input).await.unwrap();
829 assert_eq!(calls.len(), 2);
830 assert_eq!(calls[0].function.name, "first");
831 assert_eq!(calls[1].function.name, "second");
832 }
833
834 #[tokio::test]
835 async fn test_decode_call_without_index_attribute() {
836 let parser = KimiK3Parser::new();
837 let input = format!(
838 r#"{OPEN}tools{SEP}{OPEN}call tool="calc"{SEP}{CLOSE}call{SEP}{CLOSE}tools{SEP}"#
839 );
840
841 let (_content, calls) = parser.parse_complete(&input).await.unwrap();
842 assert_eq!(calls.len(), 1);
843 assert_eq!(calls[0].function.name, "calc");
844 assert_eq!(calls[0].function.arguments, "{}");
845 }
846
847 #[test]
848 fn test_has_tool_markers() {
849 let parser = KimiK3Parser::new();
850 assert!(parser.has_tool_markers(&_tools(&[])));
851 assert!(!parser.has_tool_markers(&_response("hi")));
852 assert!(!parser.has_tool_markers("plain text"));
853 }
854
855 #[tokio::test]
856 async fn test_streaming_split_markers_do_not_leak() {
857 let mut parser = KimiK3Parser::new();
858 let hi_chunk = format!("{SEP}Hi");
859 let call_open_chunk = format!(r#"{OPEN}call tool="calc" index="1"{SEP}"#);
860 let arg_chunk = _arg("x", "number", "1");
861 let close_call_chunk = format!("{CLOSE}call");
862
863 let chunks: [&str; 10] = [
864 OPEN,
865 "response",
866 hi_chunk.as_str(),
867 OPEN,
868 "tools",
869 SEP,
870 call_open_chunk.as_str(),
871 arg_chunk.as_str(),
872 close_call_chunk.as_str(),
873 SEP,
874 ];
875
876 let mut content = String::new();
877 let mut calls = Vec::new();
878 for chunk in chunks {
879 let result = parser.parse_incremental(chunk, &[]).await.unwrap();
880 content.push_str(&result.normal_text);
881 calls.extend(result.calls);
882 }
883
884 assert_eq!(content, "Hi");
885 assert!(!content.contains(OPEN));
886 assert!(!content.contains(SEP));
887 assert_eq!(calls.len(), 1);
888 assert_eq!(calls[0].name.as_deref(), Some("calc"));
889 let args: Value = serde_json::from_str(&calls[0].parameters).unwrap();
890 assert_eq!(args, serde_json::json!({"x": 1}));
891 }
892
893 #[tokio::test]
894 async fn test_streaming_consumed_response_prefix_no_call_keeps_content() {
895 let mut parser = KimiK3Parser::new();
896 let close_response_chunk = format!("response{SEP}");
897 let chunks: [&str; 4] = ["O", "K", CLOSE, close_response_chunk.as_str()];
898
899 let mut content = String::new();
900 for chunk in chunks {
901 let result = parser.parse_incremental(chunk, &[]).await.unwrap();
902 assert!(!result.normal_text.contains(CLOSE));
903 content.push_str(&result.normal_text);
904 }
905 assert_eq!(content, "OK");
906 }
907
908 #[tokio::test]
909 async fn test_streaming_multiple_tool_calls_increment_tool_index() {
910 let mut parser = KimiK3Parser::new();
911 let input = _tools(&[
912 _call("first", 1, &[_arg("a", "number", "1")]),
913 _call("second", 2, &[_arg("b", "number", "2")]),
914 ]);
915
916 let result = parser.parse_incremental(&input, &[]).await.unwrap();
917 assert_eq!(result.calls.len(), 2);
918 assert_eq!(result.calls[0].tool_index, 0);
919 assert_eq!(result.calls[1].tool_index, 1);
920 }
921
922 #[tokio::test]
923 async fn test_streaming_duplicate_xtml_index_uses_ordinal() {
924 let mut parser = KimiK3Parser::new();
928 let input = _tools(&[
929 _call("first", 7, &[_arg("a", "number", "1")]),
930 _call("second", 7, &[_arg("b", "number", "2")]),
931 _call("third", 3, &[_arg("c", "number", "3")]),
932 ]);
933
934 let result = parser.parse_incremental(&input, &[]).await.unwrap();
935 assert_eq!(result.calls.len(), 3);
936 assert_eq!(result.calls[0].tool_index, 0);
937 assert_eq!(result.calls[1].tool_index, 1);
938 assert_eq!(result.calls[2].tool_index, 2);
939 }
940
941 #[tokio::test]
942 async fn test_reset_clears_streaming_state() {
943 let mut parser = KimiK3Parser::new();
944
945 let primed = format!("{OPEN}response{SEP}Hello{CLOSE}response{SEP}")
946 + &_tools(&[_call("calc", 1, &[_arg("x", "number", "1")])]);
947 let primed_result = parser.parse_incremental(&primed, &[]).await.unwrap();
948 assert_eq!(primed_result.calls.len(), 1);
949
950 parser.reset();
951
952 let fresh_content = format!("{OPEN}response{SEP}Fresh{CLOSE}response{SEP}");
953 let content_result = parser.parse_incremental(&fresh_content, &[]).await.unwrap();
954 assert_eq!(content_result.normal_text, "Fresh");
955
956 let fresh_call = _tools(&[_call("calc", 1, &[_arg("x", "number", "2")])]);
957 let call_result = parser.parse_incremental(&fresh_call, &[]).await.unwrap();
958 assert_eq!(call_result.calls.len(), 1);
959 assert_eq!(call_result.calls[0].name.as_deref(), Some("calc"));
960 }
961
962 #[test]
963 fn test_factory_resolves_moonshot_kimi_k3() {
964 let factory = crate::factory::ParserFactory::new();
965 assert_eq!(
966 factory
967 .registry()
968 .resolve_model_to_parser("moonshotai/Kimi-K3"),
969 Some("kimi_k3".to_string())
970 );
971 }
972
973 #[test]
974 fn test_factory_resolves_underscore_kimi_k3() {
975 let factory = crate::factory::ParserFactory::new();
976 for id in ["kimi_k3", "Kimi_K3", "moonshotai/Kimi_K3"] {
977 assert_eq!(
978 factory.registry().resolve_model_to_parser(id),
979 Some("kimi_k3".to_string()),
980 "expected `{id}` to resolve to the kimi_k3 parser"
981 );
982 }
983 }
984
985 #[test]
986 fn test_factory_registers_kimi_k3_structural_tag() {
987 let factory = crate::factory::ParserFactory::new();
988 assert!(factory.registry().has_structural_tag("kimi_k3"));
989 }
990
991 fn sample_tools() -> Vec<Tool> {
992 serde_json::from_value(serde_json::json!([{
993 "type": "function",
994 "function": {
995 "name": "get_weather",
996 "parameters": {
997 "type": "object",
998 "properties": {
999 "city": {"type": "string"},
1000 "days": {"type": "integer"},
1001 "units": {"type": "string", "enum": ["c", "f"]}
1002 },
1003 "required": ["city", "days"]
1004 }
1005 }
1006 }]))
1007 .unwrap()
1008 }
1009
1010 #[test]
1011 fn test_build_structural_tag_frames_tools_section() {
1012 let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1013 let format = &tag["format"];
1014 assert_eq!(format["type"], "triggered_tags");
1015 assert_eq!(format["triggers"][0], TOOLS_OPEN);
1016 assert_eq!(format["at_least_one"], true);
1017 assert!(format.get("stop_after_first").is_none());
1020
1021 let section = &format["tags"][0];
1022 assert_eq!(section["begin"], TOOLS_OPEN);
1023 assert_eq!(section["end"], TOOLS_CLOSE);
1024 assert_eq!(section["content"]["type"], "plus");
1026 }
1027
1028 #[test]
1029 fn test_build_structural_tag_allows_closing_prefilled_channel() {
1030 let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1035 let format = &tag["format"];
1036
1037 let triggers: Vec<&str> = format["triggers"]
1038 .as_array()
1039 .unwrap()
1040 .iter()
1041 .map(|t| t.as_str().unwrap())
1042 .collect();
1043 assert!(triggers.contains(&THINK_CLOSE), "got: {triggers:?}");
1044 assert!(triggers.contains(&RESPONSE_CLOSE), "got: {triggers:?}");
1045 assert!(triggers.contains(&TOOLS_OPEN), "got: {triggers:?}");
1046
1047 let begins: Vec<&str> = format["tags"]
1048 .as_array()
1049 .unwrap()
1050 .iter()
1051 .map(|t| t["begin"].as_str().unwrap())
1052 .collect();
1053 assert_eq!(
1054 begins,
1055 vec![
1056 TOOLS_OPEN,
1057 &format!("{THINK_CLOSE}{TOOLS_OPEN}")[..],
1058 &format!("{RESPONSE_CLOSE}{TOOLS_OPEN}")[..],
1059 ]
1060 );
1061
1062 for section in format["tags"].as_array().unwrap() {
1064 assert_eq!(section["end"], TOOLS_CLOSE);
1065 assert_eq!(section["content"]["type"], "plus");
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn test_parse_complete_handles_think_close_prefixed_tools_section() {
1071 let parser = KimiK3Parser::new();
1074 let input = THINK_CLOSE.to_string()
1075 + &_tools(&[_call("get_weather", 1, &[_arg("city", "string", "SF")])]);
1076
1077 let (_content, calls) = parser.parse_complete(&input).await.unwrap();
1078 assert_eq!(calls.len(), 1);
1079 assert_eq!(calls[0].function.name, "get_weather");
1080 let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
1081 assert_eq!(args, serde_json::json!({"city": "SF"}));
1082 }
1083
1084 #[test]
1085 fn test_build_structural_tag_pins_call_framing_and_tool_name() {
1086 let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1087 let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1088 assert_eq!(call["type"], "sequence");
1089 let elems = call["elements"].as_array().unwrap();
1090 assert_eq!(
1091 elems[0]["value"],
1092 "<|open|>call tool=\"get_weather\" index=\""
1093 );
1094 assert_eq!(elems[1]["type"], "regex");
1095 assert_eq!(elems[2]["value"], "\"<|sep|>");
1096 assert_eq!(elems[4]["value"], "<|close|>call<|sep|>");
1097 }
1098
1099 #[test]
1100 fn test_build_structural_tag_strict_arguments_by_type() {
1101 let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1102 let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1103 let args = &call["elements"][3];
1104 assert_eq!(args["type"], "sequence");
1105 let arg_elems = args["elements"].as_array().unwrap();
1106 assert_eq!(arg_elems.len(), 3);
1108
1109 assert_eq!(arg_elems[0]["type"], "sequence");
1111 assert_eq!(
1112 arg_elems[0]["elements"][0]["value"],
1113 "<|open|>argument key=\"city\" type=\"string\"<|sep|>"
1114 );
1115 assert_eq!(arg_elems[0]["elements"][1]["type"], "any_text");
1116
1117 assert_eq!(
1119 arg_elems[1]["elements"][0]["value"],
1120 "<|open|>argument key=\"days\" type=\"number\"<|sep|>"
1121 );
1122 assert_eq!(arg_elems[1]["elements"][1]["type"], "json_schema");
1123
1124 assert_eq!(arg_elems[2]["type"], "optional");
1126 let units = &arg_elems[2]["content"];
1127 assert_eq!(
1128 units["elements"][0]["value"],
1129 "<|open|>argument key=\"units\" type=\"string\"<|sep|>"
1130 );
1131 assert_eq!(units["elements"][1]["type"], "or");
1132 assert_eq!(units["elements"][1]["elements"][0]["value"], "c");
1133 assert_eq!(units["elements"][1]["elements"][1]["value"], "f");
1134 }
1135
1136 #[test]
1137 fn test_build_structural_tag_escapes_attribute_values() {
1138 let tools: Vec<Tool> = serde_json::from_value(serde_json::json!([{
1139 "type": "function",
1140 "function": {
1141 "name": "a&b\"c",
1142 "parameters": {
1143 "type": "object",
1144 "properties": {"k\"q": {"type": "string"}},
1145 "required": ["k\"q"]
1146 }
1147 }
1148 }]))
1149 .unwrap();
1150 let tag = KimiK3Parser::build_structural_tag(&tools, true);
1151 let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1152 assert_eq!(
1153 call["elements"][0]["value"],
1154 "<|open|>call tool=\"a&b"c\" index=\""
1155 );
1156 let arg = &call["elements"][3]["elements"][0];
1157 assert_eq!(
1158 arg["elements"][0]["value"],
1159 "<|open|>argument key=\"k"q\" type=\"string\"<|sep|>"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_build_structural_tag_falls_back_when_no_properties() {
1165 let tools: Vec<Tool> = serde_json::from_value(serde_json::json!([{
1166 "type": "function",
1167 "function": {"name": "ping", "parameters": {"type": "object"}}
1168 }]))
1169 .unwrap();
1170 let tag = KimiK3Parser::build_structural_tag(&tools, false);
1171 assert_eq!(tag["format"]["at_least_one"], false);
1172 let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1173 assert_eq!(call["elements"][3]["type"], "star");
1175 }
1176}