From 928e13af5fcc80d5c19f2475d81afd1ef6454dda Mon Sep 17 00:00:00 2001 From: test test <2260891073@qq.com> Date: Mon, 15 Jun 2026 23:48:44 +0800 Subject: [PATCH 1/3] Fix MiniMax M3 streaming reasoning markers Signed-off-by: test test <2260891073@qq.com> --- .../test_minimax_m3_reasoning_parser.py | 121 +++++++++- vllm/reasoning/minimax_m3_reasoning_parser.py | 217 ++++++++++++------ 2 files changed, 270 insertions(+), 68 deletions(-) diff --git a/tests/reasoning/test_minimax_m3_reasoning_parser.py b/tests/reasoning/test_minimax_m3_reasoning_parser.py index e2cd14562c04..560a06311dcc 100644 --- a/tests/reasoning/test_minimax_m3_reasoning_parser.py +++ b/tests/reasoning/test_minimax_m3_reasoning_parser.py @@ -83,6 +83,20 @@ def convert_tokens_to_string(self, tokens: list[str]) -> str: return "".join(tokens) +class SplitMiniMaxM3Tokenizer(MiniMaxM3Tokenizer): + """Tokenizer that exposes marker vocab entries but encodes them as text.""" + + def tokenize(self, text: str) -> list[str]: + return list(text) + + +class RuntimeSplitMiniMaxM3Tokenizer(MiniMaxM3Tokenizer): + """Tokenizer whose runtime output splits markers despite atomic encodes.""" + + def encode_runtime(self, text: str) -> list[int]: + return [self._add_token(token) for token in list(text)] + + def make_parser( chat_template_kwargs: dict[str, str] | None = None, ) -> tuple[MiniMaxM3ReasoningParser, MiniMaxM3Tokenizer]: @@ -105,7 +119,8 @@ def run_streaming( reasoning_end_states: list[bool] = [] for chunk in chunks: - delta_token_ids = tokenizer.encode(chunk, add_special_tokens=False) + encode_runtime = getattr(tokenizer, "encode_runtime", tokenizer.encode) + delta_token_ids = encode_runtime(chunk) current_text = previous_text + chunk current_token_ids = previous_token_ids + delta_token_ids delta = parser.extract_reasoning_streaming( @@ -288,6 +303,110 @@ def test_streaming_plain_content_ends_reasoning_phase(): assert end_states == [True, True] +def test_streaming_split_marker_tokens_are_not_returned(): + tokenizer = RuntimeSplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser(tokenizer) + + reasoning, content, end_states = run_streaming( + parser, + tokenizer, + ["", "Reasoning", " content", "", "content"], + ) + + assert reasoning == "Reasoning content" + assert content == "content" + assert end_states == [False, False, False, True, True] + + +def test_streaming_split_marker_text_drives_end_state(): + tokenizer = RuntimeSplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser(tokenizer) + previous_text = "" + previous_token_ids: list[int] = [] + + for chunk in ["", "Reasoning", " content", ""]: + delta_token_ids = tokenizer.encode_runtime(chunk) + current_text = previous_text + chunk + current_token_ids = previous_token_ids + delta_token_ids + parser.extract_reasoning_streaming( + previous_text=previous_text, + current_text=current_text, + delta_text=chunk, + previous_token_ids=previous_token_ids, + current_token_ids=current_token_ids, + delta_token_ids=delta_token_ids, + ) + previous_text = current_text + previous_token_ids = current_token_ids + + assert parser.is_reasoning_end_streaming(previous_token_ids, []) is True + + +def test_streaming_split_marker_tokens_enabled_mode(): + tokenizer = RuntimeSplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser( + tokenizer, chat_template_kwargs={"thinking_mode": "enabled"} + ) + + reasoning, content, end_states = run_streaming( + parser, + tokenizer, + ["Reasoning", " content", "", "content"], + ) + + assert reasoning == "Reasoning content" + assert content == "content" + assert end_states == [False, False, True, True] + + +def test_streaming_split_marker_text_across_deltas(): + tokenizer = RuntimeSplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser(tokenizer) + + reasoning, content, end_states = run_streaming( + parser, + tokenizer, + ["", "Reasoning", " content", "", "content"], + ) + + assert reasoning == "Reasoning content" + assert content == "content" + assert end_states == [False, False, False, False, False, True, True] + + +def test_streaming_split_leading_end_marker_text_across_deltas(): + tokenizer = RuntimeSplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser(tokenizer) + + reasoning, content, end_states = run_streaming( + parser, + tokenizer, + ["", "content"], + ) + + assert reasoning is None + assert content == "content" + assert end_states == [False, True, True] + + +def test_token_id_helpers_with_split_marker_tokens(): + tokenizer = SplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser(tokenizer) + output_ids = tokenizer.encode( + "abcdef", add_special_tokens=False + ) + open_reasoning_ids = tokenizer.encode("abc", add_special_tokens=False) + content_ids = tokenizer.encode("plain", add_special_tokens=False) + + assert parser.is_reasoning_end(output_ids) + assert not parser.is_reasoning_end(open_reasoning_ids) + assert not parser.is_reasoning_end(content_ids) + assert tokenizer.decode(parser.extract_content_ids(output_ids)) == "def" + assert parser.extract_content_ids(open_reasoning_ids) == [] + assert parser.extract_content_ids(content_ids) == content_ids + assert parser.count_reasoning_tokens(output_ids) == len(tokenizer.encode("abc")) + + def test_token_id_helpers(): parser, tokenizer = make_parser() output_ids = tokenizer.encode( diff --git a/vllm/reasoning/minimax_m3_reasoning_parser.py b/vllm/reasoning/minimax_m3_reasoning_parser.py index ec75ce78bfbb..2145b0fa9dc3 100644 --- a/vllm/reasoning/minimax_m3_reasoning_parser.py +++ b/vllm/reasoning/minimax_m3_reasoning_parser.py @@ -19,10 +19,12 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser): reasoning textassistant content - The M3 tokenizer exposes both markers as complete vocabulary tokens. The - chat template may also prefill the start marker when - ``thinking_mode="enabled"``, so generated text can begin directly inside a - reasoning block without emitting ```` again. + The M3 tokenizer exposes both markers as complete vocabulary entries, but + generated marker text may be tokenized into smaller pieces. The streaming + parser therefore uses text markers for extraction instead of relying on the + single vocabulary IDs. The chat template may also prefill the start marker + when ``thinking_mode="enabled"``, so generated text can begin directly + inside a reasoning block without emitting ```` again. """ @property @@ -35,9 +37,104 @@ def end_token(self) -> str: def __init__(self, tokenizer, *args, **kwargs): super().__init__(tokenizer, *args, **kwargs) + self._start_token_ids = self._encode_marker(self.start_token) + self._end_token_ids = self._encode_marker(self.end_token) chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} self._initial_in_reasoning = chat_kwargs.get("thinking_mode") == "enabled" - self._at_response_start = True + self._reasoning_ended_streaming = False + + def _encode_marker(self, marker: str) -> tuple[int, ...]: + try: + token_ids = self.model_tokenizer.encode(marker, add_special_tokens=False) + except TypeError: + token_ids = self.model_tokenizer.encode(marker) + return tuple(token_ids) + + @staticmethod + def _contains_token_sequence( + token_ids: Sequence[int], marker_ids: Sequence[int] + ) -> bool: + if not marker_ids or len(marker_ids) > len(token_ids): + return False + marker_len = len(marker_ids) + return any( + tuple(token_ids[i : i + marker_len]) == tuple(marker_ids) + for i in range(len(token_ids) - marker_len + 1) + ) + + @staticmethod + def _rfind_token_sequence( + token_ids: Sequence[int], marker_ids: Sequence[int] + ) -> int: + if not marker_ids or len(marker_ids) > len(token_ids): + return -1 + marker_len = len(marker_ids) + for i in range(len(token_ids) - marker_len, -1, -1): + if tuple(token_ids[i : i + marker_len]) == tuple(marker_ids): + return i + return -1 + + @staticmethod + def _ends_with_token_sequence_prefix( + token_ids: Sequence[int], marker_ids: Sequence[int] + ) -> bool: + if not marker_ids: + return False + max_len = min(len(token_ids), len(marker_ids) - 1) + for prefix_len in range(max_len, 0, -1): + if tuple(token_ids[-prefix_len:]) == tuple(marker_ids[:prefix_len]): + return True + return False + + @staticmethod + def _strip_partial_marker_suffix(text: str, marker: str) -> str: + max_len = min(len(text), len(marker) - 1) + for suffix_len in range(max_len, 0, -1): + if marker.startswith(text[-suffix_len:]): + return text[:-suffix_len] + return text + + @staticmethod + def _visible_delta(previous: str | None, current: str | None) -> str | None: + if not current: + return None + if not previous: + return current + if current.startswith(previous): + delta = current[len(previous) :] + return delta or None + return current + + def _visible_segments(self, text: str) -> tuple[str | None, str | None]: + if not text: + return None, None + + if not self._initial_in_reasoning: + if self.end_token.startswith(text) and len(text) < len(self.end_token): + return None, None + if text.startswith(self.end_token): + text = text[len(self.end_token) :] + if not text: + return None, None + + if self._initial_in_reasoning and self.start_token not in text: + reasoning, end, content = text.partition(self.end_token) + if end: + return reasoning or None, content or None + reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token) + return reasoning or None, None + + if self.start_token not in text: + content = self._strip_partial_marker_suffix(text, self.start_token) + return None, content or None + + content_before, _, after_start = text.partition(self.start_token) + reasoning, end, content_after = after_start.partition(self.end_token) + if end: + return reasoning or None, (content_before + content_after) or None + + reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token) + return reasoning or None, content_before or None def extract_reasoning( self, @@ -69,26 +166,34 @@ def extract_reasoning( def is_reasoning_end_streaming( self, input_ids: Sequence[int], delta_ids: Iterable[int] ) -> bool: + if self._reasoning_ended_streaming: + return True + delta_ids = tuple(delta_ids) - if self.end_token_id in delta_ids: + if self._contains_token_sequence(delta_ids, self._end_token_ids): return True - if self.end_token_id in input_ids: + if self._contains_token_sequence(input_ids, self._end_token_ids): return True if self._initial_in_reasoning: return False - if self.start_token_id not in input_ids: + if self._ends_with_token_sequence_prefix(input_ids, self._start_token_ids): + return False + if self._ends_with_token_sequence_prefix(input_ids, self._end_token_ids): + return False + if not self._contains_token_sequence(input_ids, self._start_token_ids): return bool(input_ids) return False def extract_content_ids(self, input_ids: list[int]) -> list[int]: - if self.end_token_id in input_ids: - end_index = len(input_ids) - 1 - input_ids[::-1].index(self.end_token_id) - return input_ids[end_index + 1 :] + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids) + if end_index >= 0: + return input_ids[end_index + len(self._end_token_ids) :] - if self._initial_in_reasoning and self.start_token_id not in input_ids: + has_start = self._contains_token_sequence(input_ids, self._start_token_ids) + if self._initial_in_reasoning and not has_start: return [] - if self.start_token_id not in input_ids: + if not has_start: return input_ids return [] @@ -104,68 +209,46 @@ def extract_reasoning_streaming( if not delta_text: return None - if self._at_response_start and not self._initial_in_reasoning: - # Apply the leading-closer tolerance once. Later unmatched closers - # stay visible as content. - self._at_response_start = False - if delta_text.startswith(self.end_token): - delta_text = delta_text[len(self.end_token) :] - if not delta_text: - return None - if delta_token_ids and delta_token_ids[0] == self.end_token_id: - delta_token_ids = delta_token_ids[1:] - - if self.end_token_id in previous_token_ids: - return DeltaMessage(content=delta_text) - - if ( - self._initial_in_reasoning - and self.start_token_id not in previous_token_ids - and self.start_token_id not in delta_token_ids - ): - if self.end_token_id in delta_token_ids: - reasoning, _, content = delta_text.partition(self.end_token) - return DeltaMessage( - reasoning=reasoning or None, - content=content or None, - ) - return DeltaMessage(reasoning=delta_text) - - if ( - self.start_token_id not in previous_token_ids - and self.start_token_id not in delta_token_ids - ): - return DeltaMessage(content=delta_text) - - if self.end_token_id in delta_token_ids: - reasoning_text, _, content = delta_text.partition(self.end_token) - if self.start_token_id in delta_token_ids: - _, _, reasoning_text = reasoning_text.partition(self.start_token) - return DeltaMessage( - reasoning=reasoning_text or None, - content=content or None, - ) - - if self.start_token_id in delta_token_ids: - _, _, reasoning = delta_text.partition(self.start_token) - return DeltaMessage(reasoning=reasoning) if reasoning else None - - return DeltaMessage(reasoning=delta_text) + if not previous_text: + self._reasoning_ended_streaming = False + previous_reasoning, previous_content = self._visible_segments(previous_text) + current_reasoning, current_content = self._visible_segments(current_text) + if self.end_token in current_text or current_content is not None: + self._reasoning_ended_streaming = True + reasoning = self._visible_delta(previous_reasoning, current_reasoning) + content = self._visible_delta(previous_content, current_content) + if reasoning is None and content is None: + return None + return DeltaMessage(reasoning=reasoning, content=content) def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int: - if not self._initial_in_reasoning: - return super().count_reasoning_tokens(token_ids) - count = 0 - depth = 1 - for token_id in token_ids: - if token_id == self.start_token_id: + depth = 1 if self._initial_in_reasoning else 0 + i = 0 + while i < len(token_ids): + if tuple(token_ids[i : i + len(self._start_token_ids)]) == ( + self._start_token_ids + ): depth += 1 + i += len(self._start_token_ids) continue - if token_id == self.end_token_id: + if tuple(token_ids[i : i + len(self._end_token_ids)]) == ( + self._end_token_ids + ): if depth > 0: depth -= 1 + i += len(self._end_token_ids) continue if depth > 0: count += 1 + i += 1 return count + + def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: + start_index = self._rfind_token_sequence(input_ids, self._start_token_ids) + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids) + if end_index < 0: + return False + if start_index < 0: + return True + return end_index > start_index From b6b8c5ce500f376d94c60612844ac3316023d0b3 Mon Sep 17 00:00:00 2001 From: test test <2260891073@qq.com> Date: Mon, 22 Jun 2026 22:00:52 +0800 Subject: [PATCH 2/3] Fix MiniMax M3 split marker streaming state Signed-off-by: test test <2260891073@qq.com> --- vllm/reasoning/minimax_m3_reasoning_parser.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/vllm/reasoning/minimax_m3_reasoning_parser.py b/vllm/reasoning/minimax_m3_reasoning_parser.py index 2145b0fa9dc3..1e83836e1c50 100644 --- a/vllm/reasoning/minimax_m3_reasoning_parser.py +++ b/vllm/reasoning/minimax_m3_reasoning_parser.py @@ -42,6 +42,8 @@ def __init__(self, tokenizer, *args, **kwargs): chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} self._initial_in_reasoning = chat_kwargs.get("thinking_mode") == "enabled" self._reasoning_ended_streaming = False + self._reasoning_active_streaming = self._initial_in_reasoning + self._pending_marker_streaming = False def _encode_marker(self, marker: str) -> tuple[int, ...]: try: @@ -169,6 +171,9 @@ def is_reasoning_end_streaming( if self._reasoning_ended_streaming: return True + if self._reasoning_active_streaming or self._pending_marker_streaming: + return False + delta_ids = tuple(delta_ids) if self._contains_token_sequence(delta_ids, self._end_token_ids): return True @@ -211,10 +216,24 @@ def extract_reasoning_streaming( if not previous_text: self._reasoning_ended_streaming = False + self._reasoning_active_streaming = self._initial_in_reasoning + self._pending_marker_streaming = False previous_reasoning, previous_content = self._visible_segments(previous_text) current_reasoning, current_content = self._visible_segments(current_text) if self.end_token in current_text or current_content is not None: self._reasoning_ended_streaming = True + self._reasoning_active_streaming = False + self._pending_marker_streaming = False + else: + self._reasoning_active_streaming = ( + self._initial_in_reasoning + or self.start_token in current_text + or current_reasoning is not None + ) + self._pending_marker_streaming = not self._reasoning_active_streaming and ( + self.start_token.startswith(current_text) + or self.end_token.startswith(current_text) + ) reasoning = self._visible_delta(previous_reasoning, current_reasoning) content = self._visible_delta(previous_content, current_content) if reasoning is None and content is None: From 5f28d74764bdd6ad605378f6a96e7e9fba326027 Mon Sep 17 00:00:00 2001 From: test test <2260891073@qq.com> Date: Mon, 22 Jun 2026 23:01:14 +0800 Subject: [PATCH 3/3] Fix MiniMax M3 split end content ids Signed-off-by: test test <2260891073@qq.com> --- .../test_minimax_m3_reasoning_parser.py | 22 ++++++++ vllm/reasoning/minimax_m3_reasoning_parser.py | 53 +++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/tests/reasoning/test_minimax_m3_reasoning_parser.py b/tests/reasoning/test_minimax_m3_reasoning_parser.py index 560a06311dcc..561e4020cb1f 100644 --- a/tests/reasoning/test_minimax_m3_reasoning_parser.py +++ b/tests/reasoning/test_minimax_m3_reasoning_parser.py @@ -342,6 +342,28 @@ def test_streaming_split_marker_text_drives_end_state(): assert parser.is_reasoning_end_streaming(previous_token_ids, []) is True +def test_streaming_split_end_marker_content_ids_are_stripped(): + tokenizer = RuntimeSplitMiniMaxM3Tokenizer() + parser = MiniMaxM3ReasoningParser(tokenizer) + previous_text = "Reasoning" + previous_token_ids = tokenizer.encode_runtime(previous_text) + delta_text = "content" + delta_token_ids = tokenizer.encode_runtime(delta_text) + current_token_ids = previous_token_ids + delta_token_ids + + parser.extract_reasoning_streaming( + previous_text=previous_text, + current_text=previous_text + delta_text, + delta_text=delta_text, + previous_token_ids=previous_token_ids, + current_token_ids=current_token_ids, + delta_token_ids=delta_token_ids, + ) + + assert parser.is_reasoning_end_streaming(current_token_ids, delta_token_ids) + assert tokenizer.decode(parser.extract_content_ids(delta_token_ids)) == "content" + + def test_streaming_split_marker_tokens_enabled_mode(): tokenizer = RuntimeSplitMiniMaxM3Tokenizer() parser = MiniMaxM3ReasoningParser( diff --git a/vllm/reasoning/minimax_m3_reasoning_parser.py b/vllm/reasoning/minimax_m3_reasoning_parser.py index 1e83836e1c50..52d2851e2a4c 100644 --- a/vllm/reasoning/minimax_m3_reasoning_parser.py +++ b/vllm/reasoning/minimax_m3_reasoning_parser.py @@ -44,13 +44,42 @@ def __init__(self, tokenizer, *args, **kwargs): self._reasoning_ended_streaming = False self._reasoning_active_streaming = self._initial_in_reasoning self._pending_marker_streaming = False + self._last_streaming_delta_token_ids: tuple[int, ...] | None = None + self._last_streaming_content_token_ids: list[int] | None = None + + def _encode_text(self, text: str) -> list[int]: + try: + return list(self.model_tokenizer.encode(text, add_special_tokens=False)) + except TypeError: + return list(self.model_tokenizer.encode(text)) def _encode_marker(self, marker: str) -> tuple[int, ...]: + return tuple(self._encode_text(marker)) + + def _decode_text(self, token_ids: Sequence[int]) -> str: try: - token_ids = self.model_tokenizer.encode(marker, add_special_tokens=False) + return self.model_tokenizer.decode( + list(token_ids), skip_special_tokens=False + ) except TypeError: - token_ids = self.model_tokenizer.encode(marker) - return tuple(token_ids) + return self.model_tokenizer.decode(list(token_ids)) + + def _content_suffix_token_ids( + self, + delta_text: str, + delta_token_ids: Sequence[int], + content: str | None, + ) -> list[int]: + if content is None: + return [] + if content == delta_text: + return list(delta_token_ids) + if delta_text.endswith(content): + prefix_text = delta_text[: len(delta_text) - len(content)] + for index in range(len(delta_token_ids) + 1): + if self._decode_text(delta_token_ids[:index]) == prefix_text: + return list(delta_token_ids[index:]) + return self._encode_text(content) @staticmethod def _contains_token_sequence( @@ -190,6 +219,15 @@ def is_reasoning_end_streaming( return False def extract_content_ids(self, input_ids: list[int]) -> list[int]: + if ( + self._last_streaming_delta_token_ids == tuple(input_ids) + and self._last_streaming_content_token_ids is not None + ): + content_ids = self._last_streaming_content_token_ids + self._last_streaming_delta_token_ids = None + self._last_streaming_content_token_ids = None + return list(content_ids) + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids) if end_index >= 0: return input_ids[end_index + len(self._end_token_ids) :] @@ -218,6 +256,8 @@ def extract_reasoning_streaming( self._reasoning_ended_streaming = False self._reasoning_active_streaming = self._initial_in_reasoning self._pending_marker_streaming = False + self._last_streaming_delta_token_ids = None + self._last_streaming_content_token_ids = None previous_reasoning, previous_content = self._visible_segments(previous_text) current_reasoning, current_content = self._visible_segments(current_text) if self.end_token in current_text or current_content is not None: @@ -225,6 +265,8 @@ def extract_reasoning_streaming( self._reasoning_active_streaming = False self._pending_marker_streaming = False else: + self._last_streaming_delta_token_ids = None + self._last_streaming_content_token_ids = None self._reasoning_active_streaming = ( self._initial_in_reasoning or self.start_token in current_text @@ -236,6 +278,11 @@ def extract_reasoning_streaming( ) reasoning = self._visible_delta(previous_reasoning, current_reasoning) content = self._visible_delta(previous_content, current_content) + if self._reasoning_ended_streaming: + self._last_streaming_delta_token_ids = tuple(delta_token_ids) + self._last_streaming_content_token_ids = self._content_suffix_token_ids( + delta_text, delta_token_ids, content + ) if reasoning is None and content is None: return None return DeltaMessage(reasoning=reasoning, content=content)