浏览代码

feat(llm): add Kimi Code provider support

Ma 6 月之前
父节点
当前提交
da0c24f935

+ 6 - 1
packages/cli/src/__tests__/tui-setup-i18n.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { buildAutoInitMessages, buildInteractiveSetupCopy } from "../tui/setup.js";
+import { buildAutoInitMessages, buildInteractiveSetupCopy, resolveSetupProvider } from "../tui/setup.js";
 
 describe("tui setup i18n", () => {
   it("builds Chinese setup copy by default", () => {
@@ -15,4 +15,9 @@ describe("tui setup i18n", () => {
     expect(buildAutoInitMessages("山海", "zh-CN").initializing).toContain("正在初始化项目:山海");
     expect(buildAutoInitMessages("harbor", "en").initialized).toContain("Project initialized");
   });
+
+  it("uses Anthropic protocol for Kimi Code base URLs even when the user picked custom", () => {
+    expect(resolveSetupProvider("custom", "https://api.kimi.com/coding")).toBe("anthropic");
+    expect(resolveSetupProvider("openai", "https://api.kimi.com/coding/v1")).toBe("anthropic");
+  });
 });

+ 17 - 4
packages/cli/src/tui/setup.ts

@@ -13,6 +13,18 @@ import { GLOBAL_ENV_PATH, loadConfig } from "../utils.js";
 import { ensureProjectGitignore } from "../project-bootstrap.js";
 
 const PROVIDERS = ["openai", "anthropic", "custom"] as const;
+type SetupProvider = typeof PROVIDERS[number];
+
+export function resolveSetupProvider(provider: string, baseUrl: string): SetupProvider {
+  const normalizedProvider = PROVIDERS.includes(provider.trim() as SetupProvider)
+    ? provider.trim() as SetupProvider
+    : "openai";
+  const normalizedUrl = baseUrl.trim().toLowerCase();
+  if (normalizedUrl.includes("api.kimi.com/coding")) {
+    return "anthropic";
+  }
+  return normalizedProvider;
+}
 
 interface SetupResult {
   readonly projectRoot: string;
@@ -160,9 +172,9 @@ export async function interactiveLlmSetup(
     console.log(`  ${c("1", cyan)}  ${c(copy.steps.provider, gray)}`);
     console.log(c(`     ${copy.hints.provider}`, dim));
     const providerInput = await rl.question(`     ${c("❯", cyan)} `);
-    const provider = PROVIDERS.includes(providerInput.trim() as typeof PROVIDERS[number])
-      ? providerInput.trim()
-      : copy.defaults.provider;
+    const provider = PROVIDERS.includes(providerInput.trim() as SetupProvider)
+      ? providerInput.trim() as SetupProvider
+      : copy.defaults.provider as SetupProvider;
     console.log(`     ${c("✓", brightGreen)} ${provider}`);
     console.log();
 
@@ -194,9 +206,10 @@ export async function interactiveLlmSetup(
     console.log(c(`     ${copy.hints.scope}`, dim));
     const scope = await rl.question(`     ${c("❯", cyan)} ${c(copy.defaults.scope, dim)} `);
     const useGlobal = scope.trim().toLowerCase() !== "project";
+    const finalProvider = resolveSetupProvider(provider, baseUrl);
 
     const envContent = [
-      `INKOS_LLM_PROVIDER=${provider}`,
+      `INKOS_LLM_PROVIDER=${finalProvider}`,
       `INKOS_LLM_BASE_URL=${baseUrl.trim()}`,
       `INKOS_LLM_API_KEY=${apiKey.trim()}`,
       `INKOS_LLM_MODEL=${model.trim()}`,

+ 1 - 1
packages/core/src/__tests__/providers-group.test.ts

@@ -22,7 +22,7 @@ describe("InkosEndpoint.group", () => {
     ].sort());
     expect(byGroup("local")).toEqual(["githubCopilot", "ollama"].sort());
     expect(byGroup("codingPlan")).toEqual([
-      "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan",
+      "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan", "kimicode",
       "minimaxCodingPlan", "opencodeCodingPlan", "volcengineCodingPlan",
     ].sort());
   });

+ 7 - 7
packages/core/src/__tests__/providers-schema.test.ts

@@ -117,31 +117,31 @@ describe("providers structural integrity", () => {
     expect(getEndpoint("newapi")?.baseUrl).toBe("");
   });
 
-  it("B4:总 provider 数 = 34(不含 CodingPlan,R5 删 qwen / higress 后)", () => {
-    const nonCoding = getAllEndpoints().filter((p) => !p.id.endsWith("CodingPlan"));
+  it("B4:总 provider 数 = 34(不含 CodingPlan 分组,R5 删 qwen / higress 后)", () => {
+    const nonCoding = getAllEndpoints().filter((p) => p.group !== "codingPlan");
     expect(nonCoding.length).toBe(34);
   });
 
-  it("B6:CodingPlan 7 个 provider 全部收录", () => {
+  it("B6:CodingPlan 8 个 provider 全部收录", () => {
     const ids = getAllEndpoints().map((p) => p.id);
     for (const id of [
       "kimiCodingPlan", "minimaxCodingPlan", "bailianCodingPlan",
       "glmCodingPlan", "volcengineCodingPlan", "opencodeCodingPlan",
-      "astronCodingPlan",
+      "astronCodingPlan", "kimicode",
     ]) {
       expect(ids).toContain(id);
     }
   });
 
-  it("B6:总 provider 数 = 41 (34 base + 7 CodingPlan)", () => {
-    expect(getAllEndpoints().length).toBe(41);
+  it("B6:总 provider 数 = 42 (34 base + 8 CodingPlan)", () => {
+    expect(getAllEndpoints().length).toBe(42);
   });
 
   it("B6:CodingPlan provider 都走 anthropic-messages", () => {
     for (const id of [
       "kimiCodingPlan", "minimaxCodingPlan", "bailianCodingPlan",
       "glmCodingPlan", "volcengineCodingPlan", "opencodeCodingPlan",
-      "astronCodingPlan",
+      "astronCodingPlan", "kimicode",
     ]) {
       expect(getEndpoint(id)?.api).toBe("anthropic-messages");
     }

+ 16 - 0
packages/core/src/__tests__/service-presets-regression.test.ts

@@ -2,6 +2,22 @@ import { describe, it, expect } from "vitest";
 import { resolveServicePreset, listModelsForService } from "../llm/service-presets.js";
 
 describe("service-presets regression", () => {
+  describe("Kimi Code preset", () => {
+    it("uses Anthropic protocol on the Kimi Code coding endpoint", () => {
+      const preset = resolveServicePreset("kimicode");
+      expect(preset).toBeDefined();
+      expect(preset!.providerFamily).toBe("anthropic");
+      expect(preset!.api).toBe("anthropic-messages");
+      expect(preset!.baseUrl).toBe("https://api.kimi.com/coding");
+      expect(preset!.modelsBaseUrl).toBe("https://api.kimi.com/coding/v1");
+    });
+
+    it("exposes the Kimi Code model through provider-bank model listing", async () => {
+      const models = await listModelsForService("kimicode");
+      expect(models.map((m) => m.id)).toContain("kimi-for-coding");
+    });
+  });
+
   describe("MiniMax preset", () => {
     it("has correct domestic MiniMax baseUrl (api.minimaxi.com/anthropic)", () => {
       const preset = resolveServicePreset("minimax");

+ 23 - 0
packages/core/src/llm/providers/endpoints/kimiCode.ts

@@ -0,0 +1,23 @@
+/**
+ * Kimi Code
+ *
+ * Kimi's coding endpoint uses Anthropic-compatible messages, separate from the
+ * regular Moonshot OpenAI-compatible API.
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const KIMI_CODE: InkosEndpoint = {
+  id: "kimicode",
+  label: "Kimi Code",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://api.kimi.com/coding",
+  modelsBaseUrl: "https://api.kimi.com/coding/v1",
+  checkModel: "kimi-for-coding",
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  models: [
+    { id: "kimi-for-coding", maxOutput: 32768, contextWindowTokens: 262144, enabled: true },
+  ],
+};

+ 3 - 2
packages/core/src/llm/providers/index.ts

@@ -39,6 +39,7 @@ import { NEWAPI } from "./endpoints/newapi.js";
 import { GITHUB_COPILOT } from "./endpoints/githubCopilot.js";
 // B6 CodingPlan
 import { KIMI_CODING_PLAN } from "./endpoints/kimiCodingPlan.js";
+import { KIMI_CODE } from "./endpoints/kimiCode.js";
 import { MINIMAX_CODING_PLAN } from "./endpoints/minimaxCodingPlan.js";
 import { BAILIAN_CODING_PLAN } from "./endpoints/bailianCodingPlan.js";
 import { GLM_CODING_PLAN } from "./endpoints/glmCodingPlan.js";
@@ -58,8 +59,8 @@ const ALL_PROVIDERS: readonly InkosEndpoint[] = [
   SPARK, SENSENOVA, TENCENTCLOUD, XIAOMI_MIMO, LONGCAT, INTERNLM,
   MODELSCOPE, GITEEAI, QINIU, INFINIAI, ZEROONE, AI360,
   OLLAMA, OPENROUTER, CUSTOM, MISTRAL, XAI, NEWAPI, GITHUB_COPILOT,
-  // B6 CodingPlan(7 个)
-  KIMI_CODING_PLAN, MINIMAX_CODING_PLAN, BAILIAN_CODING_PLAN, GLM_CODING_PLAN, VOLCENGINE_CODING_PLAN, OPENCODE_CODING_PLAN, ASTRON_CODING_PLAN,
+  // B6 CodingPlan(8 个)
+  KIMI_CODING_PLAN, KIMI_CODE, MINIMAX_CODING_PLAN, BAILIAN_CODING_PLAN, GLM_CODING_PLAN, VOLCENGINE_CODING_PLAN, OPENCODE_CODING_PLAN, ASTRON_CODING_PLAN,
 ];
 
 const PROVIDERS_BY_ID: Map<string, InkosEndpoint> = new Map(

+ 1 - 1
packages/core/src/llm/providers/lookup.ts

@@ -6,7 +6,7 @@ import { getAllEndpoints, getEndpoint } from "./index.js";
  * 保证结果确定性。白名单外的 provider 视同 999(排最后)。
  */
 const PROVIDER_PRIORITY: readonly string[] = [
-  "anthropic", "openai", "google", "deepseek", "bailian", "moonshot",
+  "anthropic", "openai", "google", "deepseek", "bailian", "moonshot", "kimicode",
   "zhipu", "minimax", "xai",
   "siliconcloud", "ppio",
   "openrouter", "aihubmix", "novita",

+ 3 - 3
packages/studio/src/api/server.test.ts

@@ -86,7 +86,7 @@ const endpointIdsByGroup = {
   aggregator: ["giteeai", "infiniai", "modelscope", "newapi", "openrouter", "ppio", "qiniu", "siliconcloud"],
   local: ["githubCopilot", "ollama"],
   codingPlan: [
-    "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan",
+    "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan", "kimicode",
     "minimaxCodingPlan", "opencodeCodingPlan", "volcengineCodingPlan",
   ],
 } as const;
@@ -721,13 +721,13 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(res.status).toBe(200);
     const body = await res.json() as { services: Array<{ service: string; group?: string; connected: boolean }> };
     const bank = body.services.filter((s) => !s.service.startsWith("custom"));
-    expect(bank.length).toBe(40);
+    expect(bank.length).toBe(41);
     expect(bank.every((s) => typeof s.group === "string")).toBe(true);
     expect(bank.filter((s) => s.group === "overseas")).toHaveLength(5);
     expect(bank.filter((s) => s.group === "china")).toHaveLength(18);
     expect(bank.filter((s) => s.group === "aggregator")).toHaveLength(8);
     expect(bank.filter((s) => s.group === "local")).toHaveLength(2);
-    expect(bank.filter((s) => s.group === "codingPlan")).toHaveLength(7);
+    expect(bank.filter((s) => s.group === "codingPlan")).toHaveLength(8);
     expect(body.services.find((s) => s.service === "moonshot")?.connected).toBe(true);
     expect(body.services.find((s) => s.service === "custom:内网GPT")).toMatchObject({
       connected: true,

+ 1 - 1
packages/studio/src/api/server.ts

@@ -575,7 +575,7 @@ function formatServiceProbeError(args: {
     ].filter(Boolean).join("\n");
   }
 
-  if (args.service === "moonshot" || args.service === "kimiCodingPlan") {
+  if (args.service === "moonshot" || args.service === "kimiCodingPlan" || args.service === "kimicode") {
     return [
       `${args.label ?? args.service} 测试连接失败。`,
       context,