Merge pull request #403 from ymonster/fix/deepseek-empty-reasoning-content

fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (#399)
This commit is contained in:
claude-code-best
2026-05-02 16:02:06 +08:00
committed by GitHub
4 changed files with 91 additions and 15 deletions

View File

@@ -468,7 +468,11 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.reasoning_content).toBe('First thought.\nSecond thought.') expect(assistant.reasoning_content).toBe('First thought.\nSecond thought.')
}) })
test('skips empty thinking blocks', () => { test('preserves empty thinking blocks as reasoning_content: "" (DeepSeek v4 thinking mode)', () => {
// DeepSeek v4 thinking mode sometimes returns reasoning_content: ""
// when the model answers directly without reasoning. The empty value
// must be echoed back in the next request — otherwise DeepSeek returns
// 400 ("reasoning_content ... must be passed back"). See issue #399.
const result = anthropicMessagesToOpenAI( const result = anthropicMessagesToOpenAI(
[ [
makeUserMsg('question'), makeUserMsg('question'),
@@ -481,7 +485,23 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
{ enableThinking: true }, { enableThinking: true },
) )
const assistant = result.filter(m => m.role === 'assistant')[0] as any const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.reasoning_content).toBe('')
expect(assistant.content).toBe('Answer.')
})
test('omits reasoning_content when no thinking block is present', () => {
// No thinking block at all → no reasoning_content field on the
// OpenAI-format assistant message (relevant for non-thinking models).
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('question'),
makeAssistantMsg([{ type: 'text', text: 'Answer.' }]),
],
[] as any,
)
const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.reasoning_content).toBeUndefined() expect(assistant.reasoning_content).toBeUndefined()
expect(assistant.content).toBe('Answer.')
}) })
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ── // ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──

View File

@@ -439,6 +439,54 @@ describe('thinking support (reasoning_content)', () => {
expect(blockStarts[1].content_block.type).toBe('tool_use') expect(blockStarts[1].content_block.type).toBe('tool_use')
}) })
test('opens thinking block on empty reasoning_content (DeepSeek v4 direct-answer)', async () => {
// DeepSeek v4 thinking mode sometimes streams reasoning_content: ""
// before answering directly. We must still open a thinking block so the
// resulting assistant message carries an (empty) thinking block — that
// round-trips back as reasoning_content: "" in the next request,
// satisfying DeepSeek's requirement (see issue #399).
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: { reasoning_content: '' },
finish_reason: null,
},
],
}),
makeChunk({
choices: [
{
index: 0,
delta: { content: 'Direct answer.' },
finish_reason: null,
},
],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
])
// A thinking block was opened (and closed before the text block starts)
const blockStarts = events.filter(
e => e.type === 'content_block_start',
) as any[]
expect(blockStarts.length).toBe(2)
expect(blockStarts[0].content_block.type).toBe('thinking')
expect(blockStarts[0].content_block.thinking).toBe('')
expect(blockStarts[1].content_block.type).toBe('text')
// No empty thinking_delta should be emitted — the empty string is
// already conveyed by the thinking block's initial value.
const thinkingDeltas = events.filter(
e =>
e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
)
expect(thinkingDeltas.length).toBe(0)
})
test('thinking block index is 0, text block index is 1', async () => { test('thinking block index is 0, text block index is 1', async () => {
const events = await collectEvents([ const events = await collectEvents([
makeChunk({ makeChunk({

View File

@@ -206,12 +206,14 @@ function convertInternalAssistantMessage(
}, },
}) })
} else if (block.type === 'thinking') { } else if (block.type === 'thinking') {
// DeepSeek thinking mode: always preserve reasoning_content. // DeepSeek thinking mode: always preserve reasoning_content,
// DeepSeek requires reasoning_content to be passed back in subsequent requests, // including the empty-string case. DeepSeek v4 may return
// especially when tool calls are involved (returns 400 if missing). // reasoning_content: "" when the model answers directly, and the
// empty value must be echoed back in the next request — otherwise
// DeepSeek returns 400 ("reasoning_content ... must be passed back").
const thinkingText = (block as unknown as Record<string, unknown>) const thinkingText = (block as unknown as Record<string, unknown>)
.thinking .thinking
if (typeof thinkingText === 'string' && thinkingText) { if (typeof thinkingText === 'string') {
reasoningParts.push(thinkingText) reasoningParts.push(thinkingText)
} }
} }

View File

@@ -106,9 +106,13 @@ export async function* adaptOpenAIStreamToAnthropic(
// Skip chunks that carry only usage data (no delta content) // Skip chunks that carry only usage data (no delta content)
if (!delta) continue if (!delta) continue
// Handle reasoning_content → Anthropic thinking block // Handle reasoning_content → Anthropic thinking block.
// Empty string is a valid signal: DeepSeek v4 thinking mode sometimes
// returns reasoning_content: "" when the model answers directly. The
// empty thinking block must round-trip back to the API in subsequent
// requests, otherwise DeepSeek rejects with 400.
const reasoningContent = (delta as any).reasoning_content const reasoningContent = (delta as any).reasoning_content
if (reasoningContent != null && reasoningContent !== '') { if (reasoningContent != null) {
if (!thinkingBlockOpen) { if (!thinkingBlockOpen) {
currentContentIndex++ currentContentIndex++
thinkingBlockOpen = true thinkingBlockOpen = true
@@ -125,14 +129,16 @@ export async function* adaptOpenAIStreamToAnthropic(
} as BetaRawMessageStreamEvent } as BetaRawMessageStreamEvent
} }
yield { if (reasoningContent !== '') {
type: 'content_block_delta', yield {
index: currentContentIndex, type: 'content_block_delta',
delta: { index: currentContentIndex,
type: 'thinking_delta', delta: {
thinking: reasoningContent, type: 'thinking_delta',
}, thinking: reasoningContent,
} as BetaRawMessageStreamEvent },
} as BetaRawMessageStreamEvent
}
} }
// Handle text content // Handle text content