diff --git a/.changeset/deepseek-reasoning-complete.md b/.changeset/deepseek-reasoning-complete.md new file mode 100644 index 00000000000..738c7129ba6 --- /dev/null +++ b/.changeset/deepseek-reasoning-complete.md @@ -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. diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index 62bd6716d1e..00b9c2db33f 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -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) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 060c55055df..c556a0b9c9b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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: {}, @@ -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, @@ -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 diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f3dee7e6ec4..83365ee4155 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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 } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index ccaf1460427..21f66f8c0ad 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -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 = {}) => + ({ + 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", () => {