From 933622fbe0cc7ae0743e0be57c7368ff9e0e0591 Mon Sep 17 00:00:00 2001 From: Askhz <1361267452@qq.com> Date: Tue, 19 May 2026 14:32:45 +0800 Subject: [PATCH] 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 --- .../__tests__/openaiStreamAdapter.test.ts | 154 ++++++++++++++++++ .../src/shared/openaiStreamAdapter.ts | 14 +- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts b/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts index a2745faa8..0decce37c 100644 --- a/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts +++ b/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts @@ -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({ diff --git a/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts b/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts index f41c25739..e80e8a64a 100644 --- a/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts +++ b/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts @@ -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, 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++