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
23 changes: 23 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ export namespace ProviderTransform {
model: Provider.Model,
options: Record<string, unknown>,
): ModelMessage[] {
// FIRST: Strip reasoning parts for models that don't support interleaved reasoning.
// This MUST happen before any model-specific early returns to prevent cross-model errors
// when switching from a model with thinking/reasoning (e.g., Claude Opus) to one without.
if (model.capabilities.interleaved === false) {
msgs = msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// If all content was reasoning, add placeholder text to avoid empty messages
if (filteredContent.length === 0 && msg.content.length > 0) {
return {
...msg,
content: [{ type: "text" as const, text: "[Reasoning content from previous model]" }],
}
}
return {
...msg,
content: filteredContent,
}
}
return msg
})
}

// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
Expand Down
122 changes: 113 additions & 9 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,12 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...")
})

test("Non-DeepSeek providers leave reasoning content unchanged", () => {
test("Non-interleaved providers strip reasoning content to prevent cross-model errors", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Should not be processed" },
{ type: "reasoning", text: "Should be stripped" },
{ type: "text", text: "Answer" },
],
},
Expand Down Expand Up @@ -632,11 +632,113 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
{},
)

// Reasoning parts should be stripped for models with interleaved: false
expect(result[0].content).toEqual([{ type: "text", text: "Answer" }])
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined()
})

test("Non-interleaved providers replace message with placeholder when only reasoning content exists", () => {
const msgs = [
{
role: "assistant",
content: [{ type: "reasoning", text: "Only reasoning, no text" }],
},
] as any[]

const result = ProviderTransform.message(
msgs,
{
id: "openai/gpt-4",
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
name: "GPT-4",
capabilities: {
temperature: true,
reasoning: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.03,
output: 0.06,
cache: { read: 0.001, write: 0.002 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2023-04-01",
},
{},
)

// When all content is reasoning, a placeholder text should be inserted
expect(result[0].content).toEqual([{ type: "text", text: "[Reasoning content from previous model]" }])
})

test("Interleaved: true providers keep reasoning content in messages", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Should be kept" },
{ type: "text", text: "Answer" },
],
},
] as any[]

const result = ProviderTransform.message(
msgs,
{
id: "anthropic/claude-opus-4",
providerID: "anthropic",
api: {
id: "claude-opus-4-20250514",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
name: "Claude Opus 4",
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: true,
},
cost: {
input: 0.015,
output: 0.075,
cache: { read: 0.0015, write: 0.01875 },
},
limit: {
context: 200000,
output: 32000,
},
status: "active",
options: {},
headers: {},
release_date: "2025-05-14",
},
{},
)

// Reasoning parts should be kept for models with interleaved: true
expect(result[0].content).toEqual([
{ type: "reasoning", text: "Should not be processed" },
{ type: "reasoning", text: "Should be kept" },
{ type: "text", text: "Answer" },
])
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined()
})
})

Expand Down Expand Up @@ -871,7 +973,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
})
})

test("keeps messages with valid text alongside empty parts", () => {
test("keeps messages with valid text alongside empty parts (reasoning stripped for non-interleaved)", () => {
const msgs = [
{
role: "assistant",
Expand All @@ -885,10 +987,11 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>

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

// Reasoning is stripped because model has interleaved: false
// Empty text is filtered by Anthropic, only "Result" remains
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(2)
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({ type: "text", text: "Result" })
})

test("does not filter for non-anthropic providers", () => {
Expand Down Expand Up @@ -919,6 +1022,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
})

describe("ProviderTransform.message - strip openai metadata when store=false", () => {
// Use interleaved: true so reasoning parts are preserved for metadata testing
const openaiModel = {
id: "openai/gpt-5",
providerID: "openai",
Expand All @@ -935,7 +1039,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
interleaved: true,
},
cost: { input: 0.03, output: 0.06, cache: { read: 0.001, write: 0.002 } },
limit: { context: 128000, output: 4096 },
Expand Down
Loading