Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 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 ? { field: "reasoning_content" } : false), // kilocode_change
Comment thread
scottnzuk marked this conversation as resolved.
Outdated
},
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 ? { field: "reasoning_content" } : false), // kilocode_change
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
Expand Down
47 changes: 45 additions & 2 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function normalizeMessages(

if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
const field = model.capabilities.interleaved.field
const sdk = sdkKey(model.api.npm) ?? "openaiCompatible"
return msgs.map((msg) => {
Comment thread
scottnzuk marked this conversation as resolved.
Outdated
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
Expand All @@ -203,8 +204,8 @@ function normalizeMessages(
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
[sdk]: {
...(msg.providerOptions as any)?.[sdk],
[field]: reasoningText,
},
},
Expand All @@ -216,6 +217,48 @@ function normalizeMessages(
})
}

// 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
100 changes: 100 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,106 @@ 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()
})
})

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