Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions tests/reasoning/test_minimax_m3_reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,39 @@ def test_nonstreaming_non_leading_end_tag_is_content():
assert content == "XXX</mm:think>YYY"


def test_nonstreaming_non_leading_start_tag_is_content():
parser, _ = make_parser()
request = ChatCompletionRequest(messages=[], model="test-model")

reasoning, content = parser.extract_reasoning("XXX<mm:think>YYY", request)

assert reasoning is None
assert content == "XXX<mm:think>YYY"


def test_nonstreaming_literal_start_marker_in_json_content():
parser, _ = make_parser()
request = ChatCompletionRequest(messages=[], model="test-model")

payload = '{"note": "the <mm:think> marker leaks"}'
reasoning, content = parser.extract_reasoning(payload, request)

assert reasoning is None
assert content == payload


def test_nonstreaming_enabled_mode_literal_start_marker_in_reasoning():
parser, _ = make_parser(chat_template_kwargs={"thinking_mode": "enabled"})
request = ChatCompletionRequest(messages=[], model="test-model")

reasoning, content = parser.extract_reasoning(
"quoting <mm:think> here</mm:think>answer", request
)

assert reasoning == "quoting <mm:think> here"
assert content == "answer"


def test_nonstreaming_enabled_mode_starts_in_reasoning():
parser, _ = make_parser(chat_template_kwargs={"thinking_mode": "enabled"})
request = ChatCompletionRequest(messages=[], model="test-model")
Expand Down Expand Up @@ -275,6 +308,20 @@ def test_streaming_non_leading_end_tag_is_content():
assert end_states == [True]


def test_streaming_non_leading_start_tag_stays_content():
parser, tokenizer = make_parser()

reasoning, content, end_states = run_streaming(
parser,
tokenizer,
["the ", "<mm:think>", " marker leaks"],
)

assert reasoning is None
assert content == "the <mm:think> marker leaks"
assert end_states == [True, True, True]


def test_streaming_enabled_mode_starts_in_reasoning():
parser, tokenizer = make_parser(chat_template_kwargs={"thinking_mode": "enabled"})

Expand Down Expand Up @@ -459,3 +506,43 @@ def test_token_id_helpers_enabled_mode():
assert parser.count_reasoning_tokens(open_reasoning_ids) == len(
tokenizer.encode("abc")
)


def test_token_id_helpers_literal_start_marker_is_content():
parser, tokenizer = make_parser()
payload = '{"note": "the <mm:think> marker leaks"}'
output_ids = tokenizer.encode(payload, add_special_tokens=False)

assert parser.extract_content_ids(output_ids) == output_ids
assert parser.count_reasoning_tokens(output_ids) == 0


def test_token_id_helpers_literal_start_marker_split_tokens():
tokenizer = SplitMiniMaxM3Tokenizer()
parser = MiniMaxM3ReasoningParser(tokenizer)
payload = '{"note": "the <mm:think> marker leaks"}'
output_ids = tokenizer.encode(payload, add_special_tokens=False)

assert parser.extract_content_ids(output_ids) == output_ids
assert parser.count_reasoning_tokens(output_ids) == 0


def test_is_reasoning_end_prompt_state_preserved():
# is_reasoning_end runs on the full prompt/history to initialize reasoning
# state (serving.py, structured_output/__init__.py, abstract_parser.py), so
# it must keep rightmost-marker semantics, not the leading-only rule used
# for generated output.
parser, tokenizer = make_parser()
closed_prompt = tokenizer.encode(
"<chat>user asks</chat></mm:think>", add_special_tokens=False
)
assert parser.is_reasoning_end(closed_prompt)

enabled_parser, tokenizer = make_parser(
chat_template_kwargs={"thinking_mode": "enabled"}
)
open_prompt = tokenizer.encode(
"<mm:think>past</mm:think>answer<chat>new</chat><mm:think>",
add_special_tokens=False,
)
assert not enabled_parser.is_reasoning_end(open_prompt)
116 changes: 70 additions & 46 deletions vllm/reasoning/minimax_m3_reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ def _rfind_token_sequence(
return i
return -1

@staticmethod
def _find_token_sequence(
token_ids: Sequence[int], marker_ids: Sequence[int], start: int = 0
) -> int:
if not marker_ids or len(marker_ids) > len(token_ids):
return -1
marker_len = len(marker_ids)
for i in range(start, len(token_ids) - marker_len + 1):
if tuple(token_ids[i : i + marker_len]) == tuple(marker_ids):
return i
return -1

@staticmethod
def _starts_with_token_sequence(
token_ids: Sequence[int], marker_ids: Sequence[int]
) -> bool:
if not marker_ids or len(marker_ids) > len(token_ids):
return False
return tuple(token_ids[: len(marker_ids)]) == tuple(marker_ids)

@staticmethod
def _ends_with_token_sequence_prefix(
token_ids: Sequence[int], marker_ids: Sequence[int]
Expand Down Expand Up @@ -148,51 +168,57 @@ def _visible_segments(self, text: str) -> tuple[str | None, str | None]:
if not text:
return None, None

if self._initial_in_reasoning and self.start_token not in text:
if self._initial_in_reasoning:
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
# Only a leading start marker opens reasoning; hold back while the text so
# far is still a prefix of that marker. A start marker anywhere else is
# literal content, so it never gets stripped mid-stream.
if not text.startswith(self.start_token):
if self.start_token.startswith(text):
return None, None
return None, text

content_before, _, after_start = text.partition(self.start_token)
after_start = text[len(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
return reasoning or None, content_after or None

reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token)
return reasoning or None, content_before or None
return reasoning or None, 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:
# Markers are only meaningful at the boundary they delimit: a reasoning
# block is opened by a leading start marker (or the prefilled template in
# enabled mode) and closed by the first end marker. A start marker that
# the model emits inside content is literal text, not a block opener.
if self._initial_in_reasoning:
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:
# A leading closer is a stray token; drop it. Later closers stay content.
if model_output.startswith(self.end_token):
content = model_output[len(self.end_token) :]
return None, content or None

if not model_output.startswith(self.start_token):
return None, model_output

content_before, _, after_start = model_output.partition(self.start_token)
after_start = model_output[len(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
return reasoning, None
return reasoning, content_after or None

def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
Expand Down Expand Up @@ -232,13 +258,15 @@ def extract_content_ids(self, input_ids: list[int]) -> list[int]:
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:
# Reasoning is open only from a leading start marker (or the prefilled
# template). A start marker elsewhere is literal content, so without a
# closer the whole sequence is content.
leading_reasoning = self._initial_in_reasoning or (
self._starts_with_token_sequence(input_ids, self._start_token_ids)
)
if leading_reasoning:
return []

if not has_start:
return input_ids
return []
return input_ids

def extract_reasoning_streaming(
self,
Expand Down Expand Up @@ -288,29 +316,25 @@ def extract_reasoning_streaming(
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
# Reasoning spans from a leading start marker (or the prefilled template)
# to the first closer. A non-leading start marker is literal content.
token_ids = list(token_ids)
if self._initial_in_reasoning:
body_start = 0
elif self._starts_with_token_sequence(token_ids, self._start_token_ids):
body_start = len(self._start_token_ids)
else:
return 0
end_index = self._find_token_sequence(
token_ids, self._end_token_ids, body_start
)
body_end = end_index if end_index >= 0 else len(token_ids)
return body_end - body_start

def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
# Operates on the full prompt/history token stream to initialize
# reasoning state, so it uses rightmost-marker (current-state) semantics
# rather than the leading-only rule used for generated output above.
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:
Expand Down
Loading