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
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
198 changes: 198 additions & 0 deletions src/api/providers/__tests__/kimi-code.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { buildApiHandler } from "../../index"
import { KimiCodeHandler } from "../kimi-code"

const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.hoisted(() => ({
mockGetAccessToken: vi.fn(),
mockForceRefreshAccessToken: vi.fn(),
mockGetModels: vi.fn(),
}))

vi.mock("../../../integrations/kimi-code/oauth", () => ({
kimiCodeOAuthManager: {
getAccessToken: mockGetAccessToken,
forceRefreshAccessToken: mockForceRefreshAccessToken,
},
}))

vi.mock("../fetchers/modelCache", () => ({ getModels: mockGetModels }))

describe("KimiCodeHandler", () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAccessToken.mockResolvedValue("oauth-token")
mockForceRefreshAccessToken.mockResolvedValue("refreshed-token")
mockGetModels.mockRejectedValue(new Error("offline"))
})

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.

it("uses API key when auth method is api-key", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "my-api-key" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ choices: [{ message: { content: "response" }, finish_reason: "stop" }] }), {
status: 200,
}),
)
try {
for await (const chunk of gen) {
// consume
}
} catch {
// expected - mock is incomplete
}
expect(mockGetAccessToken).not.toHaveBeenCalled()
})

it("uses OAuth token when auth method is oauth or not specified", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
try {
for await (const chunk of gen) {
// consume
}
} catch {
// expected - mock will fail
}
expect(mockGetAccessToken).toHaveBeenCalled()
})

it("throws error when OAuth is required but no token available", async () => {
mockGetAccessToken.mockResolvedValueOnce(null)
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
await expect(async () => {
for await (const chunk of gen) {
// consume
}
}).rejects.toThrow("Not authenticated with Kimi Code")
})

it("throws error when API key auth is missing the key", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
await expect(async () => {
for await (const chunk of gen) {
// consume
}
}).rejects.toThrow("Kimi Code API key is required")
})

it("retries with forced refresh on 401 when using OAuth", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
const fetchSpy = vi.spyOn(globalThis, "fetch")
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }))
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }] }), {
status: 200,
}),
)
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
try {
for await (const chunk of gen) {
// consume
}
} catch {
// expected - mock is incomplete
}
expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce()
})

it("force-refreshes and retries exactly once after a non-streaming OAuth 401", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 })
const createCompletion = vi
.spyOn((handler as any).client.chat.completions, "create")
.mockRejectedValueOnce(unauthorized)
.mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] })

await expect(handler.completePrompt("test")).resolves.toBe("retried")
expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce()
expect(createCompletion).toHaveBeenCalledTimes(2)
})

it("does not retry on 401 when using API key auth", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" })
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status: 401 }))
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
await expect(async () => {
for await (const chunk of gen) {
// consume
}
}).rejects.toThrow()
expect(mockForceRefreshAccessToken).not.toHaveBeenCalled()
})

Comment on lines +95 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Cover the forced-refresh retry contract for completePrompt() and non-401 failures.

While createMessage() 401 retry behavior is adequately covered, the previous review's request to cover completePrompt() and non-401 OAuth failures was missed.

Please extend the test suite to ensure comprehensive coverage of the error handling and retry decisions as previously requested.

🤖 Proposed tests to add

Add these within the describe block to fulfill the coverage requirements:

	it("retries completePrompt with forced refresh on 401 when using OAuth", async () => {
		const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
		const fetchSpy = vi.spyOn(globalThis, "fetch")
		fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }))
		fetchSpy.mockResolvedValueOnce(
			new Response(JSON.stringify({ choices: [{ text: "ok" }] }), { status: 200 }),
		)
		try {
			await handler.completePrompt("test")
		} catch {
			// expected - mock is incomplete
		}
		expect(mockForceRefreshAccessToken as any).toHaveBeenCalled()
	})

	it("does not retry on non-401 errors when using OAuth", async () => {
		const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
		const fetchSpy = vi.spyOn(globalThis, "fetch")
		fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500 }))
		const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
		await expect(async () => {
			for await (const chunk of gen) {}
		}).rejects.toThrow()
		expect(mockForceRefreshAccessToken as any).not.toHaveBeenCalled()
	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/__tests__/kimi-code.spec.ts` around lines 105 - 140, Extend
the tests in the KimiCodeHandler suite to cover completePrompt() retrying after
a 401 under OAuth, including a successful second response and verification that
mockForceRefreshAccessToken is called. Also add an OAuth createMessage() case
for a non-401 failure such as 500, asserting the request rejects and
mockForceRefreshAccessToken is not called.

it("fetches models during prepareRequest", async () => {
mockGetModels.mockResolvedValueOnce({ "test-model": { maxTokens: 1000 } })
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
try {
for await (const chunk of gen) {
// consume
}
} catch {
// expected
}
expect(mockGetModels).toHaveBeenCalled()
})

it("continues when model discovery fails", async () => {
mockGetModels.mockRejectedValueOnce(new Error("discovery failed"))
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
try {
for await (const chunk of gen) {
// consume
}
} catch {
// expected - different error
}
expect(mockGetModels).toHaveBeenCalled()
})

it.each([
["failure", () => Promise.reject(new Error("offline"))],
["empty response", () => Promise.resolve({})],
])("does not repeatedly block requests after model discovery %s", async (_case, discovery) => {
mockGetModels.mockImplementationOnce(discovery)
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 }),
)
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" })

await handler.completePrompt("first")
await handler.completePrompt("second")

expect(mockGetModels).toHaveBeenCalledOnce()
})

it("uses discovered model info when available", async () => {
mockGetModels.mockResolvedValueOnce({ "kimi-for-coding": { maxTokens: 8000, contextWindow: 128000 } })
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" })
const gen = handler.createMessage("system", [{ role: "user", content: "test" }])
try {
for await (const chunk of gen) {
// consume
}
} catch {
// expected
}
const model = handler.getModel()
expect(model.info.maxTokens).toBe(8000)
})
})
Loading
Loading