diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 3d28787c88f..e145d867e00 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -15,6 +15,7 @@ export namespace ModelsDev { release_date: z.string(), attachment: z.boolean(), reasoning: z.boolean(), + interleaved_thinking: z.boolean().optional(), temperature: z.boolean(), tool_call: z.boolean(), cost: z diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 14adccf1c49..1ce0c39d723 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -325,6 +325,7 @@ export namespace Provider { capabilities: z.object({ temperature: z.boolean(), reasoning: z.boolean(), + interleavedthinking: z.boolean().optional(), attachment: z.boolean(), toolcall: z.boolean(), input: z.object({ @@ -426,6 +427,7 @@ export namespace Provider { capabilities: { temperature: model.temperature, reasoning: model.reasoning, + interleavedthinking: model.interleaved_thinking ?? false, attachment: model.attachment, toolcall: model.tool_call, input: { @@ -543,6 +545,7 @@ export namespace Provider { capabilities: { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + interleavedthinking: model.interleaved_thinking ?? existingModel?.capabilities.interleavedthinking ?? false, attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, input: { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 09dfd69a317..7bed7923026 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -103,6 +103,28 @@ export namespace ProviderTransform { }) } + // For models with interleaved thinking enabled, convert reasoning parts to text parts + // so the reasoning content is included in the API request. This is necessary because + // the AI SDK's OpenAI provider ignores reasoning parts when converting to chat messages. + if (model.capabilities.interleavedthinking) { + return msgs.map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + // Convert reasoning parts to text parts + msg.content = msg.content.map((part: any) => { + if (part.type === "reasoning" && part.text) { + return { + type: "text", + text: part.text, + providerOptions: part.providerOptions, + } + } + return part + }) + } + return msg + }) + } + return msgs } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 648f108bd66..c4f2d38d174 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -126,6 +126,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { capabilities: { temperature: true, reasoning: true, + interleavedthinking: false, attachment: false, toolcall: true, input: { text: true, audio: false, image: false, video: false, pdf: false }, @@ -180,6 +181,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { capabilities: { temperature: true, reasoning: true, + interleavedthinking: false, attachment: false, toolcall: true, input: { text: true, audio: false, image: false, video: false, pdf: false }, @@ -232,6 +234,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { capabilities: { temperature: true, reasoning: true, + interleavedthinking: false, attachment: false, toolcall: true, input: { text: true, audio: false, image: false, video: false, pdf: false }, @@ -277,6 +280,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { capabilities: { temperature: true, reasoning: false, + interleavedthinking: false, attachment: true, toolcall: true, input: { text: true, audio: false, image: true, video: false, pdf: false }, @@ -303,3 +307,114 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) }) + +describe("ProviderTransform.message - Interleaved thinking", () => { + test("Converts reasoning parts to text parts", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me analyze this step by step..." }, + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "git diff" }, + }, + ], + }, + ] 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, + interleavedthinking: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.03, + output: 0.06, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + }) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "text", text: "Let me analyze this step by step...", providerOptions: undefined }, + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "git diff" }, + }, + ]) + }) + + test("Preserves reasoning parts when interleavedthinking is false", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Reasoning that should stay as-is" }, + { type: "text", text: "Answer" }, + ], + }, + ] 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, + interleavedthinking: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.03, + output: 0.06, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + }) + + expect(result[0].content).toEqual([ + { type: "reasoning", text: "Reasoning that should stay as-is" }, + { type: "text", text: "Answer" }, + ]) + }) +})