MoonshotAI / MoonshotAI/kimi-cli

fix(openai_legacy): ensure reasoning_content on ALL assistant messages for DeepSeek V4 compatibility

Open Beginner friendly
#2,141 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
11.4k
Forks
1.3k
Avg merge
9h 47m
Merged PRs (30d)
2

Description

Problem

When using DeepSeek V4 Pro with thinking mode enabled, multi-turn conversations with tool calls fail with a 400 error:

Error code: 400 - {'error': {'message': 'The `reasoning_content` in the thinking mode must be passed back to the API.'}}

Root Cause

DeepSeek V4 Pro API always returns reasoning_content in every assistant message when reasoning_effort is set — including plain text responses without tool calls. The API then requires reasoning_content to be present on all assistant messages in subsequent requests. If even one assistant message is missing reasoning_content, the API returns the 400 error above.

However, _convert_message() currently only adds reasoning_content when the message contains a ThinkPart:

# openai_legacy.py line 216
if reasoning_content and self._reasoning_key:  # ← only adds when ThinkPart exists
    dumped_message[self._reasoning_key] = reasoning_content

Plain text assistant responses (e.g., the final answer after a tool result) have no ThinkPart, so reasoning_content is not added to the dumped message. This violates DeepSeek V4's API contract.

Reproduction

  1. Enable thinking mode with DeepSeek V4 Pro
  2. Have a conversation flow: User → ToolCall → ToolResult → TextResponse → User → ToolCall → ToolResult
  3. The second request fails because the intermediate TextResponse assistant message lacks reasoning_content
Minimal API-level reproduction
# ❌ Fails — text-only assistant without reasoning_content
messages = [
    {"role": "user", "content": "weather?"},
    {"role": "assistant", "content": "", "tool_calls": [...], "reasoning_content": "R1"},
    {"role": "tool", ...},
    {"role": "assistant", "content": "It's sunny."},  # ← MISSING reasoning_content!
    {"role": "user", "content": "what about Busan?"},
    {"role": "assistant", "content": "", "tool_calls": [...], "reasoning_content": "R2"},
    {"role": "tool", ...}
]
# → 400: The reasoning_content in the thinking mode must be passed back to the API.

# ✅ Works — all assistant messages have reasoning_content
messages = [
    ...
    {"role": "assistant", "content": "It's sunny.", "reasoning_content": "R_think"},  # ← OK
    ...
]
# → 200

Discovery & Verification

How this was found
  • Export/import workaround: Running /export then /import on a failing session resolves the issue. This works because export serializes all messages to markdown text, and import inserts it as a single user message — eliminating all structured assistant messages and sidestepping the reasoning_content requirement.
  • Resume failure pattern: The error occurs on step 1 of a resumed session. The stored context.jsonl has assistant messages with ThinkPart, but the intermediate text-only assistant messages have no ThinkPart, so reasoning_content is not added during conversion.
Fix verified locally

Tested by patching openai_legacy.py line 216.

The fix resolves the issue — DeepSeek V4 Pro thinking mode works correctly across multi-turn tool-call conversations.

Direct API verification (curl)

Confirmed via raw API calls that DeepSeek V4 Pro always returns reasoning_content when reasoning_effort is set, even for plain text responses:

# Simple "1+1=?" query with reasoning_effort="medium"
Response: {"role": "assistant", "content": "", "reasoning_content": "..."}
# Note: content is EMPTY — reasoning_content contains the answer!

And that having ALL assistant messages carry reasoning_content satisfies the API:

  • ❌ Two tool-call rounds with a text-only assistant between them → 400
  • ✅ Same messages but text-only assistant also has reasoning_content → 200

Request

When using DeepSeek models (or other backends that exhibit this behavior), ensure reasoning_content is added to all assistant messages in the history — not just those that originally had a ThinkPart. One possible approach:

-        if reasoning_content and self._reasoning_key:
+        if self._reasoning_key and message.role == "assistant":
             dumped_message[self._reasoning_key] = reasoning_content

PR #2053 added the reasoning_key default, but this condition also needs updating for DeepSeek compatibility. If a provider-agnostic change is undesirable, consider making it conditional on the provider's base URL or a new flag so only DeepSeek (and similarly behaving backends) are affected.

Scope

  • DeepSeek V4 Pro: confirmed
  • DeepSeek V4 Flash: same API behavior, likely affected
  • File: kosong/contrib/chat_provider/openai_legacy.py, _convert_message() method
  • Version: Kimi CLI 1.41.0

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in kosong/contrib/chat_provider/openai_legacy.py at _convert_message(), focusing on how assistant messages without a ThinkPart are converted. Reproduce the DeepSeek V4 multi-turn tool-call flow described in the issue and verify that all assistant messages preserve the required reasoning content without breaking other message conversions.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, cli
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.