// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use crate::{ParserResult, ReasoningParser};
use super::base_parser::BasicReasoningParser;
/// MiniMax Append-Think Reasoning Parser.
///
/// The MiniMax model starts generating reasoning content immediately WITHOUT
/// a `` prefix. The model output looks like:
/// `reasoning content here...actual response`
///
/// This parser prepends `` to the first chunk, transforming the stream into:
/// `reasoning content here...actual response`
///
/// It then delegates to `BasicReasoningParser` for standard `...`
/// extraction, splitting output into `reasoning_text` and `normal_text`.
///
/// Reference: SGLang MiniMaxAppendThinkDetector
/// https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/parser/reasoning_parser.py
#[derive(Debug)]
pub struct MiniMaxAppendThinkParser {
inner: BasicReasoningParser,
is_first_chunk: bool,
}
impl Default for MiniMaxAppendThinkParser {
fn default() -> Self {
Self {
inner: BasicReasoningParser::new(
"".into(),
"".into(),
false, // force_reasoning=false; we synthesize ourselves
true, // stream_reasoning=true
),
is_first_chunk: true,
}
}
}
impl MiniMaxAppendThinkParser {
pub fn new() -> Self {
Self::default()
}
}
impl ReasoningParser for MiniMaxAppendThinkParser {
fn detect_and_parse_reasoning(&mut self, text: &str, token_ids: &[u32]) -> ParserResult {
// Prepend and delegate to the inner parser
let augmented = format!("{}", text);
self.inner.detect_and_parse_reasoning(&augmented, token_ids)
}
fn parse_reasoning_streaming_incremental(
&mut self,
text: &str,
token_ids: &[u32],
) -> ParserResult {
if self.is_first_chunk {
self.is_first_chunk = false;
let augmented = format!("{}", text);
self.inner
.parse_reasoning_streaming_incremental(&augmented, token_ids)
} else {
self.inner
.parse_reasoning_streaming_incremental(text, token_ids)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_and_parse_no_end_token() {
let mut parser = MiniMaxAppendThinkParser::new();
let result = parser.detect_and_parse_reasoning("reasoning content here", &[]);
assert_eq!(result.reasoning_text, "reasoning content here");
assert_eq!(result.normal_text, "");
}
#[test]
fn test_detect_and_parse_with_end_token() {
let mut parser = MiniMaxAppendThinkParser::new();
let result =
parser.detect_and_parse_reasoning("reasoning contentnormal response", &[]);
assert_eq!(result.reasoning_text, "reasoning content");
assert_eq!(result.normal_text, "normal response");
}
#[test]
fn test_streaming_basic_flow() {
let mut parser = MiniMaxAppendThinkParser::new();
// First chunk: model starts reasoning without
let r1 = parser.parse_reasoning_streaming_incremental("I need to ", &[]);
assert_eq!(r1.reasoning_text, "I need to ");
assert_eq!(r1.normal_text, "");
// Middle chunk: still reasoning
let r2 = parser.parse_reasoning_streaming_incremental("check the weather", &[]);
assert_eq!(r2.reasoning_text, "check the weather");
assert_eq!(r2.normal_text, "");
// End of reasoning
let r3 = parser.parse_reasoning_streaming_incremental("The weather is sunny.", &[]);
assert_eq!(r3.reasoning_text, "");
assert_eq!(r3.normal_text, "The weather is sunny.");
}
#[test]
fn test_streaming_end_token_split_across_chunks() {
let mut parser = MiniMaxAppendThinkParser::new();
// With stream_reasoning=true, reasoning is emitted immediately
let r1 = parser.parse_reasoning_streaming_incremental("reasoning", &[]);
assert_eq!(r1.reasoning_text, "reasoning");
assert_eq!(r1.normal_text, "");
// split across chunks - partial match should buffer
let r2 = parser.parse_reasoning_streaming_incremental("
let r3 = parser.parse_reasoning_streaming_incremental("nk>normal text", &[]);
assert_eq!(r3.reasoning_text, "");
assert_eq!(r3.normal_text, "normal text");
}
#[test]
fn test_streaming_only_reasoning_no_end() {
let mut parser = MiniMaxAppendThinkParser::new();
let r1 = parser.parse_reasoning_streaming_incremental("still thinking", &[]);
assert_eq!(r1.reasoning_text, "still thinking");
assert_eq!(r1.normal_text, "");
let r2 = parser.parse_reasoning_streaming_incremental(" more thought", &[]);
assert_eq!(r2.reasoning_text, " more thought");
assert_eq!(r2.normal_text, "");
}
#[test]
fn test_streaming_with_tool_call_after_reasoning() {
let mut parser = MiniMaxAppendThinkParser::new();
let r1 = parser.parse_reasoning_streaming_incremental("let me call a tool", &[]);
assert_eq!(r1.reasoning_text, "let me call a tool");
let r2 = parser.parse_reasoning_streaming_incremental(
"",
&[],
);
assert_eq!(r2.reasoning_text, "");
assert!(
r2.normal_text
.contains("")
);
}
#[test]
fn test_streaming_tool_call_angle_bracket_split_tokens() {
// Reproduces the bug where `<` before `", &[]);
assert_eq!(r2.reasoning_text, "");
assert_eq!(r2.normal_text, "");
// Tool call start marker
let r3 = parser.parse_reasoning_streaming_incremental("", &[]);
assert_eq!(r3.normal_text, "");
// Newline
let r4 = parser.parse_reasoning_streaming_incremental("\n", &[]);
assert_eq!(r4.normal_text, "\n");
// `<` as a separate token must NOT be buffered after reasoning ends
let r5 = parser.parse_reasoning_streaming_incremental("<", &[]);
assert_eq!(r5.normal_text, "<");
// Rest of the invoke tag
let r6 = parser.parse_reasoning_streaming_incremental("invoke name=\"get_weather\">", &[]);
assert_eq!(r6.normal_text, "invoke name=\"get_weather\">");
}
}