Parcourir la source

fix(core): 避免 Studio 配置混用旧模型字段

fanghanjun il y a 6 mois
Parent
commit
fc37990a80

+ 53 - 0
packages/core/src/__tests__/config-loader.test.ts

@@ -228,6 +228,59 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.apiKey).toBe("sk-corp");
   });
 
+  it("does not mix stale top-level env-era model/baseUrl with selected Studio service", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-stale-top-level-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "studio-stale-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        configSource: "studio",
+        service: "google",
+        model: "kimi-k2.5",
+        baseUrl: "https://api.moonshot.cn/v1",
+        apiKey: "sk-moon-inline",
+        services: [
+          { service: "google", temperature: 0.7, apiFormat: "chat", stream: true },
+          { service: "moonshot", temperature: 1, apiFormat: "chat", stream: true },
+        ],
+        defaultModel: "gemini-2.5-flash",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), [
+      "INKOS_LLM_PROVIDER=custom",
+      "INKOS_LLM_BASE_URL=https://api.moonshot.cn/v1",
+      "INKOS_LLM_MODEL=kimi-k2.5",
+      "INKOS_LLM_API_KEY=sk-env-moon",
+    ].join("\n"), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({
+        services: {
+          google: { apiKey: "sk-google" },
+          moonshot: { apiKey: "sk-moon" },
+        },
+      }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root, { requireApiKey: false });
+
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.service).toBe("google");
+    expect(config.llm.provider).toBe("openai");
+    expect(config.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(config.llm.model).toBe("gemini-2.5-flash");
+    expect(config.llm.apiKey).toBe("sk-google");
+  });
+
   it("falls back to env when Studio config is still the empty bootstrap state", async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-bootstrap-"));
     for (const key of ENV_KEYS) {

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

@@ -51,6 +51,12 @@ describe("providers structural integrity", () => {
     ]);
   });
 
+  it("Zhipu check model uses a stable chat alias", () => {
+    const zhipu = getEndpoint("zhipu");
+    expect(zhipu?.checkModel).toBe("glm-4-flash");
+    expect(zhipu?.models.some((model) => model.id === "glm-4-flash" && model.enabled !== false)).toBe(true);
+  });
+
   it("A 组至少有 5 个核心 provider", () => {
     const ids = getAllEndpoints().map((p) => p.id);
     expect(ids).toContain("anthropic");

+ 2 - 1
packages/core/src/llm/providers/endpoints/zhipu.ts

@@ -15,7 +15,7 @@ export const ZHIPU: InkosEndpoint = {
   group: "china",
   api: "openai-completions",
   baseUrl: "https://open.bigmodel.cn/api/paas/v4",
-  checkModel: "glm-4.7-flash",
+  checkModel: "glm-4-flash",
   temperatureRange: [0, 1],
   defaultTemperature: 0.95,
   writingTemperature: 0.95,
@@ -43,6 +43,7 @@ export const ZHIPU: InkosEndpoint = {
     { id: "glm-z1-airx", maxOutput: 32768, contextWindowTokens: 32768 },
     { id: "glm-z1-flashx", maxOutput: 32768, contextWindowTokens: 131072 },
     { id: "glm-z1-flash", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "glm-4-flash", maxOutput: 32768, contextWindowTokens: 131072, enabled: true },
     { id: "glm-4-flash-250414", maxOutput: 32768, contextWindowTokens: 131072 },
     { id: "glm-4-flashx", maxOutput: 4095, contextWindowTokens: 131072 },
     { id: "glm-4-long", maxOutput: 4095, contextWindowTokens: 1024000 },

+ 38 - 3
packages/core/src/utils/config-loader.ts

@@ -4,6 +4,7 @@ import { join } from "node:path";
 import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
 import { getServiceApiKey } from "../llm/secrets.js";
 import { resolveServicePreset, resolveServiceProviderFamily } from "../llm/service-presets.js";
+import { getEndpoint } from "../llm/providers/index.js";
 
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
@@ -97,15 +98,25 @@ export async function loadProjectConfig(
     if (selectedEntry) {
       llm.service = selectedEntry.service;
 
-      if (!(typeof llm.model === "string" && llm.model.length > 0) && typeof llm.defaultModel === "string" && llm.defaultModel.length > 0) {
+      if (configSource === "studio") {
+        llm.model = resolveStudioServiceModel(
+          selectedEntry,
+          typeof llm.model === "string" ? llm.model : undefined,
+          typeof llm.defaultModel === "string" ? llm.defaultModel : undefined,
+        );
+      } else if (!(typeof llm.model === "string" && llm.model.length > 0) && typeof llm.defaultModel === "string" && llm.defaultModel.length > 0) {
         llm.model = llm.defaultModel;
       }
 
-      if (!(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0)) {
+      if (configSource === "studio") {
+        llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
+      } else if (!(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0)) {
         llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
       }
 
-      if (!(typeof llm.provider === "string" && llm.provider.length > 0)) {
+      if (configSource === "studio") {
+        llm.provider = deriveProviderFromService(selectedEntry.service);
+      } else if (!(typeof llm.provider === "string" && llm.provider.length > 0)) {
         llm.provider = deriveProviderFromService(selectedEntry.service);
       }
 
@@ -275,6 +286,30 @@ function selectServiceEntry(
   return services[0];
 }
 
+function resolveStudioServiceModel(
+  entry: ServiceConfigEntry,
+  currentModel: string | undefined,
+  defaultModel: string | undefined,
+): string {
+  if (entry.service === "custom") {
+    return defaultModel || currentModel || "noop-model";
+  }
+
+  const endpoint = getEndpoint(entry.service);
+  const candidate = [defaultModel, currentModel]
+    .find((model): model is string => Boolean(
+      model
+      && endpoint?.models.some((knownModel) => knownModel.enabled !== false && knownModel.id.toLowerCase() === model.toLowerCase()),
+    ));
+  if (candidate) return candidate;
+
+  return endpoint?.checkModel
+    ?? endpoint?.models.find((model) => model.enabled !== false)?.id
+    ?? defaultModel
+    ?? currentModel
+    ?? "noop-model";
+}
+
 function serviceEntryKey(entry: ServiceConfigEntry): string {
   return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
 }