Skip to content

[Bugfix] MiniMax M3 reasoning parser: treat non-leading <mm:think> as content#48690

Open
harjothkhara wants to merge 1 commit into
vllm-project:mainfrom
harjothkhara:fix/minimax-m3-literal-mm-think-marker
Open

[Bugfix] MiniMax M3 reasoning parser: treat non-leading <mm:think> as content#48690
harjothkhara wants to merge 1 commit into
vllm-project:mainfrom
harjothkhara:fix/minimax-m3-literal-mm-think-marker

Conversation

@harjothkhara

@harjothkhara harjothkhara commented Jul 15, 2026

Copy link
Copy Markdown

Purpose

Fixes #48663.

MiniMaxM3ReasoningParser recognized the <mm:think> start marker anywhere in the model output. MiniMax M3 delimits reasoning positionally — <mm:think>reasoning</mm:think>content — so a <mm:think> the model emits inside content is literal text, not a block opener. Treating it as an opener splits one valid response across both fields:

  • Everything before the marker → content
  • Everything after → reasoning
  • finish_reason is "stop", so nothing signals a problem

This reliably bites structured-output workloads whose JSON quotes the marker text (logs, transcripts, docs, issue text). In the reporter's case, 2 of 546 documents failed — exactly the two that contained <mm:think>. Concatenating content + reasoning reassembles the original, well-formed JSON: the model complied with the grammar; only the parser was wrong.

Fix

Recognize the start marker only where it delimits a boundary: a leading start marker (or the prefilled template in enabled mode) opens reasoning; a start marker anywhere else is literal content. This mirrors the parser's existing end-marker handling — a non-leading </mm:think> already stays content (test_nonstreaming_non_leading_end_tag_is_content).

Method audit — the fix is scoped to the methods that parse generated output, where a leading start marker (or the prefilled template) opens reasoning:

Method Path (generated output) Before → after on {"note": "the <mm:think> marker leaks"}
extract_reasoning non-streaming response split → intact content
_visible_segments streaming response split → intact content
extract_content_ids abstract_parser.py:853 → tool parser [] → full token IDs
count_reasoning_tokens responses/serving.py:895usage.reasoning_tokens 150

A shared _starts_with_token_sequence / _find_token_sequence helper pair keeps the token-ID checks consistent.

is_reasoning_end is deliberately left unchanged (rightmost-marker semantics). Unlike the methods above, it is called on the full prompt/history to initialize reasoning state (chat_completion/serving.py:353, v1/structured_output/__init__.py:365, abstract_parser.py:815). A prompt never starts with <mm:think>, so applying the leading-only rule there would invert the result — reporting a closed disabled-mode prompt as still-reasoning and an open enabled-mode prompt as ended, misinitializing structured-output grammar. A regression test (test_is_reasoning_end_prompt_state_preserved) locks in that contract. This also keeps the PR clear of #48550's is_reasoning_end_from_prompt() prompt-state work.

Not a duplicate

Known assumption / open question (leading whitespace)

The leading-marker checks are strict: model_output.startswith("<mm:think>"), no lstrip(). This assumes a genuine reasoning block begins with <mm:think> as the first generated token in adaptive/disabled mode (enabled mode is unaffected — the marker is prefilled, so the parser never looks for a leading marker there). If M3's chat template or tokenizer can ever emit a leading space/newline before a real <mm:think>, these checks would treat the whole response as content — the mirror image of this bug.

I could not verify M3's exact template behavior without the model (NVFP4/TP4, no local GPU), and preferred a strict check over hand-rolling whitespace tolerance into the streaming state machine on an unverified assumption. If a maintainer/model author knows M3 can prepend whitespace before the marker, it's a trivial lstrip()-tolerant follow-up — flagging it explicitly rather than guessing.

Test Plan

Added regression tests to tests/reasoning/test_minimax_m3_reasoning_parser.py covering the text and token-ID paths; each fails on unfixed main and passes with the fix (red/green verified by reverting the source):

  • test_nonstreaming_non_leading_start_tag_is_content — mirror of the existing end-marker test.
  • test_nonstreaming_literal_start_marker_in_json_content — the reporter's exact JSON payload.
  • test_nonstreaming_enabled_mode_literal_start_marker_in_reasoningenabled mode, literal marker inside prefilled reasoning.
  • test_streaming_non_leading_start_tag_stays_content — streaming, marker split across deltas.
  • test_token_id_helpers_literal_start_marker_is_content / ..._split_tokensextract_content_ids / count_reasoning_tokens, atomic and text-tokenized markers.
  • test_is_reasoning_end_prompt_state_preserved — locks the prompt-state contract for is_reasoning_end (closed prompt → ended; enabled prompt with a fresh open marker → still open).
.venv/bin/python -m pytest tests/reasoning/test_minimax_m3_reasoning_parser.py -v
pre-commit run --files vllm/reasoning/minimax_m3_reasoning_parser.py \
  tests/reasoning/test_minimax_m3_reasoning_parser.py

CPU-only, no GPU required.

Test Result

tests/reasoning/test_minimax_m3_reasoning_parser.py ............. 29 passed
  • Red/green: the 6 literal-marker tests fail on unfixed main, pass with the fix. test_is_reasoning_end_prompt_state_preserved passes on main (its behavior is unchanged) and fails against a leading-only variant of is_reasoning_end — a guard proving the prompt contract was preserved.
  • Full tests/reasoning/: 456 passed (the test_cohere_command_reasoning_parser collection error is a pre-existing missing optional dependency, cohere_melody, unrelated to this change).
  • Lint: ruff check, ruff format, mypy (3.10), SPDX header, and the other pre-commit hooks all pass.

Model eval: not applicable — parser-only fix on the reasoning/content-extraction paths; the split is exercised directly by the added unit tests, and generated model output is unchanged.

Real behavior proof

Behavior addressed: With --reasoning-parser minimax_m3, a <mm:think> string the model emits inside its answer (e.g. structured output whose JSON quotes the marker) was treated as a reasoning-block opener — splitting the response across content/reasoning, dropping content token IDs fed to the tool parser, and over-counting usage.reasoning_tokens. After the fix the marker is literal content on all paths.

Real environment tested: vLLM built from this branch on a local checkout; the parser is resolved through the production ReasoningParserManager.get_reasoning_parser("minimax_m3") dispatch — the exact lookup the OpenAI serving layer performs for --reasoning-parser minimax_m3 — and driven through the real non-streaming (extract_reasoning), streaming (extract_reasoning_streaming), and token-ID (extract_content_ids, count_reasoning_tokens) entry points. Input is the reporter's exact payload {"note": "the <mm:think> marker leaks"}. (MiniMax-M3 is NVFP4/TP4 and cannot run on the test machine, so the model weights themselves are not exercised — see below.)

Exact steps or command run after this patch:

python proof_minimax_m3.py     # response paths, AFTER: this branch
python proof_tokenids.py       # token-ID paths, AFTER: this branch
git show origin/main:vllm/reasoning/minimax_m3_reasoning_parser.py \
  > vllm/reasoning/minimax_m3_reasoning_parser.py   # revert to main
python proof_minimax_m3.py     # BEFORE
python proof_tokenids.py       # BEFORE
git restore vllm/reasoning/minimax_m3_reasoning_parser.py

Evidence after fix:

# response paths
[non-streaming] content: '{"note": "the <mm:think> marker leaks"}'   reasoning: None
[streaming]     content: '{"note": "the <mm:think> marker leaks"}'   reasoning: None
# token-ID paths
extract_content_ids    : '{"note": "the <mm:think> marker leaks"}'  (full payload)
count_reasoning_tokens : 0  (no reasoning)
RESULT: PASS

Same harness on unpatched main:

[non-streaming] content: '{"note": "the '   reasoning: ' marker leaks"}'
[streaming]     content: '{"note": "the '   reasoning: ' marker leaks"}'
extract_content_ids    : ''   (*** TRUNCATED ***)
count_reasoning_tokens : 15   (*** WRONG ***)
RESULT: FAIL

Observed result after fix: On all four paths the full valid JSON is preserved as content, no reasoning is emitted, content token IDs are intact, and reasoning_tokens is 0. On unpatched main the identical input splits the document, drops content token IDs to [], and reports 15 phantom reasoning tokens. Full unit suite: 28 passed; the 6 new tests fail on main, pass here.

What was not tested: End-to-end serving against real MiniMax-M3 weights (NVFP4, TP4) — no GPU available. The reasoning/content split is entirely in the CPU-side parser, driven here through its production dispatch with the reporter's exact payload; the untested surface is only the model generation upstream of the parser, which this change does not touch.

AI-assisted (Claude); every line reviewed and tests run locally before submission.

@mergify mergify Bot added tool-calling bug Something isn't working labels Jul 15, 2026
@harjothkhara harjothkhara force-pushed the fix/minimax-m3-literal-mm-think-marker branch from 678dd07 to 29ad0ca Compare July 15, 2026 05:45
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

… content

MiniMax M3 reasoning is delimited as <mm:think>reasoning</mm:think>content.
The parser recognized the start marker anywhere in the generated output, so a
literal <mm:think> the model emitted inside content (e.g. structured output
whose JSON quotes the marker) opened a spurious reasoning block, splitting one
valid document across content/reasoning with finish_reason "stop".

In the generated-output paths, recognize the start marker only where it opens a
block: a leading start marker (or the prefilled template in enabled mode) opens
reasoning; a start marker anywhere else is literal content. This mirrors the
existing non-leading end-marker handling.

- extract_reasoning / _visible_segments: fix the response content/reasoning
  split (non-streaming and streaming).
- extract_content_ids: no longer drops content token IDs to [] on a literal
  marker (fed to the tool parser at abstract_parser.py:853).
- count_reasoning_tokens: no longer counts literal-marker content as reasoning
  (surfaced via usage.reasoning_tokens at responses/serving.py:895).

is_reasoning_end is intentionally left on rightmost-marker semantics: it runs on
the full prompt/history to initialize reasoning state (serving.py,
structured_output/__init__.py, abstract_parser.py), where the leading-only rule
would invert the result. A regression test locks in that prompt contract.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: harjoth <harjoth.khara@gmail.com>
@harjothkhara harjothkhara force-pushed the fix/minimax-m3-literal-mm-think-marker branch from 29ad0ca to 6145e4a Compare July 15, 2026 06:18
@harjothkhara harjothkhara marked this pull request as ready for review July 15, 2026 06:27

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working tool-calling

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Bug]: minimax_m3 reasoning parser splits structured-output JSON at a <mm:think> the model wrote as content

1 participant