diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 14d91e49..5d3cb552 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -3374,3 +3374,225 @@ test('Moonshot: cn host is also detected', async () => { expect(requestBody?.store).toBeUndefined() }) + + +test('collapses multiple text blocks in tool_result to string for DeepSeek compatibility (issue #774)', async () => { + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'deepseek-reasoner', + choices: [ + { + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'deepseek-reasoner', + system: 'test system', + messages: [ + { role: 'user', content: 'Run ls' }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'Bash', + input: { command: 'ls' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: [ + { type: 'text', text: 'line one' }, + { type: 'text', text: 'line two' }, + ], + }, + ], + }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + const toolMessages = messages.filter(m => m.role === 'tool') + expect(toolMessages.length).toBe(1) + expect(toolMessages[0].tool_call_id).toBe('call_1') + expect(typeof toolMessages[0].content).toBe('string') + expect(toolMessages[0].content).toBe('line one\n\nline two') +}) + +test('collapses multiple text blocks into a single string for DeepSeek compatibility (issue #774)', async () => { + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'deepseek-reasoner', + choices: [ + { + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'deepseek-reasoner', + system: 'test system', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello!' }, + { type: 'text', text: 'How are you?' }, + ], + }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + expect(messages.length).toBe(2) // system + user + expect(messages[1].role).toBe('user') + expect(typeof messages[1].content).toBe('string') + expect(messages[1].content).toBe('Hello!\n\nHow are you?') +}) + +test('preserves mixed text and image tool results as multipart content', async () => { + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'gpt-4o', + choices: [ + { + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'gpt-4o', + system: 'test system', + messages: [ + { role: 'user', content: 'Show me' }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'Bash', + input: { command: 'cat image.png' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: [ + { type: 'text', text: 'Here is the image:' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, + }, + ], + }, + ], + }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + const toolMessages = messages.filter(m => m.role === 'tool') + expect(toolMessages.length).toBe(1) + expect(Array.isArray(toolMessages[0].content)).toBe(true) + const content = toolMessages[0].content as Array> + expect(content.length).toBe(2) + expect(content[0].type).toBe('text') + expect(content[1].type).toBe('image_url') +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index ac17ba22..1f7ed674 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -291,6 +291,15 @@ function convertToolResultContent( const text = parts[0].text ?? '' return isError ? `Error: ${text}` : text } + + // Collapse arrays of only text blocks into a single string for DeepSeek + // compatibility (issue #774). DeepSeek rejects arrays in role: "tool" messages. + const allText = parts.every(p => p.type === 'text') + if (allText) { + const text = parts.map(p => p.text ?? '').join('\n\n') + return isError ? `Error: ${text}` : text + } + if (isError && parts[0]?.type === 'text') { parts[0] = { ...parts[0], text: `Error: ${parts[0].text ?? ''}` } } else if (isError) { @@ -349,6 +358,14 @@ function convertContentBlocks( if (parts.length === 0) return '' if (parts.length === 1 && parts[0].type === 'text') return parts[0].text ?? '' + + // Collapse arrays of only text blocks into a single string for DeepSeek + // compatibility (issue #774). + const allText = parts.every(p => p.type === 'text') + if (allText) { + return parts.map(p => p.text ?? '').join('\n\n') + } + return parts }