diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3dea..a555c1d5516c 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -107,6 +107,34 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("replays native openaiCompatible reasoning_content on assistant tool-call messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + new Message({ + role: "assistant", + content: [ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "echo hello" } })], + native: { openaiCompatible: { reasoning_content: " " } }, + }), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "bash", arguments: '{"command":"echo hello"}' } }, + ], + reasoning_content: " ", + }, + ]) + }), + ) + it.effect("adds native query params to the Chat Completions URL", () => LLMClient.generate( LLM.updateRequest(request, { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 86515068d46e..33838857f66d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1194,7 +1194,12 @@ 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.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible") === "@ai-sdk/openai-compatible" && + (model.id.includes("deepseek") || (model.id.toLowerCase().includes("kimi") && model.reasoning)) + ? { field: "reasoning_content" } + : false), }, release_date: model.release_date ?? "", variants: {}, @@ -1422,7 +1427,9 @@ export const layer = Layer.effect( interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? - (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek") + (!existingModel && + apiNpm === "@ai-sdk/openai-compatible" && + (apiID.includes("deepseek") || (apiID.toLowerCase().includes("kimi") && model.reasoning)) ? { field: "reasoning_content" } : false), }, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 027efc0974b0..7b4cc54943e2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -293,6 +293,7 @@ function normalizeMessages( 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 hasToolCalls = msg.content.some((part: any) => part.type === "tool-call") // Filter out reasoning parts from content const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") @@ -300,6 +301,9 @@ function normalizeMessages( // 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. + // Moonshot/Kimi requires a non-empty placeholder on assistant messages that carry tool calls + // when thinking is enabled, otherwise the API rejects the request. + const fieldValue = reasoningText === "" && hasToolCalls ? " " : reasoningText return { ...msg, content: filteredContent, @@ -307,7 +311,7 @@ function normalizeMessages( ...msg.providerOptions, openaiCompatible: { ...msg.providerOptions?.openaiCompatible, - [field]: reasoningText, + [field]: fieldValue, }, }, } diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c362..730704bbd497 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -110,7 +110,7 @@ const messages = (input: readonly ModelMessage[]) => { Message.make({ role: message.role, content: content(message.content), - native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined, + native: isRecord(message.providerOptions) ? message.providerOptions : undefined, }), ] }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca06e..ee54a6366a6f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -276,6 +276,32 @@ it.instance( }, ) +it.instance( + "custom Moonshot openai-compatible reasoning model defaults interleaved reasoning field", + Effect.gen(function* () { + const providers = yield* list + const provider = providers[ProviderV2.ID.make("custom-moonshot-provider")] + expect(provider.models["kimi-k2.5"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) + expect(provider.models["kimi-k2-non-reasoning"].capabilities.interleaved).toBe(false) + }), + { + config: { + provider: { + "custom-moonshot-provider": { + name: "Custom Moonshot Provider", + npm: "@ai-sdk/openai-compatible", + api: "https://api.custom-moonshot.com/v1", + models: { + "kimi-k2.5": { name: "Kimi K2.5", reasoning: true }, + "kimi-k2-non-reasoning": { name: "Kimi K2 Non-Reasoning", reasoning: false }, + }, + options: { apiKey: "custom-key" }, + }, + }, + }, + }, +) + it.instance( "env variable takes precedence, config merges options", Effect.gen(function* () { @@ -1310,6 +1336,40 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) +test("models.dev Moonshot reasoning model defaults interleaved reasoning field", () => { + const provider = { + id: "moonshotai", + name: "Moonshot AI", + env: [], + npm: "@ai-sdk/openai-compatible", + api: "https://api.moonshot.ai/v1", + models: { + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + family: "kimi", + reasoning: true, + tool_call: true, + cost: { input: 0.6, output: 3 }, + limit: { context: 262_144, output: 32_768 }, + }, + "kimi-v1": { + id: "kimi-v1", + name: "Kimi V1", + family: "kimi", + reasoning: false, + tool_call: true, + cost: { input: 0.6, output: 3 }, + limit: { context: 262_144, output: 32_768 }, + }, + }, + } as unknown as ModelsDev.Provider + + const models = Provider.fromModelsDevProvider(provider).models + expect(models["kimi-k2.5"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) + expect(models["kimi-v1"].capabilities.interleaved).toBe(false) +}) + it.instance("model variants are generated for reasoning models", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c23a2aa9995c..702140dc00d6 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1366,6 +1366,72 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...") }) + test("interleaved provider injects space placeholder for tool calls without reasoning", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "echo hello" }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + { + id: ModelV2.ID.make("moonshotai/kimi-k2.5"), + providerID: ProviderV2.ID.make("moonshotai"), + api: { + id: "kimi-k2.5", + url: "https://api.moonshot.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + name: "Kimi K2.5", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { + field: "reasoning_content", + }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-04-01", + }, + {}, + ) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "echo hello" }, + }, + ]) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe(" ") + }) + test("Non-DeepSeek providers leave reasoning content unchanged", () => { const msgs = [ { diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 702bb67e390d..cfa7a8f7f46f 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -328,6 +328,26 @@ describe("session.llm-native.request", () => { ]) }) + test("maps message-level providerOptions to native message metadata", () => { + const request = LLMNative.request({ + model: baseModel, + messages: [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { command: "ls" } }], + providerOptions: { openaiCompatible: { reasoning_content: " " } }, + }, + ], + }) + + expect(request.messages).toMatchObject([ + { + role: "assistant", + native: { openaiCompatible: { reasoning_content: " " } }, + }, + ]) + }) + test("selects native request routes for provider packages", () => { const openai = LLMNative.model({ model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },