From 62bf497d74763026845b5fc174e32261a10359bb Mon Sep 17 00:00:00 2001 From: "Kuo, Joseph" <> Date: Wed, 22 Apr 2026 21:45:17 -0700 Subject: [PATCH 1/3] fix(provider): preserve Bedrock Claude reasoning replay --- packages/opencode/src/provider/transform.ts | 9 ++- .../opencode/test/provider/transform.test.ts | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1d84c7c93127..f43ef6b8f4a1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -175,7 +175,11 @@ function normalizeMessages( return result } - if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { + if ( + model.api.npm === "@ai-sdk/openai-compatible" && + 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)) { @@ -185,7 +189,8 @@ function normalizeMessages( // 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 + // Include reasoning_content | reasoning_details directly on the message for + // OpenAI-compatible assistant messages. if (reasoningText) { return { ...msg, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 791fcdedc676..8089f808b278 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -977,6 +977,73 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { ]) expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) + + test("Bedrock providers keep reasoning metadata even if interleaved is configured", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Thinking...", + providerMetadata: { + bedrock: { signature: "sig-123" }, + }, + }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + { + id: ModelID.make("bedrock/anthropic-claude-opus-4-7"), + providerID: ProviderID.make("bedrock"), + api: { + id: "anthropic.claude-opus-4-7", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Opus 4.7", + capabilities: { + temperature: true, + reasoning: true, + 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: { + field: "reasoning_content", + }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 200_000, + output: 64_000, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + }, + {}, + ) + + expect(result[0].content[0]).toMatchObject({ + type: "reasoning", + text: "Thinking...", + providerMetadata: { + bedrock: { signature: "sig-123" }, + }, + }) + expect(result[0].content[1]).toEqual({ type: "text", text: "Answer" }) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() + }) }) describe("ProviderTransform.message - empty image handling", () => { From 9b50b134069afd86d0017f8bb37aa0bf97ba0816 Mon Sep 17 00:00:00 2001 From: "Kuo, Joseph" <> Date: Thu, 30 Apr 2026 16:26:54 -0700 Subject: [PATCH 2/3] Fix Bedrock Claude reasoning replay --- packages/opencode/src/provider/transform.ts | 44 ++-- .../opencode/test/provider/transform.test.ts | 204 ++++++++++++++++-- .../opencode/test/session/message-v2.test.ts | 75 +++++++ 3 files changed, 299 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 36ec9ee9ee4e..d31fc3dd5e8a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -45,6 +45,36 @@ function sdkKey(npm: string): string | undefined { return undefined } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isBedrockClaude(model: Provider.Model) { + if (model.api.npm !== "@ai-sdk/amazon-bedrock") return false + const id = `${model.id}/${model.api.id}`.toLowerCase() + return id.includes("anthropic") || id.includes("claude") +} + +function hasBedrockThinkingSignature(part: Record) { + if (typeof part.signature === "string" && part.signature.length > 0) return true + return [part.providerOptions, part.providerMetadata].some((metadata) => { + if (!isRecord(metadata)) return false + return ["bedrock", "amazon-bedrock"].some((key) => { + const provider = metadata[key] + return isRecord(provider) && typeof provider.signature === "string" && provider.signature.length > 0 + }) + }) +} + +function keepContentPart(part: unknown, model: Provider.Model) { + if (!isRecord(part)) return true + if (part.type === "thinking") return isBedrockClaude(model) ? hasBedrockThinkingSignature(part) : true + if (part.type !== "text" && part.type !== "reasoning") return true + if (part.type === "text") return part.text !== "" + if (isBedrockClaude(model)) return hasBedrockThinkingSignature(part) + return part.text !== "" +} + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, @@ -60,12 +90,7 @@ function normalizeMessages( 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.filter((part) => keepContentPart(part, model)) if (filtered.length === 0) return undefined return { ...msg, content: filtered } }) @@ -81,12 +106,7 @@ function normalizeMessages( 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.filter((part) => keepContentPart(part, model)) if (filtered.length === 0) return undefined return { ...msg, content: filtered } }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index d5cd9dc184c7..dfcc6085b5ba 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "@/provider/transform" import { ModelID, ProviderID } from "../../src/provider/schema" +import type { ModelMessage } from "ai" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -1326,6 +1327,17 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => headers: {}, } as any + const bedrock = (apiId: string) => ({ + ...anthropicModel, + id: `amazon-bedrock/${apiId}`, + providerID: "amazon-bedrock", + api: { + id: apiId, + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + test("filters out messages with empty string content", () => { const msgs = [ { role: "user", content: "Hello" }, @@ -1442,17 +1454,6 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) test("filters empty content for bedrock provider", () => { - 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: "" }, @@ -1465,7 +1466,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, bedrockModel, {}) + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-6"), {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") @@ -1473,6 +1474,185 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) }) + test("drops unsigned Bedrock reasoning replay blocks", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking without a signature" }, + { type: "text", text: "Answer" }, + ], + }, + ] satisfies ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "Answer" }]) + }) + + test("keeps signed Bedrock reasoning replay blocks", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Signed thinking", + providerOptions: { + bedrock: { signature: "sig-123" }, + }, + }, + { type: "text", text: "Answer" }, + ], + }, + ] satisfies ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { + type: "reasoning", + text: "Signed thinking", + providerOptions: { + bedrock: { signature: "sig-123" }, + }, + }, + { type: "text", text: "Answer" }, + ]) + }) + + test("drops unsigned Bedrock thinking replay blocks without affecting direct Anthropic", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Thinking without a signature" }, + { type: "text", text: "Answer" }, + ], + }, + ] as unknown as ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "Answer" }]) + expect(ProviderTransform.message(msgs, anthropicModel, {})[0].content).toEqual([ + { type: "thinking", thinking: "Thinking without a signature" }, + { type: "text", text: "Answer" }, + ]) + }) + + test("keeps signed Bedrock thinking replay blocks", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Signed thinking", signature: "sig-thinking" }, + { type: "text", text: "Answer" }, + ], + }, + ] as unknown as ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "thinking", thinking: "Signed thinking", signature: "sig-thinking" }, + { type: "text", text: "Answer" }, + ]) + }) + + test("keeps signed Bedrock omitted-thinking replay blocks", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { + bedrock: { signature: "sig-omitted" }, + }, + }, + { type: "text", text: "Answer" }, + ], + }, + ] satisfies ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { + type: "reasoning", + text: "", + providerOptions: { + bedrock: { signature: "sig-omitted" }, + }, + }, + { type: "text", text: "Answer" }, + ]) + }) + + test("drops unsigned Bedrock Sonnet 4.6 reasoning replay blocks", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Unsigned Sonnet thinking" }, + { type: "text", text: "Answer" }, + ], + }, + ] satisfies ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-sonnet-4-6"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "Answer" }]) + }) + + test("does not require signatures for non-Claude Bedrock reasoning", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Nova reasoning" }, + { type: "text", text: "Answer" }, + ], + }, + ] satisfies ModelMessage[] + + const result = ProviderTransform.message(msgs, bedrock("amazon.nova-pro-v1:0"), {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "reasoning", text: "Nova reasoning" }, + { type: "text", text: "Answer" }, + ]) + }) + + test("does not require signatures for direct Anthropic reasoning", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Anthropic reasoning" }, + { type: "text", text: "Answer" }, + ], + }, + ] satisfies ModelMessage[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "reasoning", text: "Anthropic reasoning" }, + { 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 89bae246a78c..f42e453b19f8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -946,6 +946,81 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("drops unsigned reasoning after switching from GPT to Bedrock Claude", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + const bedrockModel: Provider.Model = { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-opus-4-7"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-opus-4-7", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + reasoning: true, + interleaved: { field: "reasoning_content" }, + }, + } + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "continue", + }, + ] satisfies MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID, undefined, { + providerID: "openai", + modelID: "gpt-5.5", + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "GPT reasoning cannot be replayed as Bedrock thinking", + time: { start: 0, end: 1 }, + metadata: { + openai: { + itemId: "rs_123", + }, + }, + }, + { + ...basePart(assistantID, "a2"), + type: "text", + text: "answer", + }, + ] satisfies MessageV2.Part[], + }, + ] + + const result = ProviderTransform.message(await MessageV2.toModelMessages(input, bedrockModel), bedrockModel, {}) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + role: "user", + content: [{ type: "text", text: "continue" }], + providerOptions: { + bedrock: { cachePoint: { type: "default" } }, + }, + }) + expect(result[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "answer" }], + providerOptions: { + bedrock: { cachePoint: { type: "default" } }, + }, + }) + }) + test("splits assistant messages on step-start boundaries", async () => { const assistantID = "m-assistant" From 80740ea90715e357fbef5ea3b0139aec76265e0f Mon Sep 17 00:00:00 2001 From: "Kuo, Joseph" <> Date: Thu, 30 Apr 2026 17:33:39 -0700 Subject: [PATCH 3/3] Preserve Bedrock redacted thinking replay --- packages/opencode/src/provider/transform.ts | 12 ++- .../opencode/test/provider/transform.test.ts | 50 ++--------- .../opencode/test/session/message-v2.test.ts | 82 +++++++++++++++---- 3 files changed, 81 insertions(+), 63 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d31fc3dd5e8a..c2f8639cc9d0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -55,23 +55,27 @@ function isBedrockClaude(model: Provider.Model) { return id.includes("anthropic") || id.includes("claude") } -function hasBedrockThinkingSignature(part: Record) { +function hasBedrockThinkingReplayMetadata(part: Record) { if (typeof part.signature === "string" && part.signature.length > 0) return true return [part.providerOptions, part.providerMetadata].some((metadata) => { if (!isRecord(metadata)) return false return ["bedrock", "amazon-bedrock"].some((key) => { const provider = metadata[key] - return isRecord(provider) && typeof provider.signature === "string" && provider.signature.length > 0 + return ( + isRecord(provider) && + ((typeof provider.signature === "string" && provider.signature.length > 0) || + (typeof provider.redactedData === "string" && provider.redactedData.length > 0)) + ) }) }) } function keepContentPart(part: unknown, model: Provider.Model) { if (!isRecord(part)) return true - if (part.type === "thinking") return isBedrockClaude(model) ? hasBedrockThinkingSignature(part) : true + if (part.type === "thinking") return isBedrockClaude(model) ? hasBedrockThinkingReplayMetadata(part) : true if (part.type !== "text" && part.type !== "reasoning") return true if (part.type === "text") return part.text !== "" - if (isBedrockClaude(model)) return hasBedrockThinkingSignature(part) + if (isBedrockClaude(model)) return hasBedrockThinkingReplayMetadata(part) return part.text !== "" } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dfcc6085b5ba..625410595c8a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1523,48 +1523,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => ]) }) - test("drops unsigned Bedrock thinking replay blocks without affecting direct Anthropic", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "thinking", thinking: "Thinking without a signature" }, - { type: "text", text: "Answer" }, - ], - }, - ] as unknown as ModelMessage[] - - const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) - - expect(result).toHaveLength(1) - expect(result[0].content).toEqual([{ type: "text", text: "Answer" }]) - expect(ProviderTransform.message(msgs, anthropicModel, {})[0].content).toEqual([ - { type: "thinking", thinking: "Thinking without a signature" }, - { type: "text", text: "Answer" }, - ]) - }) - - test("keeps signed Bedrock thinking replay blocks", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "thinking", thinking: "Signed thinking", signature: "sig-thinking" }, - { type: "text", text: "Answer" }, - ], - }, - ] as unknown as ModelMessage[] - - const result = ProviderTransform.message(msgs, bedrock("anthropic.claude-opus-4-7"), {}) - - expect(result).toHaveLength(1) - expect(result[0].content).toEqual([ - { type: "thinking", thinking: "Signed thinking", signature: "sig-thinking" }, - { type: "text", text: "Answer" }, - ]) - }) - - test("keeps signed Bedrock omitted-thinking replay blocks", () => { + test.each([ + ["signed omitted-thinking", { signature: "sig-omitted" }], + ["redacted thinking", { redactedData: "encrypted-redacted-thinking" }], + ])("keeps Bedrock %s replay blocks", (_, bedrockMetadata) => { const msgs = [ { role: "assistant", @@ -1573,7 +1535,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => type: "reasoning", text: "", providerOptions: { - bedrock: { signature: "sig-omitted" }, + bedrock: bedrockMetadata, }, }, { type: "text", text: "Answer" }, @@ -1589,7 +1551,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => type: "reasoning", text: "", providerOptions: { - bedrock: { signature: "sig-omitted" }, + bedrock: bedrockMetadata, }, }, { type: "text", text: "Answer" }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index f42e453b19f8..ffb177d42a9a 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -108,6 +108,24 @@ function basePart(messageID: string, id: string) { } } +function bedrockClaudeModel(): Provider.Model { + return { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-opus-4-7"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-opus-4-7", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + reasoning: true, + interleaved: { field: "reasoning_content" }, + }, + } +} + describe("session.message-v2.toModelMessage", () => { test("filters out messages with no parts", async () => { const input: MessageV2.WithParts[] = [ @@ -949,21 +967,7 @@ describe("session.message-v2.toModelMessage", () => { test("drops unsigned reasoning after switching from GPT to Bedrock Claude", async () => { const userID = "m-user" const assistantID = "m-assistant" - const bedrockModel: Provider.Model = { - ...model, - id: ModelID.make("amazon-bedrock/anthropic.claude-opus-4-7"), - providerID: ProviderID.make("amazon-bedrock"), - api: { - id: "anthropic.claude-opus-4-7", - url: "https://bedrock-runtime.us-east-1.amazonaws.com", - npm: "@ai-sdk/amazon-bedrock", - }, - capabilities: { - ...model.capabilities, - reasoning: true, - interleaved: { field: "reasoning_content" }, - }, - } + const bedrockModel = bedrockClaudeModel() const input: MessageV2.WithParts[] = [ { @@ -1021,6 +1025,54 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("preserves redacted thinking metadata when replaying Bedrock Claude", async () => { + const assistantID = "m-assistant" + const bedrockModel = bedrockClaudeModel() + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: bedrockModel.providerID, + modelID: bedrockModel.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "", + time: { start: 0, end: 1 }, + metadata: { + bedrock: { + redactedData: "encrypted-redacted-thinking", + }, + }, + }, + { + ...basePart(assistantID, "a2"), + type: "text", + text: "answer", + }, + ] satisfies MessageV2.Part[], + }, + ] + + const result = ProviderTransform.message(await MessageV2.toModelMessages(input, bedrockModel), bedrockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { + type: "reasoning", + text: "", + providerOptions: { + bedrock: { + redactedData: "encrypted-redacted-thinking", + }, + }, + }, + { type: "text", text: "answer" }, + ]) + }) + test("splits assistant messages on step-start boundaries", async () => { const assistantID = "m-assistant"