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

Signed-off-by: test test <[email protected]>
This commit is contained in:
Rui Yin
2026-06-23 14:43:58 -04:00
committed by GitHub
parent e368415daa
commit d8e422ccda
2 changed files with 358 additions and 68 deletions
@@ -83,6 +83,20 @@ class MiniMaxM3Tokenizer:
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,132 @@ 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,
["<mm:think>", "Reasoning", " content", "</mm:think>", "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 ["<mm:think>", "Reasoning", " content", "</mm:think>"]:
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_end_marker_content_ids_are_stripped():
tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
parser = MiniMaxM3ReasoningParser(tokenizer)
previous_text = "<mm:think>Reasoning"
previous_token_ids = tokenizer.encode_runtime(previous_text)
delta_text = "</mm:think>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(
tokenizer, chat_template_kwargs={"thinking_mode": "enabled"}
)
reasoning, content, end_states = run_streaming(
parser,
tokenizer,
["Reasoning", " content", "</mm:think>", "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,
["<mm:", "think>", "Reasoning", " content", "</mm:", "think>", "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,
["</mm:", "think>", "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(
"<mm:think>abc</mm:think>def", add_special_tokens=False
)
open_reasoning_ids = tokenizer.encode("<mm:think>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(
+216 -67
View File
@@ -19,10 +19,12 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
<mm:think>reasoning text</mm:think>assistant 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 ``<mm:think>`` 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 ``<mm:think>`` again.
"""
@property
@@ -35,9 +37,135 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
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
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:
return self.model_tokenizer.decode(
list(token_ids), skip_special_tokens=False
)
except TypeError:
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(
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 +197,46 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
) -> bool:
delta_ids = tuple(delta_ids)
if self.end_token_id in delta_ids:
if self._reasoning_ended_streaming:
return True
if self.end_token_id in input_ids:
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
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 :]
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)
if self._initial_in_reasoning and self.start_token_id not in input_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) :]
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 +252,69 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
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 not previous_text:
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:
self._reasoning_ended_streaming = True
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
or current_reasoning is not 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)
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 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)
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