diff --git a/packages/types/src/__tests__/kimi-code.test.ts b/packages/types/src/__tests__/kimi-code.test.ts new file mode 100644 index 0000000000..3d8b9ac549 --- /dev/null +++ b/packages/types/src/__tests__/kimi-code.test.ts @@ -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", + }) + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 76420bc020..88b5408fca 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -300,6 +300,7 @@ export const SECRET_STATE_KEYS = [ "openAiNativeApiKey", "deepSeekApiKey", "moonshotApiKey", + "kimiCodeApiKey", "mistralApiKey", "minimaxApiKey", "requestyApiKey", diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 87ebfaf967..257a30f3e7 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -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(), diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index fb03531c33..9967671930 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -48,6 +48,7 @@ export const dynamicProviders = [ "deepseek", "opencode-go", "kenari", + "kimi-code", ] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -126,6 +127,7 @@ export const providerNames = [ "gemini-cli", "mistral", "moonshot", + "kimi-code", "minimax", "mimo", "openai-codex", @@ -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 + +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")]) @@ -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") })), @@ -488,6 +499,7 @@ export const providerSettingsSchema = z.object({ ...deepSeekSchema.shape, ...poeSchema.shape, ...moonshotSchema.shape, + ...kimiCodeSchema.shape, ...minimaxSchema.shape, ...mimoSchema.shape, ...requestySchema.shape, @@ -569,6 +581,7 @@ export const modelIdKeysByProvider: Record = { "gemini-cli": "apiModelId", mistral: "apiModelId", moonshot: "apiModelId", + "kimi-code": "apiModelId", minimax: "apiModelId", mimo: "apiModelId", deepseek: "apiModelId", @@ -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", diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 77b0415898..9a78de11ac 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -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" @@ -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" @@ -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": diff --git a/packages/types/src/providers/kimi-code.ts b/packages/types/src/providers/kimi-code.ts new file mode 100644 index 0000000000..4857509a1a --- /dev/null +++ b/packages/types/src/providers/kimi-code.ts @@ -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 + +export type KimiCodeModelId = keyof typeof kimiCodeModels diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..21a42af908 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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 @@ -537,6 +545,8 @@ export interface WebviewMessage { | "rooCloudManualUrl" | "openAiCodexSignIn" | "openAiCodexSignOut" + | "kimiCodeSignIn" + | "kimiCodeSignOut" | "zooCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" diff --git a/src/api/index.ts b/src/api/index.ts index 48a070feb4..fccbf48867 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -20,6 +20,7 @@ import { OpenAiNativeHandler, DeepSeekHandler, MoonshotHandler, + KimiCodeHandler, MistralHandler, VsCodeLmHandler, RequestyHandler, @@ -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": diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts new file mode 100644 index 0000000000..65b61f6d5b --- /dev/null +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -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") + }) + + 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() + }) + + 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) + }) +}) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 332fc39602..9c79e56159 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -905,6 +905,12 @@ describe("OpenAiHandler", () => { await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error: API Error") }) + it("should preserve HTTP status when wrapping completion errors", async () => { + mockCreate.mockRejectedValueOnce(Object.assign(new Error("Unauthorized"), { status: 401 })) + + await expect(handler.completePrompt("Test prompt")).rejects.toMatchObject({ status: 401 }) + }) + it("should handle empty response", async () => { mockCreate.mockImplementationOnce(() => ({ choices: [{ message: { content: "" } }], diff --git a/src/api/providers/fetchers/__tests__/kimi-code.spec.ts b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts new file mode 100644 index 0000000000..f9443bdfe0 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts @@ -0,0 +1,96 @@ +import { getKimiCodeModels, mapKimiCodeModel } from "../kimi-code" + +describe("Kimi Code model discovery", () => { + beforeEach(() => vi.restoreAllMocks()) + afterEach(() => vi.useRealTimers()) + + 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" }) }), + ) + }) + + it("applies default values when optional fields are missing", () => { + const mapped = mapKimiCodeModel({ + id: "basic-model", + }) + expect(mapped.supportsReasoningBinary).toBe(false) + expect(mapped.supportsImages).toBe(false) + expect(mapped.contextWindow).toBeGreaterThan(0) + }) + + it("throws error when apiKey is missing", async () => { + await expect(getKimiCodeModels()).rejects.toThrow("Kimi Code authentication is required") + }) + + it("throws error with status code on failed request", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }), + ) + try { + await getKimiCodeModels("bad-token") + expect.fail("should have thrown") + } catch (error: any) { + expect(error.message).toContain("401") + expect(error.status).toBe(401) + } + }) + + it("returns multiple models as a record", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { id: "model-a", context_length: 100000 }, + { id: "model-b", context_length: 200000, supports_reasoning: true }, + ], + }), + { status: 200 }, + ), + ) + const models = await getKimiCodeModels("token") + expect(Object.keys(models)).toHaveLength(2) + expect(models["model-a"].contextWindow).toBe(100000) + expect(models["model-b"].supportsReasoningBinary).toBe(true) + }) + + it("aborts model discovery after its deadline", async () => { + vi.useFakeTimers() + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + }) + const result = expect(getKimiCodeModels("token")).rejects.toThrow("timed out") + + await vi.advanceTimersByTimeAsync(10_000) + await result + expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/api/providers/fetchers/kimi-code.ts b/src/api/providers/fetchers/kimi-code.ts new file mode 100644 index 0000000000..5a73991de3 --- /dev/null +++ b/src/api/providers/fetchers/kimi-code.ts @@ -0,0 +1,49 @@ +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) }) + +const KIMI_CODE_MODELS_TIMEOUT_MS = 10_000 + +export function mapKimiCodeModel(model: z.infer): 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 { + if (!apiKey) throw new Error("Kimi Code authentication is required to fetch models") + const controller = new AbortController() + const timeout = setTimeout( + () => controller.abort(new Error("Kimi Code models request timed out")), + KIMI_CODE_MODELS_TIMEOUT_MS, + ) + try { + const response = await fetch(`${KIMI_CODE_BASE_URL}/models`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }) + 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)])) + } finally { + clearTimeout(timeout) + } +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index deca6a7c00..dd0a81950f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -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 }) @@ -45,7 +46,7 @@ const inFlightRefresh = new Map>() // 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 = new Set(["zoo-gateway"]) +const AUTH_SCOPED_PROVIDERS: ReadonlySet = 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 @@ -224,6 +225,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { + if ((this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "api-key") { + if (!this.kimiOptions.kimiCodeApiKey) throw new Error("Kimi Code API key is required") + return this.kimiOptions.kimiCodeApiKey + } + + const token = forceRefresh + ? await kimiCodeOAuthManager.forceRefreshAccessToken() + : await kimiCodeOAuthManager.getAccessToken() + if (!token) throw new Error("Not authenticated with Kimi Code. Sign in from provider settings.") + return token + } + + private async prepareRequest(forceRefresh = false): Promise { + const accessToken = await this.resolveAccessToken(forceRefresh) + this.client.apiKey = accessToken + if (!this.modelDiscoveryAttempted) { + this.modelDiscoveryAttempted = true + try { + this.models = await getModels({ provider: "kimi-code", apiKey: accessToken }) + } catch (error) { + // Model discovery is best-effort; preserve the configured ID and fallback metadata. + console.debug("[KimiCode] Model discovery failed; using fallback model metadata", { + message: error instanceof Error ? error.message : String(error), + }) + } + } + } + + private canRefreshOAuth(): boolean { + return (this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "oauth" + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + await this.prepareRequest() + try { + yield* super.createMessage(systemPrompt, messages, metadata) + } catch (error) { + if (getHttpStatus(error) !== 401 || !this.canRefreshOAuth()) throw error + await this.prepareRequest(true) + yield* super.createMessage(systemPrompt, messages, metadata) + } + } + + override async completePrompt(prompt: string): Promise { + await this.prepareRequest() + try { + return await super.completePrompt(prompt) + } catch (error) { + if (getHttpStatus(error) !== 401 || !this.canRefreshOAuth()) throw error + await this.prepareRequest(true) + return super.completePrompt(prompt) + } + } + + override getModel() { + const id = this.kimiOptions.apiModelId || kimiCodeDefaultModelId + const info: ModelInfo = this.models[id] ?? kimiCodeDefaultModelInfo + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.kimiOptions, + defaultTemperature: 0, + }) + return { id, info, ...params } + } +} diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index ca0305aab7..9545068794 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -323,7 +323,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl return response.choices?.[0]?.message.content || "" } catch (error) { if (error instanceof Error) { - throw new Error(`${this.providerName} completion error: ${error.message}`) + const wrapped = new Error(`${this.providerName} completion error: ${error.message}`, { cause: error }) + const source = error as Error & { status?: number; errorDetails?: unknown; code?: unknown } + const target = wrapped as Error & { status?: number; errorDetails?: unknown; code?: unknown } + if (source.status !== undefined) target.status = source.status + if (source.errorDetails !== undefined) target.errorDetails = source.errorDetails + if (source.code !== undefined) target.code = source.code + throw wrapped } throw error diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 47e31d7e2d..2d76e46966 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2526,6 +2526,18 @@ export class ClineProvider return false } })(), + kimiCodeIsAuthenticated: await (async () => { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + return await kimiCodeOAuthManager.isAuthenticated() + } catch { + return false + } + })(), + kimiCodeOAuthState: await (async () => { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + return kimiCodeOAuthManager.getState() + })(), ...zooCodeState, platform: process.platform, arch: process.arch, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b1e5df93f9..b969df5e1f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2705,6 +2705,7 @@ describe("ClineProvider - Router Models", () => { deepseek: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2757,6 +2758,7 @@ describe("ClineProvider - Router Models", () => { deepseek: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2855,6 +2857,7 @@ describe("ClineProvider - Router Models", () => { deepseek: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 12832da249..c948901161 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -403,6 +403,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { deepseek: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -589,6 +590,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { deepseek: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -649,6 +651,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { deepseek: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -1467,3 +1470,148 @@ describe("zooCodeSignOut", () => { ) }) }) + +describe("webviewMessageHandler - kimiCodeSignIn", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("starts OAuth authorization and opens browser", async () => { + const mockStartAuthorization = vi.fn().mockResolvedValue({ + userCode: "TEST-CODE", + verificationUri: "https://auth.kimi.com/device", + expiresAt: Date.now() + 600000, + }) + const mockWaitForAuthorization = vi.fn().mockResolvedValue({ + type: "kimi-code", + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 3600000, + }) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + waitForAuthorization: mockWaitForAuthorization, + }, + })) + + const mockOpenExternal = vi.fn().mockResolvedValue(true) + ;(vscode as any).env = { openExternal: mockOpenExternal } + ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + + expect(mockStartAuthorization).toHaveBeenCalled() + expect(mockOpenExternal).toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("shows success message after successful authorization", async () => { + const mockStartAuthorization = vi.fn().mockResolvedValue({ + userCode: "TEST-CODE", + verificationUri: "https://auth.kimi.com/device", + expiresAt: Date.now() + 600000, + }) + const mockWaitForAuthorization = vi.fn().mockResolvedValue({ + type: "kimi-code", + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 3600000, + }) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + waitForAuthorization: mockWaitForAuthorization, + }, + })) + + const mockOpenExternal = vi.fn().mockResolvedValue(true) + ;(vscode as any).env = { openExternal: mockOpenExternal } + ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Successfully signed in to Kimi Code") + }) + + it("handles authorization failure", async () => { + const mockStartAuthorization = vi.fn().mockResolvedValue({ + userCode: "TEST-CODE", + verificationUri: "https://auth.kimi.com/device", + expiresAt: Date.now() + 600000, + }) + const mockWaitForAuthorization = vi.fn().mockRejectedValue(new Error("Authorization cancelled")) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + waitForAuthorization: mockWaitForAuthorization, + }, + })) + + const mockOpenExternal = vi.fn().mockResolvedValue(true) + ;(vscode as any).env = { openExternal: mockOpenExternal } + ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("handles startAuthorization error", async () => { + const mockStartAuthorization = vi.fn().mockRejectedValue(new Error("Network error")) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + }, + })) + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("Kimi Code sign in failed"), + ) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) +}) + +describe("webviewMessageHandler - kimiCodeSignOut", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("clears credentials and shows success message", async () => { + const mockClearCredentials = vi.fn().mockResolvedValue(undefined) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + clearCredentials: mockClearCredentials, + }, + })) + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignOut" }) + + expect(mockClearCredentials).toHaveBeenCalled() + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Signed out from Kimi Code") + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("handles sign out error", async () => { + const mockClearCredentials = vi.fn().mockRejectedValue(new Error("Clear failed")) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + clearCredentials: mockClearCredentials, + }, + })) + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignOut" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kimi Code sign out failed.") + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 01abc5db98..43f2520ddd 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1043,6 +1043,7 @@ export const webviewMessageHandler = async ( deepseek: {}, "opencode-go": {}, kenari: {}, + "kimi-code": {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1170,6 +1171,22 @@ export const webviewMessageHandler = async ( options: { provider: "kenari", apiKey: kenariApiKey }, }) + if (!providerFilter || providerFilter === "kimi-code") { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + const kimiCodeAuthMethod = + message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" + const kimiCodeApiKey = + kimiCodeAuthMethod === "api-key" + ? (message?.values?.kimiCodeApiKey ?? apiConfiguration.kimiCodeApiKey) + : await kimiCodeOAuthManager.getAccessToken() + if (kimiCodeApiKey) { + candidates.push({ + key: "kimi-code", + options: { provider: "kimi-code", apiKey: kimiCodeApiKey }, + }) + } + } + // Apply single provider filter if specified const modelFetchPromises = providerFilter ? candidates.filter(({ key }) => key === providerFilter) @@ -2601,6 +2618,45 @@ export const webviewMessageHandler = async ( } break } + case "kimiCodeSignIn": { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + const device = await kimiCodeOAuthManager.startAuthorization() + await provider.postStateToWebview() + await vscode.env.openExternal( + vscode.Uri.parse(device.verificationUriComplete ?? device.verificationUri), + ) + void kimiCodeOAuthManager + .waitForAuthorization() + .then(async () => { + vscode.window.showInformationMessage("Successfully signed in to Kimi Code") + await provider.postStateToWebview() + }) + .catch(async (error) => { + provider.log(`Kimi Code OAuth failed: ${error}`) + await provider.postStateToWebview() + }) + } catch (error) { + provider.log(`Kimi Code OAuth failed: ${error}`) + vscode.window.showErrorMessage( + `Kimi Code sign in failed: ${error instanceof Error ? error.message : error}`, + ) + await provider.postStateToWebview() + } + break + } + case "kimiCodeSignOut": { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + await kimiCodeOAuthManager.clearCredentials() + vscode.window.showInformationMessage("Signed out from Kimi Code") + await provider.postStateToWebview() + } catch (error) { + provider.log(`Kimi Code sign out failed: ${error}`) + vscode.window.showErrorMessage("Kimi Code sign out failed.") + } + break + } case "rooCloudManualUrl": { if (!isCloudServiceAvailable()) { provider.log("CloudService unavailable; ignoring rooCloudManualUrl") diff --git a/src/extension.ts b/src/extension.ts index f55aa61405..f006b4b52b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { Terminal } from "./integrations/terminal/Terminal" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" +import { kimiCodeOAuthManager } from "./integrations/kimi-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" @@ -157,6 +158,8 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize OpenAI Codex OAuth manager for ChatGPT subscription-based access. openAiCodexOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) + // Kimi Code OAuth tokens live only in VS Code SecretStorage, outside provider profile JSON/cloud sync. + kimiCodeOAuthManager.initialize(context) // Initialize Zoo Code auth service for extension session token management. await initZooCodeAuth(context) diff --git a/src/integrations/kimi-code/__tests__/oauth.spec.ts b/src/integrations/kimi-code/__tests__/oauth.spec.ts new file mode 100644 index 0000000000..960777e69c --- /dev/null +++ b/src/integrations/kimi-code/__tests__/oauth.spec.ts @@ -0,0 +1,465 @@ +import { KIMI_CODE_OAUTH_CONFIG, KimiCodeOAuthManager } from "../oauth" + +const createContext = () => { + const values = new Map() + return { + values, + context: { + secrets: { + get: vi.fn(async (key: string) => values.get(key)), + store: vi.fn(async (key: string, value: string) => void values.set(key, value)), + delete: vi.fn(async (key: string) => void values.delete(key)), + }, + } as any, + } +} + +describe("KimiCodeOAuthManager", () => { + beforeEach(() => vi.restoreAllMocks()) + afterEach(() => vi.useRealTimers()) + + it("uses the official public client ID and form-encoded device request", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "ABCD-EFGH", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + + const authorization = await manager.startAuthorization() + expect(authorization.userCode).toBe("ABCD-EFGH") + expect(fetch).toHaveBeenCalledWith( + KIMI_CODE_OAUTH_CONFIG.deviceAuthorizationEndpoint, + expect.objectContaining({ body: `client_id=${KIMI_CODE_OAUTH_CONFIG.clientId}` }), + ) + const cancelledPolling = manager.waitForAuthorization().catch((error) => error) + manager.cancelAuthorization() + await expect(cancelledPolling).resolves.toMatchObject({ message: "Kimi Code authorization was cancelled" }) + }) + + it("deduplicates concurrent access-token refreshes and stores refreshed credentials", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ access_token: "new", refresh_token: "rotated", expires_in: 3600 }), { + status: 200, + }), + ) + + const [first, second] = await Promise.all([manager.getAccessToken(), manager.getAccessToken()]) + expect(first).toBe("new") + expect(second).toBe("new") + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(context.secrets.store).toHaveBeenCalledTimes(1) + }) + + it("returns null when not authenticated", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + expect(await manager.isAuthenticated()).toBe(false) + expect(await manager.getAccessToken()).toBeNull() + }) + + it("returns cached access token when not expired", async () => { + const { context, values } = createContext() + const futureExpiry = Date.now() + 3600_000 + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ + type: "kimi-code", + accessToken: "valid", + refreshToken: "refresh", + expiresAt: futureExpiry, + }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + expect(await manager.getAccessToken()).toBe("valid") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("clears credentials and cancels authorization", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "token", refreshToken: "refresh", expiresAt: 99999 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + await manager.clearCredentials() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + expect(await manager.isAuthenticated()).toBe(false) + }) + + it("does not restore credentials when sign-out races an in-flight refresh", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + let resolveRefresh!: (response: Response) => void + vi.spyOn(globalThis, "fetch").mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + const tokenPromise = manager.getAccessToken() + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + const clearPromise = manager.clearCredentials() + resolveRefresh( + new Response(JSON.stringify({ access_token: "new", refresh_token: "rotated", expires_in: 3600 }), { + status: 200, + }), + ) + + await expect(tokenPromise).resolves.toBeNull() + await clearPromise + expect(context.secrets.store).not.toHaveBeenCalled() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + }) + + it("bounds token refresh requests with a deadline", async () => { + vi.useFakeTimers() + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + }) + const tokenPromise = manager.getAccessToken() + + await vi.advanceTimersByTimeAsync(30_000) + await expect(tokenPromise).resolves.toBeNull() + expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true) + expect(values.has("kimi-code-oauth-credentials")).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it("keeps the newer polling lifecycle when a prior poll finishes cancellation", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "first", + user_code: "FIRST", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "second", + user_code: "SECOND", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + + await manager.startAuthorization() + const firstPolling = manager.waitForAuthorization().catch((error) => error) + await manager.startAuthorization() + const secondPolling = manager.waitForAuthorization().catch((error) => error) + await expect(firstPolling).resolves.toMatchObject({ message: "Kimi Code authorization was cancelled" }) + + expect(manager.getState()).toMatchObject({ status: "polling", userCode: "SECOND" }) + expect(() => manager.waitForAuthorization()).not.toThrow() + manager.cancelAuthorization() + await secondPolling + }) + + it("ignores a superseded device request failure", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + let rejectFirst!: (error: Error) => void + vi.spyOn(globalThis, "fetch") + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFirst = reject + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "second", + user_code: "SECOND", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + + const first = manager.startAuthorization() + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + await manager.startAuthorization() + rejectFirst(new Error("stale request failed")) + await expect(first).rejects.toThrow("stale request failed") + + expect(manager.getState()).toMatchObject({ status: "polling", userCode: "SECOND" }) + expect(() => manager.waitForAuthorization()).not.toThrow() + const secondPolling = manager.waitForAuthorization().catch((error) => error) + manager.cancelAuthorization() + await secondPolling + }) + + it("handles polling completion successfully", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }), + ) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "new", refresh_token: "refresh", expires_in: 3600 }), { + status: 200, + }), + ) + + await manager.startAuthorization() + const credentials = await manager.waitForAuthorization() + expect(credentials.accessToken).toBe("new") + expect(await manager.isAuthenticated()).toBe(true) + }) + + it("handles slow_down error during polling", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ error: "slow_down" }), { status: 400 })) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "new", refresh_token: "refresh", expires_in: 3600 }), { + status: 200, + }), + ) + + await manager.startAuthorization() + const credentials = await manager.waitForAuthorization() + expect(credentials.accessToken).toBe("new") + }) + + it("handles authorization expiration", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 1, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 })) + + vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(2000) + + await manager.startAuthorization() + await expect(manager.waitForAuthorization()).rejects.toThrow("Kimi Code authorization expired") + }) + + it("handles device authorization errors", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_request", error_description: "Bad request" }), { + status: 400, + }), + ) + + await expect(manager.startAuthorization()).rejects.toThrow("Bad request") + }) + + it("handles token refresh failure with invalid_grant", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "invalid", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }), + ) + + const token = await manager.getAccessToken() + expect(token).toBeNull() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + }) + + it("handles refresh errors that preserve state", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ error: "server_error" }), { status: 500 }), + ) + + const token = await manager.getAccessToken() + expect(token).toBeNull() + expect(values.has("kimi-code-oauth-credentials")).toBe(true) + }) + + it("forces refresh even when token is not expired", async () => { + const { context, values } = createContext() + const futureExpiry = Date.now() + 3600_000 + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: futureExpiry }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "forced", refresh_token: "new_refresh", expires_in: 3600 }), { + status: 200, + }), + ) + + const token = await manager.forceRefreshAccessToken() + expect(token).toBe("forced") + }) + + it("handles malformed OAuth error response", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("not json", { status: 400 })) + + await expect(manager.startAuthorization()).rejects.toThrow("OAuth request failed") + }) + + it("throws error when no refresh token in device token response", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "token", expires_in: 3600 }), { status: 200 }), + ) + + await manager.startAuthorization() + await expect(manager.waitForAuthorization()).rejects.toThrow("did not return a refresh token") + }) + + it("returns state correctly", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + expect(manager.getState().status).toBe("idle") + }) + + it("throws when waitForAuthorization called without active authorization", () => { + const manager = new KimiCodeOAuthManager() + expect(() => manager.waitForAuthorization()).toThrow("No Kimi Code authorization is in progress") + }) + + it("handles invalid stored credentials", async () => { + const { context, values } = createContext() + values.set("kimi-code-oauth-credentials", "invalid json") + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + expect(await manager.getAccessToken()).toBeNull() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + }) + + it("preserves refresh token when not rotated", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "keep", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "new", expires_in: 3600 }), { status: 200 }), + ) + + await manager.getAccessToken() + const stored = JSON.parse(values.get("kimi-code-oauth-credentials")!) + expect(stored.refreshToken).toBe("keep") + }) +}) diff --git a/src/integrations/kimi-code/oauth.ts b/src/integrations/kimi-code/oauth.ts new file mode 100644 index 0000000000..cd28df8b04 --- /dev/null +++ b/src/integrations/kimi-code/oauth.ts @@ -0,0 +1,379 @@ +import type { ExtensionContext } from "vscode" +import { z } from "zod" + +export const KIMI_CODE_OAUTH_CONFIG = { + authHost: "https://auth.kimi.com", + deviceAuthorizationEndpoint: "https://auth.kimi.com/api/oauth/device_authorization", + tokenEndpoint: "https://auth.kimi.com/api/oauth/token", + deviceGrantType: "urn:ietf:params:oauth:grant-type:device_code", + // Kimi Code's official public client ID for the OAuth device flow. + // Source: Kimi Code CLI's published OAuth integration. + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", +} as const + +const KIMI_CODE_CREDENTIALS_KEY = "kimi-code-oauth-credentials" +const TOKEN_EXPIRY_BUFFER_MS = 60_000 +const OAUTH_REQUEST_TIMEOUT_MS = 30_000 + +const credentialsSchema = z.object({ + type: z.literal("kimi-code"), + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number(), + tokenType: z.string().optional(), +}) + +const deviceAuthorizationSchema = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri: z.string().url(), + verification_uri_complete: z.string().url().optional(), + expires_in: z.number().positive(), + interval: z.number().positive().optional(), +}) + +const tokenResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().positive(), + token_type: z.string().optional(), +}) + +const oauthErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}) + +export type KimiCodeCredentials = z.infer + +export type KimiCodeOAuthState = { + status: "idle" | "authorizing" | "polling" | "authenticated" | "error" + userCode?: string + verificationUri?: string + expiresAt?: number + error?: string +} + +export type KimiCodeDeviceAuthorization = { + userCode: string + verificationUri: string + verificationUriComplete?: string + expiresAt: number +} + +class KimiCodeOAuthError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message) + this.name = "KimiCodeOAuthError" + } +} + +async function postForm(endpoint: string, values: Record, signal?: AbortSignal): Promise { + const controller = new AbortController() + const forwardAbort = () => controller.abort(signal?.reason) + if (signal?.aborted) forwardAbort() + else signal?.addEventListener("abort", forwardAbort, { once: true }) + const timeout = setTimeout( + () => controller.abort(new Error("Kimi Code OAuth request timed out")), + OAUTH_REQUEST_TIMEOUT_MS, + ) + try { + return await fetch(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(values).toString(), + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + signal?.removeEventListener("abort", forwardAbort) + } +} + +async function readOAuthError(response: Response): Promise { + const text = await response.text() + try { + const parsed = oauthErrorSchema.parse(JSON.parse(text)) + return new KimiCodeOAuthError(parsed.error_description ?? parsed.error, parsed.error) + } catch { + return new KimiCodeOAuthError(`OAuth request failed: ${response.status} ${response.statusText} - ${text}`) + } +} + +export async function requestDeviceAuthorization(signal?: AbortSignal) { + const response = await postForm( + KIMI_CODE_OAUTH_CONFIG.deviceAuthorizationEndpoint, + { client_id: KIMI_CODE_OAUTH_CONFIG.clientId }, + signal, + ) + if (!response.ok) throw await readOAuthError(response) + return deviceAuthorizationSchema.parse(await response.json()) +} + +async function requestDeviceToken(deviceCode: string, signal?: AbortSignal): Promise { + const response = await postForm( + KIMI_CODE_OAUTH_CONFIG.tokenEndpoint, + { + grant_type: KIMI_CODE_OAUTH_CONFIG.deviceGrantType, + device_code: deviceCode, + client_id: KIMI_CODE_OAUTH_CONFIG.clientId, + }, + signal, + ) + if (!response.ok) throw await readOAuthError(response) + const tokens = tokenResponseSchema.parse(await response.json()) + if (!tokens.refresh_token) throw new Error("Kimi Code OAuth did not return a refresh token") + return { + type: "kimi-code", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + tokenType: tokens.token_type, + } +} + +export async function refreshKimiCodeAccessToken(credentials: KimiCodeCredentials): Promise { + const response = await postForm(KIMI_CODE_OAUTH_CONFIG.tokenEndpoint, { + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + client_id: KIMI_CODE_OAUTH_CONFIG.clientId, + }) + if (!response.ok) throw await readOAuthError(response) + const tokens = tokenResponseSchema.parse(await response.json()) + return { + type: "kimi-code", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? credentials.refreshToken, + expiresAt: Date.now() + tokens.expires_in * 1000, + tokenType: tokens.token_type ?? credentials.tokenType, + } +} + +const delay = (milliseconds: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + const timeout = signal.aborted + ? undefined + : setTimeout(() => { + signal.removeEventListener("abort", onAbort) + resolve() + }, milliseconds) + const onAbort = () => { + if (timeout) clearTimeout(timeout) + reject(new Error("Kimi Code authorization was cancelled")) + } + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener("abort", onAbort, { once: true }) + }) + +export class KimiCodeOAuthManager { + private context: ExtensionContext | null = null + private credentials: KimiCodeCredentials | null = null + private state: KimiCodeOAuthState = { status: "idle" } + private refreshPromise: Promise | null = null + private pollingPromise: Promise | null = null + private pollingController: AbortController | null = null + private credentialsGeneration = 0 + private authorizationGeneration = 0 + + initialize(context: ExtensionContext): void { + this.context = context + } + + getState(): KimiCodeOAuthState { + return { ...this.state } + } + + private async loadCredentials(): Promise { + if (this.credentials) return this.credentials + const stored = await this.context?.secrets.get(KIMI_CODE_CREDENTIALS_KEY) + if (!stored) return null + try { + this.credentials = credentialsSchema.parse(JSON.parse(stored)) + return this.credentials + } catch { + await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + return null + } + } + + private async saveCredentials( + credentials: KimiCodeCredentials, + isCurrent: () => boolean = () => true, + ): Promise { + if (!this.context) throw new Error("Kimi Code OAuth manager is not initialized") + if (!isCurrent()) return false + const serialized = JSON.stringify(credentials) + await this.context.secrets.store(KIMI_CODE_CREDENTIALS_KEY, serialized) + if (!isCurrent()) { + if ((await this.context.secrets.get(KIMI_CODE_CREDENTIALS_KEY)) === serialized) { + await this.context.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + } + return false + } + this.credentials = credentials + this.state = { status: "authenticated" } + return true + } + + async clearCredentials(): Promise { + this.credentialsGeneration++ + this.cancelAuthorization() + const refreshPromise = this.refreshPromise + if (refreshPromise) await refreshPromise.catch(() => null) + await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + this.credentials = null + this.state = { status: "idle" } + } + + async isAuthenticated(): Promise { + return (await this.getAccessToken()) !== null + } + + async getAccessToken(forceRefresh = false): Promise { + const credentials = await this.loadCredentials() + if (!credentials) return null + if (!forceRefresh && Date.now() < credentials.expiresAt - TOKEN_EXPIRY_BUFFER_MS) { + return credentials.accessToken + } + if (!this.refreshPromise) { + const generation = this.credentialsGeneration + this.refreshPromise = refreshKimiCodeAccessToken(credentials) + .then(async (next) => { + if (!(await this.saveCredentials(next, () => generation === this.credentialsGeneration))) + return null + return next + }) + .catch(async (error) => { + if (error instanceof KimiCodeOAuthError && error.code === "invalid_grant") { + this.credentialsGeneration++ + await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + this.credentials = null + this.state = { status: "idle" } + } + return null + }) + .finally(() => { + this.refreshPromise = null + }) + } + return (await this.refreshPromise)?.accessToken ?? null + } + + async forceRefreshAccessToken(): Promise { + return this.getAccessToken(true) + } + + async startAuthorization(): Promise { + this.cancelAuthorization() + const generation = ++this.authorizationGeneration + this.state = { status: "authorizing" } + const controller = new AbortController() + this.pollingController = controller + try { + const device = await requestDeviceAuthorization(controller.signal) + if (generation !== this.authorizationGeneration || this.pollingController !== controller) { + throw new Error("Kimi Code authorization was cancelled") + } + const expiresAt = Date.now() + device.expires_in * 1000 + const verificationUri = device.verification_uri_complete ?? device.verification_uri + this.state = { + status: "polling", + userCode: device.user_code, + verificationUri, + expiresAt, + } + this.pollingPromise = this.pollForToken( + device.device_code, + expiresAt, + device.interval ?? 5, + controller, + generation, + ) + return { + userCode: device.user_code, + verificationUri: device.verification_uri, + verificationUriComplete: device.verification_uri_complete, + expiresAt, + } + } catch (error) { + if (generation === this.authorizationGeneration && this.pollingController === controller) { + this.state = { status: "error", error: error instanceof Error ? error.message : String(error) } + this.pollingController = null + } + throw error + } + } + + waitForAuthorization(): Promise { + if (!this.pollingPromise) throw new Error("No Kimi Code authorization is in progress") + return this.pollingPromise + } + + cancelAuthorization(): void { + this.authorizationGeneration++ + this.pollingController?.abort() + this.pollingController = null + this.pollingPromise = null + if (this.state.status === "authorizing" || this.state.status === "polling") this.state = { status: "idle" } + } + + private async pollForToken( + deviceCode: string, + expiresAt: number, + initialIntervalSeconds: number, + controller: AbortController, + generation: number, + ): Promise { + let intervalSeconds = initialIntervalSeconds + try { + while (Date.now() < expiresAt) { + await delay(intervalSeconds * 1000, controller.signal) + try { + const credentials = await requestDeviceToken(deviceCode, controller.signal) + if (generation !== this.authorizationGeneration || this.pollingController !== controller) { + throw new Error("Kimi Code authorization was cancelled") + } + const saved = await this.saveCredentials( + credentials, + () => generation === this.authorizationGeneration && this.pollingController === controller, + ) + if (!saved) throw new Error("Kimi Code authorization was cancelled") + return credentials + } catch (error) { + if (error instanceof KimiCodeOAuthError && error.code === "authorization_pending") continue + if (error instanceof KimiCodeOAuthError && error.code === "slow_down") { + intervalSeconds += 5 + continue + } + throw error + } + } + throw new Error("Kimi Code authorization expired") + } catch (error) { + if ( + !controller.signal.aborted && + generation === this.authorizationGeneration && + this.pollingController === controller + ) { + this.state = { status: "error", error: error instanceof Error ? error.message : String(error) } + } + throw error + } finally { + if (generation === this.authorizationGeneration && this.pollingController === controller) { + this.pollingController = null + this.pollingPromise = null + } + } + } +} + +export const kimiCodeOAuthManager = new KimiCodeOAuthManager() diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 4ffb394eb2..c7093c5277 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -86,4 +86,36 @@ describe("checkExistKey", () => { } expect(checkExistKey(config)).toBe(false) }) + + it("should return true for kimi-code provider with OAuth auth method", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "oauth", + } + expect(checkExistKey(config)).toBe(true) + }) + + it("should return true for kimi-code provider without auth method (defaults to OAuth)", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + } + expect(checkExistKey(config)).toBe(true) + }) + + it("should return true for kimi-code provider with api-key auth and key present", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "test-key", + } + expect(checkExistKey(config)).toBe(true) + }) + + it("should return false for kimi-code provider with api-key auth but no key", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + } + expect(checkExistKey(config)).toBe(false) + }) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index d5a579be9d..29e844bd51 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -188,6 +188,7 @@ const dynamicProviderExtras = { deepseek: {} as { apiKey?: string; baseUrl?: string }, "opencode-go": {} as { apiKey?: string }, kenari: {} as { apiKey?: string }, + "kimi-code": {} as { apiKey?: string }, } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 357144c168..0abf11edb3 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -10,6 +10,10 @@ export function checkExistKey(config: ProviderSettings | undefined) { return true } + if (config.apiProvider === "kimi-code" && (config.kimiCodeAuthMethod ?? "oauth") === "oauth") { + return true + } + // Check all secret keys from the centralized SECRET_STATE_KEYS array. // Filter out keys that are not part of ProviderSettings (global secrets are stored separately) const providerSecretKeys = SECRET_STATE_KEYS.filter((key) => !GLOBAL_SECRET_KEYS.includes(key as any)) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2585a674ec..d38f49f958 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -56,6 +56,7 @@ import { LiteLLM, Mistral, Moonshot, + KimiCode, Ollama, OpenAI, OpenAICompatible, @@ -116,7 +117,8 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, openAiCodexIsAuthenticated } = useExtensionState() + const { organizationAllowList, openAiCodexIsAuthenticated, kimiCodeIsAuthenticated, kimiCodeOAuthState } = + useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -575,6 +577,15 @@ const ApiOptions = ({ /> )} + {selectedProvider === "kimi-code" && ( + + )} + {selectedProvider === "minimax" && ( (field: K, value: ProviderSettings[K]) => void + kimiCodeIsAuthenticated?: boolean + kimiCodeOAuthState?: { + status: "idle" | "authorizing" | "polling" | "authenticated" | "error" + userCode?: string + verificationUri?: string + error?: string + } +} + +export const KimiCode = ({ + apiConfiguration, + setApiConfigurationField, + kimiCodeIsAuthenticated = false, + kimiCodeOAuthState, +}: KimiCodeProps) => { + const { t } = useAppTranslation() + const authMethod = apiConfiguration.kimiCodeAuthMethod ?? "oauth" + const { data, refetch, isFetching } = useRouterModels({ + provider: "kimi-code", + enabled: authMethod === "oauth" ? kimiCodeIsAuthenticated : !!apiConfiguration.kimiCodeApiKey, + }) + const discoveredModels = data?.["kimi-code"] + const models: ModelRecord = + discoveredModels && Object.keys(discoveredModels).length > 0 ? discoveredModels : kimiCodeModels + + useEffect(() => { + if (authMethod === "oauth" && kimiCodeIsAuthenticated) void refetch() + }, [authMethod, kimiCodeIsAuthenticated, refetch]) + + const refreshModels = () => { + vscode.postMessage({ + type: "requestRouterModels", + values: { + provider: "kimi-code", + refresh: true, + kimiCodeAuthMethod: authMethod, + kimiCodeApiKey: apiConfiguration.kimiCodeApiKey, + }, + }) + void refetch() + } + + return ( +
+
+ + +
+ + {authMethod === "oauth" ? ( +
+ {kimiCodeIsAuthenticated ? ( +
+ + {t("settings:providers.kimiCode.authenticated")} + + +
+ ) : ( + + )} + {kimiCodeOAuthState?.status === "polling" && ( +
+

+ {t("settings:providers.kimiCode.deviceCodeHelp")} +

+ + {kimiCodeOAuthState.userCode} + + {kimiCodeOAuthState.verificationUri && ( + + {kimiCodeOAuthState.verificationUri} + + )} +
+ )} + {kimiCodeOAuthState?.status === "error" && ( +

{kimiCodeOAuthState.error}

+ )} +
+ ) : ( + + setApiConfigurationField("kimiCodeApiKey", (event.target as HTMLInputElement).value) + } + className="w-full" + data-testid="kimi-code-api-key"> + + + )} + + + + {t("settings:providers.kimiCode.docs")} +
+ ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx new file mode 100644 index 0000000000..76a2bfaddf --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { KimiCode } from "../KimiCode" + +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: { "kimi-code": {} }, refetch: vi.fn(), isFetching: false }), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + +describe("KimiCode settings", () => { + it("binds the API key input through the buffered settings setter", () => { + const setField = vi.fn() + render( + , + ) + fireEvent.input(screen.getByTestId("kimi-code-api-key"), { target: { value: "new-key" } }) + expect(setField).toHaveBeenCalledWith("kimiCodeApiKey", "new-key") + expect(screen.getByTestId("kimi-code-model-picker")).toBeInTheDocument() + }) + + it("shows device-code polling state", () => { + render( + , + ) + expect(screen.getByTestId("kimi-code-device-code")).toHaveTextContent("ABCD-EFGH") + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 6a990c37a5..8d725efed2 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -5,6 +5,7 @@ export { Gemini } from "./Gemini" export { LMStudio } from "./LMStudio" export { Mistral } from "./Mistral" export { Moonshot } from "./Moonshot" +export { KimiCode } from "./KimiCode" export { Ollama } from "./Ollama" export { OpenAI } from "./OpenAI" export { OpenAICodex } from "./OpenAICodex" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index d4c87ec02c..ab94b7f8cc 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -4,6 +4,7 @@ import { bedrockDefaultModelId, deepSeekDefaultModelId, moonshotDefaultModelId, + kimiCodeDefaultModelId, geminiDefaultModelId, mistralDefaultModelId, openRouterDefaultModelId, @@ -42,6 +43,7 @@ export const PROVIDER_SERVICE_CONFIG: Partial> = bedrock: bedrockDefaultModelId, deepseek: deepSeekDefaultModelId, moonshot: moonshotDefaultModelId, + "kimi-code": kimiCodeDefaultModelId, gemini: geminiDefaultModelId, mistral: mistralDefaultModelId, "openai-native": openAiNativeDefaultModelId, @@ -117,6 +120,7 @@ const PROVIDER_MODEL_CONFIG: Partial> gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, moonshot: { field: "apiModelId", default: moonshotDefaultModelId }, + "kimi-code": { field: "apiModelId", default: kimiCodeDefaultModelId }, minimax: { field: "apiModelId", default: minimaxDefaultModelId }, mimo: { field: "apiModelId", default: mimoDefaultModelId }, mistral: { field: "apiModelId", default: mistralDefaultModelId }, @@ -201,6 +205,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "unbound", "openai", // OpenAI Compatible "openai-codex", // OpenAI Codex has custom UI with auth and rate limits + "kimi-code", "litellm", "vercel-ai-gateway", "ollama", diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 62a30a26b2..1529089322 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -26,6 +26,7 @@ import { friendliModels, basetenModels, qwenCodeModels, + kimiCodeDefaultModelInfo, litellmDefaultModelInfo, lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, @@ -103,7 +104,12 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { lmStudioModels: (lmStudioModels.data || undefined) as ModelRecord | undefined, ollamaModels: (ollamaModels.data || undefined) as ModelRecord | undefined, }) - : { id: getProviderDefaultModelId(activeProvider ?? "openrouter"), info: undefined } + : activeProvider === "kimi-code" && apiConfiguration + ? { + id: apiConfiguration.apiModelId || getProviderDefaultModelId("kimi-code"), + info: kimiCodeDefaultModelInfo, + } + : { id: getProviderDefaultModelId(activeProvider ?? "openrouter"), info: undefined } return { provider, @@ -257,6 +263,12 @@ function getSelectedModel({ const info = moonshotModels[id as keyof typeof moonshotModels] return { id, info } } + case "kimi-code": { + const configuredId = apiConfiguration.apiModelId + const availableModels = routerModels["kimi-code"] + const id = configuredId || defaultModelId + return { id, info: availableModels?.[id] ?? kimiCodeDefaultModelInfo } + } case "minimax": { const id = apiConfiguration.apiModelId ?? defaultModelId const info = minimaxModels[id as keyof typeof minimaxModels] diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 4cb1e90027..f861b8734c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Clau API de Moonshot", "getMoonshotApiKey": "Obtenir clau API de Moonshot", "moonshotBaseUrl": "Punt d'entrada de Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Clau API de Z AI", "getZaiApiKey": "Obtenir clau API de Z AI", "zaiEntrypoint": "Punt d'entrada de Z AI", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 8566410041..df41e40d1d 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API-Schlüssel", "getMoonshotApiKey": "Moonshot API-Schlüssel erhalten", "moonshotBaseUrl": "Moonshot-Einstiegspunkt", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API-Schlüssel", "getZaiApiKey": "Z AI API-Schlüssel erhalten", "zaiEntrypoint": "Z AI Einstiegspunkt", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 9ef634f866..37d33f8172 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -539,6 +539,17 @@ "moonshotApiKey": "Moonshot API Key", "getMoonshotApiKey": "Get Moonshot API Key", "moonshotBaseUrl": "Moonshot Entrypoint", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "minimaxApiKey": "MiniMax API Key", "getMiniMaxApiKey": "Get MiniMax API Key", "minimaxBaseUrl": "MiniMax Entrypoint", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 7500e5bc77..f9373b2d0e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Clave API de Moonshot", "getMoonshotApiKey": "Obtener clave API de Moonshot", "moonshotBaseUrl": "Punto de entrada de Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Clave API de Z AI", "getZaiApiKey": "Obtener clave API de Z AI", "zaiEntrypoint": "Punto de entrada de Z AI", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 9d5178f90f..6045f41cf1 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Clé API Moonshot", "getMoonshotApiKey": "Obtenir la clé API Moonshot", "moonshotBaseUrl": "Point d'entrée Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Clé API Z AI", "getZaiApiKey": "Obtenir la clé API Z AI", "zaiEntrypoint": "Point d'entrée Z AI", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2072485021..d130aa27de 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API कुंजी", "getMoonshotApiKey": "Moonshot API कुंजी प्राप्त करें", "moonshotBaseUrl": "Moonshot प्रवेश बिंदु", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API कुंजी", "getZaiApiKey": "Z AI API कुंजी प्राप्त करें", "zaiEntrypoint": "Z AI प्रवेश बिंदु", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 0b30cb84f4..3fde6f5c95 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Kunci API Moonshot", "getMoonshotApiKey": "Dapatkan Kunci API Moonshot", "moonshotBaseUrl": "Titik Masuk Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Kunci API Z AI", "getZaiApiKey": "Dapatkan Kunci API Z AI", "zaiEntrypoint": "Titik Masuk Z AI", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 125e6a725f..b19e28fcc9 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Chiave API Moonshot", "getMoonshotApiKey": "Ottieni chiave API Moonshot", "moonshotBaseUrl": "Punto di ingresso Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Chiave API Z AI", "getZaiApiKey": "Ottieni chiave API Z AI", "zaiEntrypoint": "Punto di ingresso Z AI", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index dd07efdd9a..1f16ab3154 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot APIキー", "getMoonshotApiKey": "Moonshot APIキーを取得", "moonshotBaseUrl": "Moonshot エントリーポイント", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI APIキー", "getZaiApiKey": "Z AI APIキーを取得", "zaiEntrypoint": "Z AI エントリーポイント", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 36a4ee14e0..01891757b5 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API 키", "getMoonshotApiKey": "Moonshot API 키 받기", "moonshotBaseUrl": "Moonshot 엔트리포인트", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API 키", "getZaiApiKey": "Z AI API 키 받기", "zaiEntrypoint": "Z AI 엔트리포인트", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 5c36d91e30..ea09bce8a5 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API-sleutel", "getMoonshotApiKey": "Moonshot API-sleutel ophalen", "moonshotBaseUrl": "Moonshot-ingangspunt", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API-sleutel", "getZaiApiKey": "Z AI API-sleutel ophalen", "zaiEntrypoint": "Z AI-ingangspunt", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 9f9da60b68..bf35f445dd 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Klucz API Moonshot", "getMoonshotApiKey": "Uzyskaj klucz API Moonshot", "moonshotBaseUrl": "Punkt wejścia Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Klucz API Z AI", "getZaiApiKey": "Uzyskaj klucz API Z AI", "zaiEntrypoint": "Punkt wejścia Z AI", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b267f41573..99d6013a89 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Chave de API Moonshot", "getMoonshotApiKey": "Obter chave de API Moonshot", "moonshotBaseUrl": "Ponto de entrada Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Chave de API Z AI", "getZaiApiKey": "Obter chave de API Z AI", "zaiEntrypoint": "Ponto de entrada Z AI", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 2d427202f9..a29da12326 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API-ключ", "getMoonshotApiKey": "Получить Moonshot API-ключ", "moonshotBaseUrl": "Точка входа Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API-ключ", "getZaiApiKey": "Получить Z AI API-ключ", "zaiEntrypoint": "Точка входа Z AI", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index ba4b092cf3..13c0b9c37c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API Anahtarı", "getMoonshotApiKey": "Moonshot API Anahtarı Al", "moonshotBaseUrl": "Moonshot Giriş Noktası", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API Anahtarı", "getZaiApiKey": "Z AI API Anahtarı Al", "zaiEntrypoint": "Z AI Giriş Noktası", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 56a04f6c59..b59313d47a 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Khóa API Moonshot", "getMoonshotApiKey": "Lấy khóa API Moonshot", "moonshotBaseUrl": "Điểm vào Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Khóa API Z AI", "getZaiApiKey": "Lấy khóa API Z AI", "zaiEntrypoint": "Điểm vào Z AI", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 03fc075da9..1b408875d4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API 密钥", "getMoonshotApiKey": "获取 Moonshot API 密钥", "moonshotBaseUrl": "Moonshot 服务站点", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "minimaxApiKey": "MiniMax API 密钥", "getMiniMaxApiKey": "获取 MiniMax API 密钥", "minimaxBaseUrl": "MiniMax 服务站点", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index b84cb627e7..481bab5301 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -486,6 +486,17 @@ "moonshotApiKey": "Moonshot API 金鑰", "getMoonshotApiKey": "取得 Moonshot API 金鑰", "moonshotBaseUrl": "Moonshot 服務端點", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "minimaxApiKey": "MiniMax API 金鑰", "getMiniMaxApiKey": "取得 MiniMax API 金鑰", "minimaxBaseUrl": "MiniMax 服務端點", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 04484397c6..22828cdf23 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -54,6 +54,7 @@ describe("Model Validation Functions", () => { "opencode-go": {}, kenari: {}, "zoo-gateway": {}, + "kimi-code": {}, } const allowAllOrganization: OrganizationAllowList = { @@ -365,6 +366,48 @@ describe("Model Validation Functions", () => { }) }) }) + + describe("Kimi Code validation", () => { + it("returns undefined when using OAuth auth method", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "oauth", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + + it("returns undefined when auth method is not specified (defaults to OAuth)", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + + it("returns apiKey error when using api-key auth method without key", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.apiKey") + }) + + it("returns undefined when using api-key auth method with key", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "valid-key", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + }) }) describe("validateBedrockArn", () => { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index d780786808..0a812e9d48 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -127,6 +127,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.qwenCodeOauthPath") } break + case "kimi-code": + if ((apiConfiguration.kimiCodeAuthMethod ?? "oauth") === "api-key" && !apiConfiguration.kimiCodeApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "vercel-ai-gateway": if (!apiConfiguration.vercelAiGatewayApiKey) { return i18next.t("settings:validation.apiKey")