fix: improve MiniMax code plan integration (#1169)

This PR improves MiniMax Code Plan integration in DeerFlow by fixing three issues in the current flow: stream errors were not clearly surfaced in the UI, the frontend could not display the actual provider model ID, and MiniMax reasoning output could leak into final assistant content as inline <think>...</think>. The change adds a MiniMax-specific adapter, exposes real model IDs end-to-end, and adds a frontend fallback for historical messages.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Simon Su
2026-03-20 17:18:59 +08:00
committed by GitHub
parent 3b235fd182
commit ceab7fac14
14 changed files with 491 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ class ModelResponse(BaseModel):
"""Response model for model information."""
name: str = Field(..., description="Unique identifier for the model")
model: str = Field(..., description="Actual provider model identifier")
display_name: str | None = Field(None, description="Human-readable name")
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
@@ -61,6 +62,7 @@ async def list_models() -> ModelsListResponse:
models = [
ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
@@ -106,6 +108,7 @@ async def get_model(model_name: str) -> ModelResponse:
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,

View File

@@ -403,6 +403,7 @@ class DeerFlowClient:
"models": [
{
"name": model.name,
"model": getattr(model, "model", None),
"display_name": getattr(model, "display_name", None),
"description": getattr(model, "description", None),
"supports_thinking": getattr(model, "supports_thinking", False),
@@ -462,6 +463,7 @@ class DeerFlowClient:
return None
return {
"name": model.name,
"model": getattr(model, "model", None),
"display_name": getattr(model, "display_name", None),
"description": getattr(model, "description", None),
"supports_thinking": getattr(model, "supports_thinking", False),

View File

@@ -0,0 +1,226 @@
"""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.
This adapter preserves ``reasoning_split`` in the request payload and maps the
provider-specific reasoning field into ``additional_kwargs.reasoning_content``,
which DeerFlow already understands.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import (
_convert_delta_to_message_chunk,
_create_usage_metadata,
)
_THINK_TAG_RE = re.compile(r"<think>\s*(.*?)\s*</think>", re.DOTALL)
def _extract_reasoning_text(
reasoning_details: Any,
*,
strip_parts: bool = True,
) -> str | None:
if not isinstance(reasoning_details, list):
return None
parts: list[str] = []
for item in reasoning_details:
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)
return "\n\n".join(parts) if parts else None
def _strip_inline_think_tags(content: str) -> tuple[str, str | None]:
reasoning_parts: list[str] = []
def _replace(match: re.Match[str]) -> str:
reasoning = match.group(1).strip()
if reasoning:
reasoning_parts.append(reasoning)
return ""
cleaned = _THINK_TAG_RE.sub(_replace, content).strip()
reasoning = "\n\n".join(reasoning_parts) if reasoning_parts else None
return cleaned, reasoning
def _merge_reasoning(*values: str | None) -> str | None:
merged: list[str] = []
for value in values:
if not value:
continue
normalized = value.strip()
if normalized and normalized not in merged:
merged.append(normalized)
return "\n\n".join(merged) if merged else None
def _with_reasoning_content(
message: AIMessage | AIMessageChunk,
reasoning: str | None,
*,
preserve_whitespace: bool = False,
):
if not reasoning:
return message
additional_kwargs = dict(message.additional_kwargs)
if preserve_whitespace:
existing = additional_kwargs.get("reasoning_content")
additional_kwargs["reasoning_content"] = (
f"{existing}{reasoning}" if isinstance(existing, str) else reasoning
)
else:
additional_kwargs["reasoning_content"] = _merge_reasoning(
additional_kwargs.get("reasoning_content"),
reasoning,
)
return message.model_copy(update={"additional_kwargs": additional_kwargs})
class PatchedChatMiniMax(ChatOpenAI):
"""ChatOpenAI adapter that preserves MiniMax reasoning output."""
def _get_request_payload(
self,
input_: LanguageModelInput,
*,
stop: list[str] | None = None,
**kwargs: Any,
) -> dict:
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
extra_body = payload.get("extra_body")
if isinstance(extra_body, dict):
payload["extra_body"] = {
**extra_body,
"reasoning_split": True,
}
else:
payload["extra_body"] = {"reasoning_split": True}
return payload
def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
if chunk.get("type") == "content.delta":
return None
token_usage = chunk.get("usage")
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
usage_metadata = (
_create_usage_metadata(token_usage, chunk.get("service_tier"))
if token_usage
else None
)
if len(choices) == 0:
generation_chunk = ChatGenerationChunk(
message=default_chunk_class(content="", usage_metadata=usage_metadata),
generation_info=base_generation_info,
)
if self.output_version == "v1":
generation_chunk.message.content = []
generation_chunk.message.response_metadata["output_version"] = "v1"
return generation_chunk
choice = choices[0]
delta = choice.get("delta")
if delta is None:
return None
message_chunk = _convert_delta_to_message_chunk(delta, default_chunk_class)
generation_info = {**base_generation_info} if base_generation_info else {}
if finish_reason := choice.get("finish_reason"):
generation_info["finish_reason"] = finish_reason
if model_name := chunk.get("model"):
generation_info["model_name"] = model_name
if system_fingerprint := chunk.get("system_fingerprint"):
generation_info["system_fingerprint"] = system_fingerprint
if service_tier := chunk.get("service_tier"):
generation_info["service_tier"] = service_tier
logprobs = choice.get("logprobs")
if logprobs:
generation_info["logprobs"] = logprobs
reasoning = _extract_reasoning_text(
delta.get("reasoning_details"),
strip_parts=False,
)
if isinstance(message_chunk, AIMessageChunk):
if usage_metadata:
message_chunk.usage_metadata = usage_metadata
if reasoning:
message_chunk = _with_reasoning_content(
message_chunk,
reasoning,
preserve_whitespace=True,
)
message_chunk.response_metadata["model_provider"] = "openai"
return ChatGenerationChunk(
message=message_chunk,
generation_info=generation_info or None,
)
def _create_chat_result(
self,
response: dict | Any,
generation_info: dict | None = None,
) -> ChatResult:
result = super()._create_chat_result(response, generation_info)
response_dict = response if isinstance(response, dict) else response.model_dump()
choices = response_dict.get("choices", [])
generations: list[ChatGeneration] = []
for index, generation in enumerate(result.generations):
choice = choices[index] if index < len(choices) else {}
message = generation.message
if isinstance(message, AIMessage):
content = message.content if isinstance(message.content, str) else None
cleaned_content = content
inline_reasoning = None
if isinstance(content, str):
cleaned_content, inline_reasoning = _strip_inline_think_tags(content)
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)
updated_message = message
if cleaned_content is not None and cleaned_content != message.content:
updated_message = updated_message.model_copy(update={"content": cleaned_content})
if merged_reasoning:
updated_message = _with_reasoning_content(updated_message, merged_reasoning)
generation = ChatGeneration(
message=updated_message,
generation_info=generation.generation_info,
)
generations.append(generation)
return ChatResult(generations=generations, llm_output=result.llm_output)

View File

@@ -28,6 +28,7 @@ def mock_app_config():
"""Provide a minimal AppConfig mock."""
model = MagicMock()
model.name = "test-model"
model.model = "test-model"
model.supports_thinking = False
model.supports_reasoning_effort = False
model.model_dump.return_value = {"name": "test-model", "use": "langchain_openai:ChatOpenAI"}
@@ -98,6 +99,7 @@ class TestConfigQueries:
assert len(result["models"]) == 1
assert result["models"][0]["name"] == "test-model"
# Verify Gateway-aligned fields are present
assert "model" in result["models"][0]
assert "display_name" in result["models"][0]
assert "supports_thinking" in result["models"][0]
@@ -420,6 +422,7 @@ class TestGetModel:
def test_found(self, client):
model_cfg = MagicMock()
model_cfg.name = "test-model"
model_cfg.model = "test-model"
model_cfg.display_name = "Test Model"
model_cfg.description = "A test model"
model_cfg.supports_thinking = True
@@ -429,6 +432,7 @@ class TestGetModel:
result = client.get_model("test-model")
assert result == {
"name": "test-model",
"model": "test-model",
"display_name": "Test Model",
"description": "A test model",
"supports_thinking": True,
@@ -1048,6 +1052,7 @@ class TestScenarioConfigManagement:
# Get specific model
model_cfg = MagicMock()
model_cfg.name = model_name
model_cfg.model = model_name
model_cfg.display_name = None
model_cfg.description = None
model_cfg.supports_thinking = False
@@ -1503,6 +1508,7 @@ class TestGatewayConformance:
def test_list_models(self, mock_app_config):
model = MagicMock()
model.name = "test-model"
model.model = "gpt-test"
model.display_name = "Test Model"
model.description = "A test model"
model.supports_thinking = False
@@ -1515,10 +1521,12 @@ class TestGatewayConformance:
parsed = ModelsListResponse(**result)
assert len(parsed.models) == 1
assert parsed.models[0].name == "test-model"
assert parsed.models[0].model == "gpt-test"
def test_get_model(self, mock_app_config):
model = MagicMock()
model.name = "test-model"
model.model = "gpt-test"
model.display_name = "Test Model"
model.description = "A test model"
model.supports_thinking = True
@@ -1532,6 +1540,7 @@ class TestGatewayConformance:
assert result is not None
parsed = ModelResponse(**result)
assert parsed.name == "test-model"
assert parsed.model == "gpt-test"
def test_list_skills(self, client):
skill = MagicMock()

View File

@@ -0,0 +1,149 @@
from langchain_core.messages import AIMessageChunk, HumanMessage
from deerflow.models.patched_minimax import PatchedChatMiniMax
def _make_model(**kwargs) -> PatchedChatMiniMax:
return PatchedChatMiniMax(
model="MiniMax-M2.5",
api_key="test-key",
base_url="https://example.com/v1",
**kwargs,
)
def test_get_request_payload_preserves_thinking_and_forces_reasoning_split():
model = _make_model(extra_body={"thinking": {"type": "disabled"}})
payload = model._get_request_payload([HumanMessage(content="hello")])
assert payload["extra_body"]["thinking"]["type"] == "disabled"
assert payload["extra_body"]["reasoning_split"] is True
def test_create_chat_result_maps_reasoning_details_to_reasoning_content():
model = _make_model()
response = {
"choices": [
{
"message": {
"role": "assistant",
"content": "最终答案",
"reasoning_details": [
{
"type": "reasoning.text",
"id": "reasoning-text-1",
"format": "MiniMax-response-v1",
"index": 0,
"text": "先分析问题,再给出答案。",
}
],
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M2.5",
}
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_strips_inline_think_tags():
model = _make_model()
response = {
"choices": [
{
"message": {
"role": "assistant",
"content": "<think>\n这是思考过程。\n</think>\n\n真正回答。",
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M2.5",
}
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_convert_chunk_to_generation_chunk_preserves_reasoning_deltas():
model = _make_model()
first = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"role": "assistant",
"content": "",
"reasoning_details": [
{
"type": "reasoning.text",
"id": "reasoning-text-1",
"format": "MiniMax-response-v1",
"index": 0,
"text": "The user",
}
],
}
}
]
},
AIMessageChunk,
{},
)
second = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"content": "",
"reasoning_details": [
{
"type": "reasoning.text",
"id": "reasoning-text-1",
"format": "MiniMax-response-v1",
"index": 0,
"text": " asks.",
}
],
}
}
]
},
AIMessageChunk,
{},
)
answer = model._convert_chunk_to_generation_chunk(
{
"choices": [
{
"delta": {
"content": "最终答案",
},
"finish_reason": "stop",
}
],
"model": "MiniMax-M2.5",
},
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 == "最终答案"

View File

@@ -2,7 +2,6 @@ import "@/styles/globals.css";
import "katex/dist/katex.min.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { I18nProvider } from "@/core/i18n/context";
@@ -13,22 +12,12 @@ export const metadata: Metadata = {
description: "A LangChain-based framework for building super agents.",
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await detectLocaleServer();
return (
<html
lang={locale}
className={geist.variable}
suppressContentEditableWarning
suppressHydrationWarning
>
<html lang={locale} suppressContentEditableWarning suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" enableSystem disableTransitionOnChange>
<I18nProvider initialLocale={locale}>{children}</I18nProvider>

View File

@@ -4,24 +4,28 @@ export function GET() {
{
id: "doubao-seed-1.8",
name: "doubao-seed-1.8",
model: "doubao-seed-1-8",
display_name: "Doubao Seed 1.8",
supports_thinking: true,
},
{
id: "deepseek-v3.2",
name: "deepseek-v3.2",
model: "deepseek-chat",
display_name: "DeepSeek v3.2",
supports_thinking: true,
},
{
id: "gpt-5",
name: "gpt-5",
model: "gpt-5",
display_name: "GPT-5",
supports_thinking: true,
},
{
id: "gemini-3-pro",
name: "gemini-3-pro",
model: "gemini-3-pro",
display_name: "Gemini 3 Pro",
supports_thinking: true,
},

View File

@@ -154,7 +154,13 @@ export default function AgentChatPage() {
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isNewThread && (

View File

@@ -122,7 +122,13 @@ export default function ChatPage() {
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isNewThread && <Welcome mode={settings.context.mode} />

View File

@@ -702,9 +702,16 @@ export function InputBox({
>
<ModelSelectorTrigger asChild>
<PromptInputButton>
<ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name}
</ModelSelectorName>
<div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name}
</ModelSelectorName>
{selectedModel?.model && (
<span className="text-muted-foreground w-full truncate text-[10px] leading-none">
{selectedModel.model}
</span>
)}
</div>
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent>
@@ -716,7 +723,12 @@ export function InputBox({
value={m.name}
onSelect={() => handleModelSelect(m.name)}
>
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{m.model}
</span>
</div>
{m.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" />
) : (

View File

@@ -127,7 +127,7 @@ export function groupMessages<T>(
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return message.content.trim();
return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim();
}
if (Array.isArray(message.content)) {
return message.content
@@ -138,9 +138,36 @@ export function extractTextFromMessage(message: Message) {
return "";
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
const reasoningParts: string[] = [];
const cleaned = content
.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) {
reasoningParts.push(normalized);
}
return "";
})
.trim();
return {
content: cleaned,
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
};
}
function splitInlineReasoningFromAIMessage(message: Message) {
if (message.type !== "ai" || typeof message.content !== "string") {
return null;
}
return splitInlineReasoning(message.content);
}
export function extractContentFromMessage(message: Message) {
if (typeof message.content === "string") {
return message.content.trim();
return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim();
}
if (Array.isArray(message.content)) {
return message.content
@@ -177,6 +204,9 @@ export function extractReasoningContentFromMessage(message: Message) {
return part.thinking as string;
}
}
if (typeof message.content === "string") {
return splitInlineReasoning(message.content).reasoning;
}
return null;
}
@@ -202,7 +232,9 @@ export function extractURLFromImageURLContent(
export function hasContent(message: Message) {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
return (
splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim()
).length > 0;
}
if (Array.isArray(message.content)) {
return message.content.length > 0;
@@ -222,6 +254,9 @@ export function hasReasoning(message: Message) {
// Compatible with the Anthropic gateway
return (part as unknown as { type: "thinking" })?.type === "thinking";
}
if (typeof message.content === "string") {
return splitInlineReasoning(message.content).reasoning !== null;
}
return false;
}

View File

@@ -1,6 +1,7 @@
export interface Model {
id: string;
name: string;
model: string;
display_name: string;
description?: string | null;
supports_thinking?: boolean;

View File

@@ -31,6 +31,29 @@ export type ThreadStreamOptions = {
onToolEnd?: (event: ToolEndEvent) => void;
};
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message");
if (typeof message === "string" && message.trim()) {
return message;
}
const nestedError = Reflect.get(error, "error");
if (nestedError instanceof Error && nestedError.message.trim()) {
return nestedError.message;
}
if (typeof nestedError === "string" && nestedError.trim()) {
return nestedError;
}
}
return "Request failed.";
}
export function useThreadStream({
threadId,
context,
@@ -148,6 +171,10 @@ export function useThreadStream({
updateSubtask({ id: e.task_id, latestMessage: e.message });
}
},
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });

View File

@@ -72,7 +72,7 @@
@theme {
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1.1s;