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
28 changes: 28 additions & 0 deletions packages/llm/test/provider/openai-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenAIChat.OpenAIChatBody>(
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, {
Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down Expand Up @@ -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),
},
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,21 +293,25 @@ 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")

// 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,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
[field]: reasoningText,
[field]: fieldValue,
},
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/llm/native-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
]
})
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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")
Expand Down
66 changes: 66 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/test/session/llm-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
Loading