Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,45 @@ export namespace ProviderTransform {
model: Provider.Model,
options: Record<string, unknown>,
): 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") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
const preserveAdaptiveAnthropicReasoning = ["sonnet-4-6", "sonnet-4.6", "opus-4-6", "opus-4.6"].some(
(variant) => model.id.includes(variant) || model.api.id.includes(variant),
)

// Many providers (Anthropic, Bedrock, and proxies like openai-compatible
// forwarding to Bedrock) reject messages with empty text content blocks.
// Filter them out universally - empty text blocks are never useful.
msgs = msgs
.map((msg) => {
// Anthropic adaptive thinking signs assistant reasoning blocks positionally.
// Preserve these messages verbatim, including whitespace-only text separators.
const preserveAssistantReasoning =
preserveAdaptiveAnthropicReasoning &&
msg.role === "assistant" &&
Array.isArray(msg.content) &&
msg.content.some((part) => part.type === "reasoning")

if (preserveAssistantReasoning) return msg

if (typeof msg.content === "string") {
if (!msg.content.trim()) return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
if (typeof part.text !== "string") return false
return part.text.trim().length > 0
}
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
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
return true
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
})
.filter((msg): msg is ModelMessage => {
if (!msg) return false
if (typeof msg.content !== "string") return true
return msg.content.trim().length > 0
})

if (model.api.id.includes("claude")) {
return msgs.map((msg) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ export namespace MessageV2 {
}
result.push(userMessage)
for (const part of msg.parts) {
if (part.type === "text" && !part.ignored)
if (part.type === "text" && !part.ignored && part.text.trim())
userMessage.parts.push({
type: "text",
text: part.text,
Expand Down Expand Up @@ -684,7 +684,7 @@ export namespace MessageV2 {
parts: [],
}
for (const part of msg.parts) {
if (part.type === "text")
if (part.type === "text" && part.text.trim())
assistantMessage.parts.push({
type: "text",
text: part.text,
Expand Down Expand Up @@ -747,7 +747,7 @@ export namespace MessageV2 {
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
}
if (part.type === "reasoning") {
if (part.type === "reasoning" && part.text.trim()) {
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
Expand Down
69 changes: 59 additions & 10 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,42 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
})

test("preserves whitespace text separators in assistant reasoning messages", () => {
const adaptiveAnthropicModel = {
...anthropicModel,
id: "anthropic/claude-sonnet-4-6",
api: {
...anthropicModel.api,
id: "claude-sonnet-4-6-20260301",
},
capabilities: {
...anthropicModel.capabilities,
reasoning: true,
},
}

const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking step 1" },
{ type: "text", text: " " },
{ type: "reasoning", text: "Thinking step 2" },
{ type: "text", text: "Result" },
],
},
] as any[]

const result = ProviderTransform.message(msgs, adaptiveAnthropicModel, {})

expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(4)
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking step 1" })
expect(result[0].content[1]).toEqual({ type: "text", text: " " })
expect(result[0].content[2]).toEqual({ type: "reasoning", text: "Thinking step 2" })
expect(result[0].content[3]).toEqual({ type: "text", text: "Result" })
})

test("filters empty content for bedrock provider", () => {
const bedrockModel = {
...anthropicModel,
Expand Down Expand Up @@ -1128,30 +1164,43 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" })
})

test("does not filter for non-anthropic providers", () => {
const openaiModel = {
test("filters empty content for all providers including openai-compatible", () => {
const model = {
...anthropicModel,
providerID: "openai",
providerID: "ducc",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
id: "ducc/claude-sonnet-4-6",
url: "https://example.com/v1/",
npm: "@ai-sdk/openai-compatible",
},
}

const msgs = [
{ role: "assistant", content: "" },
{ role: "assistant", content: " " },
{
role: "assistant",
content: [{ type: "text", text: "" }],
},
{
role: "assistant",
content: [{ type: "text", text: " " }],
},
{
role: "user",
content: [
{ type: "text", text: "" },
{ type: "text", text: "hello" },
],
},
] as any[]

const result = ProviderTransform.message(msgs, openaiModel, {})
const result = ProviderTransform.message(msgs, model, {})

expect(result).toHaveLength(2)
expect(result[0].content).toBe("")
expect(result[1].content).toHaveLength(1)
expect(result).toHaveLength(1)
expect(result[0].role).toBe("user")
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toMatchObject({ type: "text", text: "hello" })
})
})

Expand Down
Loading