From 3cdec20c6b342955626b9e1ca0b2a049dfbd0d00 Mon Sep 17 00:00:00 2001 From: Simo Lin Date: Wed, 12 Nov 2025 18:27:05 +0900 Subject: [PATCH] [router] add minmax m2 reasoning parser (#13137) --- sgl-router/src/reasoning_parser/factory.rs | 19 +- sgl-router/src/reasoning_parser/mod.rs | 2 +- .../src/reasoning_parser/parsers/minimax.rs | 166 ++++++++++++++++++ .../src/reasoning_parser/parsers/mod.rs | 2 + 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 sgl-router/src/reasoning_parser/parsers/minimax.rs diff --git a/sgl-router/src/reasoning_parser/factory.rs b/sgl-router/src/reasoning_parser/factory.rs index a0d57870d..1f0786f05 100644 --- a/sgl-router/src/reasoning_parser/factory.rs +++ b/sgl-router/src/reasoning_parser/factory.rs @@ -10,7 +10,7 @@ use tokio::sync::Mutex; use crate::reasoning_parser::{ parsers::{ - BaseReasoningParser, DeepSeekR1Parser, Glm45Parser, KimiParser, Qwen3Parser, + BaseReasoningParser, DeepSeekR1Parser, Glm45Parser, KimiParser, MiniMaxParser, Qwen3Parser, QwenThinkingParser, Step3Parser, }, traits::{ParseError, ParserConfig, ReasoningParser}, @@ -189,6 +189,9 @@ impl ParserFactory { // Register Step3 parser (same format as DeepSeek-R1 but separate for debugging) registry.register_parser("step3", || Box::new(Step3Parser::new())); + // Register MiniMax parser (appends token at the beginning) + registry.register_parser("minimax", || Box::new(MiniMaxParser::new())); + // Register model patterns registry.register_pattern("deepseek-r1", "deepseek_r1"); registry.register_pattern("qwen3-thinking", "qwen3_thinking"); @@ -198,6 +201,9 @@ impl ParserFactory { registry.register_pattern("glm45", "glm45"); registry.register_pattern("kimi", "kimi"); registry.register_pattern("step3", "step3"); + registry.register_pattern("minimax", "minimax"); + registry.register_pattern("minimax-m2", "minimax"); + registry.register_pattern("mm-m2", "minimax"); Self { registry } } @@ -330,6 +336,17 @@ mod tests { assert_eq!(glm45.model_type(), "glm45"); } + #[test] + fn test_minimax_model() { + let factory = ParserFactory::new(); + let minimax = factory.create("minimax-m2").unwrap(); + assert_eq!(minimax.model_type(), "minimax"); + + // Also test alternate patterns + let mm = factory.create("mm-m2-chat").unwrap(); + assert_eq!(mm.model_type(), "minimax"); + } + #[tokio::test] async fn test_pooled_parser_reuse() { let factory = ParserFactory::new(); diff --git a/sgl-router/src/reasoning_parser/mod.rs b/sgl-router/src/reasoning_parser/mod.rs index 95ffcbc4f..0f5aaa18f 100644 --- a/sgl-router/src/reasoning_parser/mod.rs +++ b/sgl-router/src/reasoning_parser/mod.rs @@ -4,7 +4,7 @@ pub mod traits; pub use factory::{ParserFactory, ParserRegistry, PooledParser}; pub use parsers::{ - BaseReasoningParser, DeepSeekR1Parser, Glm45Parser, KimiParser, Qwen3Parser, + BaseReasoningParser, DeepSeekR1Parser, Glm45Parser, KimiParser, MiniMaxParser, Qwen3Parser, QwenThinkingParser, Step3Parser, }; pub use traits::{ParseError, ParserConfig, ParserResult, ReasoningParser}; diff --git a/sgl-router/src/reasoning_parser/parsers/minimax.rs b/sgl-router/src/reasoning_parser/parsers/minimax.rs new file mode 100644 index 000000000..c59ed9109 --- /dev/null +++ b/sgl-router/src/reasoning_parser/parsers/minimax.rs @@ -0,0 +1,166 @@ +// MiniMax M2 specific reasoning parser. +// This parser automatically appends token at the beginning of text, +// similar to the Python MiniMaxAppendThinkDetector. + +use crate::reasoning_parser::{ + parsers::BaseReasoningParser, + traits::{ParseError, ParserConfig, ParserResult, ReasoningParser}, +}; + +/// MiniMax M2 reasoning parser. +/// +/// This parser automatically appends token at the beginning of the first chunk +/// and uses and tokens for reasoning blocks. +pub struct MiniMaxParser { + base: BaseReasoningParser, + is_first_chunk: bool, +} + +impl MiniMaxParser { + /// Create a new MiniMax M2 parser. + pub fn new() -> Self { + let config = ParserConfig { + think_start_token: "".to_string(), + think_end_token: "".to_string(), + stream_reasoning: true, + max_buffer_size: 65536, + initial_in_reasoning: false, // Start with false, we'll add manually + }; + + Self { + base: BaseReasoningParser::new(config).with_model_type("minimax".to_string()), + is_first_chunk: true, + } + } +} + +impl Default for MiniMaxParser { + fn default() -> Self { + Self::new() + } +} + +impl ReasoningParser for MiniMaxParser { + fn detect_and_parse_reasoning(&mut self, text: &str) -> Result { + // For one-shot parsing, prepend token to the text + let modified_text = format!("{}", text); + self.base.detect_and_parse_reasoning(&modified_text) + } + + fn parse_reasoning_streaming_incremental( + &mut self, + text: &str, + ) -> Result { + // For the first chunk, prepend token + let modified_text = if self.is_first_chunk { + self.is_first_chunk = false; + format!("{}", text) + } else { + text.to_string() + }; + + self.base + .parse_reasoning_streaming_incremental(&modified_text) + } + + fn reset(&mut self) { + self.base.reset(); + self.is_first_chunk = true; // Reset the first chunk flag + } + + fn model_type(&self) -> &str { + self.base.model_type() + } + + fn is_in_reasoning(&self) -> bool { + self.base.is_in_reasoning() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimax_append_think_oneshot() { + let mut parser = MiniMaxParser::new(); + + // Should automatically prepend and parse as reasoning + let result = parser + .detect_and_parse_reasoning("reasoning contentnormal content") + .unwrap(); + assert_eq!(result.normal_text, "normal content"); + assert_eq!(result.reasoning_text, "reasoning content"); + } + + #[test] + fn test_minimax_without_end_token() { + let mut parser = MiniMaxParser::new(); + + // Should treat all content as reasoning when no end token + let result = parser + .detect_and_parse_reasoning("all reasoning content") + .unwrap(); + assert_eq!(result.normal_text, ""); + assert_eq!(result.reasoning_text, "all reasoning content"); + } + + #[test] + fn test_minimax_streaming_first_chunk() { + let mut parser = MiniMaxParser::new(); + + // First chunk should have prepended + let result1 = parser + .parse_reasoning_streaming_incremental("thinking about") + .unwrap(); + assert_eq!(result1.reasoning_text, "thinking about"); + assert_eq!(result1.normal_text, ""); + + // Second chunk should not have prepended + let result2 = parser + .parse_reasoning_streaming_incremental(" the problemanswer") + .unwrap(); + assert_eq!(result2.reasoning_text, "the problem"); // Text is trimmed + assert_eq!(result2.normal_text, "answer"); + } + + #[test] + fn test_minimax_reset() { + let mut parser = MiniMaxParser::new(); + + // First use + let result1 = parser + .parse_reasoning_streaming_incremental("first") + .unwrap(); + assert_eq!(result1.reasoning_text, "first"); + + // Reset the parser + parser.reset(); + + // After reset, should be first chunk again + let result2 = parser + .parse_reasoning_streaming_incremental("second") + .unwrap(); + assert_eq!(result2.reasoning_text, "second"); + } + + #[test] + fn test_minimax_already_has_think() { + let mut parser = MiniMaxParser::new(); + + // Even if text already has , it will add another one + // This mimics the Python behavior + let result = parser + .detect_and_parse_reasoning("contentanswer") + .unwrap(); + // The double gets handled by the base parser which removes duplicates + assert_eq!(result.normal_text, "answer"); + assert_eq!(result.reasoning_text, "content"); + } + + #[test] + fn test_model_type() { + let parser = MiniMaxParser::new(); + assert_eq!(parser.model_type(), "minimax"); + } +} diff --git a/sgl-router/src/reasoning_parser/parsers/mod.rs b/sgl-router/src/reasoning_parser/parsers/mod.rs index a940a055c..3da782720 100644 --- a/sgl-router/src/reasoning_parser/parsers/mod.rs +++ b/sgl-router/src/reasoning_parser/parsers/mod.rs @@ -2,6 +2,7 @@ pub mod base; pub mod deepseek_r1; pub mod glm45; pub mod kimi; +pub mod minimax; pub mod qwen3; pub mod step3; @@ -9,5 +10,6 @@ pub use base::BaseReasoningParser; pub use deepseek_r1::DeepSeekR1Parser; pub use glm45::Glm45Parser; pub use kimi::KimiParser; +pub use minimax::MiniMaxParser; pub use qwen3::{Qwen3Parser, QwenThinkingParser}; pub use step3::Step3Parser;