Merge pull request #130 from Askhz/fix/deepseek-v4-pro-reasoning-fallback
fix: add fallback for non-standard reasoning/reasoning_details fields in OpenAI stream adapter
This commit is contained in:
commit
da1985708f
|
|
@ -807,6 +807,160 @@ describe('thinking support (reasoning_content)', () => {
|
||||||
expect(thinkingDeltas.length).toBe(0)
|
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 () => {
|
test('thinking block index is 0, text block index is 1', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,18 @@ import { randomUUID } from 'crypto'
|
||||||
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
|
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
|
||||||
* This is mapped to Anthropic's cache_read_input_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(
|
export async function* adaptOpenAIStreamToAnthropic(
|
||||||
stream: AsyncIterable<ChatCompletionChunk>,
|
stream: AsyncIterable<ChatCompletionChunk>,
|
||||||
model: string,
|
model: string,
|
||||||
|
|
@ -117,7 +129,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||||
// returns reasoning_content: "" when the model answers directly. The
|
// returns reasoning_content: "" when the model answers directly. The
|
||||||
// empty thinking block must round-trip back to the API in subsequent
|
// empty thinking block must round-trip back to the API in subsequent
|
||||||
// requests, otherwise DeepSeek rejects with 400.
|
// requests, otherwise DeepSeek rejects with 400.
|
||||||
const reasoningContent = (delta as any).reasoning_content
|
const reasoningContent = extractReasoning(delta)
|
||||||
if (reasoningContent != null) {
|
if (reasoningContent != null) {
|
||||||
if (!thinkingBlockOpen) {
|
if (!thinkingBlockOpen) {
|
||||||
currentContentIndex++
|
currentContentIndex++
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user