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