Skip to content
Merged
29 changes: 29 additions & 0 deletions packages/types/src/__tests__/kimi-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
SECRET_STATE_KEYS,
dynamicProviders,
kimiCodeDefaultModelId,
providerSettingsSchema,
providerSettingsSchemaDiscriminated,
} from "../index.js"

describe("Kimi Code provider types", () => {
it("registers Kimi Code as a dynamic provider with a distinct secret", () => {
expect(dynamicProviders).toContain("kimi-code")
expect(SECRET_STATE_KEYS).toContain("kimiCodeApiKey")
expect(SECRET_STATE_KEYS).toContain("moonshotApiKey")
})

it("parses OAuth and API-key settings independently from Moonshot", () => {
expect(
providerSettingsSchemaDiscriminated.parse({
apiProvider: "kimi-code",
kimiCodeAuthMethod: "api-key",
kimiCodeApiKey: "kimi-key",
apiModelId: kimiCodeDefaultModelId,
}),
).toMatchObject({ kimiCodeApiKey: "kimi-key" })
expect(providerSettingsSchema.parse({ apiProvider: "kimi-code", kimiCodeAuthMethod: "oauth" })).toMatchObject({
kimiCodeAuthMethod: "oauth",
})
})
})
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ export const SECRET_STATE_KEYS = [
"openAiNativeApiKey",
"deepSeekApiKey",
"moonshotApiKey",
"kimiCodeApiKey",
"mistralApiKey",
"minimaxApiKey",
"requestyApiKey",
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const modelInfoSchema = z.object({
})
.optional(),
description: z.string().optional(),
displayName: z.string().optional(),
// Default effort value for models that support reasoning effort
reasoningEffort: reasoningEffortExtendedSchema.optional(),
minTokensPerCachePoint: z.number().optional(),
Expand Down
18 changes: 18 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const dynamicProviders = [
"deepseek",
"opencode-go",
"kenari",
"kimi-code",
] as const

export type DynamicProvider = (typeof dynamicProviders)[number]
Expand Down Expand Up @@ -126,6 +127,7 @@ export const providerNames = [
"gemini-cli",
"mistral",
"moonshot",
"kimi-code",
"minimax",
"mimo",
"openai-codex",
Expand Down Expand Up @@ -334,6 +336,14 @@ const moonshotSchema = apiModelIdProviderModelSchema.extend({
moonshotApiKey: z.string().optional(),
})

export const kimiCodeAuthMethodSchema = z.enum(["oauth", "api-key"])
export type KimiCodeAuthMethod = z.infer<typeof kimiCodeAuthMethodSchema>

const kimiCodeSchema = apiModelIdProviderModelSchema.extend({
kimiCodeAuthMethod: kimiCodeAuthMethodSchema.optional(),
kimiCodeApiKey: z.string().optional(),
})

const minimaxSchema = apiModelIdProviderModelSchema.extend({
minimaxBaseUrl: z
.union([z.literal("https://api.minimax.io/v1"), z.literal("https://api.minimaxi.com/v1")])
Expand Down Expand Up @@ -450,6 +460,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
poeSchema.merge(z.object({ apiProvider: z.literal("poe") })),
moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })),
kimiCodeSchema.merge(z.object({ apiProvider: z.literal("kimi-code") })),
minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })),
mimoSchema.merge(z.object({ apiProvider: z.literal("mimo") })),
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
Expand Down Expand Up @@ -488,6 +499,7 @@ export const providerSettingsSchema = z.object({
...deepSeekSchema.shape,
...poeSchema.shape,
...moonshotSchema.shape,
...kimiCodeSchema.shape,
...minimaxSchema.shape,
...mimoSchema.shape,
...requestySchema.shape,
Expand Down Expand Up @@ -569,6 +581,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
"gemini-cli": "apiModelId",
mistral: "apiModelId",
moonshot: "apiModelId",
"kimi-code": "apiModelId",
minimax: "apiModelId",
mimo: "apiModelId",
deepseek: "apiModelId",
Expand Down Expand Up @@ -677,6 +690,11 @@ export const MODELS_BY_PROVIDER: Record<
label: "Moonshot",
models: Object.keys(moonshotModels),
},
"kimi-code": {
id: "kimi-code",
label: "Kimi Code",
models: [],
},
minimax: {
id: "minimax",
label: "MiniMax",
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from "./xai.js"
export * from "./vercel-ai-gateway.js"
export * from "./opencode-go.js"
export * from "./kenari.js"
export * from "./kimi-code.js"
export * from "./zai.js"
export * from "./minimax.js"
export * from "./mimo.js"
Expand Down Expand Up @@ -53,6 +54,7 @@ import { xaiDefaultModelId } from "./xai.js"
import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
import { opencodeGoDefaultModelId } from "./opencode-go.js"
import { kenariDefaultModelId } from "./kenari.js"
import { kimiCodeDefaultModelId } from "./kimi-code.js"
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
import { minimaxDefaultModelId } from "./minimax.js"
import { mimoDefaultModelId } from "./mimo.js"
Expand Down Expand Up @@ -129,6 +131,8 @@ export function getProviderDefaultModelId(
return opencodeGoDefaultModelId
case "kenari":
return kenariDefaultModelId
case "kimi-code":
return kimiCodeDefaultModelId
case "zoo-gateway":
return zooGatewayDefaultModelId
case "anthropic":
Expand Down
18 changes: 18 additions & 0 deletions packages/types/src/providers/kimi-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ModelInfo } from "../model.js"

export const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"
export const kimiCodeDefaultModelId = "kimi-for-coding"

export const kimiCodeDefaultModelInfo: ModelInfo = {
contextWindow: 262_144,
maxTokens: 32_768,
supportsImages: false,
supportsPromptCache: false,
description: "Kimi Code's coding model for subscription and API-key access.",
}

export const kimiCodeModels = {
[kimiCodeDefaultModelId]: kimiCodeDefaultModelInfo,
} as const satisfies Record<string, ModelInfo>

export type KimiCodeModelId = keyof typeof kimiCodeModels
10 changes: 10 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,14 @@ export type ExtensionState = Pick<
mdmCompliant?: boolean
taskSyncEnabled: boolean
openAiCodexIsAuthenticated?: boolean
kimiCodeIsAuthenticated?: boolean
kimiCodeOAuthState?: {
status: "idle" | "authorizing" | "polling" | "authenticated" | "error"
userCode?: string
verificationUri?: string
expiresAt?: number
error?: string
}
zooCodeIsAuthenticated?: boolean
zooCodeUserName?: string
zooCodeUserEmail?: string
Expand Down Expand Up @@ -537,6 +545,8 @@ export interface WebviewMessage {
| "rooCloudManualUrl"
| "openAiCodexSignIn"
| "openAiCodexSignOut"
| "kimiCodeSignIn"
| "kimiCodeSignOut"
| "zooCodeSignOut"
| "switchOrganization"
| "condenseTaskContextRequest"
Expand Down
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
OpenAiNativeHandler,
DeepSeekHandler,
MoonshotHandler,
KimiCodeHandler,
MistralHandler,
VsCodeLmHandler,
RequestyHandler,
Expand Down Expand Up @@ -178,6 +179,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new QwenCodeHandler(options)
case "moonshot":
return new MoonshotHandler(options)
case "kimi-code":
return new KimiCodeHandler(options)
case "vscode-lm":
return new VsCodeLmHandler(options)
case "mistral":
Expand Down
31 changes: 31 additions & 0 deletions src/api/providers/__tests__/kimi-code.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { buildApiHandler } from "../../index"
import { KimiCodeHandler } from "../kimi-code"

vi.mock("../../../integrations/kimi-code/oauth", () => ({
kimiCodeOAuthManager: {
getAccessToken: vi.fn().mockResolvedValue("oauth-token"),
forceRefreshAccessToken: vi.fn().mockResolvedValue("refreshed-token"),
},
}))

vi.mock("../fetchers/modelCache", () => ({
getModels: vi.fn().mockRejectedValue(new Error("offline")),
}))

describe("KimiCodeHandler", () => {
it("is dispatched separately from Moonshot and preserves an unknown selected model", () => {
const handler = buildApiHandler({
apiProvider: "kimi-code",
kimiCodeAuthMethod: "api-key",
kimiCodeApiKey: "kimi-key",
apiModelId: "future-kimi-model",
})
expect(handler).toBeInstanceOf(KimiCodeHandler)
expect(handler.getModel().id).toBe("future-kimi-model")
})

it("uses kimi-for-coding only when no model is selected", () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "kimi-key" })
expect(handler.getModel().id).toBe("kimi-for-coding")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
36 changes: 36 additions & 0 deletions src/api/providers/fetchers/__tests__/kimi-code.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getKimiCodeModels, mapKimiCodeModel } from "../kimi-code"

describe("Kimi Code model discovery", () => {
beforeEach(() => vi.restoreAllMocks())

it("maps official model fields", () => {
expect(
mapKimiCodeModel({
id: "kimi-test",
context_length: 131072,
supports_reasoning: true,
supports_image_in: true,
display_name: "Kimi Test",
}),
).toMatchObject({
contextWindow: 131072,
supportsReasoningBinary: true,
supportsImages: true,
displayName: "Kimi Test",
})
})

it("uses bearer auth for GET /models", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: "kimi-for-coding", context_length: 262144 }] }), {
status: 200,
}),
)
const models = await getKimiCodeModels("secret-token")
expect(models).toHaveProperty("kimi-for-coding")
expect(fetch).toHaveBeenCalledWith(
"https://api.kimi.com/coding/v1/models",
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer secret-token" }) }),
)
})
})
37 changes: 37 additions & 0 deletions src/api/providers/fetchers/kimi-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { z } from "zod"

import { KIMI_CODE_BASE_URL, kimiCodeDefaultModelInfo, type ModelInfo, type ModelRecord } from "@roo-code/types"

const kimiCodeModelSchema = z.object({
id: z.string().min(1),
context_length: z.number().positive().optional(),
supports_reasoning: z.boolean().optional(),
supports_image_in: z.boolean().optional(),
display_name: z.string().optional(),
})

const kimiCodeModelsResponseSchema = z.object({ data: z.array(kimiCodeModelSchema) })

export function mapKimiCodeModel(model: z.infer<typeof kimiCodeModelSchema>): ModelInfo {
return {
...kimiCodeDefaultModelInfo,
contextWindow: model.context_length ?? kimiCodeDefaultModelInfo.contextWindow,
supportsReasoningBinary: model.supports_reasoning ?? false,
supportsImages: model.supports_image_in ?? false,
displayName: model.display_name,
}
}

export async function getKimiCodeModels(apiKey?: string): Promise<ModelRecord> {
if (!apiKey) throw new Error("Kimi Code authentication is required to fetch models")
const response = await fetch(`${KIMI_CODE_BASE_URL}/models`, {
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
})
if (!response.ok) {
const error = new Error(`Kimi Code models request failed: ${response.status} ${response.statusText}`)
;(error as Error & { status?: number }).status = response.status
throw error
}
const parsed = kimiCodeModelsResponseSchema.parse(await response.json())
return Object.fromEntries(parsed.data.map((model) => [model.id, mapKimiCodeModel(model)]))
}
6 changes: 5 additions & 1 deletion src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { getLMStudioModels } from "./lmstudio"
import { getPoeModels } from "./poe"
import { getDeepSeekModels } from "./deepseek"
import { getZooGatewayModels } from "./zoo-gateway"
import { getKimiCodeModels } from "./kimi-code"

const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })

Expand All @@ -45,7 +46,7 @@ const inFlightRefresh = new Map<string, Promise<ModelRecord>>()
// allowlists or org policies). For these we MUST NOT cache results on disk or
// in memory: a sign-in/out cycle could otherwise serve a previous user's model
// list to the next user, and stale data could mask backend allowlist updates.
const AUTH_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set(["zoo-gateway"])
const AUTH_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set(["zoo-gateway", "kimi-code"])

// Providers whose model list is determined by the server URL, not just by the provider name.
// Each unique baseUrl must be cached independently so that switching endpoints never serves
Expand Down Expand Up @@ -224,6 +225,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
case "zoo-gateway":
models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl })
break
case "kimi-code":
models = await getKimiCodeModels(options.apiKey)
break
default: {
// Ensures router is exhaustively checked if RouterName is a strict union.
const exhaustiveCheck: never = provider
Expand Down
1 change: 1 addition & 0 deletions src/api/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { AnthropicHandler } from "./anthropic"
export { AwsBedrockHandler } from "./bedrock"
export { DeepSeekHandler } from "./deepseek"
export { MoonshotHandler } from "./moonshot"
export { KimiCodeHandler } from "./kimi-code"
export { FakeAIHandler } from "./fake-ai"
export { GeminiHandler } from "./gemini"
export { LiteLLMHandler } from "./lite-llm"
Expand Down
Loading
Loading