Skip to content

Commit 51d9eb7

Browse files
committed
feat: add Kimi K3 provider support
1 parent 367013f commit 51d9eb7

16 files changed

Lines changed: 665 additions & 12 deletions

packages/types/src/__tests__/opencode-go.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe("opencode-go registry", () => {
2323
"glm-5.2",
2424
"kimi-k2.5",
2525
"kimi-k2.6",
26+
"kimi-k3",
2627
"mimo-v2.5",
2728
"mimo-v2.5-pro",
2829
"deepseek-v4-pro",
@@ -78,6 +79,25 @@ describe("opencode-go registry", () => {
7879
})
7980

8081
describe("opencodeGoModels registry invariants", () => {
82+
it("configures Kimi K3 with its required max reasoning and automatic caching metadata", () => {
83+
expect(getOpencodeGoModelInfo("kimi-k3")).toMatchObject({
84+
maxTokens: 131_072,
85+
contextWindow: 1_000_000,
86+
supportsImages: true,
87+
supportsPromptCache: true,
88+
supportsMaxTokens: true,
89+
supportsReasoningEffort: ["max"],
90+
requiredReasoningEffort: true,
91+
reasoningEffort: "max",
92+
preserveReasoning: true,
93+
supportsTemperature: false,
94+
inputPrice: 3,
95+
outputPrice: 15,
96+
cacheReadsPrice: 0.3,
97+
})
98+
expect(getOpencodeGoModelInfo("kimi-k3")?.cacheWritesPrice).toBeUndefined()
99+
})
100+
81101
it("every entry has a positive maxTokens and contextWindow", () => {
82102
for (const [id, info] of Object.entries(opencodeGoModels)) {
83103
expect(info.maxTokens).toBeGreaterThan(0)

packages/types/src/providers/moonshot.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ export const moonshotModels = {
6666
description:
6767
"Kimi K2.5 is the latest generation of Moonshot AI's Kimi series, featuring improved reasoning capabilities and enhanced performance across diverse tasks.",
6868
},
69+
"kimi-k3": {
70+
maxTokens: 131_072,
71+
contextWindow: 1_048_576,
72+
supportsImages: true,
73+
supportsPromptCache: true,
74+
supportsMaxTokens: true,
75+
supportsReasoningEffort: ["max"],
76+
requiredReasoningEffort: true,
77+
reasoningEffort: "max",
78+
preserveReasoning: true,
79+
supportsTemperature: false,
80+
inputPrice: 3,
81+
outputPrice: 15,
82+
cacheReadsPrice: 0.3,
83+
description:
84+
"Kimi K3 is Moonshot AI's multimodal reasoning model with a 1M-token context window and up to 128K output tokens.",
85+
},
6986
} as const satisfies Record<string, ModelInfo>
7087

7188
export const MOONSHOT_DEFAULT_TEMPERATURE = 0.6

packages/types/src/providers/opencode-go.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ export const opencodeGoModels: Record<string, ModelInfo> = {
130130
description:
131131
"Kimi K2.6 is Moonshot AI's native multimodal agentic MoE model with a 256k context window, built for long-horizon coding and tool use. Available via the Opencode Go plan.",
132132
},
133+
"kimi-k3": {
134+
maxTokens: 131_072,
135+
contextWindow: 1_000_000,
136+
supportsImages: true,
137+
supportsPromptCache: true,
138+
supportsMaxTokens: true,
139+
supportsReasoningEffort: ["max"],
140+
requiredReasoningEffort: true,
141+
reasoningEffort: "max",
142+
preserveReasoning: true,
143+
supportsTemperature: false,
144+
inputPrice: 3,
145+
outputPrice: 15,
146+
cacheReadsPrice: 0.3,
147+
description:
148+
"Kimi K3 is Moonshot AI's multimodal reasoning model with a 1M-token context window and up to 128K output tokens. Available via the Opencode Go plan.",
149+
},
133150

134151
// --- Xiaomi MiMo ---
135152
"mimo-v2.5": {

src/api/providers/__tests__/moonshot.spec.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
2424
}))
2525

2626
import type { Anthropic } from "@anthropic-ai/sdk"
27+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
2728

2829
import { moonshotDefaultModelId } from "@roo-code/types"
2930

@@ -78,6 +79,28 @@ describe("MoonshotHandler", () => {
7879
})
7980

8081
describe("getModel", () => {
82+
it("returns Kimi K3 catalog metadata without changing the default model", () => {
83+
const k3Handler = new MoonshotHandler({ ...mockOptions, apiModelId: "kimi-k3" })
84+
85+
expect(moonshotDefaultModelId).not.toBe("kimi-k3")
86+
expect(k3Handler.getModel().info).toMatchObject({
87+
maxTokens: 131_072,
88+
contextWindow: 1_048_576,
89+
supportsImages: true,
90+
supportsPromptCache: true,
91+
supportsMaxTokens: true,
92+
supportsReasoningEffort: ["max"],
93+
requiredReasoningEffort: true,
94+
reasoningEffort: "max",
95+
preserveReasoning: true,
96+
supportsTemperature: false,
97+
inputPrice: 3,
98+
outputPrice: 15,
99+
cacheReadsPrice: 0.3,
100+
})
101+
expect("cacheWritesPrice" in k3Handler.getModel().info).toBe(false)
102+
})
103+
81104
it("should return model info for valid model ID", () => {
82105
const model = handler.getModel()
83106
expect(model.id).toBe(mockOptions.apiModelId)
@@ -164,6 +187,112 @@ describe("MoonshotHandler", () => {
164187
expect(textChunks[0].text).toBe("Test response")
165188
})
166189

190+
it("omits temperature and sends required max reasoning for Kimi K3", async () => {
191+
async function* mockFullStream() {
192+
yield { type: "text-delta", text: "K3 response" }
193+
}
194+
195+
mockStreamText.mockReturnValue({
196+
fullStream: mockFullStream(),
197+
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1, details: {}, raw: {} }),
198+
})
199+
200+
const k3Handler = new MoonshotHandler({
201+
...mockOptions,
202+
apiModelId: "kimi-k3",
203+
modelTemperature: 0.9,
204+
reasoningEffort: "disable",
205+
enableReasoningEffort: false,
206+
})
207+
for await (const _chunk of k3Handler.createMessage(systemPrompt, messages)) {
208+
void _chunk
209+
}
210+
211+
expect(mockStreamText).toHaveBeenCalledWith(
212+
expect.objectContaining({
213+
temperature: undefined,
214+
maxOutputTokens: 131_072,
215+
providerOptions: { openaiCompatible: { reasoningEffort: "max" } },
216+
}),
217+
)
218+
})
219+
220+
it("serializes retained Kimi K3 reasoning through the installed AI SDK", async () => {
221+
const actualAi = await vi.importActual<typeof import("ai")>("ai")
222+
const actualOpenAICompatible =
223+
await vi.importActual<typeof import("@ai-sdk/openai-compatible")>("@ai-sdk/openai-compatible")
224+
vi.mocked(createOpenAICompatible).mockImplementationOnce(actualOpenAICompatible.createOpenAICompatible)
225+
mockStreamText.mockImplementationOnce(actualAi.streamText)
226+
227+
let requestBody: Record<string, any> | undefined
228+
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
229+
requestBody = JSON.parse(String(init?.body))
230+
return new Response(
231+
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":0,"model":"kimi-k3","choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":0,"model":"kimi-k3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}}\n\ndata: [DONE]\n\n',
232+
{ status: 200, headers: { "content-type": "text/event-stream" } },
233+
)
234+
})
235+
vi.stubGlobal("fetch", fetchMock)
236+
237+
try {
238+
const k3Handler = new MoonshotHandler({
239+
apiModelId: "kimi-k3",
240+
moonshotApiKey: "test-key",
241+
moonshotBaseUrl: "https://api.moonshot.ai/v1",
242+
modelTemperature: 0.9,
243+
})
244+
const retainedMessages = [
245+
{ role: "user", content: "Inspect the file" },
246+
{
247+
role: "assistant",
248+
content: [
249+
{ type: "reasoning", text: "I need the file contents first." },
250+
{ type: "tool_use", id: "call_123", name: "read_file", input: { path: "a.ts" } },
251+
],
252+
},
253+
{
254+
role: "user",
255+
content: [{ type: "tool_result", tool_use_id: "call_123", content: "export const a = 1" }],
256+
},
257+
] as Anthropic.Messages.MessageParam[]
258+
259+
try {
260+
for await (const _chunk of k3Handler.createMessage("system", retainedMessages)) {
261+
// Drain the stream so the installed SDK serializes the HTTP request.
262+
}
263+
} catch (error) {
264+
// The synthetic SSE only needs to support request serialization.
265+
expect((error as Error).name).toBe("AI_NoOutputGeneratedError")
266+
}
267+
268+
expect(requestBody).toMatchObject({
269+
model: "kimi-k3",
270+
reasoning_effort: "max",
271+
messages: [
272+
{ role: "system", content: "system" },
273+
{ role: "user", content: "Inspect the file" },
274+
{
275+
role: "assistant",
276+
content: null,
277+
reasoning_content: "I need the file contents first.",
278+
tool_calls: [
279+
{
280+
id: "call_123",
281+
type: "function",
282+
function: { name: "read_file", arguments: JSON.stringify({ path: "a.ts" }) },
283+
},
284+
],
285+
},
286+
{ role: "tool", tool_call_id: "call_123", content: "export const a = 1" },
287+
],
288+
})
289+
expect(requestBody).not.toHaveProperty("temperature")
290+
expect(fetchMock).toHaveBeenCalledOnce()
291+
} finally {
292+
vi.unstubAllGlobals()
293+
}
294+
})
295+
167296
it("should include usage information", async () => {
168297
async function* mockFullStream() {
169298
yield { type: "text-delta", text: "Test response" }
@@ -238,6 +367,27 @@ describe("MoonshotHandler", () => {
238367
}),
239368
)
240369
})
370+
371+
it("omits temperature and sends required max reasoning for Kimi K3", async () => {
372+
mockGenerateText.mockResolvedValue({ text: "K3 completion" })
373+
const k3Handler = new MoonshotHandler({
374+
...mockOptions,
375+
apiModelId: "kimi-k3",
376+
modelTemperature: 0.9,
377+
reasoningEffort: "disable",
378+
enableReasoningEffort: false,
379+
})
380+
381+
await k3Handler.completePrompt("Test prompt")
382+
383+
expect(mockGenerateText).toHaveBeenCalledWith(
384+
expect.objectContaining({
385+
temperature: undefined,
386+
maxOutputTokens: 131_072,
387+
providerOptions: { openaiCompatible: { reasoningEffort: "max" } },
388+
}),
389+
)
390+
})
241391
})
242392

243393
describe("processUsageMetrics", () => {

src/api/providers/__tests__/opencode-go.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ vitest.mock("../fetchers/modelCache", () => ({
3232
"glm-5.1": { ...opencodeGoModels["glm-5.1"] },
3333
// Anthropic-format model used to exercise the /v1/messages path.
3434
"qwen3.7-max": { ...opencodeGoModels["qwen3.7-max"] },
35+
"kimi-k3": { ...opencodeGoModels["kimi-k3"] },
3536
})
3637
}),
3738
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
@@ -347,6 +348,39 @@ describe("OpencodeGoHandler", () => {
347348
expect(callArgs.messages.filter((m) => m.role === "user")).toHaveLength(1)
348349
})
349350

351+
it("sends valid Kimi K3 streaming parameters and preserves reasoning history", async () => {
352+
const handler = new OpencodeGoHandler({
353+
...mockOptions,
354+
opencodeGoModelId: "kimi-k3",
355+
modelTemperature: 0.9,
356+
reasoningEffort: "disable",
357+
enableReasoningEffort: false,
358+
})
359+
const messages = [
360+
{
361+
role: "assistant" as const,
362+
content: [
363+
{ type: "reasoning", text: "prior thought" },
364+
{ type: "text" as const, text: "prior answer" },
365+
] as any,
366+
},
367+
{ role: "user" as const, content: "continue" },
368+
]
369+
370+
for await (const _chunk of handler.createMessage("sys", messages)) {
371+
void _chunk
372+
}
373+
374+
const body = mockCreate.mock.calls[0][0] as any
375+
expect(body.model).toBe("kimi-k3")
376+
expect(body.temperature).toBeUndefined()
377+
expect(body.max_completion_tokens).toBe(131_072)
378+
expect(body.reasoning_effort).toBe("max")
379+
expect(body.messages).toContainEqual(
380+
expect.objectContaining({ role: "assistant", reasoning_content: "prior thought" }),
381+
)
382+
})
383+
350384
it("emits a usage chunk with zeroed tokens when the stream reports no usage", async () => {
351385
mockCreate.mockImplementationOnce(async () => ({
352386
[Symbol.asyncIterator]: async function* () {
@@ -382,6 +416,24 @@ describe("OpencodeGoHandler", () => {
382416
})
383417

384418
describe("completePrompt", () => {
419+
it("omits temperature and sends max reasoning for Kimi K3", async () => {
420+
mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] })
421+
const handler = new OpencodeGoHandler({
422+
...mockOptions,
423+
opencodeGoModelId: "kimi-k3",
424+
modelTemperature: 0.9,
425+
reasoningEffort: "disable",
426+
enableReasoningEffort: false,
427+
})
428+
429+
await handler.completePrompt("ping")
430+
431+
const body = mockCreate.mock.calls[0][0] as any
432+
expect(body.temperature).toBeUndefined()
433+
expect(body.max_completion_tokens).toBe(131_072)
434+
expect(body.reasoning_effort).toBe("max")
435+
})
436+
385437
it("returns the message content for a non-streaming completion", async () => {
386438
mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] })
387439
const handler = new OpencodeGoHandler(mockOptions)

0 commit comments

Comments
 (0)