mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
157630285b
commit
845af59301
@ -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:
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user