fix(models): preserve MiniMax reasoning_content responses (#6071)

* fix(models): preserve MiniMax reasoning_content responses

* fix(models): preserve whitespace MiniMax reasoning deltas

* test(models): pin MiniMax reasoning precedence

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
kuku 2026-10-09 16:00:32 +08:00 committed by GitHub
parent 157630285b
commit 845af59301
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 229 additions and 16 deletions

View File

@ -1,9 +1,11 @@
"""Patched ChatOpenAI adapter for MiniMax reasoning output.
MiniMax's OpenAI-compatible chat completions API can return structured
``reasoning_details`` when ``extra_body.reasoning_split=true`` is enabled.
``langchain_openai.ChatOpenAI`` currently ignores that field, so DeerFlow's
frontend never receives reasoning content in the shape it expects.
``reasoning_details`` when ``extra_body.reasoning_split=true`` is enabled, or
the official ``reasoning_content`` string in its response and stream delta.
``langchain_openai.ChatOpenAI`` currently ignores those provider-specific
fields, so DeerFlow's frontend never receives reasoning content in the shape
it expects.
This adapter preserves ``reasoning_split`` in the request payload and maps the
provider-specific reasoning field into ``additional_kwargs.reasoning_content``,
@ -29,22 +31,31 @@ _THINK_TAG_RE = re.compile(r"<think>\s*(.*?)\s*</think>", re.DOTALL)
def _extract_reasoning_text(
reasoning_details: Any,
reasoning: Any,
*,
strip_parts: bool = True,
) -> str | None:
if not isinstance(reasoning_details, list):
if isinstance(reasoning, str):
if strip_parts:
value = reasoning.strip()
return value if value else None
return reasoning if reasoning else None
if not isinstance(reasoning, list):
return None
parts: list[str] = []
for item in reasoning_details:
for item in reasoning:
if not isinstance(item, Mapping):
continue
text = item.get("text")
if isinstance(text, str):
normalized = text.strip() if strip_parts else text
if normalized.strip():
parts.append(normalized)
if strip_parts:
normalized = text.strip()
if normalized:
parts.append(normalized)
elif text != "":
parts.append(text)
return "\n\n".join(parts) if parts else None
@ -179,14 +190,21 @@ class PatchedChatMiniMax(ChatOpenAI):
if logprobs:
generation_info["logprobs"] = logprobs
reasoning = _extract_reasoning_text(
reasoning_details = _extract_reasoning_text(
delta.get("reasoning_details"),
strip_parts=False,
)
reasoning_content = _extract_reasoning_text(
delta.get("reasoning_content"),
strip_parts=False,
)
reasoning = reasoning_details
if reasoning is None or (isinstance(delta.get("reasoning_details"), list) and not reasoning.strip() and reasoning_content is not None):
reasoning = reasoning_content
if isinstance(message_chunk, AIMessageChunk):
if usage_metadata:
message_chunk.usage_metadata = usage_metadata
if reasoning:
if reasoning is not None:
message_chunk = _with_reasoning_content(
message_chunk,
reasoning,
@ -221,7 +239,12 @@ class PatchedChatMiniMax(ChatOpenAI):
choice_message = choice.get("message", {}) if isinstance(choice, Mapping) else {}
split_reasoning = _extract_reasoning_text(choice_message.get("reasoning_details"))
merged_reasoning = _merge_reasoning(split_reasoning, inline_reasoning)
direct_reasoning = _extract_reasoning_text(choice_message.get("reasoning_content"))
merged_reasoning = _merge_reasoning(
split_reasoning,
direct_reasoning,
inline_reasoning,
)
updated_message = message
if cleaned_content is not None and cleaned_content != message.content:

View File

@ -77,6 +77,74 @@ def test_create_chat_result_maps_reasoning_details_to_reasoning_content():
assert result.generations[0].text == "最终答案"
def test_create_chat_result_maps_reasoning_content_to_reasoning_content():
model = _make_model()
response = {
"choices": [
{
"message": {
"role": "assistant",
"content": "最终答案",
"reasoning_content": "先分析问题,再给出答案。",
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M3",
}
result = model._create_chat_result(response)
message = result.generations[0].message
assert message.content == "最终答案"
assert message.additional_kwargs["reasoning_content"] == "先分析问题,再给出答案。"
assert result.generations[0].text == "最终答案"
def test_create_chat_result_prefers_reasoning_details_over_reasoning_content():
model = _make_model()
response = {
"choices": [
{
"message": {
"role": "assistant",
"content": "最终答案",
"reasoning_details": [{"type": "reasoning.text", "text": "structured"}],
"reasoning_content": "native",
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M3",
}
result = model._create_chat_result(response)
assert result.generations[0].message.additional_kwargs["reasoning_content"] == "structured\n\nnative"
def test_create_chat_result_falls_back_when_reasoning_details_are_blank():
model = _make_model()
response = {
"choices": [
{
"message": {
"role": "assistant",
"content": "最终答案",
"reasoning_details": [{"type": "reasoning.text", "text": " "}],
"reasoning_content": "native",
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M3",
}
result = model._create_chat_result(response)
assert result.generations[0].message.additional_kwargs["reasoning_content"] == "native"
def test_create_chat_result_strips_inline_think_tags():
model = _make_model()
response = {
@ -171,3 +239,123 @@ def test_convert_chunk_to_generation_chunk_preserves_reasoning_deltas():
assert combined.additional_kwargs["reasoning_content"] == "The user asks."
assert combined.content == "最终答案"
def test_convert_chunk_to_generation_chunk_preserves_reasoning_content_deltas():
model = _make_model()
first = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"role": "assistant",
"content": "",
"reasoning_content": "The user",
}
}
]
},
AIMessageChunk,
{},
)
second = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"reasoning_content": " asks.",
}
}
]
},
AIMessageChunk,
{},
)
answer = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"content": "最终答案",
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M3",
},
AIMessageChunk,
{},
)
assert first is not None
assert second is not None
assert answer is not None
combined = first.message + second.message + answer.message
assert combined.additional_kwargs["reasoning_content"] == "The user asks."
assert combined.content == "最终答案"
def test_convert_chunk_prefers_reasoning_details_over_reasoning_content():
model = _make_model()
chunk = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"reasoning_details": [{"type": "reasoning.text", "text": "structured"}],
"reasoning_content": "native",
}
}
]
},
AIMessageChunk,
{},
)
assert chunk is not None
assert chunk.message.additional_kwargs["reasoning_content"] == "structured"
def test_convert_chunk_falls_back_when_reasoning_details_are_blank():
model = _make_model()
chunk = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"reasoning_details": [{"type": "reasoning.text", "text": " "}],
"reasoning_content": "native",
}
}
]
},
AIMessageChunk,
{},
)
assert chunk is not None
assert chunk.message.additional_kwargs["reasoning_content"] == "native"
def test_convert_chunk_to_generation_chunk_preserves_whitespace_reasoning_deltas():
model = _make_model()
chunks = []
for reasoning in ["The", " ", "user", "\n "]:
chunks.append(
model._convert_chunk_to_generation_chunk(
{"choices": [{"delta": {"reasoning_content": reasoning}}]},
AIMessageChunk,
{},
)
)
assert all(chunk is not None for chunk in chunks)
combined = chunks[0].message
for chunk in chunks[1:]:
combined = combined + chunk.message
assert combined.additional_kwargs["reasoning_content"] == "The user\n "

View File

@ -594,8 +594,9 @@ models:
# supports_vision: true
# supports_thinking: true
# # PatchedChatMiniMax is the MiniMax adapter: it enables reasoning_split and
# # maps MiniMax's structured reasoning into reasoning_content (the field
# # DeerFlow understands), and it strips the per-message `name` field that
# # maps MiniMax's structured reasoning_details and native reasoning_content
# # into the reasoning_content field DeerFlow understands, and it strips the
# # per-message `name` field that
# # DeerFlow middlewares attach — MiniMax rejects requests whose user-message
# # names differ with "user name must be consistent (2013)". Declare the
# # thinking toggle so non-thinking paths (flash mode, follow-up suggestions,
@ -658,8 +659,9 @@ models:
# supports_vision: true
# supports_thinking: true
# # PatchedChatMiniMax is the MiniMax adapter: it enables reasoning_split and
# # maps MiniMax's structured reasoning into reasoning_content (the field
# # DeerFlow understands), and it strips the per-message `name` field that
# # maps MiniMax's structured reasoning_details and native reasoning_content
# # into the reasoning_content field DeerFlow understands, and it strips the
# # per-message `name` field that
# # DeerFlow middlewares attach — MiniMax rejects requests whose user-message
# # names differ with "user name must be consistent (2013)". Declare the
# # thinking toggle so non-thinking paths (flash mode, follow-up suggestions,