From e794e60eb2d64e2a2abc40c734b69a04b1049f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 6 Jan 2026 14:38:08 +0100 Subject: [PATCH 1/3] fix(provider): trim whitespace and filter empty content in transformations Implement consistent whitespace trimming across all provider transformation functions (Anthropic, OpenAI, OpenAI-compatible) to prevent API validation errors from empty or whitespace-only message content. - Trim all text content before length checks and assignments - Filter out whitespace-only messages and parts - Preserve tool results and streaming deltas without modification - Add 65 comprehensive tests for whitespace handling 5 files changed: 184 insertions(+), 56 deletions(-) --- .../src/routes/zen/util/provider/anthropic.ts | 44 +++++---- .../zen/util/provider/openai-compatible.ts | 32 ++++--- .../src/routes/zen/util/provider/openai.ts | 42 +++++---- packages/opencode/src/provider/transform.ts | 31 +++++-- .../opencode/test/provider/transform.test.ts | 91 +++++++++++++++++++ 5 files changed, 184 insertions(+), 56 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 887a6e4b5e2..c7fc9115f79 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -85,8 +85,8 @@ export function fromAnthropicRequest(body: any): CommonRequest { if (!s) continue if ((s as any).type !== "text") continue if (typeof (s as any).text !== "string") continue - if ((s as any).text.length === 0) continue - msgs.push({ role: "system", content: (s as any).text }) + if ((s as any).text.trim().length === 0) continue + msgs.push({ role: "system", content: (s as any).text.trim() }) } } @@ -115,8 +115,10 @@ export function fromAnthropicRequest(body: any): CommonRequest { const partsOut: any[] = [] for (const p of partsIn) { if (!p || !(p as any).type) continue - if ((p as any).type === "text" && typeof (p as any).text === "string") - partsOut.push({ type: "text", text: (p as any).text }) + if ((p as any).type === "text" && typeof (p as any).text === "string") { + const t = (p as any).text.trim() + if (t.length > 0) partsOut.push({ type: "text", text: t }) + } if ((p as any).type === "image") { const ip = toImg((p as any).source) if (ip) partsOut.push(ip) @@ -141,7 +143,10 @@ export function fromAnthropicRequest(body: any): CommonRequest { const tcs: any[] = [] for (const p of partsIn) { if (!p || !(p as any).type) continue - if ((p as any).type === "text" && typeof (p as any).text === "string") texts.push((p as any).text) + if ((p as any).type === "text" && typeof (p as any).text === "string") { + const t = (p as any).text.trim() + if (t.length > 0) texts.push(t) + } if ((p as any).type === "tool_use") { const name = (p as any).name const id = (p as any).id @@ -218,8 +223,8 @@ export function toAnthropicRequest(body: CommonRequest) { return ccCount <= 4 ? { cache_control: { type: "ephemeral" } } : {} } const system = sysIn - .filter((m: any) => typeof m.content === "string" && m.content.length > 0) - .map((m: any) => ({ type: "text", text: m.content, ...cc() })) + .filter((m: any) => typeof m.content === "string" && m.content.trim().length > 0) + .map((m: any) => ({ type: "text", text: m.content.trim(), ...cc() })) const msgsIn = Array.isArray(body.messages) ? body.messages : [] const msgsOut: any[] = [] @@ -242,16 +247,21 @@ export function toAnthropicRequest(body: CommonRequest) { if ((m as any).role === "user") { if (typeof (m as any).content === "string") { - msgsOut.push({ - role: "user", - content: [{ type: "text", text: (m as any).content, ...cc() }], - }) + const text = (m as any).content.trim() + if (text.length > 0) { + msgsOut.push({ + role: "user", + content: [{ type: "text", text, ...cc() }], + }) + } } else if (Array.isArray((m as any).content)) { const parts: any[] = [] for (const p of (m as any).content) { if (!p || !(p as any).type) continue - if ((p as any).type === "text" && typeof (p as any).text === "string") - parts.push({ type: "text", text: (p as any).text, ...cc() }) + if ((p as any).type === "text" && typeof (p as any).text === "string") { + const t = (p as any).text.trim() + if (t.length > 0) parts.push({ type: "text", text: t, ...cc() }) + } if ((p as any).type === "image_url") { const s = toSrc(p) if (s) parts.push({ type: "image", source: s, ...cc() }) @@ -264,8 +274,8 @@ export function toAnthropicRequest(body: CommonRequest) { if ((m as any).role === "assistant") { const out: any = { role: "assistant", content: [] as any[] } - if (typeof (m as any).content === "string" && (m as any).content.length > 0) { - ;(out.content as any[]).push({ type: "text", text: (m as any).content, ...cc() }) + if (typeof (m as any).content === "string" && (m as any).content.trim().length > 0) { + ;(out.content as any[]).push({ type: "text", text: (m as any).content.trim(), ...cc() }) } if (Array.isArray((m as any).tool_calls)) { for (const tc of (m as any).tool_calls) { @@ -448,8 +458,8 @@ export function toAnthropicResponse(resp: CommonResponse) { const content: any[] = [] - if (typeof message.content === "string" && message.content.length > 0) - content.push({ type: "text", text: message.content }) + if (typeof message.content === "string" && message.content.trim().length > 0) + content.push({ type: "text", text: message.content.trim() }) if (Array.isArray(message.tool_calls)) { for (const tc of message.tool_calls) { diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 5771ed4faa1..06060060fec 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -80,18 +80,22 @@ export function fromOaCompatibleRequest(body: any): CommonRequest { if (!m || !m.role) continue if (m.role === "system") { - if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content }) + if (typeof m.content === "string" && m.content.trim().length > 0) + msgsOut.push({ role: "system", content: m.content.trim() }) continue } if (m.role === "user") { - if (typeof m.content === "string") { - msgsOut.push({ role: "user", content: m.content }) + if (typeof m.content === "string" && m.content.trim().length > 0) { + msgsOut.push({ role: "user", content: m.content.trim() }) } else if (Array.isArray(m.content)) { const parts: any[] = [] for (const p of m.content) { if (!p || !p.type) continue - if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text }) + if (p.type === "text" && typeof p.text === "string") { + const t = p.text.trim() + if (t.length > 0) parts.push({ type: "text", text: t }) + } if (p.type === "image_url") parts.push({ type: "image_url", image_url: p.image_url }) } if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text }) @@ -102,7 +106,7 @@ export function fromOaCompatibleRequest(body: any): CommonRequest { if (m.role === "assistant") { const out: any = { role: "assistant" } - if (typeof m.content === "string") out.content = m.content + if (typeof m.content === "string" && m.content.trim().length > 0) out.content = m.content.trim() if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls msgsOut.push(out) continue @@ -148,20 +152,24 @@ export function toOaCompatibleRequest(body: CommonRequest) { if (!m || !m.role) continue if (m.role === "system") { - if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content }) + if (typeof m.content === "string" && m.content.trim().length > 0) + msgsOut.push({ role: "system", content: m.content.trim() }) continue } if (m.role === "user") { - if (typeof m.content === "string") { - msgsOut.push({ role: "user", content: m.content }) + if (typeof m.content === "string" && m.content.trim().length > 0) { + msgsOut.push({ role: "user", content: m.content.trim() }) continue } if (Array.isArray(m.content)) { const parts: any[] = [] for (const p of m.content) { if (!p || !p.type) continue - if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text }) + if (p.type === "text" && typeof p.text === "string") { + const t = p.text.trim() + if (t.length > 0) parts.push({ type: "text", text: t }) + } const ip = toImg(p) if (ip) parts.push(ip) } @@ -173,7 +181,7 @@ export function toOaCompatibleRequest(body: CommonRequest) { if (m.role === "assistant") { const out: any = { role: "assistant" } - if (typeof m.content === "string") out.content = m.content + if (typeof m.content === "string" && m.content.trim().length > 0) out.content = m.content.trim() if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls msgsOut.push(out) continue @@ -223,8 +231,8 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse { const content: any[] = [] - if (typeof message.content === "string" && message.content.length > 0) { - content.push({ type: "text", text: message.content }) + if (typeof message.content === "string" && message.content.trim().length > 0) { + content.push({ type: "text", text: message.content.trim() }) } if (Array.isArray(message.tool_calls)) { diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index dff6e13fbe3..fac3d90bf01 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -114,24 +114,25 @@ export function fromOpenaiRequest(body: any): CommonRequest { if ((m as any).role === "system" || (m as any).role === "developer") { const c = (m as any).content - if (typeof c === "string" && c.length > 0) msgs.push({ role: "system", content: c }) + if (typeof c === "string" && c.trim().length > 0) msgs.push({ role: "system", content: c.trim() }) if (Array.isArray(c)) { const t = c.find((p: any) => p && typeof p.text === "string") - if (t && typeof t.text === "string" && t.text.length > 0) msgs.push({ role: "system", content: t.text }) + if (t && typeof t.text === "string" && t.text.trim().length > 0) + msgs.push({ role: "system", content: t.text.trim() }) } continue } if ((m as any).role === "user") { const c = (m as any).content - if (typeof c === "string") { - msgs.push({ role: "user", content: c }) - } else if (Array.isArray(c)) { + if (typeof c === "string" && c.trim().length > 0) msgs.push({ role: "user", content: c.trim() }) + else if (Array.isArray(c)) { const parts: any[] = [] for (const p of c) { if (!p || !(p as any).type) continue - if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string") - parts.push({ type: "text", text: (p as any).text }) + if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string") { + if ((p as any).text.trim().length > 0) parts.push({ type: "text", text: (p as any).text.trim() }) + } const ip = toImg(p) if (ip) parts.push(ip) if ((p as any).type === "tool_result") { @@ -150,7 +151,7 @@ export function fromOpenaiRequest(body: any): CommonRequest { if ((m as any).role === "assistant") { const c = (m as any).content const out: any = { role: "assistant" } - if (typeof c === "string" && c.length > 0) out.content = c + if (typeof c === "string" && c.trim().length > 0) out.content = c.trim() if (Array.isArray((m as any).tool_calls)) out.tool_calls = (m as any).tool_calls msgs.push(out) continue @@ -205,8 +206,11 @@ export function toOpenaiRequest(body: CommonRequest) { const toPart = (p: any) => { if (!p || typeof p !== "object") return undefined - if ((p as any).type === "text" && typeof (p as any).text === "string") - return { type: "input_text", text: (p as any).text } + if ((p as any).type === "text" && typeof (p as any).text === "string") { + const t = (p as any).text.trim() + if (t.length === 0) return undefined + return { type: "input_text", text: t } + } if ((p as any).type === "image_url" && (p as any).image_url) return { type: "input_image", image_url: (p as any).image_url } const s = (p as any).source @@ -230,15 +234,17 @@ export function toOpenaiRequest(body: CommonRequest) { if ((m as any).role === "system") { const c = (m as any).content - if (typeof c === "string") input.push({ role: "system", content: c }) + if (typeof c === "string" && c.trim().length > 0) input.push({ role: "system", content: c.trim() }) continue } if ((m as any).role === "user") { const c = (m as any).content - if (typeof c === "string") { - input.push({ role: "user", content: [{ type: "input_text", text: c }] }) - } else if (Array.isArray(c)) { + if (typeof c === "string" && c.trim().length > 0) { + input.push({ role: "user", content: [{ type: "input_text", text: c.trim() }] }) + continue + } + if (Array.isArray(c)) { const parts: any[] = [] for (const p of c) { const op = toPart(p) @@ -251,8 +257,8 @@ export function toOpenaiRequest(body: CommonRequest) { if ((m as any).role === "assistant") { const c = (m as any).content - if (typeof c === "string" && c.length > 0) { - input.push({ role: "assistant", content: [{ type: "output_text", text: c }] }) + if (typeof c === "string" && c.trim().length > 0) { + input.push({ role: "assistant", content: [{ type: "output_text", text: c.trim() }] }) } if (Array.isArray((m as any).tool_calls)) { for (const tc of (m as any).tool_calls) { @@ -416,13 +422,13 @@ export function toOpenaiResponse(resp: CommonResponse) { const outputItems: any[] = [] - if (typeof msg.content === "string" && msg.content.length > 0) { + if (typeof msg.content === "string" && msg.content.trim().length > 0) { outputItems.push({ id: `msg_${Math.random().toString(36).slice(2)}`, type: "message", status: "completed", role: "assistant", - content: [{ type: "output_text", text: msg.content, annotations: [], logprobs: [] }], + content: [{ type: "output_text", text: msg.content.trim(), annotations: [], logprobs: [] }], }) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 832e8c91e95..98e8b0685c2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -23,20 +23,33 @@ export namespace ProviderTransform { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { - if (msg.content === "") return undefined - return msg + if (msg.content.trim().length === 0) return undefined + return { ...msg, content: msg.content.trim() } } if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" - } - return true - }) + const filtered = msg.content + .filter((part) => { + if (part.type === "text" || part.type === "reasoning") { + if (typeof (part as any).text === "string") return (part as any).text.trim().length > 0 + return false + } + return true + }) + .map((part) => { + if ((part.type === "text" || part.type === "reasoning") && typeof (part as any).text === "string") { + return { ...part, text: (part as any).text.trim() } + } + return part + }) if (filtered.length === 0) return undefined return { ...msg, content: filtered } }) - .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") + .filter((msg): msg is ModelMessage => { + if (msg === undefined) return false + if (typeof msg.content === "string") return msg.content.length > 0 + if (Array.isArray(msg.content)) return msg.content.length > 0 + return true + }) } if (model.api.id.includes("claude")) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 32b1ecb2444..b564d4a2a79 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -558,6 +558,97 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) }) + test("filters out messages with whitespace-only string content", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: " " }, + { role: "assistant", content: "\t\n " }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("filters out whitespace-only text parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: " " }, + { type: "text", text: "Hello" }, + { type: "text", text: "\t\n" }, + { type: "reasoning", text: " " }, + { type: "text", text: "World" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + expect(result[0].content[1]).toEqual({ type: "text", text: "World" }) + }) + + test("removes entire message when all parts are whitespace-only", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: " " }, + { type: "reasoning", text: "\t" }, + ], + }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("trims leading and trailing whitespace from string content", () => { + const msgs = [ + { role: "user", content: " Hello " }, + { role: "assistant", content: " World\t\n" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("trims leading and trailing whitespace from array text content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: " Hello " }, + { type: "text", text: "\nWorld\t" }, + { type: "reasoning", text: " Thinking " }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(3) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + expect(result[0].content[1]).toEqual({ type: "text", text: "World" }) + expect(result[0].content[2]).toEqual({ type: "reasoning", text: "Thinking" }) + }) + test("does not filter for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, From 4e9038667f6fc45fb642678c96ca241b3e4ad714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 6 Jan 2026 14:42:13 +0100 Subject: [PATCH 2/3] refactor: align variable naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jérôme Benoit --- .../console/app/src/routes/zen/util/provider/anthropic.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index c7fc9115f79..645c2136b13 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -247,11 +247,11 @@ export function toAnthropicRequest(body: CommonRequest) { if ((m as any).role === "user") { if (typeof (m as any).content === "string") { - const text = (m as any).content.trim() - if (text.length > 0) { + const c = (m as any).content.trim() + if (c.length > 0) { msgsOut.push({ role: "user", - content: [{ type: "text", text, ...cc() }], + content: [{ type: "text", text: c, ...cc() }], }) } } else if (Array.isArray((m as any).content)) { From 1fc41f8d553e89f3462c2c5f3e45d70211d36a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 16 Jan 2026 16:16:49 +0100 Subject: [PATCH 3/3] fix(test): add missing options parameter to ProviderTransform.message calls The message() function signature was updated to require 3 arguments (msgs, model, options) but 5 test calls were still using only 2 arguments, causing TypeScript errors. Fixes typecheck failures in CI. --- packages/opencode/test/provider/transform.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 49ef631d4db..5fc0954d0f0 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -604,7 +604,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") @@ -625,7 +625,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -646,7 +646,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") @@ -659,7 +659,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { role: "assistant", content: " World\t\n" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") @@ -678,7 +678,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(3)