diff --git a/backend/packages/harness/deerflow/models/patched_minimax.py b/backend/packages/harness/deerflow/models/patched_minimax.py index 7a7297bc3c..2e45d0430e 100644 --- a/backend/packages/harness/deerflow/models/patched_minimax.py +++ b/backend/packages/harness/deerflow/models/patched_minimax.py @@ -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"\s*(.*?)\s*", 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: diff --git a/backend/tests/test_patched_minimax.py b/backend/tests/test_patched_minimax.py index 3b617fdbd6..42bc3abbd2 100644 --- a/backend/tests/test_patched_minimax.py +++ b/backend/tests/test_patched_minimax.py @@ -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 " diff --git a/config.example.yaml b/config.example.yaml index 48ee1ad76e..af72f53f39 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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,