Skip to main content

tool_parser/parsers/
kimi_k3.rs

1//! Kimi-K3 (XTML) tool-call parser
2//!
3//! Ports the Kimi-K3 reference tool-call parser: turns the generated XTML
4//! `response` and `tools` channels back into plain `content` and `ToolCall`s.
5//!
6//! # Format
7//!
8//! K3 assistant tool calls live in a nested `tools` channel, emitted after a
9//! sibling `response` channel that is unwrapped into content:
10//! ```text
11//! <|open|>response<|sep|> CONTENT <|close|>response<|sep|>
12//! <|open|>tools<|sep|>
13//!   <|open|>call tool="NAME" index="N"<|sep|>
14//!     <|open|>argument key="K" type="T"<|sep|>VALUE<|close|>argument<|sep|>
15//!   <|close|>call<|sep|>
16//! <|close|>tools<|sep|>
17//! ```
18//! `<|open|>`, `<|close|>`, `<|sep|>` are literal strings in the detokenized
19//! text (not special tokens from this parser's point of view). Markers
20//! tolerate optional whitespace within them (e.g. `<|open|> tools <|sep|>`)
21//! as defense in depth; this is a no-op on clean input. Bodies use
22//! non-greedy matching so each block stops at its own first closing marker.
23//!
24//! # Argument decoding
25//! - `type="string"` -> the value is raw text, used as-is (no unescaping).
26//! - any other type -> the value is JSON-decoded; on JSON error, falls back
27//!   to the raw string rather than erroring mid-stream.
28//!
29//! # Attribute escaping
30//! Attribute values (`tool=`, `index=`, `key=`, `type=`) are escaped on the
31//! encode side (`&` -> `&amp;`, `"` -> `&quot;`); decoding reverses that in
32//! order (`&quot;` -> `"` then `&amp;` -> `&`).
33//!
34//! # Tool-call id
35//! SMG's [`ToolCall`] has no `id` field (one is assigned later by
36//! `model_gateway`), so this parser only produces `name` + `arguments` (+
37//! content). Streaming [`ToolCallItem::tool_index`] is a per-message
38//! zero-based ordinal assigned in emission order, independent of the XTML
39//! `index` attribute. Deriving it from the ordinal — rather than trusting the
40//! model-supplied `index` — keeps streamed indices (and the ids later built
41//! from them) unique and monotonic even when a model emits duplicate, sparse,
42//! or out-of-order `index` values, and matches the non-streaming path, which
43//! also indexes tool calls by their emission order.
44//!
45//! Known limitation (inherited from the reference): because string argument
46//! and response bodies are emitted raw, a value that literally contains
47//! `<|close|>argument<|sep|>` or `<|close|>response<|sep|>` is
48//! indistinguishable from a real closing marker.
49
50use 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
69/// Channel closings that may precede the tools section, one per shape the
70/// generation prompt can leave open: nothing, `think` (thinking mode), or
71/// `response` (non-thinking mode). See [`KimiK3Parser::build_structural_tag`].
72const TOOLS_SECTION_PREFIXES: [&str; 3] = ["", THINK_CLOSE, RESPONSE_CLOSE];
73
74/// Bare XTML control markers, used to assemble structural-tag literals.
75const OPEN: &str = "<|open|>";
76const CLOSE: &str = "<|close|>";
77const SEP: &str = "<|sep|>";
78
79/// A decoded `<|open|>call ...<|sep|>...<|close|>call<|sep|>` block.
80///
81/// The XTML `index` attribute is deliberately not captured: streamed tool
82/// indices are assigned by emission order (see the module-level `Tool-call id`
83/// docs), so a model-supplied `index` never influences the parser output.
84struct DecodedCall {
85    name: String,
86    /// Compact JSON object string.
87    arguments: String,
88}
89
90/// Kimi-K3 XTML tool-call parser.
91///
92/// Handles both non-streaming (`parse_complete`) and streaming
93/// (`parse_incremental`) extraction of the `tools` channel, and unwraps the
94/// sibling `response` channel into `content`.
95pub struct KimiK3Parser {
96    /// Matches `<|open|>tools<|sep|>` (tolerant of inner whitespace).
97    tools_open_re: Regex,
98    /// Matches `<|close|>tools<|sep|>`.
99    tools_close_re: Regex,
100    /// Matches `<|open|>response<|sep|>`.
101    response_open_re: Regex,
102    /// Matches `<|close|>response<|sep|>`.
103    response_close_re: Regex,
104    /// Matches a stray `<|close|>message<|sep|>` (stripped from content).
105    message_close_re: Regex,
106    /// Matches one `call` block, capturing `attrs` and `body`.
107    call_re: Regex,
108    /// Matches one `argument` block, capturing `attrs` and `val`.
109    arg_re: Regex,
110    /// Matches one `key="value"` attribute pair.
111    attr_re: Regex,
112    /// Matches a complete, unwrapped `response` channel, capturing `c`.
113    response_re: Regex,
114
115    /// Accumulates every chunk seen so far (plays the role of the streaming
116    /// `current_text`, which the engine rebuilds from scratch each call).
117    buffer: String,
118    /// Byte offset into `buffer` up to which response content has already
119    /// been emitted.
120    sent_content_idx: usize,
121    /// Number of tool calls already emitted.
122    sent_tool_call_count: usize,
123}
124
125impl KimiK3Parser {
126    /// Build an xgrammar structural tag that constrains Kimi-K3 XTML tool
127    /// calls to the declared `tools`.
128    ///
129    /// The grammar mirrors the encoder (`kimi_k3_xtml.rs`): a tool call is
130    ///
131    /// ```text
132    /// <|open|>tools<|sep|>
133    ///   <|open|>call tool="NAME" index="N"<|sep|>
134    ///     <|open|>argument key="K" type="T"<|sep|>VALUE<|close|>argument<|sep|>
135    ///   <|close|>call<|sep|>
136    /// <|close|>tools<|sep|>
137    /// ```
138    ///
139    /// A `triggered_tags` format dispatches to the tools section, then forces
140    /// the framing above. All K3 tool calls live in a single section, so its
141    /// `content` is a `plus` of call blocks (native parallel calls) and a lone
142    /// section per prefix suffices — unlike K2, which repeats a tag per call.
143    ///
144    /// # Closing the prefilled channel first
145    ///
146    /// `at_least_one` makes xgrammar mask every token that is not a trigger
147    /// prefix, so the trigger set decides what may precede the tools section.
148    /// K3's generation prompt leaves a channel open (`<|open|>think<|sep|>` in
149    /// thinking mode, `<|open|>response<|sep|>` otherwise), and that channel has
150    /// to close before a sibling `tools` section can start. Triggering on
151    /// `<|open|>tools<|sep|>` alone would forbid the closing marker, forcing a
152    /// tools section nested inside a channel the model can never close — output
153    /// the reasoning parser then swallows whole, leaving `tool_calls` empty.
154    ///
155    /// So emit one tag per prefix in [`TOOLS_SECTION_PREFIXES`] and register each
156    /// closing marker as a trigger, mirroring the Harmony builder, which likewise
157    /// pairs one tag per channel-entry shape with a trigger per prefix.
158    ///
159    /// Within a call, arguments are
160    /// constrained per the JSON schema (Strict mode): properties are pinned in
161    /// the schema's declared order (the crate enables `serde_json`'s
162    /// `preserve_order`), each `required` property mandatory and each optional
163    /// property individually skippable in its declared slot; every `type=`
164    /// attribute and value is pinned to the property's type. This declared
165    /// order is a *generation* constraint only — `decode_call` parses arguments
166    /// order-insensitively, so a model constrained here still round-trips even
167    /// though the grammar admits just one ordering. Schemas without usable
168    /// `properties` fall back to a permissive skeleton so the grammar is never
169    /// infeasible.
170    ///
171    /// `at_least_one` is wired to `tool_choice`: `true` for `"required"` (a
172    /// tool call must be emitted), `false` for `"auto"`. Mirroring K2,
173    /// `stop_after_first` is left unset so trailing tokens after the section
174    /// (`<|close|>message<|sep|><|end_of_msg|>`) remain valid.
175    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        // One tools section per prefill shape; only `begin` differs.
191        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    /// Create a new Kimi-K3 parser.
213    #[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    /// Parse the `key="value"` attribute pairs in `s`, unescaping each value
249    /// (`&quot;` -> `"` then `&amp;` -> `&`, the reverse of the encode order).
250    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("&quot;", "\"")
259                    .replace("&amp;", "&");
260                (key, value)
261            })
262            .collect()
263    }
264
265    /// Decode one `call` block (its `attrs` segment and `body`) into a
266    /// [`DecodedCall`]. Each argument is re-typed per its `type=` tag:
267    /// strings pass through raw, everything else is JSON-decoded (falling
268    /// back to the raw string on malformed JSON, so a partial stream never
269    /// errors). Returns `None` when no tool name is present.
270    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    /// Strip XTML response/message markers from generated response text.
303    ///
304    /// In chat serving, `<|open|>response<|sep|>` is often part of the
305    /// prompt generation prefix, so the model output may only contain the
306    /// body plus `<|close|>response<|sep|>`. Handles both that
307    /// consumed-prefix shape and a complete
308    /// `<|open|>response<|sep|>...<|close|>response<|sep|>` wrapper.
309    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    /// Compute non-streaming content: prefer the unwrapped `response`
331    /// channel; else fall back to stripping markers from `before` (the text
332    /// preceding the `tools` channel, or the whole output when there is no
333    /// `tools` channel at all).
334    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    /// Compute the streaming-safe slice of response content, updating
347    /// `sent_content_idx`. This is what keeps split markers from leaking:
348    /// content is only released up to the start of the next recognized
349    /// marker (or up to the point where a partial marker might still be
350    /// growing at the tail of `current_text`).
351    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    /// Decode every complete `call` block in `section` (a slice starting
395    /// just past the `tools`-open marker), skipping blocks with no tool
396    /// name.
397    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
410// ---------------------------------------------------------------------------
411// Structural-tag construction (guided decoding)
412// ---------------------------------------------------------------------------
413
414/// How one argument value is constrained in the structural tag.
415enum ArgShape {
416    /// The `type=` attribute and value grammar are pinned from the schema.
417    Fixed {
418        type_attr: &'static str,
419        value: Value,
420    },
421    /// The schema is ambiguous (union / missing / `anyOf`); allow any
422    /// well-formed `type=` attribute and any raw value up to the marker.
423    Permissive,
424}
425
426/// Escape an attribute value the same way the encoder does (`&` -> `&amp;`,
427/// then `"` -> `&quot;`), so grammar literals match the emitted bytes.
428fn escape_attr(value: &str) -> String {
429    value.replace('&', "&amp;").replace('"', "&quot;")
430}
431
432/// Build the `sequence` grammar for a single `<|open|>call ...<|close|>call`
433/// block for the tool `name` with the given JSON-schema `params`.
434fn 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
448/// Build the argument-region grammar for a tool's parameter schema (Strict
449/// mode): one slot per property in the schema's declared order, `required`
450/// properties mandatory and the rest wrapped in `optional` (individually
451/// skippable, in place). Falls back to a permissive skeleton when the schema
452/// has no usable `properties`.
453fn 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
482/// Build the grammar for one `<|open|>argument ...<|close|>argument` block,
483/// pinning `key=`, `type=`, and the value grammar from the property schema.
484fn 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
508/// Map a property schema to its XTML `type=` attribute and value grammar.
509/// K3 emits string bodies verbatim (no JSON quoting) while every other type is
510/// compact JSON, and it derives `type=` from the *value*, so `integer` becomes
511/// `number`.
512fn classify_arg(subschema: &Value) -> ArgShape {
513    // A string `enum` constrains the raw body to one of the literals.
514    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        // Verbatim body -> json_schema (which expects quotes) cannot be used.
530        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
558/// Permissive argument region: zero or more structurally-valid argument blocks
559/// with unconstrained keys/types/values. Used when the schema is unusable.
560fn permissive_args_format() -> Value {
561    json!({ "type": "star", "content": permissive_argument_format() })
562}
563
564/// One structurally-valid argument block with unconstrained key/type/value.
565///
566/// The two exclusion conventions below are deliberate, not an oversight:
567/// quote-delimited attribute tokens (`key=`, `type=`) exclude the `<|` marker
568/// *prefix*, which blocks any partial or full control marker inside a short
569/// identifier; the free-text value is marker-delimited and may legitimately
570/// contain a lone `<` or `|`, so it excludes only the complete `OPEN`/`CLOSE`/
571/// `SEP` markers to avoid over-restricting real content.
572fn 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
587/// Permissive call block used only when no declared tool name is usable.
588fn 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
603/// Length of the longest prefix of `tag` that `text` ends with (up to
604/// `tag.len() - 1`, since a full match would have been caught by the marker
605/// regexes already). Used to hold back a possibly-still-growing partial
606/// marker at the tail of the streamed text instead of leaking it as content.
607fn 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(&current_text);
663
664        let Some(m_tools) = self.tools_open_re.find(&current_text) else {
665            return Ok(StreamingParseResult {
666                normal_text: content.unwrap_or_default(),
667                calls: vec![],
668            });
669        };
670
671        let section = &current_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                // Per-message zero-based ordinal in emission order; the XTML
687                // `index` attribute is deliberately ignored (see module docs).
688                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&amp;b&quot;c",
766            1,
767            &[_arg("k&amp;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        // A model that emits colliding, sparse, or out-of-order `index`
925        // attributes must still yield unique, monotonic streamed indices: the
926        // parser assigns them by emission order and ignores the attribute.
927        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        // `stop_after_first` is intentionally unset (mirrors K2) so trailing
1018        // `<|close|>message<|sep|><|end_of_msg|>` tokens stay valid.
1019        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        // A tools section is one or more call blocks.
1025        assert_eq!(section["content"]["type"], "plus");
1026    }
1027
1028    #[test]
1029    fn test_build_structural_tag_allows_closing_prefilled_channel() {
1030        // Regression: the prompt leaves `think` or `response` open, and
1031        // `at_least_one` admits only trigger-prefixed tokens. Each closing
1032        // marker therefore needs both a trigger and a matching tag, or the
1033        // model can never close the channel it was left in.
1034        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        // Every variant frames an identical tools section.
1063        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        // What the grammar now produces under thinking + forced tools: close
1072        // the prefilled think channel, then open tools.
1073        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        // city (required, string), days (required, number), units (optional, enum).
1107        assert_eq!(arg_elems.len(), 3);
1108
1109        // Required string: bare sequence, verbatim body via any_text.
1110        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        // Required integer: rendered as type="number", JSON-schema-constrained value.
1118        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        // Optional string enum: wrapped in `optional`, value is an `or` of literals.
1125        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&amp;b&quot;c\" index=\""
1155        );
1156        let arg = &call["elements"][3]["elements"][0];
1157        assert_eq!(
1158            arg["elements"][0]["value"],
1159            "<|open|>argument key=\"k&quot;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        // Argument region degrades to a permissive `star` of argument blocks.
1174        assert_eq!(call["elements"][3]["type"], "star");
1175    }
1176}