fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (#399)#403
Conversation
…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).
📝 WalkthroughWalkthroughThis PR fixes DeepSeek thinking-mode handling in the model provider by preserving empty-string ChangesEmpty Reasoning Content Round-Trip
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
enableThinkingoption contract is now misleading and effectively ignoredLine 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
📒 Files selected for processing (4)
packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.tspackages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.tspackages/@ant/model-provider/src/shared/openaiConvertMessages.tspackages/@ant/model-provider/src/shared/openaiStreamAdapter.ts
| const reasoningContent = (delta as any).reasoning_content | ||
| if (reasoningContent != null && reasoningContent !== '') { | ||
| if (reasoningContent != null) { | ||
| if (!thinkingBlockOpen) { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -t f openaiStreamAdapter.tsRepository: claude-code-best/claude-code
Length of output: 135
🏁 Script executed:
cat -n packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts | head -160Repository: 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.tsRepository: 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.
| 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.
…pty-reasoning-content fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (claude-code-best#399)
背景
修复 #399 与 #401(同一个根因,#401 触发条件是多轮对话——次数越多越容易撞上)。
接入 DeepSeek 的
/model opus后频繁报:根因
DeepSeek v4 在 thinking mode 下,有时会直接给出答案、
reasoning_content是空字符串""(而不是缺省/null)。当前代码在两个地方把空串当成"没有思考"过滤掉:openaiStreamAdapter.ts:111—— 收到delta.reasoning_content === ''时不开 thinking block,导致 assistant 消息里压根没有 thinking blockopenaiConvertMessages.ts:214—— 即便消息里有空 thinking block,序列化回 OpenAI 格式时也被 truthy check 过滤结果:下一轮请求里上一条 assistant 消息缺
reasoning_content字段,DeepSeek 直接 400。多轮对话下,只要历史里有任何一轮命中了"直接答"路径,后续请求就会 400 ——这解释了 #401 里"对话次数 ≥20 就出错"的概率累积现象。修复
reasoning_content != null(包含空串)就开 thinking block;空串时不发多余的thinking_delta,因为 block 初始thinking: ''已经表达了空值&& 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