[Rust Frontend] Add MiMo V2.5 parser support (#57933)

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
This commit is contained in:
Bugen Zhao
2026-09-22 04:27:09 +00:00
committed by GitHub
co-authored by Codex
parent c723a831a8
commit ff3c9cba3f
13 changed files with 251 additions and 36 deletions
+2
View File
@@ -61,6 +61,8 @@ pub type Glm47ReasoningParser = DeepSeekV3ReasoningParser;
// TODO: kimi k2 may implicitly end reasoning by starting a tool call section
// using <|tool_calls_section_begin|>, we should support that.
pub type KimiK2ReasoningParser = DeepSeekV3ReasoningParser;
/// MiMo V2 uses bare `<think>...</think>` delimiters.
pub type MiMoReasoningParser = DeepSeekV3ReasoningParser;
/// MiniMax M2 currently shares the Qwen3 reasoning parser.
pub type MiniMaxM2ReasoningParser = Qwen3ReasoningParser;
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use super::qwen_coder::{Qwen3CoderToolParser, QwenCoderConfig};
use super::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
const MIMO_CONFIG: QwenCoderConfig = QwenCoderConfig {
parser_name: "MiMo",
tool_call_start: "<tool_call>",
first_tool_call_start: "<tool_call>",
next_tool_call_start: "<tool_call>",
tool_call_end: "</tool_call>",
trim_parameter_newlines: false,
};
/// MiMo V2 uses Qwen Coder's XML grammar with unframed content and parameter values.
pub struct MiMoToolParser {
inner: Qwen3CoderToolParser,
}
impl ToolParser for MiMoToolParser {
fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>> {
Ok(Box::new(Self {
inner: Qwen3CoderToolParser::with_config(tools, MIMO_CONFIG),
}))
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
self.inner.structural_tag_builder()
}
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
self.inner.parse_into(chunk, output)
}
fn finish(&mut self) -> Result<ToolParserOutput> {
self.inner.finish()
}
fn reset(&mut self) -> String {
self.inner.reset()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::ToolParserTestExt as _;
use crate::tool::test_utils::{collect_stream, split_by_chars};
use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
use serde_json::{Value, json};
#[test]
fn bare_tool_boundaries_preserve_body_whitespace() {
assert_tool_framing_preserves_body_whitespace::<MiMoToolParser>(
"",
"<tool_call>\n<function=get_weather>\n</function>\n</tool_call>",
);
}
#[test]
fn adjacent_calls_preserve_parameter_whitespace_and_scalar_types_across_chunks() {
let tools = [Tool {
name: "convert".into(),
description: None,
strict: None,
parameters: json!({"type": "object", "properties": {
"flag": {"type": "boolean"}, "empty": {"type": ["string", "null"]},
"text": {"type": "string"}, "literal": {"type": "string"}, "payload": {"type": "object"}
}}),
}];
let call = "<tool_call>\n<function=convert>\n<parameter=flag>True</parameter>\n<parameter=empty>None</parameter>\n<parameter=text>\n value\n</parameter>\n<parameter=literal>None</parameter>\n<parameter=payload>{\"city\":\"杭州\"}</parameter>\n</function>\n</tool_call>";
let input = format!("answer\n\n{call}{call}");
let expected = MiMoToolParser::create(&tools).unwrap().parse_complete(&input).unwrap();
assert_eq!(expected.normal_text(), "answer\n\n");
assert_eq!(expected.calls().len(), 2);
for call in expected.calls() {
assert_eq!(
serde_json::from_str::<Value>(&call.arguments).unwrap(),
json!({"flag": true, "empty": null, "text": "\n value\n", "literal": "None", "payload": {"city": "杭州"}})
);
}
for size in 1..=input.chars().count() {
let mut parser = MiMoToolParser::create(&tools).unwrap();
assert_eq!(
collect_stream(parser.as_mut(), &split_by_chars(&input, size)),
expected
);
}
}
}
+2
View File
@@ -11,6 +11,7 @@ mod glm_xml;
mod hy;
pub(crate) mod json;
mod kimi_k2;
mod mimo;
mod minimax_m2;
mod minimax_m3;
mod parameters;
@@ -30,6 +31,7 @@ pub use json::{
MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser,
};
pub use kimi_k2::KimiK2ToolParser;
pub use mimo::MiMoToolParser;
pub use minimax_m2::MinimaxM2ToolParser;
pub use minimax_m3::MinimaxM3ToolParser;
pub use qwen_coder::Qwen3CoderToolParser;
+18 -9
View File
@@ -284,13 +284,19 @@ impl JsonParamType {
}
}
/// Recognize JSON and Python null spellings emitted by chat templates.
fn is_null_literal(value: &str) -> bool {
value.eq_ignore_ascii_case("null") || value.eq_ignore_ascii_case("none")
}
/// Convert one parameter input to a normalized JSON value.
fn convert_with_optional_schema(param_type: Option<&JsonParamType>, input: &ParamInput) -> Value {
// Coerce the literal text `null` to JSON null, except for `string`-typed
// params, where it must stay the string "null": a model emitting the literal
// text "null" for a string field means the string, not a missing value.
// Python-style `None` follows the same schema coercion rules.
if let ParamInput::Text(value) = input
&& value.eq_ignore_ascii_case("null")
&& is_null_literal(value)
&& param_type != Some(&JsonParamType::String)
{
return Value::Null;
@@ -333,7 +339,7 @@ fn try_convert_text_value(param_type: &JsonParamType, value: &str) -> Option<Val
// For composite types with string input, simply interpret the string as JSON.
serde_json::from_str(value).ok()
}
JsonParamType::Null => value.eq_ignore_ascii_case("null").then_some(Value::Null),
JsonParamType::Null => is_null_literal(value).then_some(Value::Null),
JsonParamType::OneOf(types) => {
types.iter().find_map(|param_type| try_convert_text_value(param_type, value))
}
@@ -710,11 +716,12 @@ mod tests {
}
}));
assert_eq!(params.convert("name", text("null")), json!("null"));
assert_eq!(params.convert("name", text("NULL")), json!("NULL"));
// Non-string and schema-less params are unchanged: "null" -> null.
assert_eq!(params.convert("count", text("null")), json!(null));
assert_eq!(params.convert("anything", text("null")), json!(null));
for literal in ["null", "NULL", "None", "none", "NONE"] {
assert_eq!(params.convert("name", text(literal)), json!(literal));
// Non-string and schema-less params share the same null coercion.
assert_eq!(params.convert("count", text(literal)), json!(null));
assert_eq!(params.convert("anything", text(literal)), json!(null));
}
}
#[test]
@@ -731,9 +738,11 @@ mod tests {
}
}));
assert_eq!(params.convert("mode", text("null")), json!(null));
for literal in ["null", "NULL", "None", "none", "NONE"] {
assert_eq!(params.convert("mode", text(literal)), json!(null));
assert_eq!(params.convert("color", text(literal)), json!(literal));
}
assert_eq!(params.convert("mode", text("auto")), json!("auto"));
assert_eq!(params.convert("color", text("null")), json!("null"));
}
#[test]
+27 -14
View File
@@ -21,9 +21,9 @@ const PARAMETER_END: &str = "</parameter>";
/// Model-specific configuration for the shared Qwen Coder grammar.
///
/// Only the tool-call wrapper tokens vary across models that reuse this
/// grammar; the inner `<function=...>` / `<parameter=...>` tags are always
/// byte-identical. Seed-OSS, for example, wraps the same body in
/// Models may vary the tool-call wrapper and parameter newline framing;
/// the inner `<function=...>` / `<parameter=...>` tags are byte-identical.
/// Seed-OSS, for example, wraps the same body in
/// `<seed:tool_call>` / `</seed:tool_call>`.
#[derive(Debug, Clone, Copy)]
pub(crate) struct QwenCoderConfig {
@@ -35,6 +35,8 @@ pub(crate) struct QwenCoderConfig {
pub(crate) next_tool_call_start: &'static str,
/// Marker that closes a tool-call block.
pub(crate) tool_call_end: &'static str,
/// Consume one template-inserted newline at each parameter value boundary.
pub(crate) trim_parameter_newlines: bool,
}
const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig {
@@ -43,6 +45,7 @@ const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig {
first_tool_call_start: "\n\n<tool_call>",
next_tool_call_start: "\n<tool_call>",
tool_call_end: TOOL_CALL_END,
trim_parameter_newlines: true,
};
type QwenCoderInput<'i> = Partial<&'i str>;
@@ -200,7 +203,7 @@ fn parse_next_qwen_coder_event(
match mode {
QwenCoderMode::Text => parse_text_event(input, config, after_tool_call),
QwenCoderMode::ToolCall { end_marker_scan } => {
tool_call_event(input, end_marker_scan, config.tool_call_end)
tool_call_event(input, end_marker_scan, config)
}
}
}
@@ -231,26 +234,26 @@ fn parse_text_event(
fn tool_call_event(
input: &mut QwenCoderInput<'_>,
end_marker_scan: &mut MarkerScanState,
tool_call_end: &'static str,
config: QwenCoderConfig,
) -> ModalResult<QwenCoderEvent> {
let (body,) = seq!(
_: ws0,
take_until_marker(tool_call_end, end_marker_scan),
_: literal(tool_call_end),
take_until_marker(config.tool_call_end, end_marker_scan),
_: literal(config.tool_call_end),
)
.parse_next(input)?;
parse_tool_call_body(body)
parse_tool_call_body(body, config.trim_parameter_newlines)
}
/// Parse a Qwen Coder function block.
fn function_event(input: &mut &str) -> ModalResult<QwenCoderEvent> {
fn function_event(input: &mut &str, trim_parameter_newlines: bool) -> ModalResult<QwenCoderEvent> {
let (name, raw_params) = seq!(
_: literal(FUNCTION_START),
take_until(1.., ">"),
_: ">",
_: ws0,
repeat(0.., terminated(parameter, ws0)),
repeat(0.., terminated(|input: &mut &str| parameter(input, trim_parameter_newlines), ws0)),
_: literal(FUNCTION_END),
)
.parse_next(input)?;
@@ -262,23 +265,33 @@ fn function_event(input: &mut &str) -> ModalResult<QwenCoderEvent> {
}
/// Parse a Qwen Coder parameter block.
fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
fn parameter(input: &mut &str, trim_newlines: bool) -> ModalResult<(String, String)> {
let (name, value) = seq!(
_: literal(PARAMETER_START),
take_until(1.., ">"),
_: ">",
take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline),
take_until(0.., PARAMETER_END),
_: literal(PARAMETER_END),
)
.parse_next(input)?;
let value = if trim_newlines {
trim_one_wrapping_newline(value)
} else {
value
};
Ok((name.to_string(), value.to_string()))
}
/// Parse a Qwen Coder tool-call body.
fn parse_tool_call_body(body: &str) -> ModalResult<QwenCoderEvent> {
fn parse_tool_call_body(body: &str, trim_parameter_newlines: bool) -> ModalResult<QwenCoderEvent> {
let mut input = body;
delimited(ws0, function_event, (ws0, eof)).parse_next(&mut input)
delimited(
ws0,
|input: &mut &str| function_event(input, trim_parameter_newlines),
(ws0, eof),
)
.parse_next(&mut input)
}
/// Trim a single leading and trailing newline from a parameter value.
+1
View File
@@ -10,6 +10,7 @@ const SEED_OSS_CONFIG: QwenCoderConfig = QwenCoderConfig {
first_tool_call_start: "\n\n<seed:tool_call>",
next_tool_call_start: "\n<seed:tool_call>",
tool_call_end: "</seed:tool_call>",
trim_parameter_newlines: true,
};
/// Tool parser for Seed-OSS XML-style tool calls.