Skip to content

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

Description

@onesource2026

Your current environment

vLLM 0.24.0 (cu129), MiniMax-M3 (NVFP4), TP4, served with --reasoning-parser minimax_m3.

MiniMaxM3ReasoningParser.extract_reasoning is byte-identical on main (checked 2026-07-14), so this is not version-specific.

🐛 Describe the bug

When --reasoning-parser minimax_m3 is combined with structured output (response_format), any response whose JSON contains the literal text <mm:think> is silently split in half: everything before the marker is returned as content, everything after as reasoning.

finish_reason is "stop", so nothing signals a problem. The caller just receives truncated, unparseable JSON. Concatenating content + reasoning reassembles the original, valid JSON — the model complied with the grammar perfectly and emitted one well-formed document. Only the parser is wrong.

This is easy to dismiss as a rare edge case, but it reliably bites any workload that processes text about reasoning parsers — logs, transcripts, documentation, issue text. In our case 2 of 546 documents failed, and they were exactly the two that quoted the marker.

Reproduce

import json, urllib.request

BASE   = "http://127.0.0.1:8000"
SCHEMA = {"type": "object", "properties": {"note": {"type": "string"}},
          "required": ["note"], "additionalProperties": False}

def call(text):
    body = {
        "model": "m3",
        "messages": [{"role": "user", "content": text}],
        "max_tokens": 300,
        "temperature": 0,
        "chat_template_kwargs": {"thinking_mode": "disabled"},
        "response_format": {"type": "json_schema",
                            "json_schema": {"name": "n", "schema": SCHEMA, "strict": True}},
    }
    req = urllib.request.Request(f"{BASE}/v1/chat/completions",
                                 json.dumps(body).encode(),
                                 {"Content-Type": "application/json"})
    m = json.loads(urllib.request.urlopen(req).read())["choices"][0]["message"]
    print("content  :", repr(m.get("content")))
    print("reasoning:", repr(m.get("reasoning") or m.get("reasoning_content")))

call('Reply with JSON. Set "note" to exactly: the parser is fine')          # control
call('Reply with JSON. Set "note" to exactly: the <mm:think> marker leaks') # bug

Actual:

content  : '{"note": "the parser is fine"}'      # control — fine
reasoning: None

content  : '{"note": "the '                      # <-- sliced mid-string
reasoning: ' marker leaks"}'

Expected:

content  : '{"note": "the <mm:think> marker leaks"}'
reasoning: None

Root cause

MiniMaxM3ReasoningParser.extract_reasoning partitions the raw decoded text with no notion of whether the marker is structural or literal:

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    # <-- the JSON is cut here

There is no check for response_format / structured_outputs anywhere before the partition.

Why this can't be fixed by inspecting the output

The obvious fix — match the special token ID rather than the text — does not work. Checked with logprobs:

TOKENS: ['{"', 'note', '":', ' "', 'the', ' ', '<mm:think>', ' marker', ' leaks', '"}']
                                              ^^^^^^^^^^^^ single vocab token

The model emits the single <mm:think> vocab token for content, identical to what it would emit to open a real reasoning block. xgrammar permits it inside a JSON string because its decoded text is legal string content. So text matching and token-ID matching are both blind here — marker and content are indistinguishable in the output.

The disambiguator isn't in the output; it's in the request. If a grammar is active and thinking is disabled, the grammar pins the first token, so a reasoning block is structurally impossible — any marker found is necessarily content. The parser doesn't need to be smarter; it needs to know it shouldn't be running.

Suggested fix

extract_reasoning already receives request, so the guard is local:

@staticmethod
def _grammar_constrained(request) -> bool:
    if getattr(request, "structured_outputs", None) is not None:
        return True
    response_format = getattr(request, "response_format", None)
    if response_format is None:
        text_cfg = getattr(request, "text", None)  # ResponsesRequest
        response_format = getattr(text_cfg, "format", None) if text_cfg else None
    if response_format is None:
        return False
    rf_type = (response_format.get("type") if isinstance(response_format, dict)
               else getattr(response_format, "type", None))
    return rf_type in ("json_schema", "json_object", "structural_tag")

def extract_reasoning(self, model_output, request):
    # Grammar-constrained + thinking off => a reasoning block cannot open.
    if (not self._initial_in_reasoning
            and self._grammar_constrained(request)
            and not model_output.lstrip().startswith(self.start_token)):
        return None, model_output
    ...

The startswith clause makes it strictly conservative: if a marker does open the output (thinking leaked on), it falls through to the existing behavior.

We've been running this locally; it fixes the repro and leaves every non-grammar path byte-identical.

Notes

  • Related but distinct: [Bug]: Minimax m3 reasoning parser sending <mm:think> in content field in streaming #45687 and [Bug]: MiniMax-M3 streaming reasoning parser still leaks <mm:think> into content on latest vLLM #46042 are the inverse problem (markers leaking into content during streaming, reasoning left empty). This one is non-streaming and the opposite direction — content is swallowed into reasoning.
  • The streaming path has the same blind spot, but extract_reasoning_streaming never receives request, so this guard can't be applied there as-is.
  • This likely generalizes to other BaseThinkingReasoningParser subclasses — the base extract_reasoning partitions raw text the same way, so any model whose marker can appear in content is exposed.
  • Possibly worth noting: the existing streaming tests use a mock tokenizer where the tags are atomic special tokens. A test that asserts a grammar-constrained response containing the literal marker survives intact would cover this class of bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions