diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d47d1fe76ca3..c2f8639cc9d0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -45,6 +45,40 @@ 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 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) || + (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) ? hasBedrockThinkingReplayMetadata(part) : true + if (part.type !== "text" && part.type !== "reasoning") return true + if (part.type === "text") return part.text !== "" + if (isBedrockClaude(model)) return hasBedrockThinkingReplayMetadata(part) + return part.text !== "" +} + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, @@ -60,12 +94,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 +110,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 } }) @@ -217,16 +241,17 @@ function normalizeMessages( if ( typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field && - model.api.npm !== "@openrouter/ai-sdk-provider" + model.api.npm !== "@openrouter/ai-sdk-provider" && + model.api.npm !== "@ai-sdk/amazon-bedrock" ) { 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("") + const reasoningParts = msg.content.filter((part) => part.type === "reasoning") + const reasoningText = reasoningParts.map((part) => part.text).join("") // Filter out reasoning parts from content - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + const filteredContent = msg.content.filter((part) => part.type !== "reasoning") // Include reasoning_content | reasoning_details directly on the message for all assistant messages. // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 9b66eaa77c5d..625410595c8a 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" @@ -1121,6 +1122,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", () => { @@ -1259,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" }, @@ -1375,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: "" }, @@ -1398,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") @@ -1406,6 +1474,147 @@ 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.each([ + ["signed omitted-thinking", { signature: "sig-omitted" }], + ["redacted thinking", { redactedData: "encrypted-redacted-thinking" }], + ])("keeps Bedrock %s replay blocks", (_, bedrockMetadata) => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { + bedrock: bedrockMetadata, + }, + }, + { 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: bedrockMetadata, + }, + }, + { 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..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[] = [ @@ -946,6 +964,115 @@ 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 = bedrockClaudeModel() + + 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("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"