You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
importjson, urllib.requestBASE="http://127.0.0.1:8000"SCHEMA= {"type": "object", "properties": {"note": {"type": "string"}},
"required": ["note"], "additionalProperties": False}
defcall(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") orm.get("reasoning_content")))
call('Reply with JSON. Set "note" to exactly: the parser is fine') # controlcall('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)
ifnotend:
returnreasoning, content_beforeorNone# <-- 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:
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:
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.
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.
Your current environment
vLLM 0.24.0 (cu129), MiniMax-M3 (NVFP4), TP4, served with
--reasoning-parser minimax_m3.MiniMaxM3ReasoningParser.extract_reasoningis byte-identical onmain(checked 2026-07-14), so this is not version-specific.🐛 Describe the bug
When
--reasoning-parser minimax_m3is 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 ascontent, everything after asreasoning.finish_reasonis"stop", so nothing signals a problem. The caller just receives truncated, unparseable JSON. Concatenatingcontent + reasoningreassembles 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
Actual:
Expected:
Root cause
MiniMaxM3ReasoningParser.extract_reasoningpartitions the raw decoded text with no notion of whether the marker is structural or literal:There is no check for
response_format/structured_outputsanywhere 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: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_reasoningalready receivesrequest, so the guard is local:The
startswithclause 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
<mm:think>into content on latest vLLM #46042 are the inverse problem (markers leaking intocontentduring streaming,reasoningleft empty). This one is non-streaming and the opposite direction —contentis swallowed intoreasoning.extract_reasoning_streamingnever receivesrequest, so this guard can't be applied there as-is.BaseThinkingReasoningParsersubclasses — the baseextract_reasoningpartitions raw text the same way, so any model whose marker can appear in content is exposed.