diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index b7796f24937..325e9b74bf0 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -37,7 +37,7 @@ interface ExtendedCompletionUsage extends OpenAI.CompletionUsage { cached_tokens?: number; } -interface ExtendedChatCompletionAssistantMessageParam +export interface ExtendedChatCompletionAssistantMessageParam extends OpenAI.Chat.ChatCompletionAssistantMessageParam { reasoning_content?: string | null; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts index 6570e4aec7e..cff092bdcc5 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts @@ -178,6 +178,89 @@ describe('DeepSeekOpenAICompatibleProvider', () => { content: 'Hello \n\n[Unsupported content type: image_url]', }); }); + + // https://github.com/QwenLM/qwen-code/issues/3695 — DeepSeek's thinking + // mode rejects subsequent requests when a prior tool-calling assistant + // turn omits reasoning_content, even if the model itself returned no + // reasoning text. The provider must always send the field. + it('injects empty reasoning_content on tool-calling assistant turns missing it', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-v4-flash', + messages: [ + { role: 'user', content: 'list markdown files' }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'glob', arguments: '{"pattern":"**/*.md"}' }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: 'Found 2 matching file(s)', + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + const assistant = result.messages?.[1] as { + role: string; + reasoning_content?: string; + }; + expect(assistant.role).toBe('assistant'); + expect(assistant.reasoning_content).toBe(''); + }); + + it('preserves existing reasoning_content on tool-calling assistant turns', () => { + const originalRequest = { + model: 'deepseek-v4-flash', + messages: [ + { role: 'user' as const, content: 'list markdown files' }, + { + role: 'assistant' as const, + content: null, + reasoning_content: 'Let me glob first.', + tool_calls: [ + { + id: 'call_1', + type: 'function' as const, + function: { name: 'glob', arguments: '{"pattern":"**/*.md"}' }, + }, + ], + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionCreateParams; + + const result = provider.buildRequest(originalRequest, userPromptId); + + const assistant = result.messages?.[1] as { + reasoning_content?: string; + }; + expect(assistant.reasoning_content).toBe('Let me glob first.'); + }); + + it('does not add reasoning_content to assistant turns without tool_calls', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-v4-flash', + messages: [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + const assistant = result.messages?.[1] as { + reasoning_content?: string; + }; + expect(assistant.reasoning_content).toBeUndefined(); + }); }); describe('getDefaultGenerationConfig', () => { diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts index 5c8fc12122f..235c5cf00dc 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts @@ -7,6 +7,7 @@ import type OpenAI from 'openai'; import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import type { ExtendedChatCompletionAssistantMessageParam } from '../converter.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; import type { GenerateContentConfig } from '@google/genai'; @@ -50,40 +51,8 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro } const messages = baseRequest.messages.map((message) => { - if (!('content' in message)) { - return message; - } - - const { content } = message; - - if ( - typeof content === 'string' || - content === null || - content === undefined - ) { - return message; - } - - if (!Array.isArray(content)) { - return message; - } - - const text = content - .map((part) => { - if (typeof part === 'string') { - return part; - } - if (part.type === 'text') { - return part.text ?? ''; - } - return `[Unsupported content type: ${part.type}]`; - }) - .join('\n\n'); - - return { - ...message, - content: text, - } as OpenAI.Chat.ChatCompletionMessageParam; + const flattened = flattenContentParts(message); + return ensureReasoningContentOnToolCalls(flattened); }); return { @@ -98,3 +67,69 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro }; } } + +function flattenContentParts( + message: OpenAI.Chat.ChatCompletionMessageParam, +): OpenAI.Chat.ChatCompletionMessageParam { + if (!('content' in message)) { + return message; + } + + const { content } = message; + + if ( + typeof content === 'string' || + content === null || + content === undefined + ) { + return message; + } + + if (!Array.isArray(content)) { + return message; + } + + const text = content + .map((part) => { + if (typeof part === 'string') { + return part; + } + if (part.type === 'text') { + return part.text ?? ''; + } + return `[Unsupported content type: ${part.type}]`; + }) + .join('\n\n'); + + return { + ...message, + content: text, + } as OpenAI.Chat.ChatCompletionMessageParam; +} + +// DeepSeek's thinking mode requires reasoning_content to be replayed on every +// prior assistant turn that carried tool_calls. The model may legitimately +// return a tool round without reasoning text, so the field can be missing +// when we rebuild the request. Send an empty string in that case so the API +// contract is satisfied. https://github.com/QwenLM/qwen-code/issues/3695 +function ensureReasoningContentOnToolCalls( + message: OpenAI.Chat.ChatCompletionMessageParam, +): OpenAI.Chat.ChatCompletionMessageParam { + if (message.role !== 'assistant') { + return message; + } + if (!Array.isArray(message.tool_calls) || message.tool_calls.length === 0) { + return message; + } + const extended = message as ExtendedChatCompletionAssistantMessageParam; + if ( + typeof extended.reasoning_content === 'string' && + extended.reasoning_content.length > 0 + ) { + return message; + } + return { + ...extended, + reasoning_content: '', + } as OpenAI.Chat.ChatCompletionMessageParam; +}