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
57 changes: 41 additions & 16 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,40 @@ function sdkKey(npm: string): string | undefined {
return undefined
}

function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>) {
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,
Expand All @@ -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 }
})
Expand All @@ -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 }
})
Expand Down Expand Up @@ -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
Expand Down
233 changes: 221 additions & 12 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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: "" },
Expand All @@ -1398,14 +1466,155 @@ 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")
expect(result[1].content).toHaveLength(1)
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,
Expand Down
Loading
Loading