Skip to content

fix(providers): MiniMax-M3 reasoning leaks into delta.content instead of reasoning_content (#13558) - #13799

Merged
diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13558-minimax-m3-reasoning-leak
Sep 16, 2026
Merged

diegosouzapw merged 2 commits into
release/v3.8.51from
fix/13558-minimax-m3-reasoning-leak

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #13558

Root cause

MiniMax-M3's Anthropic-compatible endpoint (minimax/minimax-cn, targetFormat: "claude") puts
its reasoning inline as <think>...</think> inside ordinary text/text_delta content blocks
instead of a structured Anthropic thinking/thinking_delta block. Two problems compounded this:

  1. Neither Claude→OpenAI translator had any <think>-tag awareness for plain text content —
    they only special-cased the structured thinking block type
    (open-sse/translator/response/claude-to-openai.ts for streaming,
    open-sse/handlers/responseTranslator.ts for non-streaming).
  2. isTextualReasoningTagNativeRoute() (open-sse/handlers/responseSanitizer/reasoning.ts)
    explicitly excluded the minimax/minimax-cn provider ids from the textual-reasoning-tag
    gate, on the assumption that speaking Claude's wire format meant reasoning already arrived
    natively as a thinking block — true for other MiniMax models, false for M3.

The issue's own proposed fix (adding minimax-cn/MiniMax-M3 to the route list consulted by
claude-to-openai.ts) would have been a no-op: that list only feeds thinkTagParser's
passthrough-mode gate, and minimax/minimax-cn never run in passthrough mode — they're on the
Claude-wire-format translate path.

Fix

  • open-sse/handlers/responseSanitizer/reasoning.ts: isTextualReasoningTagNativeRoute() no
    longer excludes minimax/minimax-cn from the M3 pattern — only non-M3 minimax models stay
    unaffected now.
  • open-sse/translator/response/claude-to-openai.ts (streaming): reuses the existing
    initThinkState/applyThinkTag/flushThinkBuffer textual think-tag parser (already used by the
    passthrough path) on text_delta chunks, gated by shouldParseTextualReasoningTags(provider, model). Buffers correctly across a <think>/</think> tag split over multiple deltas, and
    flushes any tail left in the buffer at content_block_stop.
  • open-sse/handlers/responseTranslator.ts (non-streaming, targetFormat === FORMATS.CLAUDE
    block): runs the accumulated text content through extractThinkingFromContent(), gated the same
    way, merging any extracted reasoning into thinkingContent alongside the existing
    structured-thinking-block accumulation.

Scope note vs. the plan-file: the plan suggested threading provider as a new parameter through
translateNonStreamingResponse() (touching 5 call sites) so the non-streaming gate could see the
real provider id. That thread isn't needed for correctness here — isTextualReasoningTagNativeRoute's
non-exclusion clause already matches on the model-scoped minimax-m3 pattern regardless of
provider identity (verified: with provider unset it behaves identically to a real
minimax/minimax-cn id for this model pattern), so the fix stays self-contained in the two
translators without expanding the call-site surface.

Regression test

tests/unit/issue-13558-minimax-m3-think-leak.test.ts — 3 cases: non-streaming translation,
streaming translation (chunk-by-chunk), and a streaming case where the <think>/</think> tags
are split across two deltas (buffering boundary).

RED (on unfixed code, all 3 fail):

✖ non-streaming: Claude text block with inline <think> leaks into OpenAI message.content (reasoning not separated)
  AssertionError: expected <think> markup to be stripped from message.content,
  got: "<think>The user said hi, I should respond in a friendly way.</think>你好呀!"
✖ streaming: Claude text_delta with inline <think> leaks into OpenAI delta.content chunk-by-chunk
  AssertionError: expected no literal <think> tag in delta.content — actual: true, expected: false
✖ streaming: <think> open tag split across two deltas is buffered, not leaked
  AssertionError: expected no <think>/</think> markup fragments in delta.content,
  got: "<think>reasoning here</think>final answer"
ℹ tests 3 | pass 0 | fail 3

GREEN (with the fix):

✔ non-streaming: Claude text block with inline <think> leaks into OpenAI message.content (reasoning not separated)
✔ streaming: Claude text_delta with inline <think> leaks into OpenAI delta.content chunk-by-chunk
✔ streaming: <think> open tag split across two deltas is buffered, not leaked
ℹ tests 3 | pass 3 | fail 0

Gates run

  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> → exit 0, no new warnings.
  • npm run check:open-sse-typecheck → clean (all touched files are under open-sse/).
  • node scripts/check/check-file-size.mjs → no violations on touched files.
  • node scripts/check/check-complexity.mjs / node scripts/check/check-cognitive-complexity.mjs → clean on touched files.
  • node scripts/check/check-test-discovery.mjs → new test file discovered ([test-discovery] OK).
  • Existing tests touching claude-to-openai.ts / responseTranslator.ts / thinkTagParser.ts /
    reasoning.ts (20 files, 272 cases) — all green after one alignment (below).

Existing tests aligned

tests/unit/responsesanitizer-reasoning-split.test.ts had a describe block titled "MiniMax M3 fix
regression guards" that asserted isTextualReasoningTagNativeRoute("minimax"/"minimax-cn", "minimax-m3") was false — i.e. it encoded the exact old/buggy exclusion this PR fixes. Flipped
both assertions to true (renamed the it() titles to match), updated the surrounding comment,
and added one new case confirming non-M3 minimax models on those same tiers stay unaffected. No
assertion was weakened or removed — the two flipped assertions now match the corrected contract,
and the file's other MiniMax-M3-on-OpenAI-format-tier cases are untouched.

Sibling issues checked

#9155 / #12132 (referenced in the original issue) are about the request side
(thinking.type acceptance) and are untouched by this response-side-only fix.

diegosouzapw and others added 2 commits September 15, 2026 18:09
… of reasoning_content (#13558)

MiniMax-M3's Anthropic-compatible endpoint (minimax/minimax-cn, targetFormat:
"claude") puts its reasoning inline as <think>...</think> inside ordinary
text/text_delta blocks instead of a structured thinking block. Neither
Claude->OpenAI translator (claude-to-openai.ts streaming,
responseTranslator.ts non-streaming) had any <think>-tag awareness for plain
text content, and isTextualReasoningTagNativeRoute() explicitly excluded the
minimax/minimax-cn provider ids from the textual-reasoning gate on the false
assumption that their wire format meant reasoning already arrived natively.

Wires the existing think-tag parser (thinkTagParser.ts) into both
translators, gated by shouldParseTextualReasoningTags(), and stops excluding
minimax/minimax-cn from the M3 route match.

Regression test: tests/unit/issue-13558-minimax-m3-think-leak.test.ts
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@diegosouzapw
diegosouzapw merged commit 68927fc into release/v3.8.51 Sep 16, 2026
19 of 21 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
… of reasoning_content (diegosouzapw#13558) (diegosouzapw#13799)

Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(providers): MiniMax-M3 reasoning leaks into delta.content instead of delta.reasoning_content

1 participant