[router] minmax-m2 xml tool parser (#13148)

This commit is contained in:
Simo Lin
2025-11-13 16:24:54 +09:00
committed by GitHub
parent 2bb0317e19
commit 4c1e909a80
5 changed files with 1354 additions and 4 deletions

View File

@@ -9,8 +9,8 @@ use tokio::sync::Mutex;
use crate::tool_parser::{
parsers::{
DeepSeekParser, Glm4MoeParser, JsonParser, KimiK2Parser, LlamaParser, MistralParser,
PassthroughParser, PythonicParser, QwenParser, Step3Parser,
DeepSeekParser, Glm4MoeParser, JsonParser, KimiK2Parser, LlamaParser, MinimaxM2Parser,
MistralParser, PassthroughParser, PythonicParser, QwenParser, Step3Parser,
},
traits::ToolParser,
};
@@ -242,6 +242,7 @@ impl ParserFactory {
registry.register_parser("glm4_moe", || Box::new(Glm4MoeParser::new()));
registry.register_parser("step3", || Box::new(Step3Parser::new()));
registry.register_parser("kimik2", || Box::new(KimiK2Parser::new()));
registry.register_parser("minimax_m2", || Box::new(MinimaxM2Parser::new()));
// Register default model mappings
Self::register_default_mappings(&registry);
@@ -293,6 +294,10 @@ impl ParserFactory {
registry.map_model("Kimi-K2*", "kimik2");
registry.map_model("moonshot*/Kimi-K2*", "kimik2");
// MiniMax models
registry.map_model("minimax*", "minimax_m2");
registry.map_model("MiniMax*", "minimax_m2");
// Other models
registry.map_model("gemini-*", "json");
registry.map_model("palm-*", "json");

View File

@@ -20,8 +20,8 @@ pub use errors::{ParserError, ParserResult};
pub use factory::{ParserFactory, ParserRegistry, PooledParser};
// Re-export parsers for convenience
pub use parsers::{
DeepSeekParser, Glm4MoeParser, JsonParser, KimiK2Parser, LlamaParser, MistralParser,
PythonicParser, QwenParser, Step3Parser,
DeepSeekParser, Glm4MoeParser, JsonParser, KimiK2Parser, LlamaParser, MinimaxM2Parser,
MistralParser, PythonicParser, QwenParser, Step3Parser,
};
pub use traits::{PartialJsonParser, ToolParser};
pub use types::{FunctionCall, PartialToolCall, StreamingParseResult, ToolCall};

View File

@@ -0,0 +1,563 @@
use std::{collections::HashMap, fmt::Write as FmtWrite};
use async_trait::async_trait;
use regex::Regex;
use serde_json::Value;
use crate::{
protocols::common::Tool,
tool_parser::{
errors::{ParserError, ParserResult},
parsers::helpers,
traits::ToolParser,
types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
},
};
/// MiniMax M2 format parser for tool calls
///
/// Implements the MiniMax-M2 model's XML-based tool calling format as specified in the
/// official chat template. The M2 model uses a structured XML format for function invocations
/// to ensure reliable parsing and execution.
///
/// ## Format Reference
/// - HuggingFace Model: https://huggingface.co/MiniMaxAI/MiniMax-M2
/// - Chat Template: https://huggingface.co/MiniMaxAI/MiniMax-M2?chat_template=default
///
/// ## Tool Call Structure
/// ```xml
/// <minimax:tool_call>
/// <invoke name="function_name">
/// <parameter name="param1">value1</parameter>
/// <parameter name="param2">value2</parameter>
/// </invoke>
/// </minimax:tool_call>
/// ```
///
/// ## Key Features
/// - **Namespaced XML tags**: Uses `minimax:` namespace to avoid conflicts
/// - **Structured invocation**: Functions wrapped in `<invoke name="...">` tags
/// - **Named parameters**: Each parameter uses `<parameter name="key">value</parameter>`
/// - **Incremental streaming**: Converts XML to JSON progressively during streaming
/// - **XML entity decoding**: Handles encoded entities (`&lt;`, `&gt;`, etc.) in parameter values
pub struct MinimaxM2Parser {
// Regex patterns
tool_call_extractor: Regex,
invoke_extractor: Regex,
param_extractor: Regex,
// Streaming state
buffer: String,
prev_tool_call_arr: Vec<Value>,
current_tool_id: i32,
streamed_args_for_tool: Vec<String>,
current_function_name: String,
current_parameters: HashMap<String, Value>,
in_tool_call: bool,
function_name_sent: bool,
waiting_for_tool_call_end: bool,
// Token configuration
tool_call_start_token: &'static str,
tool_call_end_token: &'static str,
invoke_end_token: &'static str,
}
impl MinimaxM2Parser {
/// Parse a value from string with consistent logic
#[inline]
fn parse_value(text: &str) -> Value {
// Try parsing as common literals first
match text {
"true" | "True" => return Value::Bool(true),
"false" | "False" => return Value::Bool(false),
"null" | "None" => return Value::Null,
_ => {}
}
// Try parsing as number
if let Ok(num) = text.parse::<i64>() {
return Value::Number(num.into());
}
if let Ok(num) = text.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(num) {
return Value::Number(n);
}
}
// Default to string
Value::String(text.to_string())
}
/// Create a new MiniMax M2 parser
pub fn new() -> Self {
// Use (?s) flag for DOTALL mode to handle newlines
let tool_call_pattern = r"(?s)<minimax:tool_call>.*?</minimax:tool_call>";
let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern");
let invoke_pattern = r#"(?s)<invoke\s+name="([^"]+)">(.*?)</invoke>"#;
let invoke_extractor = Regex::new(invoke_pattern).expect("Valid regex pattern");
let param_pattern = r#"(?s)<parameter\s+name="([^"]+)">(.*?)</parameter>"#;
let param_extractor = Regex::new(param_pattern).expect("Valid regex pattern");
Self {
tool_call_extractor,
invoke_extractor,
param_extractor,
buffer: String::new(),
prev_tool_call_arr: Vec::new(),
current_tool_id: -1,
streamed_args_for_tool: Vec::new(),
current_function_name: String::new(),
current_parameters: HashMap::new(),
in_tool_call: false,
function_name_sent: false,
waiting_for_tool_call_end: false,
tool_call_start_token: "<minimax:tool_call>",
tool_call_end_token: "</minimax:tool_call>",
invoke_end_token: "</invoke>",
}
}
/// Parse parameters from parameter tags
fn parse_parameters(&self, params_text: &str) -> ParserResult<serde_json::Map<String, Value>> {
let mut parameters = serde_json::Map::new();
for capture in self.param_extractor.captures_iter(params_text) {
let key = capture.get(1).map_or("", |m| m.as_str()).trim();
let value_str = capture.get(2).map_or("", |m| m.as_str());
// Decode XML entities and parse value
let decoded_value = self.decode_xml_entities(value_str);
// Note: We keep JSON-like strings as strings (not parsed JSON)
// This matches the behavior of other parsers like GLM4 MOE
let value = Self::parse_value(&decoded_value);
parameters.insert(key.to_string(), value);
}
Ok(parameters)
}
/// Decode common XML entities
fn decode_xml_entities(&self, text: &str) -> String {
text.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
}
/// Parse a single tool call block
fn parse_tool_call(&self, block: &str) -> ParserResult<Option<ToolCall>> {
if let Some(captures) = self.invoke_extractor.captures(block) {
// Get function name from invoke tag attribute
let func_name = captures.get(1).map_or("", |m| m.as_str()).trim();
// Get parameters text
let params_text = captures.get(2).map_or("", |m| m.as_str());
// Parse parameters
let parameters = self.parse_parameters(params_text)?;
let arguments_str = serde_json::to_string(&parameters)
.map_err(|e| ParserError::ParsingFailed(e.to_string()))?;
Ok(Some(ToolCall {
function: FunctionCall {
name: func_name.to_string(),
arguments: arguments_str,
},
}))
} else {
Ok(None)
}
}
/// Parse all tool calls from text and return first valid position
fn parse_tool_calls_from_text(
&self,
text: &str,
) -> ParserResult<(Vec<ToolCall>, Option<usize>)> {
let mut tools = Vec::new();
let mut first_valid_pos = None;
for mat in self.tool_call_extractor.find_iter(text) {
match self.parse_tool_call(mat.as_str()) {
Ok(Some(tool)) => {
if first_valid_pos.is_none() {
first_valid_pos = Some(mat.start());
}
tools.push(tool);
}
Ok(None) => continue,
Err(e) => {
tracing::warn!("Failed to parse tool call: {}", e);
continue;
}
}
}
Ok((tools, first_valid_pos))
}
/// Parse and stream parameters incrementally
fn parse_and_stream_parameters(&mut self, text: &str, _tools: &[Tool]) -> Vec<ToolCallItem> {
let mut calls = Vec::new();
// Find all complete parameter patterns in the buffer
let param_matches: Vec<_> = self
.param_extractor
.captures_iter(text)
.map(|cap| {
let name = cap.get(1).map_or("", |m| m.as_str()).trim().to_string();
let value_str = cap.get(2).map_or("", |m| m.as_str());
let decoded = self.decode_xml_entities(value_str);
// Try parsing as JSON first (for nested objects/arrays)
let value = if decoded.starts_with('{') || decoded.starts_with('[') {
if let Ok(json_val) = serde_json::from_str::<Value>(&decoded) {
json_val
} else {
Self::parse_value(&decoded)
}
} else {
Self::parse_value(&decoded)
};
(name, value)
})
.collect();
// Build new parameters map
let mut new_params = HashMap::new();
for (name, value) in param_matches {
new_params.insert(name, value);
}
// If we have new parameters that weren't in current_parameters, stream them
if !new_params.is_empty() && new_params != self.current_parameters {
let tool_id = self.current_tool_id as usize;
// Ensure we have enough capacity
while self.streamed_args_for_tool.len() <= tool_id {
self.streamed_args_for_tool.push(String::new());
}
// Build incremental JSON with single allocation
if self.current_parameters.is_empty() {
// First parameters - start JSON object but don't close it
let mut json_fragment = String::with_capacity(256);
json_fragment.push('{');
let mut first = true;
for (key, value) in &new_params {
if !first {
json_fragment.push_str(", ");
}
write!(
&mut json_fragment,
"{}: {}",
serde_json::to_string(key).unwrap(),
serde_json::to_string(value).unwrap()
)
.unwrap();
first = false;
}
calls.push(ToolCallItem {
tool_index: tool_id,
name: None,
parameters: json_fragment.clone(),
});
self.streamed_args_for_tool[tool_id] = json_fragment;
} else {
// Additional parameters - add them incrementally
let new_keys: Vec<_> = new_params
.keys()
.filter(|k| !self.current_parameters.contains_key(*k))
.collect();
if !new_keys.is_empty() {
let mut json_fragment = String::with_capacity(128);
for key in new_keys {
let value = &new_params[key];
write!(
&mut json_fragment,
", {}: {}",
serde_json::to_string(key).unwrap(),
serde_json::to_string(value).unwrap()
)
.unwrap();
}
calls.push(ToolCallItem {
tool_index: tool_id,
name: None,
parameters: json_fragment.clone(),
});
self.streamed_args_for_tool[tool_id].push_str(&json_fragment);
}
}
// Update current parameters
self.current_parameters = new_params;
// Update prev_tool_call_arr
while self.prev_tool_call_arr.len() <= tool_id {
self.prev_tool_call_arr.push(Value::Null);
}
self.prev_tool_call_arr[tool_id] = serde_json::json!({
"name": self.current_function_name,
"arguments": self.current_parameters,
});
}
calls
}
}
impl Default for MinimaxM2Parser {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ToolParser for MinimaxM2Parser {
async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
// Check if text contains MiniMax M2 format
if !self.has_tool_markers(text) {
return Ok((text.to_string(), vec![]));
}
// Parse all tool calls and get first valid position
let (tools, first_valid_tool_pos) = self.parse_tool_calls_from_text(text)?;
// If no tools were successfully parsed, return entire text as fallback
if tools.is_empty() {
return Ok((text.to_string(), vec![]));
}
// Determine what text to return as normal_text
let normal_text = if let Some(pos) = first_valid_tool_pos {
// Return text up to the first valid tool call
text[..pos].to_string()
} else {
// No valid tool calls found, return entire text
text.to_string()
};
Ok((normal_text, tools))
}
async fn parse_incremental(
&mut self,
chunk: &str,
tools: &[Tool],
) -> ParserResult<StreamingParseResult> {
self.buffer.push_str(chunk);
let mut normal_text = String::new();
let mut calls = Vec::new();
// Build tool indices for validation
let tool_indices = helpers::get_tool_indices(tools);
loop {
// If we're waiting for the tool call end tag, check for it first
if self.waiting_for_tool_call_end {
if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) {
// Complete tool call found
self.buffer =
self.buffer[end_pos + self.tool_call_end_token.len()..].to_string();
self.in_tool_call = false;
self.waiting_for_tool_call_end = false;
self.function_name_sent = false;
self.current_function_name.clear();
self.current_parameters.clear();
self.current_tool_id += 1;
continue;
} else {
// End tag not complete yet, wait for more text
break;
}
}
// If we're not in a tool call and don't see a start token, return normal text
if !self.in_tool_call && !self.buffer.contains(self.tool_call_start_token) {
// Check if buffer might contain a partial start token at the end
if let Some(partial_len) =
helpers::ends_with_partial_token(&self.buffer, self.tool_call_start_token)
{
// Return everything except the potential partial token
let end = self.buffer.len() - partial_len;
normal_text = self.buffer[..end].to_string();
self.buffer = self.buffer[end..].to_string();
} else {
// No partial token, return all as normal text
normal_text = self.buffer.clone();
self.buffer.clear();
}
break;
}
// Look for tool call start
if !self.in_tool_call {
if let Some(start) = self.buffer.find(self.tool_call_start_token) {
normal_text = self.buffer[..start].to_string();
self.buffer =
self.buffer[start + self.tool_call_start_token.len()..].to_string();
self.in_tool_call = true;
self.function_name_sent = false;
self.current_function_name.clear();
self.current_parameters.clear();
continue;
} else {
// No start token found
break;
}
}
// We're in a tool call, try to parse function name if not sent yet
if !self.function_name_sent {
// Use regex to extract function name from <invoke name="..."> pattern
// Check if we have enough text to match the invoke pattern
if let Some(captures) = self.invoke_extractor.captures(&self.buffer) {
let function_name = captures
.get(1)
.map_or("", |m| m.as_str())
.trim()
.to_string();
// Validate function name
if tool_indices.contains_key(&function_name) {
self.current_function_name = function_name.clone();
self.function_name_sent = true;
// Initialize tool call tracking
if self.current_tool_id == -1 {
self.current_tool_id = 0;
}
// Ensure tracking arrays are large enough
helpers::ensure_capacity(
self.current_tool_id,
&mut self.prev_tool_call_arr,
&mut self.streamed_args_for_tool,
);
// Send tool name with empty parameters
calls.push(ToolCallItem {
tool_index: self.current_tool_id as usize,
name: Some(function_name),
parameters: String::new(),
});
// Find the position after the opening invoke tag (after the >)
// We only want to remove up to the opening tag, not the full match
if let Some(pos) = self.buffer.find('>') {
self.buffer = self.buffer[pos + 1..].to_string();
}
continue;
} else {
// Invalid function name, reset state
tracing::warn!("Invalid function name: {}", function_name);
self.in_tool_call = false;
normal_text.push_str(&self.buffer);
self.buffer.clear();
break;
}
}
// No complete invoke pattern found yet, wait for more text
break;
}
// Parse parameters incrementally
if self.function_name_sent {
// Process parameters and get any calls to emit
// Note: We need to be careful here - parse_and_stream_parameters needs
// to work with the buffer but we can't pass &self.buffer directly
// due to borrow checker. Instead, we'll refactor slightly.
// For now, keep the clone but mark it as a TODO for future optimization
let buffer_copy = self.buffer.clone(); // TODO: Optimize this
let parameter_calls = self.parse_and_stream_parameters(&buffer_copy, tools);
calls.extend(parameter_calls);
// Check if tool call is complete (</invoke> found)
if let Some(invoke_end) = self.buffer.find(self.invoke_end_token) {
// Add closing brace to complete the JSON object
let tool_id = self.current_tool_id as usize;
if tool_id < self.streamed_args_for_tool.len() {
let current_streamed = &self.streamed_args_for_tool[tool_id];
if !current_streamed.is_empty() && !current_streamed.ends_with('}') {
// Count opening and closing braces to check if JSON is complete
let open_braces = current_streamed.matches('{').count();
let close_braces = current_streamed.matches('}').count();
if open_braces > close_braces {
calls.push(ToolCallItem {
tool_index: tool_id,
name: None,
parameters: "}".to_string(),
});
self.streamed_args_for_tool[tool_id].push('}');
}
}
}
// Move buffer past the </invoke>
self.buffer =
self.buffer[invoke_end + self.invoke_end_token.len()..].to_string();
// Check if we have the closing </minimax:tool_call>
if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) {
// Complete tool call found
self.buffer =
self.buffer[end_pos + self.tool_call_end_token.len()..].to_string();
self.in_tool_call = false;
self.function_name_sent = false;
self.current_function_name.clear();
self.current_parameters.clear();
self.current_tool_id += 1;
continue;
} else {
// End tag not complete yet, mark that we're waiting for it
self.waiting_for_tool_call_end = true;
break;
}
}
// Tool call not complete yet, wait for more text
break;
}
}
Ok(StreamingParseResult { normal_text, calls })
}
fn has_tool_markers(&self, text: &str) -> bool {
text.contains(self.tool_call_start_token)
}
fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
}
fn reset(&mut self) {
self.buffer.clear();
self.prev_tool_call_arr.clear();
self.current_tool_id = -1;
self.streamed_args_for_tool.clear();
self.current_function_name.clear();
self.current_parameters.clear();
self.in_tool_call = false;
self.function_name_sent = false;
self.waiting_for_tool_call_end = false;
}
}

View File

@@ -8,6 +8,7 @@ pub mod glm4_moe;
pub mod json;
pub mod kimik2;
pub mod llama;
pub mod minimax_m2;
pub mod mistral;
pub mod passthrough;
pub mod pythonic;
@@ -23,6 +24,7 @@ pub use glm4_moe::Glm4MoeParser;
pub use json::JsonParser;
pub use kimik2::KimiK2Parser;
pub use llama::LlamaParser;
pub use minimax_m2::MinimaxM2Parser;
pub use mistral::MistralParser;
pub use passthrough::PassthroughParser;
pub use pythonic::PythonicParser;

View File

@@ -0,0 +1,780 @@
//! MiniMax M2 Parser Integration Tests
use sglang_router_rs::tool_parser::{MinimaxM2Parser, ToolParser};
mod common;
use common::create_test_tools;
#[tokio::test]
async fn test_minimax_complete_parsing() {
let parser = MinimaxM2Parser::new();
let input = r#"Let me search for that.
<minimax:tool_call>
<invoke name="get_weather">
<parameter name="city">Beijing</parameter>
<parameter name="date">2024-12-25</parameter>
</invoke>
</minimax:tool_call>
The weather will be..."#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(normal_text, "Let me search for that.\n");
assert_eq!(tools[0].function.name, "get_weather");
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["city"], "Beijing");
assert_eq!(args["date"], "2024-12-25");
}
#[tokio::test]
async fn test_minimax_multiple_tools() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="search">
<parameter name="query">rust tutorials</parameter>
</invoke>
</minimax:tool_call>
<minimax:tool_call>
<invoke name="translate">
<parameter name="text">Hello World</parameter>
<parameter name="target_lang">zh</parameter>
</invoke>
</minimax:tool_call>"#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(normal_text, "");
assert_eq!(tools[0].function.name, "search");
assert_eq!(tools[1].function.name, "translate");
}
#[tokio::test]
async fn test_minimax_type_conversion() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="count">42</parameter>
<parameter name="rate">1.5</parameter>
<parameter name="enabled">true</parameter>
<parameter name="data">null</parameter>
<parameter name="text">string value</parameter>
</invoke>
</minimax:tool_call>"#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(normal_text, "");
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["count"], 42);
assert_eq!(args["rate"], 1.5);
assert_eq!(args["enabled"], true);
assert_eq!(args["data"], serde_json::Value::Null);
assert_eq!(args["text"], "string value");
}
#[tokio::test]
async fn test_minimax_streaming_basic() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
// Simulate streaming chunks
let chunks = vec![
"<minimax:tool_call>",
r#"<invoke name="get_weather">"#,
r#"<parameter name="city">Shanghai</parameter>"#,
r#"<parameter name="units">celsius</parameter>"#,
"</invoke>",
"</minimax:tool_call>",
];
let mut found_name = false;
let mut found_params = false;
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
for call in result.calls {
if let Some(name) = call.name {
assert_eq!(name, "get_weather");
found_name = true;
}
if !call.parameters.is_empty() {
found_params = true;
}
}
}
assert!(found_name, "Should have found tool name during streaming");
assert!(found_params, "Should have streamed parameters");
}
#[test]
fn test_minimax_format_detection() {
let parser = MinimaxM2Parser::new();
// Should detect MiniMax format
assert!(parser.has_tool_markers("<minimax:tool_call>"));
assert!(parser.has_tool_markers("text with <minimax:tool_call> marker"));
// Should not detect other formats
assert!(!parser.has_tool_markers("<tool_call>")); // GLM4 format
assert!(!parser.has_tool_markers("[TOOL_CALLS]"));
assert!(!parser.has_tool_markers("<tool▁calls▁begin>"));
assert!(!parser.has_tool_markers("plain text"));
}
#[tokio::test]
async fn test_minimax_python_literals() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="test_func">
<parameter name="bool_true">True</parameter>
<parameter name="bool_false">False</parameter>
<parameter name="none_val">None</parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "test_func");
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["bool_true"], true);
assert_eq!(args["bool_false"], false);
assert_eq!(args["none_val"], serde_json::Value::Null);
}
#[tokio::test]
async fn test_minimax_nested_json_in_parameters() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="data">{"nested": {"key": "value"}}</parameter>
<parameter name="list">[1, 2, 3]</parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
// JSON-like strings are kept as strings, not parsed as JSON
// This matches the behavior of other parsers like GLM4 MOE
assert!(args["data"].is_string());
assert_eq!(args["data"], r#"{"nested": {"key": "value"}}"#);
assert!(args["list"].is_string());
assert_eq!(args["list"], "[1, 2, 3]");
}
#[tokio::test]
async fn test_minimax_xml_entities() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="html">&lt;div&gt;content&lt;/div&gt;</parameter>
<parameter name="text">Quote: &quot;hello&quot;</parameter>
<parameter name="code">if (a &amp;&amp; b) { }</parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["html"], "<div>content</div>");
assert_eq!(args["text"], "Quote: \"hello\"");
assert_eq!(args["code"], "if (a && b) { }");
}
#[tokio::test]
async fn test_minimax_streaming_partial_tags() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
// Chunks split mid-tag
let chunks = vec![
"<minimax:tool_c",
"all><invoke na",
r#"me="get_weather"><param"#,
r#"eter name="city">Bei"#,
"jing</parameter></inv",
"oke></minimax:tool_call>",
];
let mut found_name = false;
let mut buffer = String::new();
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
buffer.push_str(&result.normal_text);
for call in result.calls {
if let Some(name) = call.name {
assert_eq!(name, "get_weather");
found_name = true;
}
}
}
assert!(
found_name,
"Should have parsed function name from partial chunks"
);
assert_eq!(buffer, "");
}
#[tokio::test]
async fn test_minimax_streaming_incremental_json() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
let chunks = vec![
"<minimax:tool_call>",
r#"<invoke name="get_weather">"#,
r#"<parameter name="city">Paris</parameter>"#,
r#"<parameter name="units">metric</parameter>"#,
"</invoke></minimax:tool_call>",
];
let mut json_fragments = Vec::new();
let mut found_function = false;
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
for call in result.calls {
if let Some(_name) = call.name {
found_function = true;
}
if !call.parameters.is_empty() {
json_fragments.push(call.parameters.clone());
}
}
}
assert!(found_function);
// Verify JSON was built incrementally
assert!(!json_fragments.is_empty());
// First fragment should start with opening brace
if let Some(first) = json_fragments.first() {
assert!(
first.starts_with('{'),
"First JSON fragment should start with '{{': {}",
first
);
}
// Last fragment should be closing brace
if let Some(last) = json_fragments.last() {
assert!(
last.contains('}'),
"Last JSON fragment should contain '}}': {}",
last
);
}
}
#[tokio::test]
async fn test_minimax_multiple_tools_boundary() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
// Tool boundary at chunk boundary
let chunks = vec![
r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">Tokyo</parameter></invoke></minimax:tool_call>"#,
r#"<minimax:tool_call><invoke name="search"><parameter name="query">weather forecast</parameter></invoke></minimax:tool_call>"#,
];
let mut tool_names = Vec::new();
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
for call in result.calls {
if let Some(name) = call.name {
tool_names.push(name);
}
}
}
assert_eq!(tool_names.len(), 2);
assert_eq!(tool_names[0], "get_weather");
assert_eq!(tool_names[1], "search");
}
#[tokio::test]
async fn test_minimax_invalid_function_name() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
let chunks = vec![
"<minimax:tool_call>",
r#"<invoke name="invalid_function">"#,
r#"<parameter name="param">value</parameter>"#,
"</invoke></minimax:tool_call>",
];
let mut found_invalid = false;
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
// Invalid function should be skipped
for call in result.calls {
if let Some(name) = call.name {
if name == "invalid_function" {
found_invalid = true;
}
}
}
}
assert!(!found_invalid, "Invalid function should not be parsed");
}
#[tokio::test]
async fn test_minimax_empty_parameters() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="simple_func">
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "simple_func");
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args, serde_json::json!({}));
}
#[tokio::test]
async fn test_minimax_multiline_parameter_values() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="multiline">line1
line2
line3</parameter>
<parameter name="unicode">你好世界 🌍</parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["multiline"], "line1\nline2\nline3");
assert_eq!(args["unicode"], "你好世界 🌍");
}
#[tokio::test]
async fn test_minimax_nested_xml_like_content() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="template"><html><body>Hello</body></html></parameter>
<parameter name="config">{"key": "<value>nested</value>"}</parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["template"], "<html><body>Hello</body></html>");
// The nested JSON with XML-like content
let config =
serde_json::from_str::<serde_json::Value>(args["config"].as_str().unwrap()).unwrap();
assert_eq!(config["key"], "<value>nested</value>");
}
#[tokio::test]
async fn test_minimax_streaming_state_reset() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
// First tool
let chunks1 = vec![
r#"<minimax:tool_call><invoke name="get_weather">"#,
r#"<parameter name="city">London</parameter>"#,
"</invoke></minimax:tool_call>",
];
for chunk in chunks1 {
parser.parse_incremental(chunk, &tools).await.unwrap();
}
// Second tool - state should be reset
let chunks2 = vec![
r#"<minimax:tool_call><invoke name="search">"#,
r#"<parameter name="query">rust</parameter>"#,
"</invoke></minimax:tool_call>",
];
let mut second_tool_name = None;
for chunk in chunks2 {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
for call in result.calls {
if let Some(name) = call.name {
second_tool_name = Some(name);
}
}
}
assert_eq!(second_tool_name, Some("search".to_string()));
}
#[tokio::test]
async fn test_minimax_many_parameters() {
let parser = MinimaxM2Parser::new();
let mut params_xml = String::new();
for i in 1..=20 {
params_xml.push_str(&format!(
r#"<parameter name="param{}">value{}</parameter>
"#,
i, i
));
}
let input = format!(
r#"<minimax:tool_call>
<invoke name="complex_func">
{}
</invoke>
</minimax:tool_call>"#,
params_xml
);
let (_normal_text, tools) = parser.parse_complete(&input).await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "complex_func");
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
// Verify all 20 parameters are parsed
for i in 1..=20 {
let key = format!("param{}", i);
let expected_value = format!("value{}", i);
assert_eq!(args[key], expected_value);
}
}
#[tokio::test]
async fn test_minimax_character_by_character_streaming() {
// Test character-by-character streaming to simulate real-world streaming
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
let complete_text = r#"Let me help you. <minimax:tool_call>
<invoke name="get_weather">
<parameter name="city">Seattle</parameter>
<parameter name="units">celsius</parameter>
</invoke>
</minimax:tool_call> Here are the results."#;
let mut content_collected = String::new();
let mut tool_name_found = false;
let mut parameters_found = Vec::new();
// Stream character by character - feed only one character at a time
for i in 0..complete_text.len() {
let delta = &complete_text[i..i + 1];
let result = parser.parse_incremental(delta, &tools).await.unwrap();
content_collected.push_str(&result.normal_text);
for call in result.calls {
if let Some(name) = call.name {
assert_eq!(name, "get_weather");
tool_name_found = true;
}
if !call.parameters.is_empty() && !parameters_found.contains(&call.parameters) {
parameters_found.push(call.parameters.clone());
}
}
}
assert!(
tool_name_found,
"Should find tool name during character-by-character streaming"
);
assert!(
!parameters_found.is_empty(),
"Should find parameters during streaming"
);
// Should have initial content and final content
assert!(content_collected.contains("Let me help you."));
assert!(content_collected.contains("Here are the results."));
}
#[tokio::test]
async fn test_minimax_content_before_and_after_tool_calls() {
let parser = MinimaxM2Parser::new();
let input = r#"I'll analyze the weather for you now.
<minimax:tool_call>
<invoke name="get_weather">
<parameter name="city">Boston</parameter>
<parameter name="state">MA</parameter>
</invoke>
</minimax:tool_call>
Based on the analysis, here's what I found."#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
// Verify tool extraction
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "get_weather");
// Verify content preservation (only text before tool call is returned)
assert!(normal_text.contains("I'll analyze the weather for you now."));
// Text after tool call is not included in parse_complete
assert!(!normal_text.contains("Based on the analysis, here's what I found."));
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["city"], "Boston");
assert_eq!(args["state"], "MA");
}
#[tokio::test]
async fn test_minimax_incomplete_tool_call() {
let parser = MinimaxM2Parser::new();
// Incomplete tool call - missing closing tag
let input = r#"<minimax:tool_call>
<invoke name="get_weather">
<parameter name="city">Chicago</parameter>"#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
// Should not extract incomplete tool calls
assert_eq!(tools.len(), 0);
assert_eq!(normal_text, input); // Should return as normal text
}
#[tokio::test]
async fn test_minimax_malformed_invoke_tag() {
let parser = MinimaxM2Parser::new();
// Malformed invoke tag - missing name attribute
let input = r#"<minimax:tool_call>
<invoke>
<parameter name="city">Miami</parameter>
</invoke>
</minimax:tool_call>"#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
// Should not extract tool calls with malformed invoke tags
assert_eq!(tools.len(), 0);
assert_eq!(normal_text, input);
}
#[tokio::test]
async fn test_minimax_streaming_with_invalid_function_progressive() {
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
// Progressive chunks building an invalid function call
let chunks = vec![
"<minimax:tool_call>",
r#"<invoke name="invalid_function">"#,
r#"<parameter name="test">value</parameter>"#,
"</invoke>",
"</minimax:tool_call>",
];
let mut all_normal_text = String::new();
let mut found_valid_tool = false;
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
all_normal_text.push_str(&result.normal_text);
for call in result.calls {
if let Some(name) = call.name {
// Should not get here for invalid function
if tools.iter().any(|t| t.function.name == name) {
found_valid_tool = true;
}
}
}
}
assert!(
!found_valid_tool,
"Invalid function should not be parsed as tool call"
);
// The invalid tool call should be returned as normal text
assert!(all_normal_text.contains("invalid_function"));
}
#[tokio::test]
async fn test_minimax_rapid_streaming_bursts() {
// Test handling of rapid streaming bursts (multiple chunks at once)
let mut parser = MinimaxM2Parser::new();
let tools = create_test_tools();
let chunks = vec![
"<minimax:tool_call><invoke name=\"search\"><parameter name=\"query\">",
"rust programming",
"</parameter></invoke></minimax:tool_call>",
];
let mut found_function = false;
let mut parameters = Vec::new();
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
for call in result.calls {
if let Some(name) = call.name {
assert_eq!(name, "search");
found_function = true;
}
if !call.parameters.is_empty() {
parameters.push(call.parameters.clone());
}
}
}
assert!(found_function);
// Verify that parameters were streamed correctly
let final_params = parameters.join("");
assert!(final_params.contains("rust programming"));
}
#[tokio::test]
async fn test_minimax_special_characters_in_values() {
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="text">Special chars: @#$%^&*()</parameter>
<parameter name="emoji">🦀 Rust 🚀</parameter>
<parameter name="quotes">"double" and 'single' quotes</parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
assert_eq!(args["text"], "Special chars: @#$%^&*()");
assert_eq!(args["emoji"], "🦀 Rust 🚀");
assert_eq!(args["quotes"], "\"double\" and 'single' quotes");
}
#[tokio::test]
async fn test_minimax_whitespace_handling() {
let parser = MinimaxM2Parser::new();
// Test with various whitespace scenarios
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="trimmed"> spaces around </parameter>
<parameter name="newlines">
Line 1
Line 2
</parameter>
<parameter name="tabs"> tab separated </parameter>
</invoke>
</minimax:tool_call>"#;
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
assert_eq!(tools.len(), 1);
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
// Values should preserve internal whitespace but may trim edges based on parser design
assert!(args["newlines"].as_str().unwrap().contains("Line 1"));
assert!(args["newlines"].as_str().unwrap().contains("Line 2"));
assert_eq!(args["tabs"], "\ttab\tseparated\t");
}
#[tokio::test]
async fn test_minimax_no_tools() {
// Test input with no tool calls at all
let parser = MinimaxM2Parser::new();
let input = r#"This is just a normal response without any tool calls.
I can provide information directly without using any tools.
Even if I mention function names like get_weather or search,
they are not actual tool calls unless properly formatted."#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
// No tools should be extracted
assert_eq!(
tools.len(),
0,
"Should not extract any tools from plain text"
);
// All content should be returned as normal text
assert_eq!(
normal_text, input,
"All content should be returned as normal text when no tools present"
);
}
#[tokio::test]
async fn test_minimax_invalid_json_in_parameters() {
// Test handling of invalid JSON in parameter values
let parser = MinimaxM2Parser::new();
let input = r#"<minimax:tool_call>
<invoke name="process">
<parameter name="valid">{"key": "value"}</parameter>
<parameter name="invalid">{invalid json: no quotes}</parameter>
<parameter name="broken">[1, 2, unclosed</parameter>
<parameter name="mixed">Some text {"partial": json} more text</parameter>
</invoke>
</minimax:tool_call>"#;
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
// Tool should still be extracted despite invalid JSON in parameters
assert_eq!(
tools.len(),
1,
"Should extract tool even with invalid JSON in parameters"
);
assert_eq!(tools[0].function.name, "process");
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
// Parameters are stored as strings, not parsed as JSON
// Even invalid JSON should be preserved as string values
assert!(args["valid"].is_string());
assert_eq!(args["valid"], r#"{"key": "value"}"#);
assert!(args["invalid"].is_string());
assert_eq!(args["invalid"], "{invalid json: no quotes}");
assert!(args["broken"].is_string());
assert_eq!(args["broken"], "[1, 2, unclosed");
assert!(args["mixed"].is_string());
assert_eq!(args["mixed"], r#"Some text {"partial": json} more text"#);
assert_eq!(normal_text, "");
}