Skip to main content

dynamo_parsers/reasoning/
minimax_append_think_parser.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::{ParserResult, ReasoningParser};
5
6/// MiniMax Append-Think Reasoning Parser.
7///
8/// The MiniMax model starts generating reasoning content immediately WITHOUT
9/// emitting a `<think>` opener in its output. SGLang's `MiniMaxAppendThinkDetector`
10/// and vLLM's `MiniMaxM2AppendThinkReasoningParser` both handle this by simply
11/// prepending `<think>` to the emitted text and classifying the whole stream
12/// as `normal_text`/content — neither extracts reasoning based on a `</think>`
13/// marker. The tag is left inline for downstream consumers that want to render
14/// or post-process it.
15///
16/// This parser matches those upstream implementations verbatim: a pass-through
17/// with a one-time `<think>` prefix on the first streamed chunk. Reasoning
18/// content is never populated.
19///
20/// References:
21/// - SGLang MiniMaxAppendThinkDetector:
22///   <https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/parser/reasoning_parser.py>
23/// - vLLM MiniMaxM2AppendThinkReasoningParser:
24///   <https://github.com/vllm-project/vllm/blob/main/vllm/reasoning/minimax_m2_reasoning_parser.py>
25#[derive(Debug, Default)]
26pub struct MiniMaxAppendThinkParser {
27    /// Flips to true after the first streamed chunk has received the `<think>`
28    /// prefix so subsequent chunks pass through unchanged.
29    prefix_emitted: bool,
30}
31
32impl MiniMaxAppendThinkParser {
33    pub fn new() -> Self {
34        Self::default()
35    }
36}
37
38const THINK_START_TOKEN: &str = "<think>";
39
40impl ReasoningParser for MiniMaxAppendThinkParser {
41    fn detect_and_parse_reasoning(&mut self, text: &str, _token_ids: &[u32]) -> ParserResult {
42        // Non-streaming: return the full text with a single `<think>` prefix,
43        // all as normal_text.  Reasoning extraction is intentionally a no-op.
44        ParserResult {
45            normal_text: format!("{THINK_START_TOKEN}{text}"),
46            reasoning_text: String::new(),
47        }
48    }
49
50    fn parse_reasoning_streaming_incremental(
51        &mut self,
52        text: &str,
53        _token_ids: &[u32],
54    ) -> ParserResult {
55        let normal_text = if !self.prefix_emitted {
56            self.prefix_emitted = true;
57            format!("{THINK_START_TOKEN}{text}")
58        } else {
59            text.to_string()
60        };
61        ParserResult {
62            normal_text,
63            reasoning_text: String::new(),
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test] // REASONING.batch.2.a — minimax inline-reasoning
73    fn test_detect_and_parse_prepends_think_all_as_normal_text() {
74        let mut parser = MiniMaxAppendThinkParser::new();
75        let result = parser.detect_and_parse_reasoning("reasoning content here", &[]);
76        // Matches SGLang: everything is normal_text with a `<think>` prefix.
77        assert_eq!(result.normal_text, "<think>reasoning content here");
78        assert_eq!(result.reasoning_text, "");
79    }
80
81    #[test] // REASONING.batch.2.c — minimax inline-reasoning
82    fn test_detect_and_parse_with_end_token_is_still_normal_text() {
83        let mut parser = MiniMaxAppendThinkParser::new();
84        let result =
85            parser.detect_and_parse_reasoning("reasoning content</think>normal response", &[]);
86        // SGLang does not split on `</think>` — the whole string (with the
87        // prepended `<think>`) flows through as normal_text.
88        assert_eq!(
89            result.normal_text,
90            "<think>reasoning content</think>normal response"
91        );
92        assert_eq!(result.reasoning_text, "");
93    }
94
95    #[test] // REASONING.stream.2.a, REASONING.batch.2.c
96    fn test_streaming_first_chunk_gets_prefix_rest_pass_through() {
97        let mut parser = MiniMaxAppendThinkParser::new();
98
99        let r1 = parser.parse_reasoning_streaming_incremental("I need to ", &[]);
100        assert_eq!(r1.normal_text, "<think>I need to ");
101        assert_eq!(r1.reasoning_text, "");
102
103        let r2 = parser.parse_reasoning_streaming_incremental("check the weather", &[]);
104        assert_eq!(r2.normal_text, "check the weather");
105        assert_eq!(r2.reasoning_text, "");
106
107        let r3 = parser.parse_reasoning_streaming_incremental("</think>The weather is sunny.", &[]);
108        // No split — `</think>` passes through verbatim in normal_text.
109        assert_eq!(r3.normal_text, "</think>The weather is sunny.");
110        assert_eq!(r3.reasoning_text, "");
111    }
112
113    #[test] // REASONING.batch.3.a — minimax leaves tool-call shape inline
114    fn test_streaming_bare_json_tool_call_is_normal_text() {
115        // Regression: under SGLang guided decoding the model emits a bare
116        // JSON array with no `</think>`. The parser must not capture it as
117        // reasoning — it must pass through so the tool-call jail can extract
118        // it into structured tool_calls.
119        let mut parser = MiniMaxAppendThinkParser::new();
120        let r = parser.parse_reasoning_streaming_incremental(
121            r#"[{"name":"get_weather","parameters":{"location":"San Francisco"}}]"#,
122            &[],
123        );
124        assert_eq!(
125            r.normal_text,
126            r#"<think>[{"name":"get_weather","parameters":{"location":"San Francisco"}}]"#
127        );
128        assert_eq!(r.reasoning_text, "");
129    }
130
131    #[test] // REASONING.batch.3.a — minimax inline-reasoning
132    fn test_streaming_tool_call_after_reasoning_is_all_normal_text() {
133        let mut parser = MiniMaxAppendThinkParser::new();
134
135        let r1 = parser.parse_reasoning_streaming_incremental("let me call a tool", &[]);
136        assert_eq!(r1.normal_text, "<think>let me call a tool");
137
138        let r2 = parser.parse_reasoning_streaming_incremental(
139            "</think><minimax:tool_call><invoke name=\"get_weather\">",
140            &[],
141        );
142        // Entire chunk is normal_text — `</think>` is not consumed.
143        assert_eq!(
144            r2.normal_text,
145            "</think><minimax:tool_call><invoke name=\"get_weather\">"
146        );
147        assert_eq!(r2.reasoning_text, "");
148    }
149}