From fe3f724486c2f7b9099507c916eccc1c6245a2f5 Mon Sep 17 00:00:00 2001 From: Tom-Ryder Date: Sat, 21 Feb 2026 09:44:19 -0800 Subject: [PATCH 1/2] fix: filter empty content blocks for Bedrock provider --- packages/opencode/src/provider/transform.ts | 2 +- .../opencode/test/provider/transform.test.ts | 149 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b659799c1b6..0d5887e08b5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -51,7 +51,7 @@ 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") { + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 189bdfd32b4..36be3e8591f 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1022,6 +1022,155 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) +describe("ProviderTransform.message - bedrock empty content filtering", () => { + const bedrockModel = { + id: "amazon-bedrock/anthropic.claude-sonnet-4", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-sonnet-4", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Sonnet 4 (Bedrock)", + 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: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("filters out messages with empty string content", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "" }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("filters out empty text parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Hello" }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + }) + + test("filters out empty reasoning parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "" }, + { type: "text", text: "Answer" }, + { type: "reasoning", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) + }) + + test("removes entire message when all parts are empty", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "reasoning", text: "" }, + ], + }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("keeps non-text/reasoning parts even if text parts are empty", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ + type: "tool-call", + toolCallId: "123", + toolName: "bash", + input: { command: "ls" }, + }) + }) + + test("keeps messages with valid text alongside empty parts", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "" }, + { type: "text", text: "Result" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) + expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) + }) +}) + describe("ProviderTransform.message - strip openai metadata when store=false", () => { const openaiModel = { id: "openai/gpt-5", From 27922ddbf38b7e500b5387769403bd8c4f402c5d Mon Sep 17 00:00:00 2001 From: Tom-Ryder Date: Tue, 24 Feb 2026 18:28:02 -0800 Subject: [PATCH 2/2] fix: drop reasoning-only messages in interleaved filter --- packages/opencode/src/provider/transform.ts | 50 ++- .../opencode/test/provider/transform.test.ts | 365 +++++++----------- 2 files changed, 171 insertions(+), 244 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0d5887e08b5..e66cbf9685c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,37 +135,31 @@ export namespace ProviderTransform { if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { const field = model.capabilities.interleaved.field - return msgs.map((msg) => { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") - const reasoningText = reasoningParts.map((part: any) => part.text).join("") - - // Filter out reasoning parts from content - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - - // Include reasoning_content | reasoning_details directly on the message for all assistant messages - if (reasoningText) { - return { - ...msg, - content: filteredContent, - providerOptions: { - ...msg.providerOptions, - openaiCompatible: { - ...(msg.providerOptions as any)?.openaiCompatible, - [field]: reasoningText, + return msgs + .map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") + const reasoningText = reasoningParts.map((part: any) => part.text).join("") + const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + if (filteredContent.length === 0) return undefined + if (reasoningText) { + return { + ...msg, + content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...(msg.providerOptions as any)?.openaiCompatible, + [field]: reasoningText, + }, }, - }, + } } + return { ...msg, content: filteredContent } } - - return { - ...msg, - content: filteredContent, - } - } - - return msg - }) + return msg + }) + .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 36be3e8591f..7838baabc18 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -847,7 +847,7 @@ describe("ProviderTransform.message - empty image handling", () => { }) }) -describe("ProviderTransform.message - anthropic empty content filtering", () => { +describe("ProviderTransform.message - empty content filtering", () => { const anthropicModel = { id: "anthropic/claude-3-5-sonnet", providerID: "anthropic", @@ -866,152 +866,157 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false, }, - cost: { - input: 0.003, - output: 0.015, - cache: { read: 0.0003, write: 0.00375 }, - }, - limit: { - context: 200000, - output: 8192, - }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, status: "active", options: {}, headers: {}, } as any - test("filters out messages with empty string content", () => { - const msgs = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "" }, - { role: "user", content: "World" }, - ] as any[] + const bedrockModel = { + ...anthropicModel, + id: "amazon-bedrock/anthropic.claude-sonnet-4", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-sonnet-4", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Sonnet 4 (Bedrock)", + } as any - const result = ProviderTransform.message(msgs, anthropicModel, {}) + for (const [label, model] of [ + ["anthropic", anthropicModel], + ["bedrock", bedrockModel], + ] as [string, any][]) { + describe(label, () => { + test("filters out messages with empty string content", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "" }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("World") - }) + test("filters out empty text parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Hello" }, + { type: "text", text: "" }, + ], + }, + ] as any[] - test("filters out empty text parts from array content", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "text", text: "Hello" }, - { type: "text", text: "" }, - ], - }, - ] as any[] + const result = ProviderTransform.message(msgs, model, {}) - const result = ProviderTransform.message(msgs, anthropicModel, {}) + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + }) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) - }) + test("filters out empty reasoning parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "" }, + { type: "text", text: "Answer" }, + { type: "reasoning", text: "" }, + ], + }, + ] as any[] - test("filters out empty reasoning parts from array content", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "reasoning", text: "" }, - { type: "text", text: "Answer" }, - { type: "reasoning", text: "" }, - ], - }, - ] as any[] + const result = ProviderTransform.message(msgs, model, {}) - const result = ProviderTransform.message(msgs, anthropicModel, {}) + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) + }) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) - }) + test("removes entire message when all parts are empty", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "reasoning", text: "" }, + ], + }, + { role: "user", content: "World" }, + ] as any[] - test("removes entire message when all parts are empty", () => { - const msgs = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "reasoning", text: "" }, - ], - }, - { role: "user", content: "World" }, - ] as any[] + const result = ProviderTransform.message(msgs, model, {}) - const result = ProviderTransform.message(msgs, anthropicModel, {}) + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("World") - }) + test("keeps non-text/reasoning parts even if text parts are empty", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ + type: "tool-call", + toolCallId: "123", + toolName: "bash", + input: { command: "ls" }, + }) + }) - test("keeps non-text/reasoning parts even if text parts are empty", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, - ], - }, - ] as any[] + test("keeps messages with valid text alongside empty parts", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "" }, + { type: "text", text: "Result" }, + ], + }, + ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = ProviderTransform.message(msgs, model, {}) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ - type: "tool-call", - toolCallId: "123", - toolName: "bash", - input: { command: "ls" }, + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) + expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) + }) }) - }) - - test("keeps messages with valid text alongside empty parts", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "" }, - { type: "text", text: "Result" }, - ], - }, - ] 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: "reasoning", text: "Thinking..." }) - expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) - }) + } test("does not filter for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, providerID: "openai", - api: { - id: "gpt-4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, + api: { id: "gpt-4", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, } const msgs = [ { role: "assistant", content: "" }, - { - role: "assistant", - content: [{ type: "text", text: "" }], - }, + { role: "assistant", content: [{ type: "text", text: "" }] }, ] as any[] const result = ProviderTransform.message(msgs, openaiModel, {}) @@ -1022,152 +1027,80 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) -describe("ProviderTransform.message - bedrock empty content filtering", () => { - const bedrockModel = { - id: "amazon-bedrock/anthropic.claude-sonnet-4", +describe("ProviderTransform.message - interleaved reasoning filter drops empty messages", () => { + const interleavedModel = { + id: "amazon-bedrock/zai.glm-4.7", providerID: "amazon-bedrock", api: { - id: "anthropic.claude-sonnet-4", + id: "zai.glm-4.7", url: "https://bedrock-runtime.us-east-1.amazonaws.com", npm: "@ai-sdk/amazon-bedrock", }, - name: "Claude Sonnet 4 (Bedrock)", + name: "GLM 4.7", capabilities: { temperature: true, - reasoning: false, - attachment: true, + reasoning: true, + attachment: false, toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: true }, + input: { text: true, audio: false, image: false, video: false, pdf: false }, output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { - input: 0.003, - output: 0.015, - cache: { read: 0.0003, write: 0.00375 }, - }, - limit: { - context: 200000, - output: 8192, + interleaved: { field: "reasoning_content" }, }, + cost: { input: 0.45, output: 1.8, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 96000 }, status: "active", options: {}, headers: {}, } as any - test("filters out messages with empty string content", () => { - const msgs = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "" }, - { role: "user", content: "World" }, - ] as any[] - - const result = ProviderTransform.message(msgs, bedrockModel, {}) - - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("World") - }) - - test("filters out empty text parts from array content", () => { + test("drops assistant message that has only reasoning parts", () => { const msgs = [ + { role: "user", content: "hello" }, { role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "text", text: "Hello" }, - { type: "text", text: "" }, - ], + content: [{ type: "reasoning", text: "Let me think about this..." }], }, + { role: "user", content: "go on" }, ] as any[] - const result = ProviderTransform.message(msgs, bedrockModel, {}) + const result = ProviderTransform.message(msgs, interleavedModel, {}) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + expect(result).toHaveLength(2) + expect(result[0].content).toBe("hello") + expect(result[1].content).toBe("go on") }) - test("filters out empty reasoning parts from array content", () => { + test("keeps assistant message that has reasoning plus text, moves reasoning to providerOptions", () => { const msgs = [ { role: "assistant", content: [ - { type: "reasoning", text: "" }, + { type: "reasoning", text: "Thinking..." }, { type: "text", text: "Answer" }, - { type: "reasoning", text: "" }, ], }, ] as any[] - const result = ProviderTransform.message(msgs, bedrockModel, {}) + const result = ProviderTransform.message(msgs, interleavedModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) + expect((result[0] as any).providerOptions?.openaiCompatible?.reasoning_content).toBe("Thinking...") }) - test("removes entire message when all parts are empty", () => { + test("keeps assistant message with tool-call and no reasoning", () => { const msgs = [ - { role: "user", content: "Hello" }, { role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "reasoning", text: "" }, - ], + content: [{ type: "tool-call", toolCallId: "abc", toolName: "bash", input: {} }], }, - { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, bedrockModel, {}) - - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("World") - }) - - test("keeps non-text/reasoning parts even if text parts are empty", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, - ], - }, - ] as any[] - - const result = ProviderTransform.message(msgs, bedrockModel, {}) + const result = ProviderTransform.message(msgs, interleavedModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ - type: "tool-call", - toolCallId: "123", - toolName: "bash", - input: { command: "ls" }, - }) - }) - - test("keeps messages with valid text alongside empty parts", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "" }, - { type: "text", text: "Result" }, - ], - }, - ] as any[] - - const result = ProviderTransform.message(msgs, bedrockModel, {}) - - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(2) - expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) - expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) }) })