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

This commit is contained in:
Simo Lin
2025-11-12 23:24:54 -08:00
committed by GitHub
parent 2bb0317e19
commit 4c1e909a80
5 changed files with 1354 additions and 4 deletions
+7 -2
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");
+2 -2
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};
@@ -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;
}
}
@@ -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;