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:
parent
b7153a12a7
commit
933622fbe0
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user