fix: add fallback for non-standard reasoning/reasoning_details fields in OpenAI stream adapter

CoStrict DeepSeek-V4-Pro returns thinking content in `delta.reasoning`
(string) and `delta.reasoning_details` (array) instead of the standard
`delta.reasoning_content` field. Add extractReasoning() with priority
fallback so all three formats are mapped to Anthropic thinking blocks.

Co-Authored-By: Auto <noreply@anthropic.com>
This commit is contained in:
Askhz 2026-05-19 14:32:45 +08:00
parent b7153a12a7
commit 933622fbe0
2 changed files with 167 additions and 1 deletions

View File

@ -807,6 +807,160 @@ describe('thinking support (reasoning_content)', () => {
expect(thinkingDeltas.length).toBe(0)
})
test('converts delta.reasoning string to thinking block', async () => {
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: { reasoning: 'Let me think...' },
finish_reason: null,
},
],
}),
makeChunk({
choices: [
{
index: 0,
delta: { reasoning: ' more deeply.' },
finish_reason: null,
},
],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
])
const blockStart = events.find(e => e.type === 'content_block_start') as any
expect(blockStart.content_block.type).toBe('thinking')
const thinkingDeltas = events.filter(
e =>
e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
) as any[]
expect(thinkingDeltas.length).toBe(2)
expect(thinkingDeltas[0].delta.thinking).toBe('Let me think...')
expect(thinkingDeltas[1].delta.thinking).toBe(' more deeply.')
})
test('converts delta.reasoning_details array to thinking block', async () => {
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: {
reasoning_details: [
{ type: 'text', text: 'Step 1: ', format: 'markdown', index: 0 },
],
},
finish_reason: null,
},
],
}),
makeChunk({
choices: [
{
index: 0,
delta: {
reasoning_details: [
{ type: 'text', text: 'analyze.', format: 'markdown', index: 1 },
],
},
finish_reason: null,
},
],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
])
const blockStart = events.find(e => e.type === 'content_block_start') as any
expect(blockStart.content_block.type).toBe('thinking')
const thinkingDeltas = events.filter(
e =>
e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
) as any[]
expect(thinkingDeltas.length).toBe(2)
expect(thinkingDeltas[0].delta.thinking).toBe('Step 1: ')
expect(thinkingDeltas[1].delta.thinking).toBe('analyze.')
})
test('opens thinking block on empty reasoning_details (DeepSeek-V4-Pro direct-answer)', async () => {
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: {
reasoning_details: [
{ type: 'text', text: '', format: 'markdown', index: 0 },
],
},
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 IS opened
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('')
// NO thinking_delta is emitted (empty string conveyed by 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('prefers reasoning_content over reasoning when both present', async () => {
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: {
reasoning_content: 'standard field',
reasoning: 'bare field',
},
finish_reason: null,
},
],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
])
const thinkingDeltas = events.filter(
e =>
e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
) as any[]
expect(thinkingDeltas.length).toBe(1)
expect(thinkingDeltas[0].delta.thinking).toBe('standard field')
})
test('thinking block index is 0, text block index is 1', async () => {
const events = await collectEvents([
makeChunk({

View File

@ -32,6 +32,18 @@ import { randomUUID } from 'crypto'
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
* This is mapped to Anthropic's cache_read_input_tokens.
*/
function extractReasoning(delta: any): string | undefined {
if (delta.reasoning_content != null) return delta.reasoning_content as string;
if (typeof delta.reasoning === 'string') return delta.reasoning;
if (Array.isArray(delta.reasoning_details) && delta.reasoning_details.length > 0) {
return (delta.reasoning_details as Array<{ type?: string; text?: string; format?: string; index?: number }>)
.filter(d => d != null && typeof d.text === 'string')
.map(d => d.text!)
.join('');
}
return undefined;
}
export async function* adaptOpenAIStreamToAnthropic(
stream: AsyncIterable<ChatCompletionChunk>,
model: string,
@ -117,7 +129,7 @@ export async function* adaptOpenAIStreamToAnthropic(
// 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 = extractReasoning(delta)
if (reasoningContent != null) {
if (!thinkingBlockOpen) {
currentContentIndex++