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
35 changes: 33 additions & 2 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,43 @@ function normalizeMessages(
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("")

// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
if (field === "reasoning_details") {
const existingDetails = msg.providerOptions?.openaiCompatible?.reasoning_details
const reasoningDetails = [
...(Array.isArray(existingDetails) ? existingDetails : []),
...reasoningParts.flatMap((part: any) => {
const details = part.providerOptions?.openaiCompatible?.reasoning_details
if (Array.isArray(details)) return details
return []
}),
]

if (reasoningDetails.length === 0) {
return {
...msg,
content: filteredContent,
}
}

return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
reasoning_details: reasoningDetails,
},
},
}
}

const reasoningText = reasoningParts.map((part: any) => part.text).join("")

// Include reasoning_content | reasoning_details directly on the message for all assistant messages.
// Include reasoning_content directly on the message for all assistant messages.
// Always set the field even when empty — some providers (e.g. DeepSeek) may return empty
// reasoning_content which still needs to be sent back in subsequent requests.
return {
Expand Down
96 changes: 96 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,102 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
})
})

describe("ProviderTransform.message - reasoning details", () => {
const kimiModel = {
id: ModelID.make("moonshotai/kimi-k2.6"),
providerID: ProviderID.make("kilo"),
api: {
id: "moonshotai/kimi-k2.6",
url: "https://api.kilo.ai/api/gateway",
npm: "@ai-sdk/openai-compatible",
},
name: "Kimi K2.6",
capabilities: {
temperature: true,
reasoning: true,
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: {
field: "reasoning_details",
},
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 262144,
output: 262144,
},
status: "active",
options: {},
headers: {},
release_date: "2026-04-20",
} as any

test("does not synthesize string reasoning_details from reasoning text", () => {
const result = ProviderTransform.message(
[
{
role: "assistant",
content: [
{ type: "reasoning", text: "Prior model reasoning should not become reasoning_details." },
{
type: "tool-call",
toolCallId: "test",
toolName: "bash",
input: { command: "pwd" },
},
],
},
] as any[],
kimiModel,
{},
) as any[]

expect(result[0].content).toEqual([
{
type: "tool-call",
toolCallId: "test",
toolName: "bash",
input: { command: "pwd" },
},
])
expect(result[0].providerOptions?.openaiCompatible?.reasoning_details).toBeUndefined()
})

test("preserves structured reasoning_details from provider options", () => {
const reasoningDetails = [{ type: "reasoning.text", text: "structured reasoning" }]
const result = ProviderTransform.message(
[
{
role: "assistant",
content: [
{
type: "reasoning",
text: "structured reasoning",
providerOptions: {
openaiCompatible: {
reasoning_details: reasoningDetails,
},
},
},
{ type: "text", text: "Answer" },
],
},
] as any[],
kimiModel,
{},
) as any[]

expect(result[0].content).toEqual([{ type: "text", text: "Answer" }])
expect(result[0].providerOptions.openaiCompatible.reasoning_details).toEqual(reasoningDetails)
})
})

describe("ProviderTransform.message - empty image handling", () => {
const mockModel = {
id: "anthropic/claude-3-5-sonnet",
Expand Down
Loading