mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
Merge pull request #403 from ymonster/fix/deepseek-empty-reasoning-content
fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (#399)
This commit is contained in:
@@ -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) ──
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user