fix: Collapse all-text arrays to string for DeepSeek compatibility (#806)

Fixes #774. When tool_result content contains multiple text blocks,
they were serialized as arrays instead of strings, causing DeepSeek
to reject the request with 400 error.

Changes:
- convertToolResultContent: collapse all-text arrays to joined string
- convertContentBlocks: defensive collapse for user/assistant messages
- Arrays with images are preserved (not collapsed)

Tests: 3 new tests added, 53 pass, 0 fail

Co-authored-by: nick.mesen <nickmesen@users.noreply.github.com>
This commit is contained in:
nickmesen
2026-04-21 09:17:12 -06:00
committed by GitHub
parent e908864da7
commit 761924daa7
2 changed files with 239 additions and 0 deletions

View File

@@ -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<string, unknown> | 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<Record<string, unknown>>
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<string, unknown> | 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<Record<string, unknown>>
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<string, unknown> | 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<Record<string, unknown>>
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<Record<string, unknown>>
expect(content.length).toBe(2)
expect(content[0].type).toBe('text')
expect(content[1].type).toBe('image_url')
})

View File

@@ -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
}