Skip to content

Commit df6c57d

Browse files
mrubensellipsis-dev[bot]CellenLee
authored
feat: add moonshot provider (#6046)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: CellenLee <[email protected]>
1 parent b1bc085 commit df6c57d

File tree

32 files changed

+522
-0
lines changed

32 files changed

+522
-0
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export const SECRET_STATE_KEYS = [
159159
"geminiApiKey",
160160
"openAiNativeApiKey",
161161
"deepSeekApiKey",
162+
"moonshotApiKey",
162163
"mistralApiKey",
163164
"unboundApiKey",
164165
"requestyApiKey",

packages/types/src/provider-settings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const providerNames = [
2222
"gemini-cli",
2323
"openai-native",
2424
"mistral",
25+
"moonshot",
2526
"deepseek",
2627
"unbound",
2728
"requesty",
@@ -187,6 +188,13 @@ const deepSeekSchema = apiModelIdProviderModelSchema.extend({
187188
deepSeekApiKey: z.string().optional(),
188189
})
189190

191+
const moonshotSchema = apiModelIdProviderModelSchema.extend({
192+
moonshotBaseUrl: z
193+
.union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")])
194+
.optional(),
195+
moonshotApiKey: z.string().optional(),
196+
})
197+
190198
const unboundSchema = baseProviderSettingsSchema.extend({
191199
unboundApiKey: z.string().optional(),
192200
unboundModelId: z.string().optional(),
@@ -241,6 +249,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
241249
openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })),
242250
mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })),
243251
deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
252+
moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })),
244253
unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
245254
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
246255
humanRelaySchema.merge(z.object({ apiProvider: z.literal("human-relay") })),
@@ -269,6 +278,7 @@ export const providerSettingsSchema = z.object({
269278
...openAiNativeSchema.shape,
270279
...mistralSchema.shape,
271280
...deepSeekSchema.shape,
281+
...moonshotSchema.shape,
272282
...unboundSchema.shape,
273283
...requestySchema.shape,
274284
...humanRelaySchema.shape,

packages/types/src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from "./groq.js"
99
export * from "./lite-llm.js"
1010
export * from "./lm-studio.js"
1111
export * from "./mistral.js"
12+
export * from "./moonshot.js"
1213
export * from "./ollama.js"
1314
export * from "./openai.js"
1415
export * from "./openrouter.js"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
// https://platform.moonshot.ai/
4+
export type MoonshotModelId = keyof typeof moonshotModels
5+
6+
export const moonshotDefaultModelId: MoonshotModelId = "kimi-k2-0711-preview"
7+
8+
export const moonshotModels = {
9+
"kimi-k2-0711-preview": {
10+
maxTokens: 32_000,
11+
contextWindow: 131_072,
12+
supportsImages: false,
13+
supportsPromptCache: true,
14+
inputPrice: 0.6, // $0.60 per million tokens (cache miss)
15+
outputPrice: 2.5, // $2.50 per million tokens
16+
cacheWritesPrice: 0, // $0 per million tokens (cache miss)
17+
cacheReadsPrice: 0.15, // $0.15 per million tokens (cache hit)
18+
description: `Kimi K2 is a state-of-the-art mixture-of-experts (MoE) language model with 32 billion activated parameters and 1 trillion total parameters.`,
19+
},
20+
} as const satisfies Record<string, ModelInfo>
21+
22+
export const MOONSHOT_DEFAULT_TEMPERATURE = 0.6

src/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
GeminiHandler,
1818
OpenAiNativeHandler,
1919
DeepSeekHandler,
20+
MoonshotHandler,
2021
MistralHandler,
2122
VsCodeLmHandler,
2223
UnboundHandler,
@@ -89,6 +90,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
8990
return new OpenAiNativeHandler(options)
9091
case "deepseek":
9192
return new DeepSeekHandler(options)
93+
case "moonshot":
94+
return new MoonshotHandler(options)
9295
case "vscode-lm":
9396
return new VsCodeLmHandler(options)
9497
case "mistral":
@@ -110,6 +113,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
110113
case "litellm":
111114
return new LiteLLMHandler(options)
112115
default:
116+
apiProvider satisfies "gemini-cli" | undefined
113117
return new AnthropicHandler(options)
114118
}
115119
}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Mocks must come first, before imports
2+
const mockCreate = vi.fn()
3+
vi.mock("openai", () => {
4+
return {
5+
__esModule: true,
6+
default: vi.fn().mockImplementation(() => ({
7+
chat: {
8+
completions: {
9+
create: mockCreate.mockImplementation(async (options) => {
10+
if (!options.stream) {
11+
return {
12+
id: "test-completion",
13+
choices: [
14+
{
15+
message: { role: "assistant", content: "Test response", refusal: null },
16+
finish_reason: "stop",
17+
index: 0,
18+
},
19+
],
20+
usage: {
21+
prompt_tokens: 10,
22+
completion_tokens: 5,
23+
total_tokens: 15,
24+
cached_tokens: 2,
25+
},
26+
}
27+
}
28+
29+
// Return async iterator for streaming
30+
return {
31+
[Symbol.asyncIterator]: async function* () {
32+
yield {
33+
choices: [
34+
{
35+
delta: { content: "Test response" },
36+
index: 0,
37+
},
38+
],
39+
usage: null,
40+
}
41+
yield {
42+
choices: [
43+
{
44+
delta: {},
45+
index: 0,
46+
},
47+
],
48+
usage: {
49+
prompt_tokens: 10,
50+
completion_tokens: 5,
51+
total_tokens: 15,
52+
cached_tokens: 2,
53+
},
54+
}
55+
},
56+
}
57+
}),
58+
},
59+
},
60+
})),
61+
}
62+
})
63+
64+
import OpenAI from "openai"
65+
import type { Anthropic } from "@anthropic-ai/sdk"
66+
67+
import { moonshotDefaultModelId } from "@roo-code/types"
68+
69+
import type { ApiHandlerOptions } from "../../../shared/api"
70+
71+
import { MoonshotHandler } from "../moonshot"
72+
73+
describe("MoonshotHandler", () => {
74+
let handler: MoonshotHandler
75+
let mockOptions: ApiHandlerOptions
76+
77+
beforeEach(() => {
78+
mockOptions = {
79+
moonshotApiKey: "test-api-key",
80+
apiModelId: "moonshot-chat",
81+
moonshotBaseUrl: "https://api.moonshot.ai/v1",
82+
}
83+
handler = new MoonshotHandler(mockOptions)
84+
vi.clearAllMocks()
85+
})
86+
87+
describe("constructor", () => {
88+
it("should initialize with provided options", () => {
89+
expect(handler).toBeInstanceOf(MoonshotHandler)
90+
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
91+
})
92+
93+
it.skip("should throw error if API key is missing", () => {
94+
expect(() => {
95+
new MoonshotHandler({
96+
...mockOptions,
97+
moonshotApiKey: undefined,
98+
})
99+
}).toThrow("Moonshot API key is required")
100+
})
101+
102+
it("should use default model ID if not provided", () => {
103+
const handlerWithoutModel = new MoonshotHandler({
104+
...mockOptions,
105+
apiModelId: undefined,
106+
})
107+
expect(handlerWithoutModel.getModel().id).toBe(moonshotDefaultModelId)
108+
})
109+
110+
it("should use default base URL if not provided", () => {
111+
const handlerWithoutBaseUrl = new MoonshotHandler({
112+
...mockOptions,
113+
moonshotBaseUrl: undefined,
114+
})
115+
expect(handlerWithoutBaseUrl).toBeInstanceOf(MoonshotHandler)
116+
// The base URL is passed to OpenAI client internally
117+
expect(OpenAI).toHaveBeenCalledWith(
118+
expect.objectContaining({
119+
baseURL: "https://api.moonshot.ai/v1",
120+
}),
121+
)
122+
})
123+
124+
it("should use chinese base URL if provided", () => {
125+
const customBaseUrl = "https://api.moonshot.cn/v1"
126+
const handlerWithCustomUrl = new MoonshotHandler({
127+
...mockOptions,
128+
moonshotBaseUrl: customBaseUrl,
129+
})
130+
expect(handlerWithCustomUrl).toBeInstanceOf(MoonshotHandler)
131+
// The custom base URL is passed to OpenAI client
132+
expect(OpenAI).toHaveBeenCalledWith(
133+
expect.objectContaining({
134+
baseURL: customBaseUrl,
135+
}),
136+
)
137+
})
138+
139+
it("should set includeMaxTokens to true", () => {
140+
// Create a new handler and verify OpenAI client was called with includeMaxTokens
141+
const _handler = new MoonshotHandler(mockOptions)
142+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.moonshotApiKey }))
143+
})
144+
})
145+
146+
describe("getModel", () => {
147+
it("should return model info for valid model ID", () => {
148+
const model = handler.getModel()
149+
expect(model.id).toBe(mockOptions.apiModelId)
150+
expect(model.info).toBeDefined()
151+
expect(model.info.maxTokens).toBe(32_000)
152+
expect(model.info.contextWindow).toBe(131_072)
153+
expect(model.info.supportsImages).toBe(false)
154+
expect(model.info.supportsPromptCache).toBe(true) // Should be true now
155+
})
156+
157+
it("should return provided model ID with default model info if model does not exist", () => {
158+
const handlerWithInvalidModel = new MoonshotHandler({
159+
...mockOptions,
160+
apiModelId: "invalid-model",
161+
})
162+
const model = handlerWithInvalidModel.getModel()
163+
expect(model.id).toBe("invalid-model") // Returns provided ID
164+
expect(model.info).toBeDefined()
165+
// With the current implementation, it's the same object reference when using default model info
166+
expect(model.info).toBe(handler.getModel().info)
167+
// Should have the same base properties
168+
expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow)
169+
// And should have supportsPromptCache set to true
170+
expect(model.info.supportsPromptCache).toBe(true)
171+
})
172+
173+
it("should return default model if no model ID is provided", () => {
174+
const handlerWithoutModel = new MoonshotHandler({
175+
...mockOptions,
176+
apiModelId: undefined,
177+
})
178+
const model = handlerWithoutModel.getModel()
179+
expect(model.id).toBe(moonshotDefaultModelId)
180+
expect(model.info).toBeDefined()
181+
expect(model.info.supportsPromptCache).toBe(true)
182+
})
183+
184+
it("should include model parameters from getModelParams", () => {
185+
const model = handler.getModel()
186+
expect(model).toHaveProperty("temperature")
187+
expect(model).toHaveProperty("maxTokens")
188+
})
189+
})
190+
191+
describe("createMessage", () => {
192+
const systemPrompt = "You are a helpful assistant."
193+
const messages: Anthropic.Messages.MessageParam[] = [
194+
{
195+
role: "user",
196+
content: [
197+
{
198+
type: "text" as const,
199+
text: "Hello!",
200+
},
201+
],
202+
},
203+
]
204+
205+
it("should handle streaming responses", async () => {
206+
const stream = handler.createMessage(systemPrompt, messages)
207+
const chunks: any[] = []
208+
for await (const chunk of stream) {
209+
chunks.push(chunk)
210+
}
211+
212+
expect(chunks.length).toBeGreaterThan(0)
213+
const textChunks = chunks.filter((chunk) => chunk.type === "text")
214+
expect(textChunks).toHaveLength(1)
215+
expect(textChunks[0].text).toBe("Test response")
216+
})
217+
218+
it("should include usage information", async () => {
219+
const stream = handler.createMessage(systemPrompt, messages)
220+
const chunks: any[] = []
221+
for await (const chunk of stream) {
222+
chunks.push(chunk)
223+
}
224+
225+
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
226+
expect(usageChunks.length).toBeGreaterThan(0)
227+
expect(usageChunks[0].inputTokens).toBe(10)
228+
expect(usageChunks[0].outputTokens).toBe(5)
229+
})
230+
231+
it("should include cache metrics in usage information", async () => {
232+
const stream = handler.createMessage(systemPrompt, messages)
233+
const chunks: any[] = []
234+
for await (const chunk of stream) {
235+
chunks.push(chunk)
236+
}
237+
238+
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
239+
expect(usageChunks.length).toBeGreaterThan(0)
240+
expect(usageChunks[0].cacheWriteTokens).toBe(0)
241+
expect(usageChunks[0].cacheReadTokens).toBe(2)
242+
})
243+
})
244+
245+
describe("processUsageMetrics", () => {
246+
it("should correctly process usage metrics including cache information", () => {
247+
// We need to access the protected method, so we'll create a test subclass
248+
class TestMoonshotHandler extends MoonshotHandler {
249+
public testProcessUsageMetrics(usage: any) {
250+
return this.processUsageMetrics(usage)
251+
}
252+
}
253+
254+
const testHandler = new TestMoonshotHandler(mockOptions)
255+
256+
const usage = {
257+
prompt_tokens: 100,
258+
completion_tokens: 50,
259+
total_tokens: 150,
260+
cached_tokens: 20,
261+
}
262+
263+
const result = testHandler.testProcessUsageMetrics(usage)
264+
265+
expect(result.type).toBe("usage")
266+
expect(result.inputTokens).toBe(100)
267+
expect(result.outputTokens).toBe(50)
268+
expect(result.cacheWriteTokens).toBe(0)
269+
expect(result.cacheReadTokens).toBe(20)
270+
})
271+
272+
it("should handle missing cache metrics gracefully", () => {
273+
class TestMoonshotHandler extends MoonshotHandler {
274+
public testProcessUsageMetrics(usage: any) {
275+
return this.processUsageMetrics(usage)
276+
}
277+
}
278+
279+
const testHandler = new TestMoonshotHandler(mockOptions)
280+
281+
const usage = {
282+
prompt_tokens: 100,
283+
completion_tokens: 50,
284+
total_tokens: 150,
285+
// No cached_tokens
286+
}
287+
288+
const result = testHandler.testProcessUsageMetrics(usage)
289+
290+
expect(result.type).toBe("usage")
291+
expect(result.inputTokens).toBe(100)
292+
expect(result.outputTokens).toBe(50)
293+
expect(result.cacheWriteTokens).toBe(0)
294+
expect(result.cacheReadTokens).toBeUndefined()
295+
})
296+
})
297+
})

0 commit comments

Comments
 (0)