diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 92b001a6f691..16a1be5a7641 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1221,6 +1221,23 @@ export namespace Provider { } } + // Anthropic/Bedrock via openai-compatible rejects assistant messages with content: "" + // When an assistant turn has only tool_calls and no text, the AI SDK serializes + // content as "". Replace with null so the API accepts the request. + if (model.api.npm === "@ai-sdk/openai-compatible" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + if (Array.isArray(body.messages)) { + let changed = false + for (const msg of body.messages) { + if (msg.role === "assistant" && msg.content === "") { + msg.content = null + changed = true + } + } + if (changed) opts.body = JSON.stringify(body) + } + } + const res = await fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05b9f031fe64..913a6531b4e3 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -51,7 +51,11 @@ export namespace ProviderTransform { ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { + if ( + model.api.npm === "@ai-sdk/anthropic" || + model.api.npm === "@ai-sdk/amazon-bedrock" || + model.api.npm === "@ai-sdk/openai-compatible" + ) { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { @@ -59,12 +63,29 @@ export namespace ProviderTransform { return msg } 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 + .map((part: any) => { + // Filter empty text blocks nested inside tool-result content arrays + if (part.type === "tool-result" && part.output?.type === "content" && Array.isArray(part.output.value)) { + const cleaned = part.output.value.filter( + (block: any) => block.type !== "text" || (block.text && block.text !== ""), + ) + return { + ...part, + output: { + ...part.output, + value: cleaned.length > 0 ? cleaned : [{ type: "text", text: "[No output]" }], + }, + } + } + return part + }) + .filter((part: any) => { + if (part.type === "text" || part.type === "reasoning") { + return part.text && part.text !== "" + } + return true + }) if (filtered.length === 0) return undefined return { ...msg, content: filtered } }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8e4babd61924..8bc95e074db8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -598,19 +598,18 @@ export namespace MessageV2 { return attachment.url.startsWith("data:") && attachment.url.includes(",") }) + const mediaBlocks = attachments.map((attachment) => ({ + type: "media", + mediaType: attachment.mime, + data: iife(() => { + const commaIndex = attachment.url.indexOf(",") + return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) + }), + })) + const value = [...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), ...mediaBlocks] return { type: "content", - value: [ - { type: "text", text: outputObject.text }, - ...attachments.map((attachment) => ({ - type: "media", - mediaType: attachment.mime, - data: iife(() => { - const commaIndex = attachment.url.indexOf(",") - return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) - }), - })), - ], + value: value.length > 0 ? value : [{ type: "text", text: "[No output]" }], } } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index b14d27522406..36d916a169c5 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2282,3 +2282,232 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }, }) }) + +test("openai-compatible provider transforms empty assistant content to null", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "test-compat": { + name: "Test OpenAI Compatible", + npm: "@ai-sdk/openai-compatible", + api: "https://api.test.com/v1", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 8000, output: 2000 }, + }, + }, + options: { + apiKey: "test-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let captured: any = null + const mock = async (input: any, init?: any) => { + if (init?.body && init.method === "POST") { + captured = JSON.parse(init.body) + } + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + + const original = globalThis.fetch + try { + globalThis.fetch = mock as any + + const model = await Provider.getModel(ProviderID.make("test-compat"), ModelID.make("test-model")) + await Provider.getLanguage(model) + + const provider = await Provider.getProvider(ProviderID.make("test-compat")) + const opts = provider?.options as any + + if (opts?.fetch) { + await opts.fetch("https://api.test.com/v1/chat", { + method: "POST", + body: JSON.stringify({ + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "" }, + { role: "user", content: "next" }, + ], + }), + }) + + expect(captured.messages[0].content).toBe("hi") + expect(captured.messages[1].content).toBe(null) + expect(captured.messages[2].content).toBe("next") + } + } finally { + globalThis.fetch = original + } + }, + }) +}) + +test("openai-compatible provider preserves non-empty assistant content", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "test-compat": { + name: "Test OpenAI Compatible", + npm: "@ai-sdk/openai-compatible", + api: "https://api.test.com/v1", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 8000, output: 2000 }, + }, + }, + options: { + apiKey: "test-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let captured: any = null + const mock = async (input: any, init?: any) => { + if (init?.body && init.method === "POST") { + captured = JSON.parse(init.body) + } + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + + const original = globalThis.fetch + try { + globalThis.fetch = mock as any + + const model = await Provider.getModel(ProviderID.make("test-compat"), ModelID.make("test-model")) + await Provider.getLanguage(model) + + const provider = await Provider.getProvider(ProviderID.make("test-compat")) + const opts = provider?.options as any + + if (opts?.fetch) { + await opts.fetch("https://api.test.com/v1/chat", { + method: "POST", + body: JSON.stringify({ + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "response" }, + { role: "user", content: "next" }, + ], + }), + }) + + expect(captured.messages[1].content).toBe("response") + } + } finally { + globalThis.fetch = original + } + }, + }) +}) + +test("openai-compatible provider only transforms assistant role empty content", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "test-compat": { + name: "Test OpenAI Compatible", + npm: "@ai-sdk/openai-compatible", + api: "https://api.test.com/v1", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 8000, output: 2000 }, + }, + }, + options: { + apiKey: "test-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let captured: any = null + const mock = async (input: any, init?: any) => { + if (init?.body && init.method === "POST") { + captured = JSON.parse(init.body) + } + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + + const original = globalThis.fetch + try { + globalThis.fetch = mock as any + + const model = await Provider.getModel(ProviderID.make("test-compat"), ModelID.make("test-model")) + await Provider.getLanguage(model) + + const provider = await Provider.getProvider(ProviderID.make("test-compat")) + const opts = provider?.options as any + + if (opts?.fetch) { + await opts.fetch("https://api.test.com/v1/chat", { + method: "POST", + body: JSON.stringify({ + messages: [ + { role: "user", content: "" }, + { role: "assistant", content: "" }, + { role: "system", content: "" }, + ], + }), + }) + + expect(captured?.messages?.[0]?.content).toBe("") + expect(captured?.messages?.[1]?.content).toBe(null) + expect(captured?.messages?.[2]?.content).toBe("") + } + } finally { + globalThis.fetch = original + } + }, + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 917d357eafae..c3a7cdfcfa3f 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -995,6 +995,93 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toBe("World") }) + test("filters empty text blocks from tool-result content arrays", () => { + const msgs = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "test-id", + output: { + type: "content", + value: [ + { type: "text", text: "" }, + { type: "text", text: "Valid content" }, + { type: "text", text: "" }, + ], + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + const toolResult = result[0].content[0] as any + expect(toolResult.output.value).toEqual([{ type: "text", text: "Valid content" }]) + }) + + test("replaces all-empty tool-result content with placeholder", () => { + const msgs = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "test-id", + output: { + type: "content", + value: [ + { type: "text", text: "" }, + { type: "text", text: "" }, + ], + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + const toolResult = result[0].content[0] as any + expect(toolResult.output.value).toEqual([{ type: "text", text: "[No output]" }]) + }) + + test("preserves non-text blocks in tool-result content", () => { + const msgs = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "test-id", + output: { + type: "content", + value: [ + { type: "text", text: "" }, + { type: "image", url: "data:image/png;base64,abc" }, + { type: "text", text: "Output text" }, + ], + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + const toolResult = result[0].content[0] as any + expect(toolResult.output.value).toEqual([ + { type: "image", url: "data:image/png;base64,abc" }, + { type: "text", text: "Output text" }, + ]) + }) + test("filters out empty text parts from array content", () => { const msgs = [ { @@ -1128,6 +1215,38 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) }) + test("filters empty content for openai-compatible provider", () => { + const compatibleModel = { + ...anthropicModel, + id: "custom/custom-model", + providerID: "custom", + api: { + id: "custom-model", + url: "https://api.custom.com", + npm: "@ai-sdk/openai-compatible", + }, + } + + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "" }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, compatibleModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) + }) + test("does not filter for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729bb9..ea402d4381df 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -358,6 +358,159 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("handles tool output with empty text but has attachments", () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-1"), + type: "file", + mime: "image/png", + filename: "screenshot.png", + url: "data:image/png;base64,abc123", + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: "abc123" }], + }, + }, + ], + }, + ]) + }) + + test("handles tool output with empty text and no attachments returns placeholder", () => { + const userID = "m-user" + const assistantID = "m-assistant" + + // Create an output object with empty text and no attachments + const output: any = { text: "", attachments: [] } + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output, + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [], + }, + } as any, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { + type: "content", + value: [{ type: "text", text: "[No output]" }], + }, + }, + ], + }, + ]) + }) + test("omits provider metadata when assistant model differs", () => { const userID = "m-user" const assistantID = "m-assistant"