# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING from vllm.entrypoints.openai.engine.protocol import DeltaMessage from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser): """Reasoning parser for MiniMax M3 explicit thinking blocks. MiniMax M3 emits reasoning as: reasoning textassistant content 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 def start_token(self) -> str: return "" @property def end_token(self) -> str: return "" 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._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, model_output: str, request: "ChatCompletionRequest | ResponsesRequest", ) -> tuple[str | None, str | None]: # MiniMax M3 can start a response with a stray closer. Drop that first # token only; later unmatched closers stay visible as content. if not self._initial_in_reasoning and model_output.startswith(self.end_token): content = model_output[len(self.end_token) :] return None, content or None if self._initial_in_reasoning and self.start_token not in model_output: reasoning, end, content = model_output.partition(self.end_token) if not end: return model_output, None return reasoning, content or None if self.start_token not in model_output: return None, model_output content_before, _, after_start = model_output.partition(self.start_token) reasoning, end, content_after = after_start.partition(self.end_token) if not end: return reasoning, content_before or None return reasoning, (content_before + content_after) or None def is_reasoning_end_streaming( self, input_ids: Sequence[int], delta_ids: Iterable[int] ) -> bool: 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 if self._contains_token_sequence(input_ids, self._end_token_ids): return True if self._initial_in_reasoning: return False 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._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) :] has_start = self._contains_token_sequence(input_ids, self._start_token_ids) if self._initial_in_reasoning and not has_start: return [] if not has_start: return input_ids return [] def extract_reasoning_streaming( self, previous_text: str, current_text: str, delta_text: str, previous_token_ids: Sequence[int], current_token_ids: Sequence[int], delta_token_ids: Sequence[int], ) -> DeltaMessage | None: if not delta_text: return 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 ) 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: count = 0 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 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