Skip to content

Commit 14765c7

Browse files
[None][fix] MiniMax-M3: resolve an unprefilled prompt as outside reasoning (#20837)
Signed-off-by: Pranav Shrestha <254760092+pranav-nvidia@users.noreply.github.com> Co-authored-by: Pranav Shrestha <254760092+pranav-nvidia@users.noreply.github.com>
1 parent 3467615 commit 14765c7

4 files changed

Lines changed: 135 additions & 20 deletions

File tree

‎tensorrt_llm/llmapi/reasoning_parser.py‎

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ def resolve_prefilled_thinking(cls, reasoning_parser: str,
116116
parser, parser has not opted in, or neither marker is at
117117
the tail). Callers must treat this as "ask elsewhere"
118118
(e.g. the relayed disagg value), not as thinking-off.
119+
A prompt ending with neither marker is referred to the parser's
120+
`resolve_unmarked_prompt`, which returns None unless the parser
121+
recognises the tail.
119122
"""
120123
entry = cls._parsers.get(reasoning_parser.lower())
121124
if entry is None:
@@ -129,7 +132,7 @@ def resolve_prefilled_thinking(cls, reasoning_parser: str,
129132
return False
130133
if tail.endswith(parser_cls.reasoning_start):
131134
return True
132-
return None
135+
return parser_cls.resolve_unmarked_prompt(tail)
133136

134137
@classmethod
135138
def keys(cls) -> KeysView[str]:
@@ -163,6 +166,15 @@ class BaseReasoningParser(ABC):
163166
reasoning_start: ClassVar[str]
164167
reasoning_end: ClassVar[str]
165168

169+
@classmethod
170+
def resolve_unmarked_prompt(cls, tail: str) -> Optional[bool]:
171+
"""Mode of a rendered prompt whose stripped tail ends with neither marker.
172+
173+
None leaves the mode unresolved, which is right for a template that
174+
prefills a marker whenever it prefills anything at all.
175+
"""
176+
return None
177+
166178
def __init__(self,
167179
*,
168180
chat_template_kwargs: Optional[dict[str, Any]] = None) -> None:
@@ -542,35 +554,46 @@ class MiniMaxM3ReasoningParser(DeepSeekR1Parser):
542554
tag; with thinking disabled it prefills ``</mm:think>``, so the output is
543555
all content; in adaptive mode (the template default) it prefills nothing
544556
and the model emits ``<mm:think>`` or the bare sentinel first. The parser
545-
therefore takes its mode from an explicit ``thinking_mode`` or from the
546-
mode the server resolved off the rendered prompt (relayed as
547-
``enable_thinking``), and otherwise starts outside reasoning. Both
548-
tags are delimiters and never appear in the result; text before an
549-
opening tag is content; an opening tag without a closing tag is
550-
reasoning cut off by the token limit. ``parse`` and ``parse_delta``
551-
agree on every input for every chunking.
557+
takes its mode from the one the server resolved off the rendered prompt
558+
(relayed as ``enable_thinking``), falls back to ``thinking_mode`` when
559+
nothing was resolved, and otherwise starts outside reasoning. Both tags
560+
are delimiters and never appear in the result; text before an opening tag
561+
is content; an opening tag without a closing tag is reasoning cut off by
562+
the token limit. ``parse`` and ``parse_delta`` agree for every chunking.
552563
"""
553564

554565
resolves_thinking_from_prompt = True
555566
reasoning_start = "<mm:think>"
556567
reasoning_end = "</mm:think>"
568+
# The template's generation prompt is ``bos_token ~ 'ai' ~ '\\n'``.
569+
_assistant_header = "]~b]ai"
570+
571+
@classmethod
572+
def resolve_unmarked_prompt(cls, tail: str) -> Optional[bool]:
573+
# Adaptive mode prefills nothing after the header, so the output
574+
# starts outside reasoning. Any other unmarked tail, such as a custom
575+
# template that prefills text after ``<mm:think>``, carries no mode the
576+
# tail can read and is left to ``thinking_mode``.
577+
if tail.endswith(cls._assistant_header):
578+
return False
579+
return None
557580

558581
def __init__(self,
559582
*,
560583
chat_template_kwargs: Optional[dict[str, Any]] = None) -> None:
561584
kwargs = chat_template_kwargs or {}
562-
# The template's own knob is `thinking_mode`; only "enabled" prefills
563-
# the opening tag. Without it, trust the mode the server resolved
564-
# from the rendered prompt, relayed as `enable_thinking` (a caller
565-
# cannot pass that key: the template never reads it, so the
566-
# unused-kwargs guard rejects it). A bare `thinking` is what the
567-
# Responses path injects for `reasoning.effort`; this template does
568-
# not act on it, so neither does the parser.
585+
# The server overwrites `enable_thinking` with the mode resolved from
586+
# the rendered prompt, so it wins over `thinking_mode`, which decides
587+
# only when nothing was resolved. The Responses path's bare `thinking`
588+
# is ignored because the template never reads it.
589+
resolved = kwargs.get("enable_thinking")
569590
thinking_mode = kwargs.get("thinking_mode")
570-
if thinking_mode is not None:
591+
if resolved is not None:
592+
prefilled_open = resolved is True
593+
elif thinking_mode is not None:
571594
prefilled_open = thinking_mode == "enabled"
572595
else:
573-
prefilled_open = kwargs.get("enable_thinking") is True
596+
prefilled_open = False
574597
super().__init__(reasoning_at_start=prefilled_open,
575598
chat_template_kwargs=chat_template_kwargs)
576599
self.reasoning_start = "<mm:think>"

‎tests/unittest/llmapi/apps/test_reasoning_prompt_resolution.py‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,16 @@ def render(chat_template_kwargs: dict | None, add_generation_prompt: bool = True
6060

6161

6262
def build_args(
63-
rendered_prompt: str | None, request_kwargs: dict | None, add_generation_prompt: bool = True
63+
rendered_prompt: str | None,
64+
request_kwargs: dict | None,
65+
add_generation_prompt: bool = True,
66+
reasoning_parser: str = "poolside_v1",
6467
) -> ChatPostprocArgs:
6568
"""Mirror what `openai_server` does after rendering the prompt."""
6669
args = ChatPostprocArgs(
6770
role="assistant", model="test-model", chat_template_kwargs=request_kwargs
6871
)
69-
args.reasoning_parser = "poolside_v1"
72+
args.reasoning_parser = reasoning_parser
7073
args.num_prompt_tokens = 3
7174
if args.reasoning_parser and rendered_prompt and add_generation_prompt:
7275
thinking = ReasoningParserFactory.resolve_prefilled_thinking(
@@ -208,3 +211,46 @@ def test_unrendered_prompt_falls_back_without_crashing() -> None:
208211
# Unresolved, so the pre-existing split on an emitted `<think>` applies.
209212
assert content == "c"
210213
assert reasoning == "b"
214+
215+
216+
# MiniMax-M3 adaptive mode: the prompt ends at the assistant header and the model emits its tags.
217+
M3_ADAPTIVE_PROMPT = "]~b]user\nhi[e~[\n]~b]ai\n"
218+
M3_OUTPUT = "<mm:think>hidden</mm:think>visible"
219+
220+
221+
@pytest.mark.parametrize(
222+
"request_kwargs",
223+
[
224+
# Injected by the Anthropic adapter for `thinking: {"type": "enabled"}`.
225+
{"enable_thinking": True},
226+
# Passed by a caller with the unused-kwargs guard relaxed.
227+
{"thinking_mode": "enabled"},
228+
],
229+
)
230+
def test_minimax_m3_adaptive_prompt_ignores_request_thinking_flag(request_kwargs: dict) -> None:
231+
"""A thinking flag that the prompt does not reflect must not open a block."""
232+
args = build_args(M3_ADAPTIVE_PROMPT, request_kwargs, reasoning_parser="minimax_m3")
233+
234+
content, reasoning = apply_reasoning_parser(args, 0, M3_OUTPUT, streaming=False)
235+
236+
assert (content, reasoning) == ("visible", "hidden")
237+
238+
239+
def test_minimax_m3_prefilled_reasoning_text_is_left_to_thinking_mode() -> None:
240+
"""Text prefilled after `<mm:think>` is not the adaptive header: nothing is resolved."""
241+
prompt = M3_ADAPTIVE_PROMPT + "<mm:think>Let me think."
242+
args = build_args(prompt, {"thinking_mode": "enabled"}, reasoning_parser="minimax_m3")
243+
244+
content, reasoning = apply_reasoning_parser(
245+
args, 0, "hidden</mm:think>visible", streaming=False
246+
)
247+
248+
assert (content, reasoning) == ("visible", "hidden")
249+
250+
251+
def test_minimax_m3_context_worker_stamps_adaptive_prompt_as_off() -> None:
252+
args = build_args(M3_ADAPTIVE_PROMPT, {"enable_thinking": True}, reasoning_parser="minimax_m3")
253+
254+
response = chat_response_post_processor(_FakeResult(M3_OUTPUT), args)
255+
256+
assert response.choices[0].disaggregated_params.resolved_thinking is False

‎tests/unittest/llmapi/apps/test_render_core.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,11 @@ class TestPreparationContext:
930930
"{% if add_generation_prompt %}<assistant><think>{% endif %}"
931931
)
932932
PREFILLED_CLOSED = PREFILLED_OPEN.replace("<think>", "</think>")
933+
# MiniMax-M3 in adaptive mode: the generation prompt is the bare assistant header.
934+
M3_ADAPTIVE = (
935+
"{% for m in messages %}]~b]{{ m.role }}\n{{ m.content }}[e~[\n{% endfor %}"
936+
"{% if add_generation_prompt %}]~b]ai\n{% endif %}"
937+
)
933938

934939
def _context(self, template, **overrides):
935940
res = resources(make_tokenizer(chat_template=template), **overrides)
@@ -949,6 +954,10 @@ def test_a_prompt_that_closes_thinking_is_recorded_as_off(self) -> None:
949954
def test_a_prompt_that_prefills_nothing_records_nothing(self, tokenizer) -> None:
950955
assert render_chat(chat_request(), resources(tokenizer)).context["resolved_thinking"] == {}
951956

957+
def test_the_minimax_m3_adaptive_header_is_recorded_as_off(self) -> None:
958+
# The template prefills nothing after its header, so only that parser resolves the prompt.
959+
assert self._context(self.M3_ADAPTIVE)["resolved_thinking"] == {"minimax_m3": False}
960+
952961
def test_no_generation_prompt_records_nothing(self) -> None:
953962
res = resources(make_tokenizer(chat_template=self.PREFILLED_OPEN))
954963

‎tests/unittest/llmapi/test_reasoning_parser.py‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ def test_resolved_mode_overrides_whatever_the_caller_sent(
548548

549549
@pytest.mark.parametrize("parser", [
550550
"deepseek-r1", "deepseek_v4", "deepseek_v41", "qwen3", "qwen3_5",
551-
"minimax_m2", "minimax_m3", "gemma4", "kimi_k2", "kimi_k25"
551+
"minimax_m2", "gemma4", "kimi_k2", "kimi_k25"
552552
])
553553
def test_resolve_prefilled_thinking_requires_opt_in(parser: str) -> None:
554554
"""Parsers that have not opted in must never be resolved from the prompt.
@@ -1505,6 +1505,15 @@ def _m3_parser(kwargs: dict | None):
15051505
({
15061506
"thinking": True
15071507
}, f"{M3_START}a{M3_END}b", "b", "a"),
1508+
# The mode resolved from the rendered prompt wins over `thinking_mode`.
1509+
({
1510+
"thinking_mode": "enabled",
1511+
"enable_thinking": False
1512+
}, f"{M3_START}a{M3_END}b", "b", "a"),
1513+
({
1514+
"thinking_mode": "disabled",
1515+
"enable_thinking": True
1516+
}, f"a{M3_END}b", "b", "a"),
15081517
])
15091518
def test_minimax_m3_reasoning_parser(kwargs: dict | None, text: str,
15101519
content: str,
@@ -1514,6 +1523,34 @@ def test_minimax_m3_reasoning_parser(kwargs: dict | None, text: str,
15141523
assert result.reasoning_content == reasoning_content
15151524

15161525

1526+
# The official template: ``bos_token ~ role``, ``eos_token`` after each
1527+
# message, and ``]~b]ai`` as the generation prompt.
1528+
M3_HEADER = "]~b]ai\n"
1529+
M3_PROMPT = f"]~b]user\nhi[e~[\n{M3_HEADER}"
1530+
1531+
1532+
@pytest.mark.parametrize(
1533+
("prompt", "expected"),
1534+
[
1535+
(f"{M3_PROMPT}{M3_START}", True),
1536+
(f"{M3_PROMPT}{M3_END}", False),
1537+
# Adaptive: nothing after the header, so the output starts outside
1538+
# reasoning even though a prior turn rendered its own tags.
1539+
(M3_PROMPT, False),
1540+
(f"]~b]ai\n{M3_START}a{M3_END}b[e~[\n]~b]user\nmore[e~[\n{M3_HEADER}",
1541+
False),
1542+
# Prefilled text after the opening tag is not adaptive; the tail carries
1543+
# no mode it can read, so `thinking_mode` decides.
1544+
(f"{M3_PROMPT}{M3_START}Let me think.", None),
1545+
# A custom template that ends elsewhere is left unresolved as before.
1546+
("<user>hi</user>\n<assistant>", None),
1547+
])
1548+
def test_minimax_m3_resolves_the_prompt_tail(prompt: str,
1549+
expected: bool | None) -> None:
1550+
assert ReasoningParserFactory.resolve_prefilled_thinking(
1551+
"minimax_m3", prompt) is expected
1552+
1553+
15171554
def _m3_stream(kwargs: dict | None, chunks: list[str]) -> tuple[str, str]:
15181555
reasoning_parser = _m3_parser(kwargs)
15191556
content, reasoning_content = "", ""

0 commit comments

Comments
 (0)