@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 6299628d32
Fork 0

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 """