Skip to main content

tool_parser/parsers/
glm4_moe.rs

1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use openai_protocol::common::Tool;
5use regex::Regex;
6use serde_json::Value;
7
8use crate::{
9    errors::{ParserError, ParserResult},
10    factory::GrammarStart,
11    parsers::helpers,
12    traits::ToolParser,
13    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
14};
15
16/// GLM-4 MoE format parser for tool calls
17///
18/// Handles both GLM-4 MoE and GLM-4.7 MoE formats:
19/// - GLM-4: `<tool_call>{name}\n<arg_key>{key}</arg_key>\n<arg_value>{value}</arg_value>\n</tool_call>`
20/// - GLM-4.7: `<tool_call>{name}<arg_key>{key}</arg_key><arg_value>{value}</arg_value></tool_call>`
21///
22/// Features:
23/// - XML-style tags for tool calls
24/// - Key-value pairs for arguments
25/// - Support for multiple sequential tool calls
26pub struct Glm4MoeParser {
27    /// Regex for extracting complete tool calls
28    tool_call_extractor: Regex,
29    /// Regex for extracting function details
30    func_detail_extractor: Regex,
31    /// Regex for extracting argument key-value pairs
32    arg_extractor: Regex,
33
34    /// Buffer for accumulating incomplete patterns across chunks
35    buffer: String,
36
37    /// Stores complete tool call info (name and arguments) for each tool being parsed
38    prev_tool_call_arr: Vec<Value>,
39
40    /// Index of currently streaming tool call (-1 means no active tool)
41    current_tool_id: i32,
42
43    /// Tracks raw JSON string content streamed to client for each tool's arguments
44    streamed_args_for_tool: Vec<String>,
45
46    /// Token configuration
47    bot_token: &'static str,
48    eot_token: &'static str,
49}
50
51impl Glm4MoeParser {
52    /// Create a new generic GLM MoE parser with a custom func_detail_extractor pattern
53    ///
54    /// # Arguments
55    /// - `func_detail_pattern`: Regex pattern for extracting function name and arguments
56    ///   - For GLM-4: `r"(?s)<tool_call>([^\n]*)\n(.*)</tool_call>"`
57    ///   - For GLM-4.7: `r"(?s)<tool_call>\s*([^<\s]+)\s*(.*?)</tool_call>"`
58    #[expect(
59        clippy::expect_used,
60        reason = "regex patterns are compile-time string literals"
61    )]
62    pub(crate) fn new(func_detail_pattern: &str) -> Self {
63        // Use (?s) flag for DOTALL mode to handle newlines
64        let tool_call_pattern = r"(?s)<tool_call>.*?</tool_call>";
65        let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern");
66
67        let func_detail_extractor = Regex::new(func_detail_pattern).expect("Valid regex pattern");
68
69        let arg_pattern = r"(?s)<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>";
70        let arg_extractor = Regex::new(arg_pattern).expect("Valid regex pattern");
71
72        Self {
73            tool_call_extractor,
74            func_detail_extractor,
75            arg_extractor,
76            buffer: String::new(),
77            prev_tool_call_arr: Vec::new(),
78            current_tool_id: -1,
79            streamed_args_for_tool: Vec::new(),
80            bot_token: "<tool_call>",
81            eot_token: "</tool_call>",
82        }
83    }
84
85    /// Create a new GLM-4.5/4.6 MoE parser (with newline-based format)
86    pub fn glm45() -> Self {
87        Self::new(r"(?s)<tool_call>([^\n]*)\n(.*)</tool_call>")
88    }
89
90    /// Create a new GLM-4.7 MoE parser (with whitespace-based format)
91    pub fn glm47() -> Self {
92        Self::new(r"(?s)<tool_call>\s*([^<\s]+)\s*(.*?)</tool_call>")
93    }
94
95    /// The xgrammar structural tag for the GLM-4.7 / GLM-5 tool-call format,
96    /// mirroring xgrammar's built-in `glm_4_7` tag (the one the serving engine applies for
97    /// `required` and named tool choices). Each call is `<tool_call>{name}`,
98    /// the arguments rendered from the tool's JSON schema in xgrammar's
99    /// `glm_xml` style (`<arg_key>k</arg_key><arg_value>v</arg_value>`), then
100    /// `</tool_call>`; free text is allowed around the calls and `<tool_call>`
101    /// is the trigger. `at_least_one` forces a call, for `required` and named
102    /// choices.
103    ///
104    /// Wire shape: `{"type": "json_schema", "json_schema": …, "style":
105    /// "glm_xml"}` is xgrammar's `JSONSchemaFormat` (python/xgrammar/
106    /// structural_tag.py), present since the 0.2 line that also ships the
107    /// built-in `glm_4_7` tag; one serving engine pins 0.2.3, where the tag emitted
108    /// here compiles with `Grammar.from_structural_tag` and accepts the
109    /// model's real call syntax. The schema is passed through as written,
110    /// like the JSON-schema builders (mistral, kimik2, inkling): a tool with
111    /// no parameters, whether the field is absent or an explicit `null`,
112    /// arrives as `{}` (`Function::parameters` normalises both) and renders
113    /// as a call with an empty argument body.
114    ///
115    /// Thinking prompts: GLM-4.7 (`enable_thinking`, on by default) and GLM-5
116    /// (always) end the generation prompt with `<think>`, so a call forced
117    /// from the first token would land inside the thinking block, where the
118    /// reasoning parser swallows it. This builder is thinking-agnostic; when
119    /// the gateway reports that the prompt ends inside `<think>`, the
120    /// registry wraps the tag in [`Self::reasoning_prefix`] (see
121    /// `ParserRegistry::generate_tool_constraint`).
122    pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
123        let tags: Vec<Value> = tools
124            .iter()
125            .filter(|tool| !tool.function.name.is_empty())
126            .map(|tool| {
127                serde_json::json!({
128                    "type": "tag",
129                    "begin": format!("<tool_call>{}", tool.function.name),
130                    "content": {
131                        "type": "json_schema",
132                        "json_schema": tool.function.parameters,
133                        "style": "glm_xml",
134                    },
135                    "end": "</tool_call>",
136                })
137            })
138            .collect();
139        serde_json::json!({
140            "format": {
141                "type": "triggered_tags",
142                "triggers": ["<tool_call>"],
143                "tags": tags,
144                "at_least_one": at_least_one,
145            }
146        })
147    }
148
149    /// What precedes a forced call on a thinking prompt, by where the engine
150    /// starts the grammar; the registry emits `sequence[prefix, calls]`.
151    ///
152    /// Where the engine applies the grammar only after its reasoning parser
153    /// has seen the model's own `</think>`, nothing precedes the calls: the first
154    /// constrained
155    /// token is the forced `<tool_call>`, as under the engine's own server.
156    /// Where it applies the grammar from the first token, the prefix is
157    /// xgrammar's built-in `glm_4_7` block (`reasoning=True`): the thought,
158    /// free of the think and call markers, closed by `</think>`, so the call
159    /// is not forced into the thought. Where the worker does not say, the
160    /// prefix is free text that writes none of the call markers, with
161    /// nothing owed at its end: the thought and its `</think>` on the one
162    /// kind of engine, a newline or nothing on the other. (A block closed by
163    /// `</think>` on an engine that has already consumed the model's
164    /// `</think>` holds the model to a second one, with a fabricated
165    /// observation and answer in between.)
166    pub fn reasoning_prefix(start: GrammarStart) -> Option<Value> {
167        const CALL_MARKERS: [&str; 6] = [
168            "<tool_call>",
169            "</tool_call>",
170            "<arg_key>",
171            "</arg_key>",
172            "<arg_value>",
173            "</arg_value>",
174        ];
175        match start {
176            GrammarStart::AfterReasoning => None,
177            GrammarStart::Unknown => Some(serde_json::json!({
178                "type": "any_text",
179                "excludes": CALL_MARKERS,
180            })),
181            GrammarStart::FirstToken => {
182                let excludes = [&["<think>", "</think>"][..], &CALL_MARKERS[..]].concat();
183                Some(serde_json::json!({
184                    "type": "tag",
185                    "begin": "",
186                    "content": { "type": "any_text", "excludes": excludes },
187                    "end": "</think>",
188                }))
189            }
190        }
191    }
192
193    /// Parse arguments, coercing each value by its declared schema type and
194    /// falling back to [`infer_value`] when the type is unknown.
195    fn parse_arguments(
196        &self,
197        args_text: &str,
198        param_types: &HashMap<String, String>,
199    ) -> serde_json::Map<String, Value> {
200        let mut arguments = serde_json::Map::new();
201
202        for capture in self.arg_extractor.captures_iter(args_text) {
203            let key = capture.get(1).map_or("", |m| m.as_str()).trim();
204            let value_str = capture.get(2).map_or("", |m| m.as_str());
205
206            let value =
207                helpers::coerce_by_schema_type(value_str, param_types.get(key).map(String::as_str))
208                    .unwrap_or_else(|| infer_value(value_str.trim()));
209
210            arguments.insert(key.to_string(), value);
211        }
212
213        arguments
214    }
215
216    /// Parse a single tool call block
217    fn parse_tool_call(&self, block: &str, tools: &[Tool]) -> ParserResult<Option<ToolCall>> {
218        if let Some(captures) = self.func_detail_extractor.captures(block) {
219            // Get function name
220            let func_name = captures.get(1).map_or("", |m| m.as_str()).trim();
221
222            // Get arguments text
223            let args_text = captures.get(2).map_or("", |m| m.as_str());
224
225            let param_types = helpers::param_types_for_function(tools, func_name);
226            let arguments = self.parse_arguments(args_text, &param_types);
227
228            let arguments_str = serde_json::to_string(&arguments)
229                .map_err(|e| ParserError::ParsingFailed(e.to_string()))?;
230
231            Ok(Some(ToolCall {
232                function: FunctionCall {
233                    name: func_name.to_string(),
234                    arguments: arguments_str,
235                },
236            }))
237        } else {
238            Ok(None)
239        }
240    }
241
242    /// Parse all tool calls from text (shared logic for complete and incremental parsing)
243    fn parse_tool_calls_from_text(&self, text: &str, tools: &[Tool]) -> Vec<ToolCall> {
244        let mut parsed = Vec::new();
245
246        for mat in self.tool_call_extractor.find_iter(text) {
247            match self.parse_tool_call(mat.as_str(), tools) {
248                Ok(Some(tool)) => parsed.push(tool),
249                Ok(None) => continue,
250                Err(e) => {
251                    tracing::debug!("Failed to parse tool call: {}", e);
252                    continue;
253                }
254            }
255        }
256
257        parsed
258    }
259}
260
261impl Glm4MoeParser {
262    /// Shared non-streaming parse, schema-aware when `tools` are provided.
263    fn parse_complete_inner(
264        &self,
265        text: &str,
266        tools: &[Tool],
267    ) -> ParserResult<(String, Vec<ToolCall>)> {
268        if !self.has_tool_markers(text) {
269            return Ok((text.to_string(), vec![]));
270        }
271
272        // Find where tool calls begin
273        // Safe: has_tool_markers() already confirmed the marker exists
274        let idx = text
275            .find("<tool_call>")
276            .ok_or_else(|| ParserError::ParsingFailed("tool call marker not found".to_string()))?;
277        let normal_text = text[..idx].to_string();
278
279        let parsed = self.parse_tool_calls_from_text(text, tools);
280
281        // If no tools were successfully parsed despite having markers, return entire text as fallback
282        if parsed.is_empty() {
283            return Ok((text.to_string(), vec![]));
284        }
285
286        Ok((normal_text, parsed))
287    }
288}
289
290/// Infer a JSON value from raw text when the schema type is unknown: JSON
291/// (numbers/bools/null/objects/arrays), then Python-style literals, then string.
292fn infer_value(value_str: &str) -> Value {
293    if let Ok(json_val) = serde_json::from_str::<Value>(value_str) {
294        return json_val;
295    }
296    match value_str {
297        "true" | "True" => Value::Bool(true),
298        "false" | "False" => Value::Bool(false),
299        "null" | "None" => Value::Null,
300        _ => {
301            if let Ok(num) = value_str.parse::<i64>() {
302                Value::Number(num.into())
303            } else if let Ok(num) = value_str.parse::<f64>() {
304                serde_json::Number::from_f64(num)
305                    .map_or_else(|| Value::String(value_str.to_string()), Value::Number)
306            } else {
307                Value::String(value_str.to_string())
308            }
309        }
310    }
311}
312
313impl Default for Glm4MoeParser {
314    fn default() -> Self {
315        Self::glm45()
316    }
317}
318
319#[async_trait]
320impl ToolParser for Glm4MoeParser {
321    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
322        self.parse_complete_inner(text, &[])
323    }
324
325    async fn parse_complete_with_tools(
326        &self,
327        text: &str,
328        tools: &[Tool],
329    ) -> ParserResult<(String, Vec<ToolCall>)> {
330        self.parse_complete_inner(text, tools)
331    }
332
333    async fn parse_incremental(
334        &mut self,
335        chunk: &str,
336        tools: &[Tool],
337    ) -> ParserResult<StreamingParseResult> {
338        // Python logic: Wait for complete tool call, then parse it all at once
339        self.buffer.push_str(chunk);
340        let current_text = &self.buffer.clone();
341
342        // Check if we have bot_token
343        let start = current_text.find(self.bot_token);
344        if start.is_none() {
345            self.buffer.clear();
346            // If we're in the middle of streaming (current_tool_id > 0), don't return text
347            let normal_text = if self.current_tool_id > 0 {
348                String::new()
349            } else {
350                current_text.clone()
351            };
352            return Ok(StreamingParseResult {
353                normal_text,
354                calls: vec![],
355            });
356        }
357
358        // Check if we have eot_token (end of tool call)
359        let end = current_text.find(self.eot_token);
360        if let Some(end_pos) = end {
361            // We have a complete tool call!
362
363            // Initialize state if this is the first tool call
364            if self.current_tool_id == -1 {
365                self.current_tool_id = 0;
366                self.prev_tool_call_arr = Vec::new();
367                self.streamed_args_for_tool = vec![String::new()];
368            }
369
370            // Ensure we have enough entries in our tracking arrays
371            helpers::ensure_capacity(
372                self.current_tool_id,
373                &mut self.prev_tool_call_arr,
374                &mut self.streamed_args_for_tool,
375            );
376
377            // Parse the complete block using shared helper
378            let block_end = end_pos + self.eot_token.len();
379            let parsed_tools = self.parse_tool_calls_from_text(&current_text[..block_end], tools);
380
381            // Extract normal text before tool calls
382            let idx = current_text.find(self.bot_token);
383            let normal_text = if let Some(pos) = idx {
384                current_text[..pos].trim().to_string()
385            } else {
386                String::new()
387            };
388
389            // Build tool indices for validation
390            let tool_indices = helpers::get_tool_indices(tools);
391
392            let mut calls = Vec::new();
393
394            if !parsed_tools.is_empty() {
395                // Take the first tool and convert to ToolCallItem
396                let tool_call = &parsed_tools[0];
397                let tool_id = self.current_tool_id as usize;
398
399                // Validate tool name
400                if !tool_indices.contains_key(&tool_call.function.name) {
401                    // Invalid tool name - skip this tool, preserve indexing for next tool
402                    tracing::debug!("Invalid tool name '{}' - skipping", tool_call.function.name);
403                    helpers::reset_current_tool_state(
404                        &mut self.buffer,
405                        &mut false, // glm45_moe/glm47_moe doesn't track name_sent per tool
406                        &mut self.streamed_args_for_tool,
407                        &self.prev_tool_call_arr,
408                    );
409                    return Ok(StreamingParseResult::default());
410                }
411
412                calls.push(ToolCallItem {
413                    tool_index: tool_id,
414                    name: Some(tool_call.function.name.clone()),
415                    parameters: tool_call.function.arguments.clone(),
416                });
417
418                // Store in tracking arrays
419                if self.prev_tool_call_arr.len() <= tool_id {
420                    self.prev_tool_call_arr
421                        .resize_with(tool_id + 1, || Value::Null);
422                }
423
424                // Parse parameters as JSON and store
425                if let Ok(args) = serde_json::from_str::<Value>(&tool_call.function.arguments) {
426                    self.prev_tool_call_arr[tool_id] = serde_json::json!({
427                        "name": tool_call.function.name,
428                        "arguments": args,
429                    });
430                }
431
432                if self.streamed_args_for_tool.len() <= tool_id {
433                    self.streamed_args_for_tool
434                        .resize_with(tool_id + 1, String::new);
435                }
436                self.streamed_args_for_tool[tool_id].clone_from(&tool_call.function.arguments);
437
438                self.current_tool_id += 1;
439            }
440
441            // Remove processed portion from buffer
442            self.buffer = current_text[block_end..].to_string();
443            return Ok(StreamingParseResult { normal_text, calls });
444        }
445
446        // No complete tool call yet - return normal text before start token
447        // Safe: start.is_none() case was handled above (early return)
448        let Some(start_pos) = start else {
449            return Ok(StreamingParseResult::default());
450        };
451        let normal_text = current_text[..start_pos].to_string();
452        self.buffer = current_text[start_pos..].to_string();
453
454        Ok(StreamingParseResult {
455            normal_text,
456            calls: vec![],
457        })
458    }
459
460    fn has_tool_markers(&self, text: &str) -> bool {
461        text.contains(self.bot_token)
462    }
463
464    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
465        helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
466    }
467
468    fn reset(&mut self) {
469        self.buffer.clear();
470        self.prev_tool_call_arr.clear();
471        self.current_tool_id = -1;
472        self.streamed_args_for_tool.clear();
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use openai_protocol::common::Function;
479
480    use super::*;
481
482    fn tool(name: &str, strict: Option<bool>, parameters: Value) -> Tool {
483        Tool {
484            tool_type: "function".to_string(),
485            function: Function {
486                name: name.to_string(),
487                description: None,
488                parameters,
489                strict,
490                extra: Default::default(),
491            },
492        }
493    }
494
495    /// The tag follows xgrammar's built-in `glm_4_7` shape: one `<tool_call>`
496    /// trigger, a `glm_xml`-styled schema per tool passed through as written
497    /// (a no-argument tool's `{}` included), and `at_least_one` forcing a call.
498    #[test]
499    fn structural_tag_mirrors_the_xgrammar_glm_4_7_model() {
500        let schema =
501            serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}});
502        let tools = vec![
503            tool("get_weather", None, schema.clone()),
504            tool("lookup", Some(false), schema.clone()),
505            tool("ping", None, serde_json::json!({})),
506            tool("", None, schema.clone()),
507        ];
508        let tag = Glm4MoeParser::build_structural_tag(&tools, true);
509        let format = &tag["format"];
510        assert_eq!(format["type"], "triggered_tags");
511        assert_eq!(format["triggers"], serde_json::json!(["<tool_call>"]));
512        assert_eq!(format["at_least_one"], true);
513        let tags = format["tags"].as_array().unwrap();
514        assert_eq!(tags.len(), 3, "a nameless tool has no tag");
515        assert_eq!(tags[0]["begin"], "<tool_call>get_weather");
516        assert_eq!(tags[0]["end"], "</tool_call>");
517        assert_eq!(tags[0]["content"]["type"], "json_schema");
518        assert_eq!(tags[0]["content"]["style"], "glm_xml");
519        assert_eq!(tags[0]["content"]["json_schema"], schema);
520        assert_eq!(
521            tags[1]["content"]["json_schema"], schema,
522            "strict is not a constraint switch here, as in the other builders"
523        );
524        assert_eq!(
525            tags[2]["content"]["json_schema"],
526            serde_json::json!({}),
527            "a no-argument tool keeps its empty schema"
528        );
529
530        let tag = Glm4MoeParser::build_structural_tag(&tools, false);
531        assert_eq!(tag["format"]["at_least_one"], false);
532    }
533
534    #[test]
535    fn reasoning_prefix_is_free_text_that_owes_nothing_at_its_end() {
536        let prefix = Glm4MoeParser::reasoning_prefix(GrammarStart::Unknown).unwrap();
537        assert_eq!(prefix["type"], "any_text");
538        assert!(
539            prefix.get("end").is_none(),
540            "nothing the model must write before the calls: {prefix}"
541        );
542        let excludes = prefix["excludes"].as_array().unwrap();
543        for token in [
544            "<tool_call>",
545            "</tool_call>",
546            "<arg_key>",
547            "</arg_key>",
548            "<arg_value>",
549            "</arg_value>",
550        ] {
551            assert!(
552                excludes.contains(&Value::String(token.to_string())),
553                "{token} must not appear before the forced call"
554            );
555        }
556        assert!(
557            !excludes.contains(&Value::String("</think>".to_string())),
558            "the model closes its thought here when the engine applies the grammar from the first token"
559        );
560    }
561
562    /// Where the worker says where its engine starts the grammar, the prefix
563    /// is the one shape that engine needs: none at all behind a reasoning
564    /// parser's gate, the `</think>`-closed thought block (xgrammar's
565    /// `glm_4_7` with `reasoning=True`) from the first token.
566    #[test]
567    fn reasoning_prefix_follows_where_the_engine_starts_the_grammar() {
568        assert_eq!(
569            Glm4MoeParser::reasoning_prefix(GrammarStart::AfterReasoning),
570            None,
571            "the grammar starts at the engine's gate: the call is forced right there"
572        );
573        let block = Glm4MoeParser::reasoning_prefix(GrammarStart::FirstToken).unwrap();
574        assert_eq!(block["type"], "tag");
575        assert_eq!(block["begin"], "");
576        assert_eq!(block["end"], "</think>");
577        assert_eq!(block["content"]["type"], "any_text");
578        let excludes = block["content"]["excludes"].as_array().unwrap();
579        for token in ["<think>", "</think>", "<tool_call>", "</arg_value>"] {
580            assert!(
581                excludes.contains(&Value::String(token.to_string())),
582                "{token} must not appear inside the thought block"
583            );
584        }
585    }
586
587    fn tool_with_props(props: Value) -> Vec<Tool> {
588        vec![Tool {
589            tool_type: "function".to_string(),
590            function: Function {
591                name: "f".to_string(),
592                description: None,
593                parameters: serde_json::json!({"type": "object", "properties": props}),
594                strict: None,
595                extra: Default::default(),
596            },
597        }]
598    }
599
600    // String-typed params stay strings even when they look numeric/bool/array.
601    #[tokio::test]
602    async fn test_schema_aware_coercion_keeps_strings() {
603        let tools = tool_with_props(serde_json::json!({
604            "limit": {"type": "string"},
605            "flag": {"type": "string"},
606            "coords": {"type": "string"},
607            "count": {"type": "integer"},
608        }));
609        let text = "<tool_call>f\n\
610            <arg_key>limit</arg_key>\n<arg_value>4</arg_value>\n\
611            <arg_key>flag</arg_key>\n<arg_value>true</arg_value>\n\
612            <arg_key>coords</arg_key>\n<arg_value>[60,30]</arg_value>\n\
613            <arg_key>count</arg_key>\n<arg_value>5</arg_value>\n\
614            </tool_call>";
615        let (_, calls) = Glm4MoeParser::glm45()
616            .parse_complete_with_tools(text, &tools)
617            .await
618            .unwrap();
619        assert_eq!(calls.len(), 1);
620        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
621        assert_eq!(args["limit"], Value::String("4".to_string()));
622        assert_eq!(args["flag"], Value::String("true".to_string()));
623        assert_eq!(args["coords"], Value::String("[60,30]".to_string()));
624        assert_eq!(args["count"], Value::Number(5.into()));
625    }
626
627    // The streaming path threads `tools` separately, so cover it too.
628    #[tokio::test]
629    async fn test_streaming_schema_aware_coercion() {
630        let tools = tool_with_props(serde_json::json!({
631            "limit": {"type": "string"},
632            "count": {"type": "integer"},
633        }));
634        let text = "<tool_call>f\n\
635            <arg_key>limit</arg_key>\n<arg_value>4</arg_value>\n\
636            <arg_key>count</arg_key>\n<arg_value>5</arg_value>\n\
637            </tool_call>";
638        let result = Glm4MoeParser::glm45()
639            .parse_incremental(text, &tools)
640            .await
641            .unwrap();
642        let args: Value = serde_json::from_str(&result.calls[0].parameters).unwrap();
643        assert_eq!(args["limit"], Value::String("4".to_string()));
644        assert_eq!(args["count"], Value::Number(5.into()));
645    }
646
647    #[tokio::test]
648    async fn test_declared_strings_preserve_whitespace() {
649        let tools = tool_with_props(serde_json::json!({
650            "code": {"type": "string"},
651            "needle": {"type": "string"},
652            "blank": {"type": "string"},
653            "quoted": {"type": "string"},
654            "count": {"type": "integer"},
655        }));
656        let args_text = concat!(
657            "<arg_key>code</arg_key><arg_value>    return 1\n</arg_value>",
658            "<arg_key>needle</arg_key><arg_value>    total = 1\n</arg_value>",
659            "<arg_key>blank</arg_key><arg_value> \t\n</arg_value>",
660            "<arg_key>quoted</arg_key><arg_value> \"    return 1\\n\" </arg_value>",
661            "<arg_key>count</arg_key><arg_value> 42\n</arg_value>",
662            "<arg_key>unknown</arg_key><arg_value> legacy text\n</arg_value>",
663        );
664        let expected = serde_json::json!({
665            "code": "    return 1\n",
666            "needle": "    total = 1\n",
667            "blank": " \t\n",
668            "quoted": "    return 1\n",
669            "count": 42,
670            "unknown": "legacy text",
671        });
672
673        for (make_parser, name_separator) in [
674            (Glm4MoeParser::glm45 as fn() -> Glm4MoeParser, "\n"),
675            (Glm4MoeParser::glm47 as fn() -> Glm4MoeParser, ""),
676        ] {
677            let text = format!("<tool_call>f{name_separator}{args_text}</tool_call>");
678            let (_, calls) = make_parser()
679                .parse_complete_with_tools(&text, &tools)
680                .await
681                .unwrap();
682            assert_eq!(calls.len(), 1);
683            let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
684            assert_eq!(args, expected);
685
686            // Keep the model's opening special token intact; split the argument
687            // text into small chunks, including inside values and closing tags.
688            let mut parser = make_parser();
689            let start = "<tool_call>".len();
690            let result = parser
691                .parse_incremental(&text[..start], &tools)
692                .await
693                .unwrap();
694            assert!(result.calls.is_empty());
695            let mut arguments = String::new();
696            for chunk in text.as_bytes()[start..].chunks(7) {
697                let result = parser
698                    .parse_incremental(std::str::from_utf8(chunk).unwrap(), &tools)
699                    .await
700                    .unwrap();
701                for call in result.calls {
702                    arguments.push_str(&call.parameters);
703                }
704            }
705            let args: Value = serde_json::from_str(&arguments).unwrap();
706            assert_eq!(args, expected);
707        }
708    }
709}