Skip to content

fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (#399)#403

Merged
claude-code-best merged 1 commit into
claude-code-best:mainfrom
ymonster:fix/deepseek-empty-reasoning-content
May 2, 2026
Merged

fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (#399)#403
claude-code-best merged 1 commit into
claude-code-best:mainfrom
ymonster:fix/deepseek-empty-reasoning-content

Conversation

@ymonster
Copy link
Copy Markdown
Contributor

@ymonster ymonster commented May 2, 2026

背景

修复 #399#401(同一个根因,#401 触发条件是多轮对话——次数越多越容易撞上)。

接入 DeepSeek 的 /model opus 后频繁报:

API Error: 400 The `reasoning_content` in the thinking mode must be passed back to the API.

根因

DeepSeek v4 在 thinking mode 下,有时会直接给出答案、reasoning_content 是空字符串 ""(而不是缺省/null)。当前代码在两个地方把空串当成"没有思考"过滤掉:

  1. openaiStreamAdapter.ts:111 —— 收到 delta.reasoning_content === '' 时不开 thinking block,导致 assistant 消息里压根没有 thinking block
  2. openaiConvertMessages.ts:214 —— 即便消息里有空 thinking block,序列化回 OpenAI 格式时也被 truthy check 过滤

结果:下一轮请求里上一条 assistant 消息缺 reasoning_content 字段,DeepSeek 直接 400。多轮对话下,只要历史里有任何一轮命中了"直接答"路径,后续请求就会 400 ——这解释了 #401 里"对话次数 ≥20 就出错"的概率累积现象。

修复

  • stream adapter:只要 reasoning_content != null(包含空串)就开 thinking block;空串时不发多余的 thinking_delta,因为 block 初始 thinking: '' 已经表达了空值
  • convert messages:把过滤条件从 && thinkingText 放宽到只要是 string 就 push,让空 thinking block 序列化为 reasoning_content: "" 回传

测试

bun test packages/@ant/model-provider:111 pass / 0 fail

新增/更新的关键用例:

  • openaiStreamAdapter.test.ts:新增 opens thinking block on empty reasoning_content (DeepSeek v4 direct-answer)
  • openaiConvertMessages.test.ts:原 skips empty thinking blocks(写的是 bug 行为)改为 preserves empty thinking blocks as reasoning_content: "" (DeepSeek v4 thinking mode)
  • openaiConvertMessages.test.ts:新增 omits reasoning_content when no thinking block is present,作为非 thinking 模型的回归保护

src/services/api/openai 的 39 个消费方测试也全过。

校验

  • bun test packages/@ant/model-provider 全过
  • bun test src/services/api/openai 全过
  • bun run typecheck 零错误
  • bunx biome ci 改动文件无 lint/格式问题

Closes #399
Closes #401

…laude-code-best#399)

DeepSeek v4 in thinking mode sometimes returns reasoning_content: ""
when the model answers directly without internal reasoning. Two places
were filtering the empty string out, which dropped the thinking block
from the assistant turn entirely. The next request then omitted
reasoning_content for that prior turn, and DeepSeek rejected with
400 "reasoning_content ... must be passed back to the API".

Fix:
- openaiStreamAdapter: open a thinking block whenever reasoning_content
  is present (including ""); skip the empty thinking_delta event since
  the empty value is already conveyed by the block's initial state.
- openaiConvertMessages: preserve empty thinking blocks as
  reasoning_content: "" when serializing assistant messages back to
  the OpenAI/DeepSeek format.

Tests:
- New: empty reasoning_content opens a thinking block (adapter).
- Updated: empty thinking blocks now round-trip as reasoning_content: ""
  instead of being dropped.
- New: assistant messages with no thinking block still omit
  reasoning_content (regression guard for non-thinking models).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

📝 Walkthrough

Walkthrough

This PR fixes DeepSeek thinking-mode handling in the model provider by preserving empty-string reasoning_content when converting between Anthropic and OpenAI message formats, ensuring round-trip fidelity required by DeepSeek's API validation.

Changes

Empty Reasoning Content Round-Trip

Layer / File(s) Summary
Core Conversion Logic
packages/@ant/model-provider/src/shared/openaiConvertMessages.ts
convertInternalAssistantMessage now pushes reasoning_content for any string value (including ""), not just non-empty strings. Comments updated to reflect DeepSeek's requirement to echo empty-string reasoning_content.
Stream Adaptation Logic
packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts
adaptOpenAIStreamToAnthropic opens a thinking content block whenever delta.reasoning_content is present, including when it is "". Delta events are only emitted for non-empty content.
Conversion Tests
packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts
Updated DeepSeek thinking-mode tests: empty thinking: "" blocks now convert to reasoning_content: '', and a new test verifies that missing thinking blocks result in undefined reasoning_content.
Stream Adapter Tests
packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts
Added test for empty reasoning_content (DeepSeek v4 direct-answer mode): verifies that a '' value opens a thinking block without emitting thinking_delta events.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A bunny hops through empty strings with cheer,
No more dropped reasoning_content here!
DeepSeek's direct thoughts now round-trip true,
Empty blocks preserved, the API's happy too! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly describes the main change: preserving empty reasoning_content for DeepSeek v4 thinking mode, which directly addresses the core issue being fixed.
Linked Issues check ✅ Passed The PR fully addresses issue #399 by preserving empty reasoning_content in both stream adapter and message conversion logic, preventing the 400 API error.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the empty reasoning_content handling issue; no unrelated modifications detected.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/@ant/model-provider/src/shared/openaiConvertMessages.ts (1)

16-19: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

enableThinking option contract is now misleading and effectively ignored

Line 36 ignores ConvertMessagesOptions, while Lines 17-19 still document conditional behavior. Since callers still pass { enableThinking }, this is a behavior break at the API boundary.

Suggested fix (honor option or remove it explicitly)
 export function anthropicMessagesToOpenAI(
   messages: (UserMessage | AssistantMessage)[],
   systemPrompt: SystemPrompt,
-  // options retained for API compatibility; thinking blocks are now always preserved
-  _options?: ConvertMessagesOptions,
+  options?: ConvertMessagesOptions,
 ): ChatCompletionMessageParam[] {
+  const preserveThinking = options?.enableThinking === true
   const result: ChatCompletionMessageParam[] = []
@@
-      case 'assistant':
-        result.push(...convertInternalAssistantMessage(msg))
+      case 'assistant':
+        result.push(...convertInternalAssistantMessage(msg, preserveThinking))
         break
@@
-function convertInternalAssistantMessage(
+function convertInternalAssistantMessage(
   msg: AssistantMessage,
+  preserveThinking: boolean,
 ): ChatCompletionMessageParam[] {
@@
-    } else if (block.type === 'thinking') {
+    } else if (preserveThinking && block.type === 'thinking') {
       const thinkingText = (block as unknown as Record<string, unknown>).thinking
       if (typeof thinkingText === 'string') {
         reasoningParts.push(thinkingText)
       }
     }

Also applies to: 35-36

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/`@ant/model-provider/src/shared/openaiConvertMessages.ts around
lines 16 - 19, The ConvertMessagesOptions.enableThinking flag is documented but
the message conversion logic currently ignores it, breaking callers that pass {
enableThinking }; update the conversion function in this module (the message
conversion implementation in openaiConvertMessages.ts) to read
ConvertMessagesOptions.enableThinking and, when true, preserve thinking blocks
as reasoning_content on assistant messages (e.g., in the code path that builds
assistant messages), or if you choose not to support it, remove enableThinking
from ConvertMessagesOptions and all callers and docs that pass it so the API
contract is consistent; ensure references to ConvertMessagesOptions and
enableThinking are updated together.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/`@ant/model-provider/src/shared/openaiStreamAdapter.ts:
- Around line 114-116: Replace the unsafe cast and add a string type guard: read
reasoning_content via (delta as Record<string, unknown>).reasoning_content
(instead of (delta as any).reasoning_content) and only treat it as valid when
typeof reasoningContent === 'string' (allowing empty strings); then only
open/emit the thinking_delta block when thinkingBlockOpen is false and
reasoningContent is a string, and ensure any code that assigns to the thinking
field or emits the thinking_delta uses the typed string value to avoid
non-string values flowing through.

---

Outside diff comments:
In `@packages/`@ant/model-provider/src/shared/openaiConvertMessages.ts:
- Around line 16-19: The ConvertMessagesOptions.enableThinking flag is
documented but the message conversion logic currently ignores it, breaking
callers that pass { enableThinking }; update the conversion function in this
module (the message conversion implementation in openaiConvertMessages.ts) to
read ConvertMessagesOptions.enableThinking and, when true, preserve thinking
blocks as reasoning_content on assistant messages (e.g., in the code path that
builds assistant messages), or if you choose not to support it, remove
enableThinking from ConvertMessagesOptions and all callers and docs that pass it
so the API contract is consistent; ensure references to ConvertMessagesOptions
and enableThinking are updated together.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b8593550-9aec-49cf-9dd9-11506118024b

📥 Commits

Reviewing files that changed from the base of the PR and between 3eba5ad and 1b10ea3.

📒 Files selected for processing (4)
  • packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts
  • packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts
  • packages/@ant/model-provider/src/shared/openaiConvertMessages.ts
  • packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts

Comment on lines 114 to 116
const reasoningContent = (delta as any).reasoning_content
if (reasoningContent != null && reasoningContent !== '') {
if (reasoningContent != null) {
if (!thinkingBlockOpen) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f openaiStreamAdapter.ts

Repository: claude-code-best/claude-code

Length of output: 135


🏁 Script executed:

cat -n packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts | head -160

Repository: claude-code-best/claude-code

Length of output: 6838


🏁 Script executed:

cat -n packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts | sed -n '1,30p'

Repository: claude-code-best/claude-code

Length of output: 1921


🏁 Script executed:

rg -A 30 "reasoning_content" packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts

Repository: claude-code-best/claude-code

Length of output: 3371


Add type guard for reasoning_content to ensure only strings emit thinking_delta

Line 114 uses as any which violates the TypeScript strict mode requirement. Additionally, without a string type guard, non-string non-null values can flow into the thinking field and emit invalid events. The suggested fix uses Record<string, unknown> and adds typeof reasoningContent === 'string' to match the intended behavior (empty strings are valid per the documented DeepSeek workaround).

Suggested fix
-    const reasoningContent = (delta as any).reasoning_content
-    if (reasoningContent != null) {
+    const reasoningContent = (delta as Record<string, unknown>).reasoning_content
+    if (typeof reasoningContent === 'string') {
       if (!thinkingBlockOpen) {
         currentContentIndex++
         thinkingBlockOpen = true
         openBlockIndices.add(currentContentIndex)

         yield {
           type: 'content_block_start',
           index: currentContentIndex,
           content_block: {
             type: 'thinking',
             thinking: '',
             signature: '',
           },
         } as BetaRawMessageStreamEvent
       }

       if (reasoningContent !== '') {
         yield {
           type: 'content_block_delta',
           index: currentContentIndex,
           delta: {
             type: 'thinking_delta',
             thinking: reasoningContent,
           },
         } as BetaRawMessageStreamEvent
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reasoningContent = (delta as any).reasoning_content
if (reasoningContent != null && reasoningContent !== '') {
if (reasoningContent != null) {
if (!thinkingBlockOpen) {
const reasoningContent = (delta as Record<string, unknown>).reasoning_content
if (typeof reasoningContent === 'string') {
if (!thinkingBlockOpen) {
currentContentIndex++
thinkingBlockOpen = true
openBlockIndices.add(currentContentIndex)
yield {
type: 'content_block_start',
index: currentContentIndex,
content_block: {
type: 'thinking',
thinking: '',
signature: '',
},
} as BetaRawMessageStreamEvent
}
if (reasoningContent !== '') {
yield {
type: 'content_block_delta',
index: currentContentIndex,
delta: {
type: 'thinking_delta',
thinking: reasoningContent,
},
} as BetaRawMessageStreamEvent
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/`@ant/model-provider/src/shared/openaiStreamAdapter.ts around lines
114 - 116, Replace the unsafe cast and add a string type guard: read
reasoning_content via (delta as Record<string, unknown>).reasoning_content
(instead of (delta as any).reasoning_content) and only treat it as valid when
typeof reasoningContent === 'string' (allowing empty strings); then only
open/emit the thinking_delta block when thinkingBlockOpen is false and
reasoningContent is a string, and ensure any code that assigns to the thinking
field or emits the thinking_delta uses the typed string value to avoid
non-string values flowing through.

@claude-code-best claude-code-best merged commit 4cbf406 into claude-code-best:main May 2, 2026
3 checks passed
y574444354 pushed a commit to y574444354/csc that referenced this pull request May 12, 2026
…pty-reasoning-content

fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (claude-code-best#399)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants