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 defs = RootDefs::from_params(params);
468
469    let elements: Vec<Value> = properties
470        .iter()
471        .map(|(key, subschema)| {
472            let argument = build_argument_format(key, subschema, defs);
473            if required.contains(key.as_str()) {
474                argument
475            } else {
476                json!({ "type": "optional", "content": argument })
477            }
478        })
479        .collect();
480
481    json!({ "type": "sequence", "elements": elements })
482}
483
484/// Build the grammar for one `<|open|>argument ...<|close|>argument` block,
485/// pinning `key=`, `type=`, and the value grammar from the property schema.
486fn build_argument_format(key: &str, subschema: &Value, defs: RootDefs<'_>) -> Value {
487    let esc_key = escape_attr(key);
488    match classify_arg(subschema, defs) {
489        ArgShape::Fixed { type_attr, value } => json!({
490            "type": "sequence",
491            "elements": [
492                { "type": "const_string", "value": format!("{OPEN}argument key=\"{esc_key}\" type=\"{type_attr}\"{SEP}") },
493                value,
494                { "type": "const_string", "value": format!("{CLOSE}argument{SEP}") },
495            ]
496        }),
497        ArgShape::Permissive => json!({
498            "type": "sequence",
499            "elements": [
500                { "type": "const_string", "value": format!("{OPEN}argument key=\"{esc_key}\" type=\"") },
501                { "type": "any_text", "excludes": ["\"", "<|"] },
502                { "type": "const_string", "value": format!("\"{SEP}") },
503                { "type": "any_text", "excludes": [CLOSE, OPEN, SEP] },
504                { "type": "const_string", "value": format!("{CLOSE}argument{SEP}") },
505            ]
506        }),
507    }
508}
509
510/// The definition blocks that sit at the root of a tool's parameter schema,
511/// plus the root schema itself.
512///
513/// Each argument's subschema is compiled as its own standalone grammar
514/// document, but a `$ref` such as `#/$defs/Node` is a pointer from the *root*
515/// of the document it appears in. Without the definition block travelling
516/// alongside the subschema the pointer dangles, and the grammar compiler
517/// rejects the whole tool call rather than just that argument.
518#[derive(Clone, Copy, Default)]
519struct RootDefs<'a> {
520    root: Option<&'a Value>,
521    defs: Option<&'a Value>,
522    definitions: Option<&'a Value>,
523}
524
525impl<'a> RootDefs<'a> {
526    fn from_params(params: &'a Value) -> Self {
527        Self {
528            root: Some(params),
529            defs: params.get("$defs"),
530            definitions: params.get("definitions"),
531        }
532    }
533
534    fn is_empty(self) -> bool {
535        self.defs.is_none() && self.definitions.is_none()
536    }
537
538    fn block(self, name: &str) -> Option<&'a Value> {
539        match name {
540            "$defs" => self.defs,
541            "definitions" => self.definitions,
542            _ => None,
543        }
544    }
545
546    /// Resolve a local JSON pointer against the parameter schema.
547    ///
548    /// `#` addresses the root schema itself, which a root-recursive property
549    /// legitimately names.
550    fn resolve_pointer(self, pointer: &str) -> Option<&'a Value> {
551        if pointer == "#" || pointer == "#/" {
552            return self.root;
553        }
554        let rest = pointer.strip_prefix("#/")?;
555        let mut segments = rest.split('/');
556        let mut node = self.block(segments.next()?)?;
557        for segment in segments {
558            // JSON-pointer escapes, innermost first per RFC 6901.
559            let key = segment.replace("~1", "/").replace("~0", "~");
560            node = node.get(&key)?;
561        }
562        Some(node)
563    }
564
565    /// The schema a subschema's own `$ref` names, if it has one.
566    ///
567    /// Only the pointer is followed — sibling keywords stay with the property
568    /// and take precedence, since in 2020-12 a `$ref` applies *alongside* them
569    /// rather than replacing them. Following a single hop also keeps a `$ref`
570    /// cycle from looping here; a recursive definition still compiles because
571    /// the block below travels with the schema and the compiler expands it.
572    fn resolve_ref(self, subschema: &'a Value) -> Option<&'a Value> {
573        let pointer = subschema.get("$ref")?.as_str()?;
574        self.resolve_pointer(pointer)
575    }
576
577    /// Whether every local pointer inside `schema` names something that exists.
578    ///
579    /// One dangling pointer makes the compiler reject the entire tool call, so
580    /// an argument that contains one is better left permissive.
581    fn local_refs_resolve(self, schema: &Value) -> bool {
582        match schema {
583            Value::Object(map) => {
584                for (key, value) in map {
585                    if matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef") {
586                        if let Some(pointer) = value.as_str() {
587                            if pointer.starts_with('#') && self.resolve_pointer(pointer).is_none() {
588                                return false;
589                            }
590                        }
591                    }
592                }
593                map.values().all(|value| self.local_refs_resolve(value))
594            }
595            Value::Array(items) => items.iter().all(|item| self.local_refs_resolve(item)),
596            _ => true,
597        }
598    }
599
600    /// Return `subschema` with the definition blocks merged in, so any local
601    /// pointer inside it still resolves once it is compiled on its own.
602    ///
603    /// A subschema that declares its own block keeps every entry in it; the
604    /// root's entries fill in the names it does not define, so a property with
605    /// a partial block does not lose access to the rest of the tool's
606    /// definitions. Only schemas that actually reference something pay the
607    /// extra bytes.
608    fn attach(self, subschema: &Value) -> Value {
609        if self.is_empty() || !contains_local_ref(subschema) {
610            return subschema.clone();
611        }
612        let mut out = subschema.clone();
613        let Some(object) = out.as_object_mut() else {
614            return subschema.clone();
615        };
616        for (name, block) in [("$defs", self.defs), ("definitions", self.definitions)] {
617            let Some(Value::Object(root_entries)) = block else {
618                continue;
619            };
620            match object.get_mut(name) {
621                Some(Value::Object(own)) => {
622                    for (key, value) in root_entries {
623                        own.entry(key.clone()).or_insert_with(|| value.clone());
624                    }
625                }
626                Some(_) => {}
627                None => {
628                    object.insert(name.to_string(), Value::Object(root_entries.clone()));
629                }
630            }
631        }
632        out
633    }
634}
635
636/// Whether `schema` contains a reference pointing inside its own document.
637///
638/// Covers `$ref` plus the `$dynamicRef` / `$recursiveRef` spellings, so a
639/// schema that uses one of the less common forms still gets the definition
640/// block attached rather than silently compiling against a dangling pointer.
641fn contains_local_ref(schema: &Value) -> bool {
642    match schema {
643        Value::Object(map) => {
644            let refs_locally = map.iter().any(|(key, value)| {
645                matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef")
646                    && value.as_str().is_some_and(|t| t.starts_with('#'))
647            });
648            refs_locally || map.values().any(contains_local_ref)
649        }
650        Value::Array(items) => items.iter().any(contains_local_ref),
651        _ => false,
652    }
653}
654
655/// Map a property schema to its XTML `type=` attribute and value grammar.
656/// K3 emits string bodies verbatim (no JSON quoting) while every other type is
657/// compact JSON, and it derives `type=` from the *value*, so `integer` becomes
658/// `number`.
659fn classify_arg(subschema: &Value, defs: RootDefs<'_>) -> ArgShape {
660    // A pointer naming something the schema does not contain would make the
661    // compiler reject the whole tool call, so keep such an argument permissive
662    // rather than emitting a grammar that cannot compile.
663    if !defs.local_refs_resolve(subschema) {
664        return ArgShape::Permissive;
665    }
666
667    // A `$ref` contributes the keywords its target declares, but a keyword on
668    // the property itself wins: in 2020-12 `$ref` applies *alongside* its
669    // siblings rather than replacing them. The subschema is still embedded
670    // whole further down, so the pointer and every sibling reach the compiler.
671    let target = defs.resolve_ref(subschema);
672    let keyword = |name: &str| {
673        subschema
674            .get(name)
675            .or_else(|| target.and_then(|schema| schema.get(name)))
676    };
677
678    // A string `enum` constrains the raw body to one of the literals.
679    if let Some(values) = keyword("enum").and_then(Value::as_array) {
680        if !values.is_empty() && values.iter().all(Value::is_string) {
681            let options: Vec<Value> = values
682                .iter()
683                .filter_map(Value::as_str)
684                .map(|v| json!({ "type": "const_string", "value": v }))
685                .collect();
686            return ArgShape::Fixed {
687                type_attr: "string",
688                value: json!({ "type": "or", "elements": options }),
689            };
690        }
691    }
692
693    match keyword("type").and_then(Value::as_str) {
694        // Verbatim body -> json_schema (which expects quotes) cannot be used.
695        Some("string") => ArgShape::Fixed {
696            type_attr: "string",
697            value: json!({ "type": "any_text", "excludes": [CLOSE, OPEN, SEP] }),
698        },
699        Some("integer") | Some("number") => ArgShape::Fixed {
700            type_attr: "number",
701            value: json!({ "type": "json_schema", "json_schema": defs.attach(subschema) }),
702        },
703        Some("boolean") => ArgShape::Fixed {
704            type_attr: "boolean",
705            value: json!({ "type": "json_schema", "json_schema": { "type": "boolean" } }),
706        },
707        Some("object") => ArgShape::Fixed {
708            type_attr: "object",
709            value: json!({ "type": "json_schema", "json_schema": defs.attach(subschema) }),
710        },
711        Some("array") => ArgShape::Fixed {
712            type_attr: "array",
713            value: json!({ "type": "json_schema", "json_schema": defs.attach(subschema) }),
714        },
715        Some("null") => ArgShape::Fixed {
716            type_attr: "null",
717            value: json!({ "type": "const_string", "value": "null" }),
718        },
719        _ => ArgShape::Permissive,
720    }
721}
722
723/// Permissive argument region: zero or more structurally-valid argument blocks
724/// with unconstrained keys/types/values. Used when the schema is unusable.
725fn permissive_args_format() -> Value {
726    json!({ "type": "star", "content": permissive_argument_format() })
727}
728
729/// One structurally-valid argument block with unconstrained key/type/value.
730///
731/// The two exclusion conventions below are deliberate, not an oversight:
732/// quote-delimited attribute tokens (`key=`, `type=`) exclude the `<|` marker
733/// *prefix*, which blocks any partial or full control marker inside a short
734/// identifier; the free-text value is marker-delimited and may legitimately
735/// contain a lone `<` or `|`, so it excludes only the complete `OPEN`/`CLOSE`/
736/// `SEP` markers to avoid over-restricting real content.
737fn permissive_argument_format() -> Value {
738    json!({
739        "type": "sequence",
740        "elements": [
741            { "type": "const_string", "value": format!("{OPEN}argument key=\"") },
742            { "type": "any_text", "excludes": ["\"", "<|"] },
743            { "type": "const_string", "value": "\" type=\"" },
744            { "type": "any_text", "excludes": ["\"", "<|"] },
745            { "type": "const_string", "value": format!("\"{SEP}") },
746            { "type": "any_text", "excludes": [CLOSE, OPEN, SEP] },
747            { "type": "const_string", "value": format!("{CLOSE}argument{SEP}") },
748        ]
749    })
750}
751
752/// Permissive call block used only when no declared tool name is usable.
753fn permissive_call_format() -> Value {
754    json!({
755        "type": "sequence",
756        "elements": [
757            { "type": "const_string", "value": format!("{OPEN}call tool=\"") },
758            { "type": "any_text", "excludes": ["\"", "<|"] },
759            { "type": "const_string", "value": "\" index=\"" },
760            { "type": "regex", "pattern": "[1-9][0-9]*" },
761            { "type": "const_string", "value": format!("\"{SEP}") },
762            permissive_args_format(),
763            { "type": "const_string", "value": format!("{CLOSE}call{SEP}") },
764        ]
765    })
766}
767
768/// Length of the longest prefix of `tag` that `text` ends with (up to
769/// `tag.len() - 1`, since a full match would have been caught by the marker
770/// regexes already). Used to hold back a possibly-still-growing partial
771/// marker at the tail of the streamed text instead of leaking it as content.
772fn partial_tag_overlap(text: &str, tag: &str) -> usize {
773    let max_len = text.len().min(tag.len().saturating_sub(1));
774    for n in (1..=max_len).rev() {
775        if text.ends_with(&tag[..n]) {
776            return n;
777        }
778    }
779    0
780}
781
782impl Default for KimiK3Parser {
783    fn default() -> Self {
784        Self::new()
785    }
786}
787
788#[async_trait]
789impl ToolParser for KimiK3Parser {
790    async fn parse_complete(&self, output: &str) -> ParserResult<(String, Vec<ToolCall>)> {
791        let Some(m_open) = self.tools_open_re.find(output) else {
792            let content = self.content(output, output);
793            return Ok((content.unwrap_or_default(), vec![]));
794        };
795
796        let before = &output[..m_open.start()];
797        let start = m_open.end();
798        let section_end = self
799            .tools_close_re
800            .find_at(output, start)
801            .map_or(output.len(), |m| m.start());
802        let section = &output[start..section_end];
803
804        let calls = self
805            .decode_calls_in_section(section)
806            .into_iter()
807            .map(|decoded| ToolCall {
808                function: FunctionCall {
809                    name: decoded.name,
810                    arguments: decoded.arguments,
811                },
812            })
813            .collect();
814
815        let content = self.content(output, before);
816        Ok((content.unwrap_or_default(), calls))
817    }
818
819    async fn parse_incremental(
820        &mut self,
821        chunk: &str,
822        _tools: &[Tool],
823    ) -> ParserResult<StreamingParseResult> {
824        self.buffer.push_str(chunk);
825        let current_text = self.buffer.clone();
826
827        let content = self.extract_response_content(&current_text);
828
829        let Some(m_tools) = self.tools_open_re.find(&current_text) else {
830            return Ok(StreamingParseResult {
831                normal_text: content.unwrap_or_default(),
832                calls: vec![],
833            });
834        };
835
836        let section = &current_text[m_tools.end()..];
837        let decoded_calls = self.decode_calls_in_section(section);
838
839        if decoded_calls.len() <= self.sent_tool_call_count {
840            return Ok(StreamingParseResult {
841                normal_text: content.unwrap_or_default(),
842                calls: vec![],
843            });
844        }
845
846        let calls = decoded_calls
847            .iter()
848            .skip(self.sent_tool_call_count)
849            .enumerate()
850            .map(|(i, decoded)| ToolCallItem {
851                // Per-message zero-based ordinal in emission order; the XTML
852                // `index` attribute is deliberately ignored (see module docs).
853                tool_index: self.sent_tool_call_count + i,
854                name: Some(decoded.name.clone()),
855                parameters: decoded.arguments.clone(),
856            })
857            .collect();
858        self.sent_tool_call_count = decoded_calls.len();
859
860        Ok(StreamingParseResult {
861            normal_text: content.unwrap_or_default(),
862            calls,
863        })
864    }
865
866    fn has_tool_markers(&self, text: &str) -> bool {
867        self.tools_open_re.is_match(text)
868    }
869
870    fn reset(&mut self) {
871        self.buffer.clear();
872        self.sent_content_idx = 0;
873        self.sent_tool_call_count = 0;
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use serde_json::Value;
880
881    use super::*;
882
883    fn _arg(key: &str, typ: &str, value: &str) -> String {
884        format!(r#"{OPEN}argument key="{key}" type="{typ}"{SEP}{value}{CLOSE}argument{SEP}"#)
885    }
886
887    fn _call(tool: &str, index: i32, args: &[String]) -> String {
888        let body: String = args.concat();
889        format!(r#"{OPEN}call tool="{tool}" index="{index}"{SEP}{body}{CLOSE}call{SEP}"#)
890    }
891
892    fn _response(content: &str) -> String {
893        format!("{OPEN}response{SEP}{content}{CLOSE}response{SEP}")
894    }
895
896    fn _tools(calls: &[String]) -> String {
897        let body: String = calls.concat();
898        format!("{OPEN}tools{SEP}{body}{CLOSE}tools{SEP}")
899    }
900
901    #[tokio::test]
902    async fn test_parse_complete_response_and_typed_arguments() {
903        let parser = KimiK3Parser::new();
904        let input = _response("answer")
905            + &_tools(&[_call(
906                "calc",
907                1,
908                &[
909                    _arg("x", "number", "1"),
910                    _arg("flag", "boolean", "true"),
911                    _arg("text", "string", "raw"),
912                ],
913            )]);
914
915        let (content, calls) = parser.parse_complete(&input).await.unwrap();
916        assert_eq!(content, "answer");
917        assert_eq!(calls.len(), 1);
918        assert_eq!(calls[0].function.name, "calc");
919        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
920        assert_eq!(
921            args,
922            serde_json::json!({"x": 1, "flag": true, "text": "raw"})
923        );
924    }
925
926    #[tokio::test]
927    async fn test_parse_complete_unescapes_attributes() {
928        let parser = KimiK3Parser::new();
929        let input = _tools(&[_call(
930            "a&amp;b&quot;c",
931            1,
932            &[_arg("k&amp;q", "string", "v")],
933        )]);
934
935        let (_content, calls) = parser.parse_complete(&input).await.unwrap();
936        assert_eq!(calls.len(), 1);
937        assert_eq!(calls[0].function.name, "a&b\"c");
938        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
939        assert_eq!(args, serde_json::json!({"k&q": "v"}));
940    }
941
942    #[tokio::test]
943    async fn test_parse_complete_allows_less_than_in_attributes() {
944        let parser = KimiK3Parser::new();
945        let input = _tools(&[_call("calc<beta", 1, &[_arg("foo<bar", "string", "raw")])]);
946
947        let (_content, calls) = parser.parse_complete(&input).await.unwrap();
948        assert_eq!(calls.len(), 1);
949        assert_eq!(calls[0].function.name, "calc<beta");
950        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
951        assert_eq!(args, serde_json::json!({"foo<bar": "raw"}));
952    }
953
954    #[tokio::test]
955    async fn test_parse_complete_no_tools_channel() {
956        let parser = KimiK3Parser::new();
957        let input = _response("hi");
958
959        let (content, calls) = parser.parse_complete(&input).await.unwrap();
960        assert_eq!(content, "hi");
961        assert_eq!(calls.len(), 0);
962    }
963
964    #[tokio::test]
965    async fn test_parse_complete_whitespace_degraded_markers() {
966        let parser = KimiK3Parser::new();
967        let input = format!("{OPEN} response {SEP}answer{CLOSE} response {SEP}");
968
969        let (content, calls) = parser.parse_complete(&input).await.unwrap();
970        assert_eq!(content, "answer");
971        assert_eq!(calls.len(), 0);
972    }
973
974    #[tokio::test]
975    async fn test_parse_complete_malformed_json_argument_falls_back_to_raw() {
976        let parser = KimiK3Parser::new();
977        let input = _tools(&[_call("calc", 1, &[_arg("x", "number", "not-json")])]);
978
979        let (_content, calls) = parser.parse_complete(&input).await.unwrap();
980        assert_eq!(calls.len(), 1);
981        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
982        assert_eq!(args, serde_json::json!({"x": "not-json"}));
983    }
984
985    #[tokio::test]
986    async fn test_parse_complete_multiple_tool_calls() {
987        let parser = KimiK3Parser::new();
988        let input = _tools(&[
989            _call("first", 1, &[_arg("a", "number", "1")]),
990            _call("second", 2, &[_arg("b", "number", "2")]),
991        ]);
992
993        let (_content, calls) = parser.parse_complete(&input).await.unwrap();
994        assert_eq!(calls.len(), 2);
995        assert_eq!(calls[0].function.name, "first");
996        assert_eq!(calls[1].function.name, "second");
997    }
998
999    #[tokio::test]
1000    async fn test_decode_call_without_index_attribute() {
1001        let parser = KimiK3Parser::new();
1002        let input = format!(
1003            r#"{OPEN}tools{SEP}{OPEN}call tool="calc"{SEP}{CLOSE}call{SEP}{CLOSE}tools{SEP}"#
1004        );
1005
1006        let (_content, calls) = parser.parse_complete(&input).await.unwrap();
1007        assert_eq!(calls.len(), 1);
1008        assert_eq!(calls[0].function.name, "calc");
1009        assert_eq!(calls[0].function.arguments, "{}");
1010    }
1011
1012    #[test]
1013    fn test_has_tool_markers() {
1014        let parser = KimiK3Parser::new();
1015        assert!(parser.has_tool_markers(&_tools(&[])));
1016        assert!(!parser.has_tool_markers(&_response("hi")));
1017        assert!(!parser.has_tool_markers("plain text"));
1018    }
1019
1020    #[tokio::test]
1021    async fn test_streaming_split_markers_do_not_leak() {
1022        let mut parser = KimiK3Parser::new();
1023        let hi_chunk = format!("{SEP}Hi");
1024        let call_open_chunk = format!(r#"{OPEN}call tool="calc" index="1"{SEP}"#);
1025        let arg_chunk = _arg("x", "number", "1");
1026        let close_call_chunk = format!("{CLOSE}call");
1027
1028        let chunks: [&str; 10] = [
1029            OPEN,
1030            "response",
1031            hi_chunk.as_str(),
1032            OPEN,
1033            "tools",
1034            SEP,
1035            call_open_chunk.as_str(),
1036            arg_chunk.as_str(),
1037            close_call_chunk.as_str(),
1038            SEP,
1039        ];
1040
1041        let mut content = String::new();
1042        let mut calls = Vec::new();
1043        for chunk in chunks {
1044            let result = parser.parse_incremental(chunk, &[]).await.unwrap();
1045            content.push_str(&result.normal_text);
1046            calls.extend(result.calls);
1047        }
1048
1049        assert_eq!(content, "Hi");
1050        assert!(!content.contains(OPEN));
1051        assert!(!content.contains(SEP));
1052        assert_eq!(calls.len(), 1);
1053        assert_eq!(calls[0].name.as_deref(), Some("calc"));
1054        let args: Value = serde_json::from_str(&calls[0].parameters).unwrap();
1055        assert_eq!(args, serde_json::json!({"x": 1}));
1056    }
1057
1058    #[tokio::test]
1059    async fn test_streaming_consumed_response_prefix_no_call_keeps_content() {
1060        let mut parser = KimiK3Parser::new();
1061        let close_response_chunk = format!("response{SEP}");
1062        let chunks: [&str; 4] = ["O", "K", CLOSE, close_response_chunk.as_str()];
1063
1064        let mut content = String::new();
1065        for chunk in chunks {
1066            let result = parser.parse_incremental(chunk, &[]).await.unwrap();
1067            assert!(!result.normal_text.contains(CLOSE));
1068            content.push_str(&result.normal_text);
1069        }
1070        assert_eq!(content, "OK");
1071    }
1072
1073    #[tokio::test]
1074    async fn test_streaming_multiple_tool_calls_increment_tool_index() {
1075        let mut parser = KimiK3Parser::new();
1076        let input = _tools(&[
1077            _call("first", 1, &[_arg("a", "number", "1")]),
1078            _call("second", 2, &[_arg("b", "number", "2")]),
1079        ]);
1080
1081        let result = parser.parse_incremental(&input, &[]).await.unwrap();
1082        assert_eq!(result.calls.len(), 2);
1083        assert_eq!(result.calls[0].tool_index, 0);
1084        assert_eq!(result.calls[1].tool_index, 1);
1085    }
1086
1087    #[tokio::test]
1088    async fn test_streaming_duplicate_xtml_index_uses_ordinal() {
1089        // A model that emits colliding, sparse, or out-of-order `index`
1090        // attributes must still yield unique, monotonic streamed indices: the
1091        // parser assigns them by emission order and ignores the attribute.
1092        let mut parser = KimiK3Parser::new();
1093        let input = _tools(&[
1094            _call("first", 7, &[_arg("a", "number", "1")]),
1095            _call("second", 7, &[_arg("b", "number", "2")]),
1096            _call("third", 3, &[_arg("c", "number", "3")]),
1097        ]);
1098
1099        let result = parser.parse_incremental(&input, &[]).await.unwrap();
1100        assert_eq!(result.calls.len(), 3);
1101        assert_eq!(result.calls[0].tool_index, 0);
1102        assert_eq!(result.calls[1].tool_index, 1);
1103        assert_eq!(result.calls[2].tool_index, 2);
1104    }
1105
1106    #[tokio::test]
1107    async fn test_reset_clears_streaming_state() {
1108        let mut parser = KimiK3Parser::new();
1109
1110        let primed = format!("{OPEN}response{SEP}Hello{CLOSE}response{SEP}")
1111            + &_tools(&[_call("calc", 1, &[_arg("x", "number", "1")])]);
1112        let primed_result = parser.parse_incremental(&primed, &[]).await.unwrap();
1113        assert_eq!(primed_result.calls.len(), 1);
1114
1115        parser.reset();
1116
1117        let fresh_content = format!("{OPEN}response{SEP}Fresh{CLOSE}response{SEP}");
1118        let content_result = parser.parse_incremental(&fresh_content, &[]).await.unwrap();
1119        assert_eq!(content_result.normal_text, "Fresh");
1120
1121        let fresh_call = _tools(&[_call("calc", 1, &[_arg("x", "number", "2")])]);
1122        let call_result = parser.parse_incremental(&fresh_call, &[]).await.unwrap();
1123        assert_eq!(call_result.calls.len(), 1);
1124        assert_eq!(call_result.calls[0].name.as_deref(), Some("calc"));
1125    }
1126
1127    #[test]
1128    fn test_factory_resolves_moonshot_kimi_k3() {
1129        let factory = crate::factory::ParserFactory::new();
1130        assert_eq!(
1131            factory
1132                .registry()
1133                .resolve_model_to_parser("moonshotai/Kimi-K3"),
1134            Some("kimi_k3".to_string())
1135        );
1136    }
1137
1138    #[test]
1139    fn test_factory_resolves_underscore_kimi_k3() {
1140        let factory = crate::factory::ParserFactory::new();
1141        for id in ["kimi_k3", "Kimi_K3", "moonshotai/Kimi_K3"] {
1142            assert_eq!(
1143                factory.registry().resolve_model_to_parser(id),
1144                Some("kimi_k3".to_string()),
1145                "expected `{id}` to resolve to the kimi_k3 parser"
1146            );
1147        }
1148    }
1149
1150    #[test]
1151    fn test_factory_registers_kimi_k3_structural_tag() {
1152        let factory = crate::factory::ParserFactory::new();
1153        assert!(factory.registry().has_structural_tag("kimi_k3"));
1154    }
1155
1156    fn sample_tools() -> Vec<Tool> {
1157        serde_json::from_value(serde_json::json!([{
1158            "type": "function",
1159            "function": {
1160                "name": "get_weather",
1161                "parameters": {
1162                    "type": "object",
1163                    "properties": {
1164                        "city": {"type": "string"},
1165                        "days": {"type": "integer"},
1166                        "units": {"type": "string", "enum": ["c", "f"]}
1167                    },
1168                    "required": ["city", "days"]
1169                }
1170            }
1171        }]))
1172        .unwrap()
1173    }
1174
1175    /// Build a structural tag from one tool whose parameters are `params`.
1176    fn tag_for_params(params: serde_json::Value) -> Value {
1177        let tools: Vec<Tool> = serde_json::from_value(serde_json::json!([{
1178            "type": "function",
1179            "function": { "name": "f", "parameters": params }
1180        }]))
1181        .unwrap();
1182        KimiK3Parser::build_structural_tag(&tools, true)
1183    }
1184
1185    /// Collect every `json_schema` payload embedded in the structural tag.
1186    fn embedded_schemas(node: &Value, out: &mut Vec<Value>) {
1187        match node {
1188            Value::Object(map) => {
1189                if map.get("type").and_then(Value::as_str) == Some("json_schema") {
1190                    if let Some(schema) = map.get("json_schema") {
1191                        out.push(schema.clone());
1192                    }
1193                }
1194                for value in map.values() {
1195                    embedded_schemas(value, out);
1196                }
1197            }
1198            Value::Array(items) => {
1199                for item in items {
1200                    embedded_schemas(item, out);
1201                }
1202            }
1203            _ => {}
1204        }
1205    }
1206
1207    fn schemas_in(tag: &Value) -> Vec<Value> {
1208        let mut out = Vec::new();
1209        embedded_schemas(tag, &mut out);
1210        out
1211    }
1212
1213    // ---- $defs / $ref propagation -------------------------------------
1214    //
1215    // Every embedded `json_schema` is compiled as its own document, so a
1216    // `#/$defs/...` pointer inside one only resolves if the definition block
1217    // travels with it. These shapes are taken from the walle validator cases
1218    // that the Kimi vendor verifier runs (TestRefInProperties, TestReferences,
1219    // TestNestedDefsDepth), each of which failed the whole tool call with
1220    // "Cannot find field $defs" before the block was carried across.
1221
1222    #[test]
1223    fn nested_ref_inside_array_items_carries_defs() {
1224        let tag = tag_for_params(serde_json::json!({
1225            "type": "object",
1226            "$defs": { "Tag": {"type": "string"} },
1227            "properties": {
1228                "tags": {"type": "array", "items": {"$ref": "#/$defs/Tag"}}
1229            },
1230            "required": ["tags"]
1231        }));
1232
1233        let schemas = schemas_in(&tag);
1234        assert!(!schemas.is_empty(), "array argument should embed a schema");
1235        for schema in &schemas {
1236            assert!(
1237                schema.get("$defs").is_some(),
1238                "embedded schema must carry $defs: {schema}"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn bare_ref_property_resolves_to_its_definition() {
1245        let tag = tag_for_params(serde_json::json!({
1246            "type": "object",
1247            "$defs": {
1248                "Address": {
1249                    "type": "object",
1250                    "properties": {"city": {"type": "string"}}
1251                }
1252            },
1253            "properties": { "address": {"$ref": "#/$defs/Address"} },
1254            "required": ["address"]
1255        }));
1256
1257        // Resolving the ref lets the argument be typed rather than falling
1258        // through to the permissive shape.
1259        let rendered = serde_json::to_string(&tag).unwrap();
1260        assert!(
1261            rendered.contains(r#"type=\"object\""#),
1262            "bare $ref should classify as object: {rendered}"
1263        );
1264        assert!(!schemas_in(&tag).is_empty());
1265    }
1266
1267    #[test]
1268    fn recursive_definition_does_not_loop() {
1269        // `Node.next` points back at `Node`; resolution follows one hop only
1270        // and the block travels with the schema for the compiler to expand.
1271        let tag = tag_for_params(serde_json::json!({
1272            "type": "object",
1273            "$defs": {
1274                "Node": {
1275                    "type": "object",
1276                    "properties": {
1277                        "value": {"type": "string"},
1278                        "next": {"anyOf": [{"type": "null"}, {"$ref": "#/$defs/Node"}]}
1279                    }
1280                }
1281            },
1282            "properties": { "node": {"$ref": "#/$defs/Node"} },
1283            "required": ["node"]
1284        }));
1285
1286        for schema in schemas_in(&tag) {
1287            assert!(
1288                schema.get("$defs").is_some(),
1289                "recursive schema must keep $defs: {schema}"
1290            );
1291        }
1292    }
1293
1294    #[test]
1295    fn pointer_into_a_definition_subpath_resolves() {
1296        // `#/$defs/group/items` addresses a node *inside* a definition.
1297        let tag = tag_for_params(serde_json::json!({
1298            "type": "object",
1299            "$defs": {
1300                "group": {
1301                    "type": "array",
1302                    "items": {"type": "object", "properties": {"data": {"type": "string"}}}
1303                }
1304            },
1305            "properties": { "root": {"$ref": "#/$defs/group/items"} },
1306            "required": ["root"]
1307        }));
1308
1309        let rendered = serde_json::to_string(&tag).unwrap();
1310        assert!(
1311            rendered.contains(r#"type=\"object\""#),
1312            "sub-path pointer should resolve to the object it names: {rendered}"
1313        );
1314    }
1315
1316    #[test]
1317    fn draft07_definitions_block_is_also_carried() {
1318        let tag = tag_for_params(serde_json::json!({
1319            "type": "object",
1320            "definitions": { "Item": {"type": "string"} },
1321            "properties": {
1322                "items": {"type": "array", "items": {"$ref": "#/definitions/Item"}}
1323            },
1324            "required": ["items"]
1325        }));
1326
1327        for schema in schemas_in(&tag) {
1328            assert!(
1329                schema.get("definitions").is_some(),
1330                "embedded schema must carry definitions: {schema}"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn schema_without_refs_is_left_untouched() {
1337        let tag = tag_for_params(serde_json::json!({
1338            "type": "object",
1339            "$defs": { "Unused": {"type": "string"} },
1340            "properties": { "payload": {"type": "object", "properties": {"a": {"type": "string"}}} },
1341            "required": ["payload"]
1342        }));
1343
1344        for schema in schemas_in(&tag) {
1345            assert!(
1346                schema.get("$defs").is_none(),
1347                "a schema with no $ref should not be padded with $defs: {schema}"
1348            );
1349        }
1350    }
1351
1352    #[test]
1353    fn dynamic_ref_spelling_also_carries_defs() {
1354        // `$dynamicRef` orphans just as `$ref` does when the property is
1355        // sliced out of the parameters document.
1356        let tag = tag_for_params(serde_json::json!({
1357            "type": "object",
1358            "$defs": { "Item": {"type": "string"} },
1359            "properties": {
1360                "items": {"type": "array", "items": {"$dynamicRef": "#/$defs/Item"}}
1361            },
1362            "required": ["items"]
1363        }));
1364
1365        for schema in schemas_in(&tag) {
1366            assert!(
1367                schema.get("$defs").is_some(),
1368                "$dynamicRef must also pull the block across: {schema}"
1369            );
1370        }
1371    }
1372
1373    #[test]
1374    fn child_defs_block_keeps_root_definitions_too() {
1375        // A property that declares a partial block of its own must not lose
1376        // access to the rest of the tool's definitions.
1377        let tag = tag_for_params(serde_json::json!({
1378            "type": "object",
1379            "$defs": { "Root": {"type": "string"} },
1380            "properties": {
1381                "p": {
1382                    "type": "object",
1383                    "$defs": { "Local": {"type": "number"} },
1384                    "properties": { "x": {"$ref": "#/$defs/Root"} }
1385                }
1386            },
1387            "required": ["p"]
1388        }));
1389
1390        let schemas = schemas_in(&tag);
1391        assert!(!schemas.is_empty());
1392        for schema in &schemas {
1393            let defs = schema["$defs"].as_object().expect("block present");
1394            assert!(defs.contains_key("Local"), "own entry kept: {schema}");
1395            assert!(defs.contains_key("Root"), "root entry merged in: {schema}");
1396        }
1397    }
1398
1399    #[test]
1400    fn root_pointer_resolves_to_the_parameter_schema() {
1401        // `"$ref": "#"` names the root schema — a root-recursive property.
1402        let tag = tag_for_params(serde_json::json!({
1403            "type": "object",
1404            "properties": {
1405                "name": {"type": "string"},
1406                "child": {"$ref": "#"}
1407            },
1408            "required": ["child"]
1409        }));
1410
1411        let rendered = serde_json::to_string(&tag).unwrap();
1412        assert!(
1413            rendered.contains(r#"key=\"child\" type=\"object\""#),
1414            "root pointer should classify as object: {rendered}"
1415        );
1416    }
1417
1418    #[test]
1419    fn sibling_type_wins_over_the_referenced_definition() {
1420        // In 2020-12 `$ref` applies alongside its siblings; a `type` on the
1421        // property itself must not be discarded with the pointer.
1422        let tag = tag_for_params(serde_json::json!({
1423            "type": "object",
1424            "$defs": { "Payload": {"properties": {"a": {"type": "string"}}} },
1425            "properties": {
1426                "p": {"$ref": "#/$defs/Payload", "type": "object"}
1427            },
1428            "required": ["p"]
1429        }));
1430
1431        let rendered = serde_json::to_string(&tag).unwrap();
1432        assert!(
1433            rendered.contains(r#"key=\"p\" type=\"object\""#),
1434            "sibling type should be honoured: {rendered}"
1435        );
1436        // The pointer itself still reaches the compiler.
1437        for schema in schemas_in(&tag) {
1438            assert_eq!(schema["$ref"], "#/$defs/Payload");
1439            assert!(schema.get("$defs").is_some());
1440        }
1441    }
1442
1443    #[test]
1444    fn nested_dangling_pointer_falls_back_to_permissive() {
1445        // The pointer sits inside `items`, not at the top of the property, and
1446        // names something the block does not contain.
1447        let tag = tag_for_params(serde_json::json!({
1448            "type": "object",
1449            "$defs": { "Tag": {"type": "string"} },
1450            "properties": {
1451                "tags": {"type": "array", "items": {"$ref": "#/$defs/Missing"}}
1452            },
1453            "required": ["tags"]
1454        }));
1455
1456        assert!(
1457            schemas_in(&tag).is_empty(),
1458            "an unresolvable nested pointer must not be embedded"
1459        );
1460    }
1461
1462    #[test]
1463    fn dangling_ref_falls_back_to_permissive() {
1464        // Nothing to resolve against: the argument stays unconstrained rather
1465        // than emitting a grammar the compiler will reject.
1466        let tag = tag_for_params(serde_json::json!({
1467            "type": "object",
1468            "properties": { "x": {"$ref": "#/$defs/Missing"} },
1469            "required": ["x"]
1470        }));
1471        assert!(schemas_in(&tag).is_empty());
1472    }
1473
1474    #[test]
1475    fn test_build_structural_tag_frames_tools_section() {
1476        let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1477        let format = &tag["format"];
1478        assert_eq!(format["type"], "triggered_tags");
1479        assert_eq!(format["triggers"][0], TOOLS_OPEN);
1480        assert_eq!(format["at_least_one"], true);
1481        // `stop_after_first` is intentionally unset (mirrors K2) so trailing
1482        // `<|close|>message<|sep|><|end_of_msg|>` tokens stay valid.
1483        assert!(format.get("stop_after_first").is_none());
1484
1485        let section = &format["tags"][0];
1486        assert_eq!(section["begin"], TOOLS_OPEN);
1487        assert_eq!(section["end"], TOOLS_CLOSE);
1488        // A tools section is one or more call blocks.
1489        assert_eq!(section["content"]["type"], "plus");
1490    }
1491
1492    #[test]
1493    fn test_build_structural_tag_allows_closing_prefilled_channel() {
1494        // Regression: the prompt leaves `think` or `response` open, and
1495        // `at_least_one` admits only trigger-prefixed tokens. Each closing
1496        // marker therefore needs both a trigger and a matching tag, or the
1497        // model can never close the channel it was left in.
1498        let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1499        let format = &tag["format"];
1500
1501        let triggers: Vec<&str> = format["triggers"]
1502            .as_array()
1503            .unwrap()
1504            .iter()
1505            .map(|t| t.as_str().unwrap())
1506            .collect();
1507        assert!(triggers.contains(&THINK_CLOSE), "got: {triggers:?}");
1508        assert!(triggers.contains(&RESPONSE_CLOSE), "got: {triggers:?}");
1509        assert!(triggers.contains(&TOOLS_OPEN), "got: {triggers:?}");
1510
1511        let begins: Vec<&str> = format["tags"]
1512            .as_array()
1513            .unwrap()
1514            .iter()
1515            .map(|t| t["begin"].as_str().unwrap())
1516            .collect();
1517        assert_eq!(
1518            begins,
1519            vec![
1520                TOOLS_OPEN,
1521                &format!("{THINK_CLOSE}{TOOLS_OPEN}")[..],
1522                &format!("{RESPONSE_CLOSE}{TOOLS_OPEN}")[..],
1523            ]
1524        );
1525
1526        // Every variant frames an identical tools section.
1527        for section in format["tags"].as_array().unwrap() {
1528            assert_eq!(section["end"], TOOLS_CLOSE);
1529            assert_eq!(section["content"]["type"], "plus");
1530        }
1531    }
1532
1533    #[tokio::test]
1534    async fn test_parse_complete_handles_think_close_prefixed_tools_section() {
1535        // What the grammar now produces under thinking + forced tools: close
1536        // the prefilled think channel, then open tools.
1537        let parser = KimiK3Parser::new();
1538        let input = THINK_CLOSE.to_string()
1539            + &_tools(&[_call("get_weather", 1, &[_arg("city", "string", "SF")])]);
1540
1541        let (_content, calls) = parser.parse_complete(&input).await.unwrap();
1542        assert_eq!(calls.len(), 1);
1543        assert_eq!(calls[0].function.name, "get_weather");
1544        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
1545        assert_eq!(args, serde_json::json!({"city": "SF"}));
1546    }
1547
1548    #[test]
1549    fn test_build_structural_tag_pins_call_framing_and_tool_name() {
1550        let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1551        let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1552        assert_eq!(call["type"], "sequence");
1553        let elems = call["elements"].as_array().unwrap();
1554        assert_eq!(
1555            elems[0]["value"],
1556            "<|open|>call tool=\"get_weather\" index=\""
1557        );
1558        assert_eq!(elems[1]["type"], "regex");
1559        assert_eq!(elems[2]["value"], "\"<|sep|>");
1560        assert_eq!(elems[4]["value"], "<|close|>call<|sep|>");
1561    }
1562
1563    #[test]
1564    fn test_build_structural_tag_strict_arguments_by_type() {
1565        let tag = KimiK3Parser::build_structural_tag(&sample_tools(), true);
1566        let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1567        let args = &call["elements"][3];
1568        assert_eq!(args["type"], "sequence");
1569        let arg_elems = args["elements"].as_array().unwrap();
1570        // city (required, string), days (required, number), units (optional, enum).
1571        assert_eq!(arg_elems.len(), 3);
1572
1573        // Required string: bare sequence, verbatim body via any_text.
1574        assert_eq!(arg_elems[0]["type"], "sequence");
1575        assert_eq!(
1576            arg_elems[0]["elements"][0]["value"],
1577            "<|open|>argument key=\"city\" type=\"string\"<|sep|>"
1578        );
1579        assert_eq!(arg_elems[0]["elements"][1]["type"], "any_text");
1580
1581        // Required integer: rendered as type="number", JSON-schema-constrained value.
1582        assert_eq!(
1583            arg_elems[1]["elements"][0]["value"],
1584            "<|open|>argument key=\"days\" type=\"number\"<|sep|>"
1585        );
1586        assert_eq!(arg_elems[1]["elements"][1]["type"], "json_schema");
1587
1588        // Optional string enum: wrapped in `optional`, value is an `or` of literals.
1589        assert_eq!(arg_elems[2]["type"], "optional");
1590        let units = &arg_elems[2]["content"];
1591        assert_eq!(
1592            units["elements"][0]["value"],
1593            "<|open|>argument key=\"units\" type=\"string\"<|sep|>"
1594        );
1595        assert_eq!(units["elements"][1]["type"], "or");
1596        assert_eq!(units["elements"][1]["elements"][0]["value"], "c");
1597        assert_eq!(units["elements"][1]["elements"][1]["value"], "f");
1598    }
1599
1600    #[test]
1601    fn test_build_structural_tag_escapes_attribute_values() {
1602        let tools: Vec<Tool> = serde_json::from_value(serde_json::json!([{
1603            "type": "function",
1604            "function": {
1605                "name": "a&b\"c",
1606                "parameters": {
1607                    "type": "object",
1608                    "properties": {"k\"q": {"type": "string"}},
1609                    "required": ["k\"q"]
1610                }
1611            }
1612        }]))
1613        .unwrap();
1614        let tag = KimiK3Parser::build_structural_tag(&tools, true);
1615        let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1616        assert_eq!(
1617            call["elements"][0]["value"],
1618            "<|open|>call tool=\"a&amp;b&quot;c\" index=\""
1619        );
1620        let arg = &call["elements"][3]["elements"][0];
1621        assert_eq!(
1622            arg["elements"][0]["value"],
1623            "<|open|>argument key=\"k&quot;q\" type=\"string\"<|sep|>"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_build_structural_tag_falls_back_when_no_properties() {
1629        let tools: Vec<Tool> = serde_json::from_value(serde_json::json!([{
1630            "type": "function",
1631            "function": {"name": "ping", "parameters": {"type": "object"}}
1632        }]))
1633        .unwrap();
1634        let tag = KimiK3Parser::build_structural_tag(&tools, false);
1635        assert_eq!(tag["format"]["at_least_one"], false);
1636        let call = &tag["format"]["tags"][0]["content"]["content"]["elements"][0];
1637        // Argument region degrades to a permissive `star` of argument blocks.
1638        assert_eq!(call["elements"][3]["type"], "star");
1639    }
1640}