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
30 changes: 24 additions & 6 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,32 @@ function normalizeMessages(
})

// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
// and remove empty/whitespace-only text and empty reasoning parts from array content.
// Anthropic returns 400 "text content blocks must contain non-whitespace text" for any
// text block that is empty OR whitespace-only, so whitespace must be treated like empty.
if (model.api.npm === "@ai-sdk/anthropic") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
if (msg.content.trim() === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
// MessageV2.toModelMessages substitutes a single space for empty assistant text
// that sits between signed reasoning blocks; the AI SDK merges that separator into
// an adjacent block so it never reaches the API as a standalone whitespace block.
// Preserve a whitespace-only text part only when this message still carries
// signed/redacted Anthropic reasoning that survives filtering below; otherwise drop
// it, since a standalone whitespace text block is rejected by Anthropic.
const hasSignedReasoning = msg.content.some(
(part) =>
part.type === "reasoning" &&
(part.providerOptions?.anthropic?.signature != null ||
part.providerOptions?.anthropic?.redactedData != null),
)
const filtered = msg.content.filter((part) => {
if (part.type === "text") {
return part.text !== ""
return hasSignedReasoning ? part.text !== "" : part.text.trim() !== ""
}
if (part.type === "reasoning") {
return (
Expand All @@ -158,18 +172,22 @@ function normalizeMessages(
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}

// Bedrock specific transforms
// Bedrock specific transforms. Claude on Bedrock has the same Anthropic content rules, so
// empty/whitespace-only text blocks are rejected too. Unlike the direct Anthropic path,
// MessageV2.toModelMessages does not emit the single-space reasoning separator for Bedrock
// (its substitution is gated on the 'anthropic' signature namespace), so whitespace-only
// text is always stray here and can be dropped unconditionally.
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
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") {
return part.text !== ""
return part.text.trim() !== ""
}
if (part.type === "reasoning") {
return (
Expand Down
111 changes: 111 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1744,6 +1744,117 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
])
})

test("drops an assistant message whose only text part is whitespace", () => {
// Regression: github-copilot Claude (via /v1/messages) returned HTTP 400
// "text content blocks must contain non-whitespace text" when history contained an
// assistant turn whose single content block was a lone space (e.g. an empty response
// from a cheap fallback model that was later replayed to Claude).
const msgs = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: [{ type: "text", text: " " }] },
{ 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("drops whitespace-only text parts but keeps real text", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "text", text: " " },
{ type: "text", text: "Hello" },
{ type: "text", text: "\n\t" },
],
},
] as any[]

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

expect(result).toHaveLength(1)
expect(result[0].content).toEqual([{ type: "text", text: "Hello" }])
})

test("preserves a single-space separator between signed reasoning blocks", () => {
// MessageV2 emits a " " separator alongside signed reasoning; it must survive so the
// AI SDK can merge it, otherwise signed-reasoning replay positioning changes.
const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerOptions: { anthropic: { signature: "sig1" } } },
{ type: "text", text: " " },
],
},
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]

expect(result).toHaveLength(1)
const texts = result[0].content.filter((p: any) => p.type === "text")
expect(texts).toHaveLength(1)
expect(texts[0].text).toBe(" ")
expect(result[0].content.some((p: any) => p.type === "reasoning")).toBe(true)
})

test("drops whitespace text when reasoning is empty/unsigned (no separator leak)", () => {
// The space must not survive merely because a reasoning part is present: an empty,
// unsigned reasoning part is filtered out, which would otherwise leave a lone " ".
const msgs = [
{ role: "user", content: "Hi" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "" },
{ type: "text", text: " " },
],
},
{ role: "user", content: "Bye" },
] as any[]

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

expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hi")
expect(result[1].content).toBe("Bye")
})

test("drops whitespace-only text for bedrock claude", () => {
const bedrockModel = {
...anthropicModel,
id: "amazon-bedrock/anthropic.claude-opus-4-6",
providerID: "amazon-bedrock",
api: {
id: "anthropic.claude-opus-4-6",
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
}

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

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

expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hello")
expect(result[1].content).toEqual([{ type: "text", text: "Answer" }])
})
})

describe("ProviderTransform.message - strip openai metadata when store=false", () => {
Expand Down
Loading