Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/deepseek-reasoning-complete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix DeepSeek reasoning_content error in thinking mode for multi-turn tool calls. Applies to all provider paths including OpenRouter and custom OpenAI-compatible. Ensures empty reasoning_content is always preserved for historical messages and dynamic SDK key selection for OpenRouter models.
8 changes: 6 additions & 2 deletions packages/kilo-gateway/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider {
const openrouter = createOpenRouter(sdkOptions)
const alibaba = createAlibaba(sdkOptions)
const anthropic = createAnthropic(sdkOptions)
const openai = createOpenAI(sdkOptions)
const openaiCompatible = createOpenAICompatible({ ...sdkOptions, name: "openaiCompatible" })
const openai = createOpenAI({ ...sdkOptions, extractReasoning: true } as any)
const openaiCompatible = createOpenAICompatible({
...sdkOptions,
name: "openaiCompatible",
extractReasoning: true,
} as any)

return {
languageModel(modelId) {
Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
video: model.modalities?.output?.includes("video") ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? false,
},
interleaved: model.interleaved ?? false,
interleaved: model.interleaved ?? (model.reasoning && model.id.includes("deepseek") ? { field: "reasoning_content" } : false), // kilocode_change
},
release_date: model.release_date ?? "",
variants: {},
Expand Down Expand Up @@ -1185,7 +1185,8 @@ const layer: Layer.Layer<
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved: model.interleaved ?? false,
interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ??
(model.reasoning && (model.id?.includes("deepseek") || modelID.includes("deepseek")) ? { field: "reasoning_content" } : false), // kilocode_change
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
Expand Down Expand Up @@ -1413,6 +1414,10 @@ const layer: Layer.Layer<
options["includeUsage"] = true
}

if (model.capabilities.reasoning && options["extractReasoning"] === undefined) {
options["extractReasoning"] = true // kilocode_change
}

const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
Expand Down
99 changes: 78 additions & 21 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,37 +185,94 @@ function normalizeMessages(

if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
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 sdk = sdkKey(model.api.npm) ?? "openaiCompatible"
msgs = msgs.map((msg) => {
if (msg.role === "assistant") {
let reasoningText = ""

// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
if (Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
reasoningText = reasoningParts.map((part: any) => part.text).join("")

// kilocode_change start - cherry-picked from anomalyco/opencode#24146;
// will be reverted on the next wholesale upstream merge.
// 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
// reasoning_content which still needs to be sent back in subsequent requests.
return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
[field]: reasoningText,
// Filter out reasoning parts from content for interleaved messages
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")

return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
[sdk]: {
...(msg.providerOptions as any)?.[sdk],
[field]: reasoningText,
},
},
},
}
} else if (typeof msg.content === "string") {
// Handle string content messages - inject empty reasoning_content if missing
const existing = (msg.providerOptions as any)?.[sdk]?.[field]
if (existing === undefined || existing === null) {
return {
...msg,
providerOptions: {
...msg.providerOptions,
[sdk]: {
...(msg.providerOptions as any)?.[sdk],
[field]: "",
},
},
}
}
return msg
}
// kilocode_change end
}

return msg
})
}

// kilocode_change start - cherry-picked from anomalyco/opencode#24250;
// inject empty reasoning_content for all assistant messages on reasoning models.
// This handles historical messages saved before the fix and tool-only responses
// where the SDK produced no reasoning part but the API still requires the field.
if (model.capabilities.reasoning) {
const sdk = sdkKey(model.api.npm) ?? "openaiCompatible"
const field =
(typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field) ||
"reasoning_content"

msgs = msgs.map((msg) => {
if (msg.role !== "assistant") return msg

// Don't overwrite already-set reasoning content
const existing = (msg.providerOptions as any)?.[sdk]?.[field]
if (existing !== undefined && existing !== null) return msg

// Extract reasoning from content parts if present
let reasoningText = ""
if (Array.isArray(msg.content)) {
const parts = msg.content.filter((p: any) => p.type === "reasoning")
reasoningText = parts.map((p: any) => p.text).join("")
}

// Don't double-set if already handled by interleaved block (would have stripped reasoning parts)
if (reasoningText) return msg

return {
...msg,
providerOptions: {
...msg.providerOptions,
[sdk]: {
...(msg.providerOptions as any)?.[sdk],
[field]: reasoningText, // empty string for historical/no-reasoning messages
},
},
}
})
}
// kilocode_change end

return msgs
}

Expand Down
116 changes: 116 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,122 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
])
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined()
})

const baseCapabilities = {
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 },
}

const createModel = (overrides: Partial<any> = {}) =>
({
id: "test/test-model",
providerID: "test",
api: {
id: "test-model",
url: "https://api.test.com",
npm: "@ai-sdk/openai",
},
name: "Test Model",
capabilities: {
...baseCapabilities,
interleaved: false,
},
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",
...overrides,
}) as any

test("OpenRouter DeepSeek uses 'openrouter' key for reasoning_content", () => {
const model = createModel({
id: "deepseek-ai/DeepSeek-R1-0528",
providerID: "openrouter",
api: { id: "deepseek-deepseek-r1", url: "https://api.openrouter.ai", npm: "@openrouter/ai-sdk-provider" },
capabilities: { ...baseCapabilities, reasoning: true, interleaved: { field: "reasoning_content" } },
})
const msgs = [{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking step" },
{ type: "tool-call", toolCallId: "t1", toolName: "bash", input: { cmd: "ls" } },
],
}] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.openrouter?.reasoning_content).toBe("thinking step")
expect(result[0].providerOptions?.openaiCompatible).toBeUndefined()
})

test("assistant message without reasoning part gets empty reasoning_content", () => {
const model = createModel({
id: "deepseek/deepseek-reasoner",
providerID: "deepseek",
api: { id: "deepseek-reasoner", url: "https://api.deepseek.com", npm: "@ai-sdk/openai-compatible" },
capabilities: { ...baseCapabilities, reasoning: true, interleaved: false },
})
const msgs = [{
role: "assistant",
content: [{ type: "text", text: "Hello" }],
}] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("")
})

test("model with reasoning:true and no interleaved gets auto-injected", () => {
const model = createModel({
id: "openrouter/deepseek-r1",
providerID: "openrouter",
api: { id: "deepseek-ai/DeepSeek-R1", url: "https://api.openrouter.ai", npm: "@openrouter/ai-sdk-provider" },
capabilities: { ...baseCapabilities, reasoning: true, interleaved: false },
})
const msgs = [{
role: "assistant",
content: [{ type: "text", text: "No reasoning here" }],
}] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.openrouter?.reasoning_content).toBe("")
})

test("non-reasoning model does not add reasoning_content", () => {
const model = createModel({
id: "openai/gpt-4",
providerID: "openai",
api: { id: "gpt-4", url: "https://api.openai.com", npm: "@ai-sdk/openai" },
capabilities: { ...baseCapabilities, reasoning: false, interleaved: false },
})
const msgs = [{ role: "assistant", content: [{ type: "text", text: "Hello" }] }] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined()
})

test("interleaved block handles string content assistant messages", () => {
const model = createModel({
id: "deepseek/deepseek-reasoner",
providerID: "deepseek",
api: { id: "deepseek-reasoner", url: "https://api.deepseek.com", npm: "@ai-sdk/openai-compatible" },
capabilities: { ...baseCapabilities, reasoning: true, interleaved: { field: "reasoning_content" } },
})
const msgs = [{
role: "assistant",
content: "This is a string content message"
}] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].content).toBe("This is a string content message")
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("")
})
})

describe("ProviderTransform.message - empty image handling", () => {
Expand Down