diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index e459380faae4..51a2316d133b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -473,6 +473,29 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re }) } + // Final safety net: filter out messages with empty content from any provider. + // Individual transforms above each have their own (or no) guard; this single + // pass catches every current and future case that could otherwise cause + // provider API errors — including DeepSeek (messages.N: all messages must + // have non-empty content), Bedrock (ValidationException), and others. + msgs = msgs + .map((msg) => { + if (typeof msg.content === "string") { + if (msg.content === "") return undefined + return msg + } + if (!Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if (part.type === "text") return part.text !== "" + if (part.type === "reasoning") return part.text.trim().length > 0 + return true + }) + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => msg !== undefined) + + return msgs } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5fa530155d6e..bb623ceb2ed8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4502,3 +4502,132 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + + +describe("ProviderTransform.message - empty content filtering", () => { + const deepseekModel = { + id: ModelV2.ID.make("deepseek/deepseek-chat"), + providerID: ProviderV2.ID.make("deepseek"), + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "DeepSeek Chat", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { + field: "reasoning_content", + }, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-04-01", + } + + test("filters out messages with empty string content", () => { + const msgs = [ + { role: "user", content: "" }, + { role: "user", content: "valid message" }, + { role: "assistant", content: "" }, + ] as any[] + const result = ProviderTransform.message(msgs, deepseekModel, {}) + expect(result).toHaveLength(1) + expect(result[0].content).toBe("valid message") + }) + + test("filters out messages with empty array content", () => { + const msgs = [ + { role: "assistant", content: [] }, + { role: "user", content: "valid" }, + ] as any[] + const result = ProviderTransform.message(msgs, deepseekModel, {}) + expect(result).toHaveLength(1) + expect(result[0].content).toBe("valid") + }) + + test("filters out messages where all text parts are empty", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "reasoning", text: "" }, + ], + }, + { role: "user", content: "valid" }, + ] as any[] + const result = ProviderTransform.message(msgs, deepseekModel, {}) + expect(result).toHaveLength(1) + expect(result[0].content).toBe("valid") + }) + + test("preserves non-empty messages in mixed context", () => { + const msgs = [ + { role: "system", content: "system prompt" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "" }, + { role: "user", content: "follow up" }, + { + role: "assistant", + content: [{ type: "text", text: "reply" }], + }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "reasoning", text: " " }, + ], + }, + ] as any[] + const result = ProviderTransform.message(msgs, deepseekModel, {}) as any[] + expect(result).toHaveLength(4) + expect(result[0].content).toBe("system prompt") + expect(result[1].content).toBe("hello") + expect(result[2].content).toBe("follow up") + expect(result[3].content[0].text).toBe("reply") + }) + + test("applies empty-content guard regardless of provider", () => { + // The guard is provider-agnostic — verify it works for a non-DeepSeek model too + const openaiModel = { + id: ModelV2.ID.make("openai/gpt-4"), + providerID: ProviderV2.ID.make("openai"), + api: { id: "gpt-4", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + name: "GPT-4", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-03-14", + } + + const msgs = [ + { role: "user", content: "" }, + { role: "assistant", content: [] }, + { role: "user", content: "only valid message" }, + ] as any[] + const result = ProviderTransform.message(msgs, openaiModel, {}) + expect(result).toHaveLength(1) + expect(result[0].content).toBe("only valid message") + }) +}) +