From a31204ef09a326b5eb9033977f4fd8246461c819 Mon Sep 17 00:00:00 2001 From: 64JohnLee <64lamei@gmail.com> Date: Tue, 19 May 2026 19:46:22 +0800 Subject: [PATCH] test(models): cover reasoning_content preservation on tool-call assistant messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two regression tests for PatchedChatDeepSeek._get_request_payload that verify reasoning_content is injected when an assistant message also carries tool_calls — the exact scenario reported in issues #3043 (Kimi K2.5) and #3050 (DeepSeek Chat / V4 Flash) where the API rejects with "reasoning_content missing in assistant tool call message". Also renames the existing config.example.yaml deepseek-v3 block to the correct model name (deepseek-reasoner) and adds a new deepseek-chat example that explicitly calls out PatchedChatDeepSeek as the required class. Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/test_patched_deepseek.py | 71 +++++++++++++++++++++++++- config.example.yaml | 31 +++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_patched_deepseek.py b/backend/tests/test_patched_deepseek.py index b663afef3..83d3d44e2 100644 --- a/backend/tests/test_patched_deepseek.py +++ b/backend/tests/test_patched_deepseek.py @@ -11,7 +11,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage def _make_model(**kwargs): @@ -184,3 +184,72 @@ def test_positional_fallback_when_count_differs(): assistant_msg = next(m for m in payload["messages"] if m["role"] == "assistant") assert assistant_msg["reasoning_content"] == "My reasoning" + + +def test_reasoning_content_injected_when_assistant_has_tool_calls(): + """reasoning_content is preserved on assistant messages that also carry tool_calls. + + Regression for issues #3043 and #3050: APIs like Kimi K2.5 and DeepSeek Chat (thinking + mode) reject the request with 'reasoning_content missing in assistant tool call message' + when reasoning_content is dropped from a tool-call turn. + """ + model = _make_model() + + human = HumanMessage(content="Search for X") + ai_with_tools = AIMessage( + content="", + tool_calls=[{"id": "call_1", "name": "search", "args": {"q": "X"}, "type": "tool_call"}], + additional_kwargs={"reasoning_content": "I need to search for X"}, + ) + + tool_calls_payload = [{"id": "call_1", "type": "function", "function": {"name": "search", "arguments": '{"q": "X"}'}}] + base_payload = { + "messages": [ + _make_payload_message("user", "Search for X"), + _make_payload_message("assistant", "", tool_calls=tool_calls_payload), + ] + } + + with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload): + with patch.object(model, "_convert_input") as mock_convert: + mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai_with_tools]) + payload = model._get_request_payload([human, ai_with_tools]) + + assistant_msg = next(m for m in payload["messages"] if m["role"] == "assistant") + assert assistant_msg["reasoning_content"] == "I need to search for X" + assert assistant_msg["tool_calls"] == tool_calls_payload + + +def test_reasoning_content_preserved_across_tool_call_turn(): + """reasoning_content survives a full tool-call round-trip (assistant → tool → next turn). + + The payload for the follow-up model call still includes the assistant tool-call message + from turn 1; that message must carry reasoning_content or the API rejects the request. + """ + model = _make_model() + + human = HumanMessage(content="Search for X") + ai_with_tools = AIMessage( + content="", + tool_calls=[{"id": "call_1", "name": "search", "args": {"q": "X"}, "type": "tool_call"}], + additional_kwargs={"reasoning_content": "I need to search for X"}, + ) + tool_result = ToolMessage(content="Found results for X", tool_call_id="call_1") + + tool_calls_payload = [{"id": "call_1", "type": "function", "function": {"name": "search", "arguments": '{"q": "X"}'}}] + base_payload = { + "messages": [ + _make_payload_message("user", "Search for X"), + _make_payload_message("assistant", "", tool_calls=tool_calls_payload), + {"role": "tool", "content": "Found results for X", "tool_call_id": "call_1"}, + ] + } + + with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload): + with patch.object(model, "_convert_input") as mock_convert: + mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai_with_tools, tool_result]) + payload = model._get_request_payload([human, ai_with_tools, tool_result]) + + assistant_msg = next(m for m in payload["messages"] if m["role"] == "assistant") + assert assistant_msg["reasoning_content"] == "I need to search for X" + assert assistant_msg["tool_calls"] == tool_calls_payload diff --git a/config.example.yaml b/config.example.yaml index 7396f6cfb..a46f03b43 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -171,9 +171,9 @@ models: # thinking: # type: disabled - # Example: DeepSeek model (with thinking support) - # - name: deepseek-v3 - # display_name: DeepSeek V3 (Thinking) + # Example: DeepSeek Reasoner (deepseek-reasoner / R1) with thinking support + # - name: deepseek-reasoner + # display_name: DeepSeek Reasoner # use: deerflow.models.patched_deepseek:PatchedChatDeepSeek # model: deepseek-reasoner # api_key: $DEEPSEEK_API_KEY @@ -181,7 +181,30 @@ models: # max_retries: 2 # max_tokens: 8192 # supports_thinking: true - # supports_vision: false # DeepSeek V3 does not support vision + # supports_vision: false # DeepSeek Reasoner does not support vision + # when_thinking_enabled: + # extra_body: + # thinking: + # type: enabled + # when_thinking_disabled: + # extra_body: + # thinking: + # type: disabled + + # Example: DeepSeek Chat (deepseek-chat) with thinking enabled + # Use PatchedChatDeepSeek — NOT langchain_deepseek:ChatDeepSeek — to avoid + # "reasoning_content must be passed back to the API" errors in multi-turn + # tool-call conversations (affects DeepSeek Chat, DeepSeek V4 Flash, etc.). + # - name: deepseek-chat + # display_name: DeepSeek Chat + # use: deerflow.models.patched_deepseek:PatchedChatDeepSeek + # model: deepseek-chat + # api_key: $DEEPSEEK_API_KEY + # timeout: 600.0 + # max_retries: 2 + # max_tokens: 8192 + # supports_thinking: true + # supports_vision: false # when_thinking_enabled: # extra_body: # thinking: