[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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user