A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 11, 16:15 UTC
[bugfix] fix MiniMaxM2ReasoningParser streaming output not separating reasoning_content. (#29882)
Signed-off-by: Rei <1477174254@qq.com>
Rei. committed
Dec 11, 2025 at 17:05 UTC
6299628d326f429eba78736acb44e76749b281f5
3 files changed
+468
tests/reasoning/test_minimax_m2_append_reasoning_parser.py
new
+195
@@ -0,0 +1,195 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+from transformers import AutoTokenizer
6
+
7
+from tests.reasoning.utils import run_reasoning_extraction
8
+from vllm.reasoning import ReasoningParser, ReasoningParserManager
9
+
10
+parser_name = "minimax_m2_append_think"
11
+end_token = "</think>"
12
+
13
+# MiniMax M2 model path
14
+REASONING_MODEL_NAME = "MiniMaxAI/MiniMax-M2"
15
+
16
+
17
+@pytest.fixture(scope="module")
18
+def minimax_m2_tokenizer():
19
+ return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
20
+
21
+
22
+# =============================================================================
23
+# MiniMaxM2AppendThinkReasoningParser behavior:
24
+# - Prepends <think> to the beginning of the output
25
+# - Does NOT separate reasoning and content
26
+# - Returns everything as content (with <think> prepended)
27
+# - reasoning is always None
28
+#
29
+# This parser is used when you want to keep the raw output with <think> added
30
+# =============================================================================
31
+
32
+# Case: simple output with end token
33
+SIMPLE_OUTPUT = {
34
+ "output": "This is reasoning</think>This is response",
35
+ "reasoning": None,
36
+ "content": "<think>This is reasoning</think>This is response",
37
+ "is_reasoning_end": True,
38
+}
39
+
40
+# Case: output without end token (reasoning in progress)
41
+NO_END_TOKEN = {
42
+ "output": "This is reasoning in progress",
43
+ "reasoning": None,
44
+ "content": "<think>This is reasoning in progress",
45
+ "is_reasoning_end": False,
46
+}
47
+
48
+# Case: only end token
49
+ONLY_END_TOKEN = {
50
+ "output": "</think>This is response",
51
+ "reasoning": None,
52
+ "content": "<think></think>This is response",
53
+ "is_reasoning_end": True,
54
+}
55
+
56
+# Case: multiple lines
57
+MULTIPLE_LINES = {
58
+ "output": "Line 1\nLine 2</think>Response 1\nResponse 2",
59
+ "reasoning": None,
60
+ "content": "<think>Line 1\nLine 2</think>Response 1\nResponse 2",
61
+ "is_reasoning_end": True,
62
+}
63
+
64
+# Case: empty output (non-streaming prepends <think>)
65
+EMPTY = {
66
+ "output": "",
67
+ "reasoning": None,
68
+ "content": "<think>",
69
+ "is_reasoning_end": False,
70
+}
71
+
72
+# Case: empty output streaming (no tokens = no output)
73
+EMPTY_STREAMING = {
74
+ "output": "",
75
+ "reasoning": None,
76
+ "content": None,
77
+ "is_reasoning_end": False,
78
+}
79
+
80
+# Case: special characters
81
+SPECIAL_CHARS = {
82
+ "output": "Let me think... 1+1=2</think>Yes!",
83
+ "reasoning": None,
84
+ "content": "<think>Let me think... 1+1=2</think>Yes!",
85
+ "is_reasoning_end": True,
86
+}
87
+
88
+# Case: code in output
89
+CODE_OUTPUT = {
90
+ "output": "```python\nprint('hi')\n```</think>Here's the code.",
91
+ "reasoning": None,
92
+ "content": "<think>```python\nprint('hi')\n```</think>Here's the code.",
93
+ "is_reasoning_end": True,
94
+}
95
+
96
+TEST_CASES = [
97
+ pytest.param(
98
+ False,
99
+ SIMPLE_OUTPUT,
100
+ id="simple_output",
101
+ ),
102
+ pytest.param(
103
+ True,
104
+ SIMPLE_OUTPUT,
105
+ id="simple_output_streaming",
106
+ ),
107
+ pytest.param(
108
+ False,
109
+ NO_END_TOKEN,
110
+ id="no_end_token",
111
+ ),
112
+ pytest.param(
113
+ True,
114
+ NO_END_TOKEN,
115
+ id="no_end_token_streaming",
116
+ ),
117
+ pytest.param(
118
+ False,
119
+ ONLY_END_TOKEN,
120
+ id="only_end_token",
121
+ ),
122
+ pytest.param(
123
+ True,
124
+ ONLY_END_TOKEN,
125
+ id="only_end_token_streaming",
126
+ ),
127
+ pytest.param(
128
+ False,
129
+ MULTIPLE_LINES,
130
+ id="multiple_lines",
131
+ ),
132
+ pytest.param(
133
+ True,
134
+ MULTIPLE_LINES,
135
+ id="multiple_lines_streaming",
136
+ ),
137
+ pytest.param(
138
+ False,
139
+ EMPTY,
140
+ id="empty",
141
+ ),
142
+ pytest.param(
143
+ True,
144
+ EMPTY_STREAMING,
145
+ id="empty_streaming",
146
+ ),
147
+ pytest.param(
148
+ False,
149
+ SPECIAL_CHARS,
150
+ id="special_chars",
151
+ ),
152
+ pytest.param(
153
+ True,
154
+ SPECIAL_CHARS,
155
+ id="special_chars_streaming",
156
+ ),
157
+ pytest.param(
158
+ False,
159
+ CODE_OUTPUT,
160
+ id="code_output",
161
+ ),
162
+ pytest.param(
163
+ True,
164
+ CODE_OUTPUT,
165
+ id="code_output_streaming",
166
+ ),
167
+]
168
+
169
+
170
+@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
171
+def test_reasoning(
172
+ streaming: bool,
173
+ param_dict: dict,
174
+ minimax_m2_tokenizer,
175
+):
176
+ output = minimax_m2_tokenizer.tokenize(param_dict["output"])
177
+ # decode everything to tokens
178
+ output_tokens: list[str] = [
179
+ minimax_m2_tokenizer.convert_tokens_to_string([token]) for token in output
180
+ ]
181
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
182
+ minimax_m2_tokenizer
183
+ )
184
+
185
+ reasoning, content = run_reasoning_extraction(
186
+ parser, output_tokens, streaming=streaming
187
+ )
188
+
189
+ assert reasoning == param_dict["reasoning"]
190
+ assert content == param_dict["content"]
191
+
192
+ # Test is_reasoning_end
193
+ output_ids = minimax_m2_tokenizer.convert_tokens_to_ids(output)
194
+ is_reasoning_end = parser.is_reasoning_end(output_ids)
195
+ assert is_reasoning_end == param_dict["is_reasoning_end"]
tests/reasoning/test_minimax_m2_reasoning_parser.py
new
+230
@@ -0,0 +1,230 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+from transformers import AutoTokenizer
6
+
7
+from tests.reasoning.utils import run_reasoning_extraction
8
+from vllm.reasoning import ReasoningParser, ReasoningParserManager
9
+
10
+parser_name = "minimax_m2"
11
+end_token = "</think>"
12
+
13
+# MiniMax M2 model path
14
+REASONING_MODEL_NAME = "MiniMaxAI/MiniMax-M2"
15
+
16
+
17
+@pytest.fixture(scope="module")
18
+def minimax_m2_tokenizer():
19
+ return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
20
+
21
+
22
+# =============================================================================
23
+# MiniMax M2 specific behavior:
24
+# - Model does NOT generate <think> start token
25
+# - Model only generates </think> end token
26
+# - All content before </think> is reasoning
27
+# - All content after </think> is the actual response (content)
28
+# =============================================================================
29
+
30
+# Case: reasoning + end token + content (typical case)
31
+SIMPLE_REASONING = {
32
+ "output": "This is a reasoning section</think>This is the rest",
33
+ "reasoning": "This is a reasoning section",
34
+ "content": "This is the rest",
35
+ "is_reasoning_end": True,
36
+}
37
+
38
+# Case: reasoning + end token only (no content after)
39
+COMPLETE_REASONING = {
40
+ "output": "This is a reasoning section</think>",
41
+ "reasoning": "This is a reasoning section",
42
+ "content": None,
43
+ "is_reasoning_end": True,
44
+}
45
+
46
+# Case: no end token yet (streaming in progress, all is reasoning)
47
+NO_END_TOKEN = {
48
+ "output": "This is reasoning in progress",
49
+ "reasoning": "This is reasoning in progress",
50
+ "content": None,
51
+ "is_reasoning_end": False,
52
+}
53
+
54
+# Case: multiple lines of reasoning
55
+MULTIPLE_LINES = {
56
+ "output": "First line\nSecond line</think>Response first line\nResponse second",
57
+ "reasoning": "First line\nSecond line",
58
+ "content": "Response first line\nResponse second",
59
+ "is_reasoning_end": True,
60
+}
61
+
62
+# Case: only end token (empty reasoning, immediate response)
63
+SHORTEST_REASONING_NO_STREAMING = {
64
+ "output": "</think>This is the response",
65
+ "reasoning": "",
66
+ "content": "This is the response",
67
+ "is_reasoning_end": True,
68
+}
69
+
70
+# Case: only end token streaming (reasoning is None because it's just the token)
71
+SHORTEST_REASONING_STREAMING = {
72
+ "output": "</think>This is the response",
73
+ "reasoning": None,
74
+ "content": "This is the response",
75
+ "is_reasoning_end": True,
76
+}
77
+
78
+# Case: empty output
79
+EMPTY = {
80
+ "output": "",
81
+ "reasoning": "",
82
+ "content": None,
83
+ "is_reasoning_end": False,
84
+}
85
+
86
+# Case: empty streaming
87
+EMPTY_STREAMING = {
88
+ "output": "",
89
+ "reasoning": None,
90
+ "content": None,
91
+ "is_reasoning_end": False,
92
+}
93
+
94
+# Case: long reasoning with special characters
95
+SPECIAL_CHARS = {
96
+ "output": "Let me think... 1+1=2, right?</think>Yes, 1+1=2.",
97
+ "reasoning": "Let me think... 1+1=2, right?",
98
+ "content": "Yes, 1+1=2.",
99
+ "is_reasoning_end": True,
100
+}
101
+
102
+# Case: reasoning with code blocks
103
+CODE_IN_REASONING = {
104
+ "output": "```python\nprint('hello')\n```</think>Here is the code.",
105
+ "reasoning": "```python\nprint('hello')\n```",
106
+ "content": "Here is the code.",
107
+ "is_reasoning_end": True,
108
+}
109
+
110
+TEST_CASES = [
111
+ # Core cases: no start token (MiniMax M2 actual behavior)
112
+ pytest.param(
113
+ False,
114
+ SIMPLE_REASONING,
115
+ id="simple_reasoning",
116
+ ),
117
+ pytest.param(
118
+ True,
119
+ SIMPLE_REASONING,
120
+ id="simple_reasoning_streaming",
121
+ ),
122
+ pytest.param(
123
+ False,
124
+ COMPLETE_REASONING,
125
+ id="complete_reasoning",
126
+ ),
127
+ pytest.param(
128
+ True,
129
+ COMPLETE_REASONING,
130
+ id="complete_reasoning_streaming",
131
+ ),
132
+ pytest.param(
133
+ False,
134
+ NO_END_TOKEN,
135
+ id="no_end_token",
136
+ ),
137
+ pytest.param(
138
+ True,
139
+ NO_END_TOKEN,
140
+ id="no_end_token_streaming",
141
+ ),
142
+ pytest.param(
143
+ False,
144
+ MULTIPLE_LINES,
145
+ id="multiple_lines",
146
+ ),
147
+ pytest.param(
148
+ True,
149
+ MULTIPLE_LINES,
150
+ id="multiple_lines_streaming",
151
+ ),
152
+ pytest.param(
153
+ False,
154
+ SHORTEST_REASONING_NO_STREAMING,
155
+ id="shortest_reasoning",
156
+ ),
157
+ pytest.param(
158
+ True,
159
+ SHORTEST_REASONING_STREAMING,
160
+ id="shortest_reasoning_streaming",
161
+ ),
162
+ pytest.param(
163
+ False,
164
+ EMPTY,
165
+ id="empty",
166
+ ),
167
+ pytest.param(
168
+ True,
169
+ EMPTY_STREAMING,
170
+ id="empty_streaming",
171
+ ),
172
+ pytest.param(
173
+ False,
174
+ SPECIAL_CHARS,
175
+ id="special_chars",
176
+ ),
177
+ pytest.param(
178
+ True,
179
+ SPECIAL_CHARS,
180
+ id="special_chars_streaming",
181
+ ),
182
+ pytest.param(
183
+ False,
184
+ CODE_IN_REASONING,
185
+ id="code_in_reasoning",
186
+ ),
187
+ pytest.param(
188
+ True,
189
+ CODE_IN_REASONING,
190
+ id="code_in_reasoning_streaming",
191
+ ),
192
+]
193
+
194
+
195
+@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
196
+def test_reasoning(
197
+ streaming: bool,
198
+ param_dict: dict,
199
+ minimax_m2_tokenizer,
200
+):
201
+ output = minimax_m2_tokenizer.tokenize(param_dict["output"])
202
+ # decode everything to tokens
203
+ output_tokens: list[str] = [
204
+ minimax_m2_tokenizer.convert_tokens_to_string([token]) for token in output
205
+ ]
206
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
207
+ minimax_m2_tokenizer
208
+ )
209
+
210
+ reasoning, content = run_reasoning_extraction(
211
+ parser, output_tokens, streaming=streaming
212
+ )
213
+
214
+ assert reasoning == param_dict["reasoning"]
215
+ assert content == param_dict["content"]
216
+
217
+ # Test is_reasoning_end
218
+ output_ids = minimax_m2_tokenizer.convert_tokens_to_ids(output)
219
+ is_reasoning_end = parser.is_reasoning_end(output_ids)
220
+ assert is_reasoning_end == param_dict["is_reasoning_end"]
221
+
222
+ # Test extract_content
223
+ if param_dict["content"] is not None:
224
+ content = parser.extract_content_ids(output_ids)
225
+ assert content == minimax_m2_tokenizer.convert_tokens_to_ids(
226
+ minimax_m2_tokenizer.tokenize(param_dict["content"])
227
+ )
228
+ else:
229
+ content = parser.extract_content_ids(output)
230
+ assert content == []
vllm/reasoning/minimax_m2_reasoning_parser.py
+43
@@ -19,6 +19,10 @@ logger = init_logger(__name__)
19
class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
20
"""
21
Reasoning parser for MiniMax M2 model.
22
+
23
+ MiniMax M2 models don't generate <think> start token, only </think> end
24
+ token. All content before </think> is reasoning, content after is the
25
+ actual response.
26
"""
27
28
@property
@@ -31,6 +35,45 @@ class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
35
"""The token that ends reasoning content."""
36
return "</think>"
37
38
+ def extract_reasoning_streaming(
39
+ self,
40
+ previous_text: str,
41
+ current_text: str,
42
+ delta_text: str,
43
+ previous_token_ids: Sequence[int],
44
+ current_token_ids: Sequence[int],
45
+ delta_token_ids: Sequence[int],
46
+ ) -> DeltaMessage | None:
47
+ """
48
+ Extract reasoning content from a delta message for streaming.
49
+
50
+ MiniMax M2 models don't generate <think> start token, so we assume
51
+ all content is reasoning until we encounter the </think> end token.
52
+ """
53
+ # Skip single end token
54
+ if len(delta_token_ids) == 1 and delta_token_ids[0] == self.end_token_id:
55
+ return None
56
+
57
+ # Check if end token has already appeared in previous tokens
58
+ # meaning we're past the reasoning phase
59
+ if self.end_token_id in previous_token_ids:
60
+ # We're past the reasoning phase, this is content
61
+ return DeltaMessage(content=delta_text)
62
+
63
+ # Check if end token is in delta tokens
64
+ if self.end_token_id in delta_token_ids:
65
+ # End token in delta, split reasoning and content
66
+ end_index = delta_text.find(self.end_token)
67
+ reasoning = delta_text[:end_index]
68
+ content = delta_text[end_index + len(self.end_token) :]
69
+ return DeltaMessage(
70
+ reasoning=reasoning if reasoning else None,
71
+ content=content if content else None,
72
+ )
73
+
74
+ # No end token yet, all content is reasoning
75
+ return DeltaMessage(reasoning=delta_text)
76
+
77
78
class MiniMaxM2AppendThinkReasoningParser(ReasoningParser):
79
"""