From 3fb8bc187cae4d7e6d4009b3d157c1d8acc8de7c Mon Sep 17 00:00:00 2001 From: T Date: Fri, 24 Jul 2026 10:35:53 -0400 Subject: [PATCH 1/2] feat(webview): add batchNearby utility for smarter tool ask batching Replace batchConsecutive with batchNearby in ChatView.tsx to allow merging same-type tool asks even when separated by ignorable messages (api_req_started/finished, empty text rows, reasoning). Semantic boundaries (user_feedback, visible text, completion_result, checkpoint_saved, error) stop the merge, preserving correct ordering. This fixes UX where models like qwen insert API request rows between tool calls, causing UI to show multiple 'Zoo wants to read this file' instead of a single batched prompt. --- .../types/src/__tests__/kimi-code.test.ts | 29 ++ .../__tests__/provider-identifiers.test.ts | 2 + packages/types/src/global-settings.ts | 1 + packages/types/src/model.ts | 1 + packages/types/src/provider-identifiers.ts | 1 + packages/types/src/provider-settings.ts | 17 + packages/types/src/providers/index.ts | 4 + packages/types/src/providers/kimi-code.ts | 23 + packages/types/src/vscode-extension-host.ts | 10 + src/api/index.ts | 3 + src/api/providers/__tests__/kimi-code.spec.ts | 236 +++++++++ src/api/providers/__tests__/openai.spec.ts | 6 + .../fetchers/__tests__/kimi-code.spec.ts | 100 ++++ src/api/providers/fetchers/kimi-code.ts | 58 +++ src/api/providers/fetchers/modelCache.ts | 6 +- src/api/providers/index.ts | 1 + src/api/providers/kimi-code.ts | 118 +++++ src/api/providers/openai.ts | 8 +- src/api/transform/__tests__/reasoning.spec.ts | 51 ++ src/api/transform/reasoning.ts | 19 + src/core/webview/ClineProvider.ts | 16 + .../webview/__tests__/ClineProvider.spec.ts | 3 + .../__tests__/webviewMessageHandler.spec.ts | 148 ++++++ src/core/webview/webviewMessageHandler.ts | 56 +++ src/extension.ts | 3 + .../kimi-code/__tests__/oauth.spec.ts | 465 ++++++++++++++++++ src/integrations/kimi-code/oauth.ts | 379 ++++++++++++++ .../__tests__/checkExistApiConfig.spec.ts | 32 ++ src/shared/api.ts | 1 + src/shared/checkExistApiConfig.ts | 4 + webview-ui/src/components/chat/ChatView.tsx | 64 ++- .../src/components/settings/ApiOptions.tsx | 13 +- .../src/components/settings/constants.ts | 1 + .../settings/providers/KimiCode.tsx | 152 ++++++ .../providers/__tests__/KimiCode.spec.tsx | 41 ++ .../components/settings/providers/index.ts | 1 + .../settings/utils/providerModelConfig.ts | 5 + .../components/ui/hooks/useSelectedModel.ts | 14 +- webview-ui/src/i18n/locales/ca/settings.json | 11 + webview-ui/src/i18n/locales/de/settings.json | 11 + webview-ui/src/i18n/locales/en/settings.json | 11 + webview-ui/src/i18n/locales/es/settings.json | 11 + webview-ui/src/i18n/locales/fr/settings.json | 11 + webview-ui/src/i18n/locales/hi/settings.json | 11 + webview-ui/src/i18n/locales/id/settings.json | 11 + webview-ui/src/i18n/locales/it/settings.json | 11 + webview-ui/src/i18n/locales/ja/settings.json | 11 + webview-ui/src/i18n/locales/ko/settings.json | 11 + webview-ui/src/i18n/locales/nl/settings.json | 11 + webview-ui/src/i18n/locales/pl/settings.json | 11 + .../src/i18n/locales/pt-BR/settings.json | 11 + webview-ui/src/i18n/locales/ru/settings.json | 11 + webview-ui/src/i18n/locales/tr/settings.json | 11 + webview-ui/src/i18n/locales/vi/settings.json | 11 + .../src/i18n/locales/zh-CN/settings.json | 11 + .../src/i18n/locales/zh-TW/settings.json | 11 + .../src/utils/__tests__/batchNearby.spec.ts | 396 +++++++++++++++ .../src/utils/__tests__/validate.spec.ts | 43 ++ webview-ui/src/utils/batchNearby.ts | 76 +++ webview-ui/src/utils/validate.ts | 5 + 60 files changed, 2801 insertions(+), 9 deletions(-) create mode 100644 packages/types/src/__tests__/kimi-code.test.ts create mode 100644 packages/types/src/providers/kimi-code.ts create mode 100644 src/api/providers/__tests__/kimi-code.spec.ts create mode 100644 src/api/providers/fetchers/__tests__/kimi-code.spec.ts create mode 100644 src/api/providers/fetchers/kimi-code.ts create mode 100644 src/api/providers/kimi-code.ts create mode 100644 src/integrations/kimi-code/__tests__/oauth.spec.ts create mode 100644 src/integrations/kimi-code/oauth.ts create mode 100644 webview-ui/src/components/settings/providers/KimiCode.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx create mode 100644 webview-ui/src/utils/__tests__/batchNearby.spec.ts create mode 100644 webview-ui/src/utils/batchNearby.ts 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/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index 11f9d90cc7..b3640a8f5d 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -45,6 +45,7 @@ const expectedProviderIdentifiers = [ "gemini-cli", "mistral", "moonshot", + "kimi-code", "minimax", "mimo", "openai-codex", @@ -104,6 +105,7 @@ describe("provider identifiers", () => { providerIdentifiers.moonshot, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.kimiCode, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) expect(internalProviders).toEqual([providerIdentifiers.vscodeLm]) 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-identifiers.ts b/packages/types/src/provider-identifiers.ts index 30bfb9d17f..f231bc1ab1 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -28,6 +28,7 @@ export const providerIdentifiers = { geminiCli: "gemini-cli", mistral: "mistral", moonshot: "moonshot", + kimiCode: "kimi-code", minimax: "minimax", mimo: "mimo", openaiCodex: "openai-codex", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 194273f944..3898c65bcc 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -55,6 +55,7 @@ export const dynamicProviders = [ providerIdentifiers.moonshot, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.kimiCode, ] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -309,6 +310,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")]) @@ -425,6 +434,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") })), @@ -463,6 +473,7 @@ export const providerSettingsSchema = z.object({ ...deepSeekSchema.shape, ...poeSchema.shape, ...moonshotSchema.shape, + ...kimiCodeSchema.shape, ...minimaxSchema.shape, ...mimoSchema.shape, ...requestySchema.shape, @@ -544,6 +555,7 @@ export const modelIdKeysByProvider: Record = { "gemini-cli": "apiModelId", mistral: "apiModelId", moonshot: "apiModelId", + "kimi-code": "apiModelId", minimax: "apiModelId", mimo: "apiModelId", deepseek: "apiModelId", @@ -652,6 +664,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..c9a458ed1f --- /dev/null +++ b/packages/types/src/providers/kimi-code.ts @@ -0,0 +1,23 @@ +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 kimiCodeReasoningEfforts = ["low", "high", "max"] as const + +export const kimiCodeDefaultModelInfo: ModelInfo = { + contextWindow: 262_144, + maxTokens: 32_768, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: [...kimiCodeReasoningEfforts], + requiredReasoningEffort: true, + reasoningEffort: "max", + 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 9bd53f907a..c35a5da538 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 @@ -538,6 +546,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..d78c252304 --- /dev/null +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -0,0 +1,236 @@ +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("does not force-refresh on non-401 OAuth failures", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status: 500 })) + 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) + }) + + it("defaults to max reasoning effort and advertises low/high/max support", () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const model = handler.getModel() + expect(model.info.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(model.info.requiredReasoningEffort).toBe(true) + expect(model.reasoning).toEqual({ reasoning_effort: "max" }) + }) + + it("sends the user-selected reasoning effort", () => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + reasoningEffort: "low", + }) + expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "low" }) + }) + + it("falls back to the model default when a persisted effort from another provider is unsupported", () => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + reasoningEffort: "medium", + }) + expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "max" }) + }) +}) 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..3fbe995752 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts @@ -0,0 +1,100 @@ +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, + supportsReasoningEffort: ["low", "high", "max"], + requiredReasoningEffort: true, + reasoningEffort: "max", + 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.supportsReasoningEffort).toBe(false) + expect(mapped.requiredReasoningEffort).toBe(false) + expect(mapped.reasoningEffort).toBeUndefined() + 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"].supportsReasoningEffort).toEqual(["low", "high", "max"]) + }) + + 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..10b93e26cf --- /dev/null +++ b/src/api/providers/fetchers/kimi-code.ts @@ -0,0 +1,58 @@ +import { z } from "zod" + +import { + KIMI_CODE_BASE_URL, + kimiCodeDefaultModelInfo, + kimiCodeReasoningEfforts, + 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 { + const supportsReasoning = model.supports_reasoning ?? false + return { + ...kimiCodeDefaultModelInfo, + contextWindow: model.context_length ?? kimiCodeDefaultModelInfo.contextWindow, + supportsReasoningEffort: supportsReasoning ? [...kimiCodeReasoningEfforts] : false, + requiredReasoningEffort: supportsReasoning, + reasoningEffort: supportsReasoning ? "max" : undefined, + 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 c9e7784949..f56ddba34f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -31,6 +31,7 @@ import { getPoeModels } from "./poe" import { getDeepSeekModels } from "./deepseek" import { getMoonshotModels } from "./moonshot" import { getZooGatewayModels } from "./zoo-gateway" +import { getKimiCodeModels } from "./kimi-code" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -46,7 +47,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 @@ -230,6 +231,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/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index 15e531b0b7..a14fffe550 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -640,6 +640,57 @@ describe("reasoning.ts", () => { expect(result).toBeUndefined() }) + + it("should fall back to the model default when the persisted effort is outside the capability array", () => { + const modelWithEffortArray: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + } + + const options = { + ...baseOptions, + model: modelWithEffortArray, + settings: { reasoningEffort: "medium" } as ProviderSettings, + reasoningEffort: undefined, + } + + expect(getOpenAiReasoning(options)).toEqual({ reasoning_effort: "max" }) + }) + + it("should not fall back to the model default when reasoning was explicitly disabled", () => { + const modelWithEffortArray: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + } + + const options = { + ...baseOptions, + model: modelWithEffortArray, + settings: { enableReasoningEffort: false, reasoningEffort: "disable" } as ProviderSettings, + reasoningEffort: undefined, + } + + expect(getOpenAiReasoning(options)).toBeUndefined() + }) + + it("should not fall back to the model default when the persisted effort is an explicit none", () => { + const modelWithEffortArray: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + } + + const options = { + ...baseOptions, + model: modelWithEffortArray, + settings: { reasoningEffort: "none" } as ProviderSettings, + reasoningEffort: undefined, + } + + expect(getOpenAiReasoning(options)).toBeUndefined() + }) }) describe("Gemini reasoning (effort models)", () => { diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index 37a19bec55..c51111125a 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -129,6 +129,25 @@ export const getOpenAiReasoning = ({ reasoningEffort, settings, }: GetModelReasoningOptions): OpenAiReasoningParams | undefined => { + // A persisted effort from another provider may fall outside this model's + // capability array; fall back to the model's default effort (mirrors the + // Gemini path) so the request still carries a valid value. + const capability = model.supportsReasoningEffort + if ( + settings?.enableReasoningEffort !== false && + Array.isArray(capability) && + model.reasoningEffort && + (capability as ReadonlyArray).includes(model.reasoningEffort) && + settings?.reasoningEffort && + settings.reasoningEffort !== "disable" && + settings.reasoningEffort !== "none" && + !(capability as ReadonlyArray).includes(settings.reasoningEffort) + ) { + return { + reasoning_effort: model.reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"], + } + } + if (!shouldUseReasoningEffort({ model, settings })) return undefined if (reasoningEffort === "disable" || !reasoningEffort) return undefined diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 613a5c1c1d..68a440a6f5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2559,6 +2559,22 @@ 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 () => { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + return kimiCodeOAuthManager.getState() + } catch { + return undefined + } + })(), ...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 7af3752b0d..dbb4fe52d8 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2706,6 +2706,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2759,6 +2760,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2858,6 +2860,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "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 de6bb54c57..26aeeb2f1d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -404,6 +404,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -591,6 +592,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -652,6 +654,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -1470,3 +1473,148 @@ describe("zooCodeSignOut", () => { ) }) }) + +describe("webviewMessageHandler - kimiCodeSignIn", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + 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() + vi.resetModules() + }) + + 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 dd15879c1c..02f8602b5a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1053,6 +1053,7 @@ export const webviewMessageHandler = async ( moonshot: {}, "opencode-go": {}, kenari: {}, + "kimi-code": {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1195,6 +1196,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) @@ -2626,6 +2643,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 467fbcc45d..5c32e567e2 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -87,6 +87,38 @@ 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) + }) + it("should return false for zoo-gateway without session token or auth", () => { const config: ProviderSettings = { apiProvider: "zoo-gateway", diff --git a/src/shared/api.ts b/src/shared/api.ts index 0cec753591..056612f9f9 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -189,6 +189,7 @@ const dynamicProviderExtras = { moonshot: {} 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 7e5ff2f258..9ce55a9ac3 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -18,6 +18,10 @@ export function checkExistKey(config: ProviderSettings | undefined, zooCodeIsAut return true } + if (config.apiProvider === "kimi-code" && (config.kimiCodeAuthMethod ?? "oauth") === "oauth") { + return true + } + // Zoo Gateway uses session auth (profile token and/or global Zoo Code login), // not a traditional API key listed in SECRET_STATE_KEYS. if (config.apiProvider === "zoo-gateway") { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 01c4796c16..9e5a354092 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -8,7 +8,7 @@ import { LRUCache } from "lru-cache" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" -import { batchConsecutive } from "@src/utils/batchConsecutive" +import { batchNearby } from "@src/utils/batchNearby" import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types" import { getCompletionCheckpoint, getSuggestionMode, isRetiredProvider } from "@roo-code/types" @@ -1268,10 +1268,64 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (msg.type !== "say") return false + return ( + msg.say === "api_req_started" || + msg.say === "api_req_finished" || + (msg.say === "text" && !msg.text?.trim()) || + msg.say === "reasoning" + ) + } + + // Semantic boundaries that stop batching. When we hit one of these, + // any current batch is finalized and the boundary message is preserved as-is: + // - user feedback / new user messages + // - visible assistant text (the model spoke to the user) + // - completion result (turn ended) + // - checkpoint saved + // - errors + const isBoundary = (msg: ClineMessage): boolean => { + if (msg.type !== "say") return false + return ( + msg.say === "user_feedback" || + msg.say === "user_feedback_diff" || + (msg.say === "text" && !!msg.text?.trim()) || + msg.say === "completion_result" || + msg.say === "checkpoint_saved" || + msg.say === "error" || + msg.say === "condense_context" || + msg.say === "codebase_search_result" + ) + } + + // Consolidate tool asks into batches, allowing ignorable messages between targets. + // Unlike batchConsecutive which only merges truly adjacent items, batchNearby + // skips over api_req_started/finished, empty text rows, and reasoning rows that + // models like qwen insert between tool calls during streaming. + const readFileBatched = batchNearby(filtered, { + isTarget: isReadFileAsk, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeReadFileBatch, + }) + const listFilesBatched = batchNearby(readFileBatched, { + isTarget: isListFilesAsk, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeListFilesBatch, + }) + const result = batchNearby(listFilesBatched, { + isTarget: isEditFileAsk, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeEditFileBatch, + }) if (isCondensing) { result.push({ 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 ed3df0209d..da660976f4 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 93dca94a24..50886d4538 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, @@ -261,6 +267,12 @@ function getSelectedModel({ const staticInfo = moonshotModels[id as keyof typeof moonshotModels] return { id, info: routerInfo ?? staticInfo } } + 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..e1ff092237 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": "Authentifizierungsmethode", + "oauth": "Kimi Code-Abonnement (OAuth)", + "apiKey": "Kimi Code-API-Schlüssel", + "apiKeyLabel": "Kimi Code-API-Schlüssel", + "signIn": "Bei Kimi Code anmelden", + "signOut": "Abmelden", + "authenticated": "Bei Kimi Code angemeldet", + "deviceCodeHelp": "Gib diesen Gerätecode auf der Kimi-Autorisierungsseite ein:", + "docs": "Kimi Code-Dokumentation" + }, "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..4a5ce59d79 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": "Método de autenticación", + "oauth": "Suscripción a Kimi Code (OAuth)", + "apiKey": "Clave de API de Kimi Code", + "apiKeyLabel": "Clave de API de Kimi Code", + "signIn": "Iniciar sesión en Kimi Code", + "signOut": "Cerrar sesión", + "authenticated": "Sesión iniciada en Kimi Code", + "deviceCodeHelp": "Introduce este código de dispositivo en la página de autorización de Kimi:", + "docs": "Documentación de Kimi Code" + }, "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..03c64cc1e9 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": "Méthode d'authentification", + "oauth": "Abonnement Kimi Code (OAuth)", + "apiKey": "Clé API Kimi Code", + "apiKeyLabel": "Clé API Kimi Code", + "signIn": "Se connecter à Kimi Code", + "signOut": "Se déconnecter", + "authenticated": "Connecté à Kimi Code", + "deviceCodeHelp": "Saisissez ce code d'appareil sur la page d'autorisation Kimi :", + "docs": "Documentation Kimi Code" + }, "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..2f6c6fc542 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": "प्रमाणीकरण विधि", + "oauth": "Kimi Code सदस्यता (OAuth)", + "apiKey": "Kimi Code API कुंजी", + "apiKeyLabel": "Kimi Code API कुंजी", + "signIn": "Kimi Code में साइन इन करें", + "signOut": "साइन आउट करें", + "authenticated": "Kimi Code में साइन इन किया गया", + "deviceCodeHelp": "Kimi प्राधिकरण पृष्ठ पर यह डिवाइस कोड दर्ज करें:", + "docs": "Kimi Code दस्तावेज़ीकरण" + }, "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..f98af2bc28 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": "Metode autentikasi", + "oauth": "Langganan Kimi Code (OAuth)", + "apiKey": "Kunci API Kimi Code", + "apiKeyLabel": "Kunci API Kimi Code", + "signIn": "Masuk ke Kimi Code", + "signOut": "Keluar", + "authenticated": "Masuk ke Kimi Code", + "deviceCodeHelp": "Masukkan kode perangkat ini di halaman otorisasi Kimi:", + "docs": "Dokumentasi Kimi Code" + }, "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..ea66a2ad23 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": "Metodo di autenticazione", + "oauth": "Abbonamento Kimi Code (OAuth)", + "apiKey": "Chiave API Kimi Code", + "apiKeyLabel": "Chiave API Kimi Code", + "signIn": "Accedi a Kimi Code", + "signOut": "Esci", + "authenticated": "Accesso a Kimi Code effettuato", + "deviceCodeHelp": "Inserisci questo codice dispositivo nella pagina di autorizzazione Kimi:", + "docs": "Documentazione Kimi Code" + }, "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..eed8315620 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": "認証方法", + "oauth": "Kimi Code サブスクリプション (OAuth)", + "apiKey": "Kimi Code API キー", + "apiKeyLabel": "Kimi Code API キー", + "signIn": "Kimi Code にサインイン", + "signOut": "サインアウト", + "authenticated": "Kimi Code にサインイン済み", + "deviceCodeHelp": "Kimi 認証ページでこのデバイスコードを入力してください:", + "docs": "Kimi Code ドキュメント" + }, "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..ae8fd3b9ef 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": "인증 방법", + "oauth": "Kimi Code 구독 (OAuth)", + "apiKey": "Kimi Code API 키", + "apiKeyLabel": "Kimi Code API 키", + "signIn": "Kimi Code에 로그인", + "signOut": "로그아웃", + "authenticated": "Kimi Code에 로그인됨", + "deviceCodeHelp": "Kimi 인증 페이지에서 이 디바이스 코드를 입력하세요:", + "docs": "Kimi Code 문서" + }, "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..45b92345d9 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": "Authenticatiemethode", + "oauth": "Kimi Code-abonnement (OAuth)", + "apiKey": "Kimi Code API-sleutel", + "apiKeyLabel": "Kimi Code API-sleutel", + "signIn": "Inloggen bij Kimi Code", + "signOut": "Uitloggen", + "authenticated": "Ingelogd bij Kimi Code", + "deviceCodeHelp": "Voer deze apparaatcode in op de Kimi-autorisatiepagina:", + "docs": "Kimi Code-documentatie" + }, "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__/batchNearby.spec.ts b/webview-ui/src/utils/__tests__/batchNearby.spec.ts new file mode 100644 index 0000000000..2ae8fc9252 --- /dev/null +++ b/webview-ui/src/utils/__tests__/batchNearby.spec.ts @@ -0,0 +1,396 @@ +import { batchNearby } from "../batchNearby" + +interface TestItem { + ts: number + type: string + text: string + say?: string +} + +/** Helper: create a minimal test item with an identifiable text field. */ +function msg(text: string, type = "say", say?: string): TestItem { + return { ts: Date.now(), type, text, say } +} + +/** Predicate: matches items whose text starts with "match". */ +const isMatch = (m: TestItem) => !!m.text?.startsWith("match") + +/** Predicate for realistic qwen tests: matches JSON tool calls. */ +const isToolCall = (m: TestItem) => !!m.text?.startsWith("{") + +/** Ignorable: api_req_started/finished, empty text, reasoning. */ +const isIgnorableBetweenTargets = (m: TestItem): boolean => { + if (m.type !== "say") return false + return ( + m.say === "api_req_started" || + m.say === "api_req_finished" || + (m.say === "text" && !m.text?.trim()) || + m.say === "reasoning" + ) +} + +/** Boundary: user_feedback, visible text, completion_result, checkpoint_saved, error. */ +const isBoundary = (m: TestItem): boolean => { + if (m.type !== "say") return false + return ( + m.say === "user_feedback" || + m.say === "user_feedback_diff" || + (m.say === "text" && !!m.text?.trim()) || + m.say === "completion_result" || + m.say === "checkpoint_saved" || + m.say === "error" || + m.say === "condense_context" + ) +} + +/** Synthesize: merges a batch into a single item with a "BATCH:" marker. */ +const synthesizeBatch = (batch: TestItem[]): TestItem => ({ + ...batch[0], + text: `BATCH:${batch.map((m) => m.text).join(",")}`, +}) + +describe("batchNearby", () => { + test("empty input returns empty output", () => { + expect( + batchNearby([], { isTarget: isMatch, isIgnorableBetweenTargets, isBoundary, synthesize: synthesizeBatch }), + ).toEqual([]) + }) + + test("no matches returns passthrough", () => { + const messages = [msg("a"), msg("b"), msg("c")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toEqual(messages) + }) + + test("single match is passed through without batching", () => { + const messages = [msg("a"), msg("match-1", "ask"), msg("b")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) + expect(result[1].text).toBe("match-1") + }) + + test("two consecutive matches produce one synthetic message", () => { + const messages = [msg("a"), msg("match-1", "ask"), msg("match-2", "ask"), msg("b")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) + expect(result[0].text).toBe("a") + expect(result[1].text).toBe("BATCH:match-1,match-2") + expect(result[2].text).toBe("b") + }) + + test("non-consecutive matches separated by ignorable messages ARE batched", () => { + const messages = [msg("a"), msg("match-1", "ask"), msg("", "say", "api_req_started"), msg("match-2", "ask")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) + expect(result[0].text).toBe("a") + expect(result[1].text).toBe("BATCH:match-1,match-2") + }) + + test("non-consecutive matches separated by empty text are batched", () => { + const messages = [msg("match-1", "ask"), msg("", "say", "text"), msg("match-2", "ask")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(1) + expect(result[0].text).toBe("BATCH:match-1,match-2") + }) + + test("boundary message stops batching", () => { + const messages = [msg("match-1", "ask"), msg("visible text", "say", "text"), msg("match-2", "ask")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) + expect(result[0].text).toBe("match-1") + expect(result[1].text).toBe("visible text") + expect(result[2].text).toBe("match-2") + }) + + test("boundary message stops batching with ignorable before it", () => { + const messages = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("visible text", "say", "text"), + msg("match-2", "ask"), + ] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) + expect(result[0].text).toBe("match-1") + expect(result[1].text).toBe("visible text") + expect(result[2].text).toBe("match-2") + }) + + test("multiple batches separated by boundaries", () => { + const messages = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("match-2", "ask"), + msg("visible text", "say", "text"), + msg("match-3", "ask"), + msg("", "say", "reasoning"), + msg("match-4", "ask"), + ] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) + expect(result[0].text).toBe("BATCH:match-1,match-2") + expect(result[1].text).toBe("visible text") + expect(result[2].text).toBe("BATCH:match-3,match-4") + }) + + test("user_feedback stops batching", () => { + const messages = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("match-2", "ask"), + msg("feedback", "say", "user_feedback"), + ] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "feedback"] + expect(result[0].text).toBe("BATCH:match-1,match-2") + expect(result[1].text).toBe("feedback") + }) + + test("error stops batching", () => { + const messages = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("match-2", "ask"), + msg("err", "say", "error"), + ] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "err"] + expect(result[0].text).toBe("BATCH:match-1,match-2") + expect(result[1].text).toBe("err") + }) + + test("checkpoint_saved stops batching", () => { + const messages = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("match-2", "ask"), + msg("ck", "say", "checkpoint_saved"), + ] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "ck"] + expect(result[0].text).toBe("BATCH:match-1,match-2") + expect(result[1].text).toBe("ck") + }) + + test("completion_result stops batching", () => { + const messages = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("match-2", "ask"), + msg("done", "say", "completion_result"), + ] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "done"] + expect(result[0].text).toBe("BATCH:match-1,match-2") + expect(result[1].text).toBe("done") + }) + + test("non-ignorable non-target message stops batching", () => { + const messages = [msg("match-1", "ask"), msg("command_output", "say", "command_output"), msg("match-2", "ask")] + const result = batchNearby(messages, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) + expect(result[0].text).toBe("match-1") + expect(result[1].text).toBe("command_output") + expect(result[2].text).toBe("match-2") + }) + + test("all items match → single synthetic message", () => { + const items = [msg("match-1", "ask"), msg("match-2", "ask"), msg("match-3", "ask")] + const result = batchNearby(items, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(1) + expect(result[0].text).toBe("BATCH:match-1,match-2,match-3") + }) + + test("does not mutate the input array", () => { + const items = [msg("match-1", "ask"), msg("match-2", "ask")] + const original = [...items] + batchNearby(items, { isTarget: isMatch, isIgnorableBetweenTargets, isBoundary, synthesize: synthesizeBatch }) + expect(items).toHaveLength(2) + expect(items).toEqual(original) + }) + + test("returns a new array, not the same reference", () => { + const items = [msg("a"), msg("b")] + const result = batchNearby(items, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).not.toBe(items) + }) + + test("synthesize callback receives the correct batches", () => { + const spy = vi.fn(synthesizeBatch) + const items = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("match-2", "ask"), + msg("other"), + msg("match-3", "ask"), + msg("", "say", "reasoning"), + msg("match-4", "ask"), + ] + batchNearby(items, { isTarget: isMatch, isIgnorableBetweenTargets, isBoundary, synthesize: spy }) + expect(spy).toHaveBeenCalledTimes(2) + expect(spy.mock.calls[0][0]).toHaveLength(2) + expect(spy.mock.calls[1][0]).toHaveLength(2) + }) + + test("batch at the end of the array", () => { + const items = [msg("other"), msg("match-1", "ask"), msg("", "say", "api_req_started"), msg("match-2", "ask")] + const result = batchNearby(items, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) + expect(result[0].text).toBe("other") + expect(result[1].text).toBe("BATCH:match-1,match-2") + }) + + test("batch at the beginning of the array", () => { + const items = [msg("match-1", "ask"), msg("", "say", "api_req_finished"), msg("match-2", "ask"), msg("other")] + const result = batchNearby(items, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(2) + expect(result[0].text).toBe("BATCH:match-1,match-2") + expect(result[1].text).toBe("other") + }) + + test("multiple ignorable messages between targets", () => { + const items = [ + msg("match-1", "ask"), + msg("", "say", "api_req_started"), + msg("", "say", "reasoning"), + msg("", "say", "api_req_finished"), + msg("match-2", "ask"), + ] + const result = batchNearby(items, { + isTarget: isMatch, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(1) + expect(result[0].text).toBe("BATCH:match-1,match-2") + }) + + test("realistic qwen scenario: tool calls with api_req rows between them", () => { + const messages = [ + msg('{"tool":"readFile","path":"a.ts"}', "ask"), + msg("", "say", "api_req_started"), + msg("", "say", "text"), // empty streaming row + msg('{"tool":"readFile","path":"b.ts"}', "ask"), + msg("", "say", "api_req_finished"), + msg('{"tool":"editFile","path":"c.ts"}', "ask"), + ] + const result = batchNearby(messages, { + isTarget: isToolCall, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(1) // all JSON tool calls batched together (no boundary between them) + expect(result[0].text).toBe( + 'BATCH:{"tool":"readFile","path":"a.ts"},{"tool":"readFile","path":"b.ts"},{"tool":"editFile","path":"c.ts"}', + ) + }) + + test("realistic qwen scenario: two turns separated by user feedback", () => { + const messages = [ + msg('{"tool":"readFile","path":"a.ts"}', "ask"), + msg("", "say", "api_req_started"), + msg('{"tool":"readFile","path":"b.ts"}', "ask"), + msg("feedback", "say", "user_feedback"), // boundary + msg('{"tool":"readFile","path":"c.ts"}', "ask"), + msg("", "say", "api_req_started"), + msg('{"tool":"readFile","path":"d.ts"}', "ask"), + ] + const result = batchNearby(messages, { + isTarget: isToolCall, + isIgnorableBetweenTargets, + isBoundary, + synthesize: synthesizeBatch, + }) + expect(result).toHaveLength(3) // [BATCH:a,b, "feedback", BATCH:c,d] — api_req_started skipped as ignorable + expect(result[0].text).toBe('BATCH:{"tool":"readFile","path":"a.ts"},{"tool":"readFile","path":"b.ts"}') + expect(result[1].text).toBe("feedback") + expect(result[2].text).toBe('BATCH:{"tool":"readFile","path":"c.ts"},{"tool":"readFile","path":"d.ts"}') + }) +}) diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 700bf27891..8aebe3f8e5 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": {}, moonshot: {}, } @@ -366,6 +367,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/batchNearby.ts b/webview-ui/src/utils/batchNearby.ts new file mode 100644 index 0000000000..a839c10940 --- /dev/null +++ b/webview-ui/src/utils/batchNearby.ts @@ -0,0 +1,76 @@ +/** + * Batch tool asks that are near each other, allowing ignorable messages in between. + * + * Unlike `batchConsecutive` which only merges truly adjacent items, this function + * merges items of the same type even when separated by low-information or invisible + * messages (e.g., api_req_started/finished, empty text rows, partial streaming). + * + * It stops merging when it hits a "semantic boundary": user feedback, visible assistant + * text, completion result, different tool group, checkpoint, error, etc. + */ + +export interface BatchNearbyOptions { + /** Returns true if this item is the target type to batch (e.g., readFile ask) */ + isTarget: (item: T) => boolean + /** Returns true if this item can be skipped over when looking for more targets */ + isIgnorableBetweenTargets: (item: T) => boolean + /** Returns true if this item is a semantic boundary that stops merging */ + isBoundary: (item: T) => boolean + /** Synthesize a batch of items into a single item */ + synthesize: (batch: T[]) => T +} + +/** + * Walk an item array and batch runs of items matching `isTarget`, allowing + * ignorable messages between them. Stops at semantic boundaries. + * + * - Runs of length 1 are passed through unchanged. + * - Runs of length >= 2 are replaced by a single synthetic item. + * - Non-matching / boundary items are preserved in-order. + */ +export function batchNearby(items: T[], options: BatchNearbyOptions): T[] { + const { isTarget, isIgnorableBetweenTargets, isBoundary, synthesize } = options + + const result: T[] = [] + let i = 0 + + while (i < items.length) { + if (isBoundary(items[i])) { + // Boundary stops any current batch and is preserved as-is + result.push(items[i]) + i++ + } else if (isTarget(items[i])) { + // Start collecting a batch of targets, skipping ignorable messages in between + const batch: T[] = [items[i]] + let j = i + 1 + + while (j < items.length) { + if (isBoundary(items[j])) { + break // boundary stops the batch + } + if (isTarget(items[j])) { + batch.push(items[j]) + j++ + } else if (isIgnorableBetweenTargets(items[j])) { + j++ // skip ignorable messages between targets + } else { + break // non-ignorable, non-target message stops the batch + } + } + + if (batch.length > 1) { + result.push(synthesize(batch)) + } else { + result.push(batch[0]) + } + + i = j // items[j] was not consumed — re-examine it on next iteration + } else { + // Non-target, non-boundary item — preserve as-is + result.push(items[i]) + i++ + } + } + + return result +} 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") From 9d4172af7d7ca56e1a5fc5937de20c7ea7416558 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 24 Jul 2026 23:52:43 +0800 Subject: [PATCH 2/2] fix: batchNearby restores dropped ignorable items when bridge fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a single target is followed by ignorable messages and then a boundary (no second target found), the original code silently dropped those ignorable items — contradicting the documented contract that all items are preserved in-order. Fix: track skipped ignorable items in pendingIgnorable, flush them after the batch result so they're never lost. When bridge succeeds (second target found), pending items are consumed into the synthesized batch as expected. --- .../src/utils/__tests__/batchNearby.spec.ts | 15 ++++++++------- webview-ui/src/utils/batchNearby.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/utils/__tests__/batchNearby.spec.ts b/webview-ui/src/utils/__tests__/batchNearby.spec.ts index 2ae8fc9252..b73fb2ca7d 100644 --- a/webview-ui/src/utils/__tests__/batchNearby.spec.ts +++ b/webview-ui/src/utils/__tests__/batchNearby.spec.ts @@ -145,10 +145,11 @@ describe("batchNearby", () => { isBoundary, synthesize: synthesizeBatch, }) - expect(result).toHaveLength(3) + expect(result).toHaveLength(4) expect(result[0].text).toBe("match-1") - expect(result[1].text).toBe("visible text") - expect(result[2].text).toBe("match-2") + expect(result[1].text).toBe("") // api_req_started restored after single target + expect(result[2].text).toBe("visible text") + expect(result[3].text).toBe("match-2") }) test("multiple batches separated by boundaries", () => { @@ -186,7 +187,7 @@ describe("batchNearby", () => { isBoundary, synthesize: synthesizeBatch, }) - expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "feedback"] + expect(result).toHaveLength(2) // bridge succeeded → pending consumed; [BATCH:match-1,match-2, "feedback"] expect(result[0].text).toBe("BATCH:match-1,match-2") expect(result[1].text).toBe("feedback") }) @@ -204,7 +205,7 @@ describe("batchNearby", () => { isBoundary, synthesize: synthesizeBatch, }) - expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "err"] + expect(result).toHaveLength(2) // bridge succeeded → pending consumed; [BATCH:match-1,match-2, "err"] expect(result[0].text).toBe("BATCH:match-1,match-2") expect(result[1].text).toBe("err") }) @@ -222,7 +223,7 @@ describe("batchNearby", () => { isBoundary, synthesize: synthesizeBatch, }) - expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "ck"] + expect(result).toHaveLength(2) // bridge succeeded → pending consumed; [BATCH:match-1,match-2, "ck"] expect(result[0].text).toBe("BATCH:match-1,match-2") expect(result[1].text).toBe("ck") }) @@ -240,7 +241,7 @@ describe("batchNearby", () => { isBoundary, synthesize: synthesizeBatch, }) - expect(result).toHaveLength(2) // api_req_started ignorable → skipped; [BATCH:match-1,match-2, "done"] + expect(result).toHaveLength(2) // bridge succeeded → pending consumed; [BATCH:match-1,match-2, "done"] expect(result[0].text).toBe("BATCH:match-1,match-2") expect(result[1].text).toBe("done") }) diff --git a/webview-ui/src/utils/batchNearby.ts b/webview-ui/src/utils/batchNearby.ts index a839c10940..d76e2ee149 100644 --- a/webview-ui/src/utils/batchNearby.ts +++ b/webview-ui/src/utils/batchNearby.ts @@ -43,6 +43,7 @@ export function batchNearby(items: T[], options: BatchNearbyOptions): T[] // Start collecting a batch of targets, skipping ignorable messages in between const batch: T[] = [items[i]] let j = i + 1 + const pendingIgnorable: T[] = [] while (j < items.length) { if (isBoundary(items[j])) { @@ -52,16 +53,22 @@ export function batchNearby(items: T[], options: BatchNearbyOptions): T[] batch.push(items[j]) j++ } else if (isIgnorableBetweenTargets(items[j])) { - j++ // skip ignorable messages between targets + pendingIgnorable.push(items[j]) // track but don't commit yet + j++ } else { break // non-ignorable, non-target message stops the batch } } if (batch.length > 1) { + // Bridge succeeded — pending ignorable items are metadata consumed by the batch result.push(synthesize(batch)) } else { + // Bridge failed — restore pending ignorable items to preserve in-order semantics result.push(batch[0]) + if (pendingIgnorable.length > 0) { + result.push(...pendingIgnorable) + } } i = j // items[j] was not consumed — re-examine it on next iteration