[Bugfix] Parse MiniMax M3 streaming reasoning by text markers (#45718)

Signed-off-by: test test <2260891073@qq.com>

Rui Yin committed Jun 24, 2026 at 02:43 UTC d8e422ccda9b39ca8a0756b5b195513045abcee8
2 files changed +357 -67
tests/reasoning/test_minimax_m3_reasoning_parser.py
+142 -1
@@ -83,6 +83,20 @@ class MiniMaxM3Tokenizer:
83 return "".join(tokens)
84
85
86 +class SplitMiniMaxM3Tokenizer(MiniMaxM3Tokenizer):
87 + """Tokenizer that exposes marker vocab entries but encodes them as text."""
88 +
89 + def tokenize(self, text: str) -> list[str]:
90 + return list(text)
91 +
92 +
93 +class RuntimeSplitMiniMaxM3Tokenizer(MiniMaxM3Tokenizer):
94 + """Tokenizer whose runtime output splits markers despite atomic encodes."""
95 +
96 + def encode_runtime(self, text: str) -> list[int]:
97 + return [self._add_token(token) for token in list(text)]
98 +
99 +
100 def make_parser(
101 chat_template_kwargs: dict[str, str] | None = None,
102 ) -> tuple[MiniMaxM3ReasoningParser, MiniMaxM3Tokenizer]:
@@ -105,7 +119,8 @@ def run_streaming(
119 reasoning_end_states: list[bool] = []
120
121 for chunk in chunks:
108 - delta_token_ids = tokenizer.encode(chunk, add_special_tokens=False)
122 + encode_runtime = getattr(tokenizer, "encode_runtime", tokenizer.encode)
123 + delta_token_ids = encode_runtime(chunk)
124 current_text = previous_text + chunk
125 current_token_ids = previous_token_ids + delta_token_ids
126 delta = parser.extract_reasoning_streaming(
@@ -288,6 +303,132 @@ def test_streaming_plain_content_ends_reasoning_phase():
303 assert end_states == [True, True]
304
305
306 +def test_streaming_split_marker_tokens_are_not_returned():
307 + tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
308 + parser = MiniMaxM3ReasoningParser(tokenizer)
309 +
310 + reasoning, content, end_states = run_streaming(
311 + parser,
312 + tokenizer,
313 + ["<mm:think>", "Reasoning", " content", "</mm:think>", "content"],
314 + )
315 +
316 + assert reasoning == "Reasoning content"
317 + assert content == "content"
318 + assert end_states == [False, False, False, True, True]
319 +
320 +
321 +def test_streaming_split_marker_text_drives_end_state():
322 + tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
323 + parser = MiniMaxM3ReasoningParser(tokenizer)
324 + previous_text = ""
325 + previous_token_ids: list[int] = []
326 +
327 + for chunk in ["<mm:think>", "Reasoning", " content", "</mm:think>"]:
328 + delta_token_ids = tokenizer.encode_runtime(chunk)
329 + current_text = previous_text + chunk
330 + current_token_ids = previous_token_ids + delta_token_ids
331 + parser.extract_reasoning_streaming(
332 + previous_text=previous_text,
333 + current_text=current_text,
334 + delta_text=chunk,
335 + previous_token_ids=previous_token_ids,
336 + current_token_ids=current_token_ids,
337 + delta_token_ids=delta_token_ids,
338 + )
339 + previous_text = current_text
340 + previous_token_ids = current_token_ids
341 +
342 + assert parser.is_reasoning_end_streaming(previous_token_ids, []) is True
343 +
344 +
345 +def test_streaming_split_end_marker_content_ids_are_stripped():
346 + tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
347 + parser = MiniMaxM3ReasoningParser(tokenizer)
348 + previous_text = "<mm:think>Reasoning"
349 + previous_token_ids = tokenizer.encode_runtime(previous_text)
350 + delta_text = "</mm:think>content"
351 + delta_token_ids = tokenizer.encode_runtime(delta_text)
352 + current_token_ids = previous_token_ids + delta_token_ids
353 +
354 + parser.extract_reasoning_streaming(
355 + previous_text=previous_text,
356 + current_text=previous_text + delta_text,
357 + delta_text=delta_text,
358 + previous_token_ids=previous_token_ids,
359 + current_token_ids=current_token_ids,
360 + delta_token_ids=delta_token_ids,
361 + )
362 +
363 + assert parser.is_reasoning_end_streaming(current_token_ids, delta_token_ids)
364 + assert tokenizer.decode(parser.extract_content_ids(delta_token_ids)) == "content"
365 +
366 +
367 +def test_streaming_split_marker_tokens_enabled_mode():
368 + tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
369 + parser = MiniMaxM3ReasoningParser(
370 + tokenizer, chat_template_kwargs={"thinking_mode": "enabled"}
371 + )
372 +
373 + reasoning, content, end_states = run_streaming(
374 + parser,
375 + tokenizer,
376 + ["Reasoning", " content", "</mm:think>", "content"],
377 + )
378 +
379 + assert reasoning == "Reasoning content"
380 + assert content == "content"
381 + assert end_states == [False, False, True, True]
382 +
383 +
384 +def test_streaming_split_marker_text_across_deltas():
385 + tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
386 + parser = MiniMaxM3ReasoningParser(tokenizer)
387 +
388 + reasoning, content, end_states = run_streaming(
389 + parser,
390 + tokenizer,
391 + ["<mm:", "think>", "Reasoning", " content", "</mm:", "think>", "content"],
392 + )
393 +
394 + assert reasoning == "Reasoning content"
395 + assert content == "content"
396 + assert end_states == [False, False, False, False, False, True, True]
397 +
398 +
399 +def test_streaming_split_leading_end_marker_text_across_deltas():
400 + tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
401 + parser = MiniMaxM3ReasoningParser(tokenizer)
402 +
403 + reasoning, content, end_states = run_streaming(
404 + parser,
405 + tokenizer,
406 + ["</mm:", "think>", "content"],
407 + )
408 +
409 + assert reasoning is None
410 + assert content == "content"
411 + assert end_states == [False, True, True]
412 +
413 +
414 +def test_token_id_helpers_with_split_marker_tokens():
415 + tokenizer = SplitMiniMaxM3Tokenizer()
416 + parser = MiniMaxM3ReasoningParser(tokenizer)
417 + output_ids = tokenizer.encode(
418 + "<mm:think>abc</mm:think>def", add_special_tokens=False
419 + )
420 + open_reasoning_ids = tokenizer.encode("<mm:think>abc", add_special_tokens=False)
421 + content_ids = tokenizer.encode("plain", add_special_tokens=False)
422 +
423 + assert parser.is_reasoning_end(output_ids)
424 + assert not parser.is_reasoning_end(open_reasoning_ids)
425 + assert not parser.is_reasoning_end(content_ids)
426 + assert tokenizer.decode(parser.extract_content_ids(output_ids)) == "def"
427 + assert parser.extract_content_ids(open_reasoning_ids) == []
428 + assert parser.extract_content_ids(content_ids) == content_ids
429 + assert parser.count_reasoning_tokens(output_ids) == len(tokenizer.encode("abc"))
430 +
431 +
432 def test_token_id_helpers():
433 parser, tokenizer = make_parser()
434 output_ids = tokenizer.encode(
vllm/reasoning/minimax_m3_reasoning_parser.py
+215 -66
@@ -19,10 +19,12 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
19
20 <mm:think>reasoning text</mm:think>assistant content
21
22 - The M3 tokenizer exposes both markers as complete vocabulary tokens. The
23 - chat template may also prefill the start marker when
24 - ``thinking_mode="enabled"``, so generated text can begin directly inside a
25 - reasoning block without emitting ``<mm:think>`` again.
22 + The M3 tokenizer exposes both markers as complete vocabulary entries, but
23 + generated marker text may be tokenized into smaller pieces. The streaming
24 + parser therefore uses text markers for extraction instead of relying on the
25 + single vocabulary IDs. The chat template may also prefill the start marker
26 + when ``thinking_mode="enabled"``, so generated text can begin directly
27 + inside a reasoning block without emitting ``<mm:think>`` again.
28 """
29
30 @property
@@ -35,9 +37,135 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
37
38 def __init__(self, tokenizer, *args, **kwargs):
39 super().__init__(tokenizer, *args, **kwargs)
40 + self._start_token_ids = self._encode_marker(self.start_token)
41 + self._end_token_ids = self._encode_marker(self.end_token)
42 chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
43 self._initial_in_reasoning = chat_kwargs.get("thinking_mode") == "enabled"
40 - self._at_response_start = True
44 + self._reasoning_ended_streaming = False
45 + self._reasoning_active_streaming = self._initial_in_reasoning
46 + self._pending_marker_streaming = False
47 + self._last_streaming_delta_token_ids: tuple[int, ...] | None = None
48 + self._last_streaming_content_token_ids: list[int] | None = None
49 +
50 + def _encode_text(self, text: str) -> list[int]:
51 + try:
52 + return list(self.model_tokenizer.encode(text, add_special_tokens=False))
53 + except TypeError:
54 + return list(self.model_tokenizer.encode(text))
55 +
56 + def _encode_marker(self, marker: str) -> tuple[int, ...]:
57 + return tuple(self._encode_text(marker))
58 +
59 + def _decode_text(self, token_ids: Sequence[int]) -> str:
60 + try:
61 + return self.model_tokenizer.decode(
62 + list(token_ids), skip_special_tokens=False
63 + )
64 + except TypeError:
65 + return self.model_tokenizer.decode(list(token_ids))
66 +
67 + def _content_suffix_token_ids(
68 + self,
69 + delta_text: str,
70 + delta_token_ids: Sequence[int],
71 + content: str | None,
72 + ) -> list[int]:
73 + if content is None:
74 + return []
75 + if content == delta_text:
76 + return list(delta_token_ids)
77 + if delta_text.endswith(content):
78 + prefix_text = delta_text[: len(delta_text) - len(content)]
79 + for index in range(len(delta_token_ids) + 1):
80 + if self._decode_text(delta_token_ids[:index]) == prefix_text:
81 + return list(delta_token_ids[index:])
82 + return self._encode_text(content)
83 +
84 + @staticmethod
85 + def _contains_token_sequence(
86 + token_ids: Sequence[int], marker_ids: Sequence[int]
87 + ) -> bool:
88 + if not marker_ids or len(marker_ids) > len(token_ids):
89 + return False
90 + marker_len = len(marker_ids)
91 + return any(
92 + tuple(token_ids[i : i + marker_len]) == tuple(marker_ids)
93 + for i in range(len(token_ids) - marker_len + 1)
94 + )
95 +
96 + @staticmethod
97 + def _rfind_token_sequence(
98 + token_ids: Sequence[int], marker_ids: Sequence[int]
99 + ) -> int:
100 + if not marker_ids or len(marker_ids) > len(token_ids):
101 + return -1
102 + marker_len = len(marker_ids)
103 + for i in range(len(token_ids) - marker_len, -1, -1):
104 + if tuple(token_ids[i : i + marker_len]) == tuple(marker_ids):
105 + return i
106 + return -1
107 +
108 + @staticmethod
109 + def _ends_with_token_sequence_prefix(
110 + token_ids: Sequence[int], marker_ids: Sequence[int]
111 + ) -> bool:
112 + if not marker_ids:
113 + return False
114 + max_len = min(len(token_ids), len(marker_ids) - 1)
115 + for prefix_len in range(max_len, 0, -1):
116 + if tuple(token_ids[-prefix_len:]) == tuple(marker_ids[:prefix_len]):
117 + return True
118 + return False
119 +
120 + @staticmethod
121 + def _strip_partial_marker_suffix(text: str, marker: str) -> str:
122 + max_len = min(len(text), len(marker) - 1)
123 + for suffix_len in range(max_len, 0, -1):
124 + if marker.startswith(text[-suffix_len:]):
125 + return text[:-suffix_len]
126 + return text
127 +
128 + @staticmethod
129 + def _visible_delta(previous: str | None, current: str | None) -> str | None:
130 + if not current:
131 + return None
132 + if not previous:
133 + return current
134 + if current.startswith(previous):
135 + delta = current[len(previous) :]
136 + return delta or None
137 + return current
138 +
139 + def _visible_segments(self, text: str) -> tuple[str | None, str | None]:
140 + if not text:
141 + return None, None
142 +
143 + if not self._initial_in_reasoning:
144 + if self.end_token.startswith(text) and len(text) < len(self.end_token):
145 + return None, None
146 + if text.startswith(self.end_token):
147 + text = text[len(self.end_token) :]
148 + if not text:
149 + return None, None
150 +
151 + if self._initial_in_reasoning and self.start_token not in text:
152 + reasoning, end, content = text.partition(self.end_token)
153 + if end:
154 + return reasoning or None, content or None
155 + reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token)
156 + return reasoning or None, None
157 +
158 + if self.start_token not in text:
159 + content = self._strip_partial_marker_suffix(text, self.start_token)
160 + return None, content or None
161 +
162 + content_before, _, after_start = text.partition(self.start_token)
163 + reasoning, end, content_after = after_start.partition(self.end_token)
164 + if end:
165 + return reasoning or None, (content_before + content_after) or None
166 +
167 + reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token)
168 + return reasoning or None, content_before or None
169
170 def extract_reasoning(
171 self,
@@ -69,26 +197,46 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
197 def is_reasoning_end_streaming(
198 self, input_ids: Sequence[int], delta_ids: Iterable[int]
199 ) -> bool:
200 + if self._reasoning_ended_streaming:
201 + return True
202 +
203 + if self._reasoning_active_streaming or self._pending_marker_streaming:
204 + return False
205 +
206 delta_ids = tuple(delta_ids)
73 - if self.end_token_id in delta_ids:
207 + if self._contains_token_sequence(delta_ids, self._end_token_ids):
208 return True
75 - if self.end_token_id in input_ids:
209 + if self._contains_token_sequence(input_ids, self._end_token_ids):
210 return True
211 if self._initial_in_reasoning:
212 return False
79 - if self.start_token_id not in input_ids:
213 + if self._ends_with_token_sequence_prefix(input_ids, self._start_token_ids):
214 + return False
215 + if self._ends_with_token_sequence_prefix(input_ids, self._end_token_ids):
216 + return False
217 + if not self._contains_token_sequence(input_ids, self._start_token_ids):
218 return bool(input_ids)
219 return False
220
221 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
84 - if self.end_token_id in input_ids:
85 - end_index = len(input_ids) - 1 - input_ids[::-1].index(self.end_token_id)
86 - return input_ids[end_index + 1 :]
222 + if (
223 + self._last_streaming_delta_token_ids == tuple(input_ids)
224 + and self._last_streaming_content_token_ids is not None
225 + ):
226 + content_ids = self._last_streaming_content_token_ids
227 + self._last_streaming_delta_token_ids = None
228 + self._last_streaming_content_token_ids = None
229 + return list(content_ids)
230
88 - if self._initial_in_reasoning and self.start_token_id not in input_ids:
231 + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids)
232 + if end_index >= 0:
233 + return input_ids[end_index + len(self._end_token_ids) :]
234 +
235 + has_start = self._contains_token_sequence(input_ids, self._start_token_ids)
236 + if self._initial_in_reasoning and not has_start:
237 return []
238
91 - if self.start_token_id not in input_ids:
239 + if not has_start:
240 return input_ids
241 return []
242
@@ -104,68 +252,69 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
252 if not delta_text:
253 return None
254
107 - if self._at_response_start and not self._initial_in_reasoning:
108 - # Apply the leading-closer tolerance once. Later unmatched closers
109 - # stay visible as content.
110 - self._at_response_start = False
111 - if delta_text.startswith(self.end_token):
112 - delta_text = delta_text[len(self.end_token) :]
113 - if not delta_text:
114 - return None
115 - if delta_token_ids and delta_token_ids[0] == self.end_token_id:
116 - delta_token_ids = delta_token_ids[1:]
117 -
118 - if self.end_token_id in previous_token_ids:
119 - return DeltaMessage(content=delta_text)
120 -
121 - if (
122 - self._initial_in_reasoning
123 - and self.start_token_id not in previous_token_ids
124 - and self.start_token_id not in delta_token_ids
125 - ):
126 - if self.end_token_id in delta_token_ids:
127 - reasoning, _, content = delta_text.partition(self.end_token)
128 - return DeltaMessage(
129 - reasoning=reasoning or None,
130 - content=content or None,
131 - )
132 - return DeltaMessage(reasoning=delta_text)
133 -
134 - if (
135 - self.start_token_id not in previous_token_ids
136 - and self.start_token_id not in delta_token_ids
137 - ):
138 - return DeltaMessage(content=delta_text)
139 -
140 - if self.end_token_id in delta_token_ids:
141 - reasoning_text, _, content = delta_text.partition(self.end_token)
142 - if self.start_token_id in delta_token_ids:
143 - _, _, reasoning_text = reasoning_text.partition(self.start_token)
144 - return DeltaMessage(
145 - reasoning=reasoning_text or None,
146 - content=content or None,
255 + if not previous_text:
256 + self._reasoning_ended_streaming = False
257 + self._reasoning_active_streaming = self._initial_in_reasoning
258 + self._pending_marker_streaming = False
259 + self._last_streaming_delta_token_ids = None
260 + self._last_streaming_content_token_ids = None
261 + previous_reasoning, previous_content = self._visible_segments(previous_text)
262 + current_reasoning, current_content = self._visible_segments(current_text)
263 + if self.end_token in current_text or current_content is not None:
264 + self._reasoning_ended_streaming = True
265 + self._reasoning_active_streaming = False
266 + self._pending_marker_streaming = False
267 + else:
268 + self._last_streaming_delta_token_ids = None
269 + self._last_streaming_content_token_ids = None
270 + self._reasoning_active_streaming = (
271 + self._initial_in_reasoning
272 + or self.start_token in current_text
273 + or current_reasoning is not None
274 )
148 -
149 - if self.start_token_id in delta_token_ids:
150 - _, _, reasoning = delta_text.partition(self.start_token)
151 - return DeltaMessage(reasoning=reasoning) if reasoning else None
152 -
153 - return DeltaMessage(reasoning=delta_text)
275 + self._pending_marker_streaming = not self._reasoning_active_streaming and (
276 + self.start_token.startswith(current_text)
277 + or self.end_token.startswith(current_text)
278 + )
279 + reasoning = self._visible_delta(previous_reasoning, current_reasoning)
280 + content = self._visible_delta(previous_content, current_content)
281 + if self._reasoning_ended_streaming:
282 + self._last_streaming_delta_token_ids = tuple(delta_token_ids)
283 + self._last_streaming_content_token_ids = self._content_suffix_token_ids(
284 + delta_text, delta_token_ids, content
285 + )
286 + if reasoning is None and content is None:
287 + return None
288 + return DeltaMessage(reasoning=reasoning, content=content)
289
290 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
156 - if not self._initial_in_reasoning:
157 - return super().count_reasoning_tokens(token_ids)
158 -
291 count = 0
160 - depth = 1
161 - for token_id in token_ids:
162 - if token_id == self.start_token_id:
292 + depth = 1 if self._initial_in_reasoning else 0
293 + i = 0
294 + while i < len(token_ids):
295 + if tuple(token_ids[i : i + len(self._start_token_ids)]) == (
296 + self._start_token_ids
297 + ):
298 depth += 1
299 + i += len(self._start_token_ids)
300 continue
165 - if token_id == self.end_token_id:
301 + if tuple(token_ids[i : i + len(self._end_token_ids)]) == (
302 + self._end_token_ids
303 + ):
304 if depth > 0:
305 depth -= 1
306 + i += len(self._end_token_ids)
307 continue
308 if depth > 0:
309 count += 1
310 + i += 1
311 return count
312 +
313 + def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
314 + start_index = self._rfind_token_sequence(input_ids, self._start_token_ids)
315 + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids)
316 + if end_index < 0:
317 + return False
318 + if start_index < 0:
319 + return True
320 + return end_index > start_index