From 954cc6d0f574389142f93d3dcea74f6662fa3e4b Mon Sep 17 00:00:00 2001 From: Tushar Agarwal <76201310+Tushar49@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:38:36 +0530 Subject: [PATCH] fix(provider): drop whitespace-only text blocks for Anthropic/Bedrock GitHub Copilot Claude models (routed through the Anthropic /v1/messages endpoint via @ai-sdk/anthropic) returned HTTP 400 "messages: text content blocks must contain non-whitespace text" whenever the conversation history contained an assistant turn whose only text block was whitespace (e.g. a single space). This commonly happens when a cheap fallback model returns an empty/whitespace response that is later replayed to Claude, and it makes the affected models unusable mid-conversation even though they are listed and selectable. ProviderTransform.message already strips empty ("") text/reasoning parts for Anthropic, but the text guard only matched exactly "" and let whitespace-only blocks (" ", "\n", ...) through, while the reasoning guard right below already used .trim(). Treat whitespace-only text the same as empty. A single space is also deliberately emitted by MessageV2.toModelMessages as a separator between *signed* reasoning blocks, so preserve whitespace-only text only when the message still carries surviving signed/redacted Anthropic reasoning (the AI SDK merges that separator into an adjacent block). Bedrock never emits that separator, so whitespace-only text is dropped unconditionally there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 30 ++++- .../opencode/test/provider/transform.test.ts | 111 ++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 79cfa3ea508a..8e692121b297 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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 ( @@ -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 ( diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 674d4ef00475..d2a8fe0ea766 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -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", () => {