Просмотр исходного кода

fix: absorb duplicate hook families and allow local llms without keys

Ma 7 месяцев назад
Родитель
Сommit
eb70c787ac

+ 37 - 2
packages/cli/src/__tests__/cli-integration.test.ts

@@ -25,12 +25,12 @@ function run(args: string[], options?: { env?: Record<string, string> }): string
   });
 }
 
-function runStderr(args: string[]): { stdout: string; stderr: string; exitCode: number } {
+function runStderr(args: string[], options?: { env?: Record<string, string> }): { stdout: string; stderr: string; exitCode: number } {
   try {
     const stdout = execFileSync("node", [cliEntry, ...args], {
       cwd: projectDir,
       encoding: "utf-8",
-      env: { ...process.env, HOME: projectDir },
+      env: { ...process.env, HOME: projectDir, ...options?.env },
       timeout: 10_000,
     });
     return { stdout, stderr: "", exitCode: 0 };
@@ -310,6 +310,41 @@ describe("CLI integration", () => {
       await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
       await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
     });
+
+    it("treats localhost OpenAI-compatible endpoints as API-key optional", async () => {
+      await stat(join(projectDir, "inkos.json")).catch(() => {
+        run(["init"]);
+      });
+      const configPath = join(projectDir, "inkos.json");
+      const envPath = join(projectDir, ".env");
+      const originalConfig = await readFile(configPath, "utf-8");
+      const originalEnv = await readFile(envPath, "utf-8");
+
+      try {
+        const config = JSON.parse(originalConfig);
+        config.llm.provider = "openai";
+        config.llm.baseUrl = "http://127.0.0.1:11434/v1";
+        config.llm.model = "gpt-oss:20b";
+        await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
+        await writeFile(envPath, [
+          "INKOS_LLM_PROVIDER=openai",
+          "INKOS_LLM_BASE_URL=http://127.0.0.1:11434/v1",
+          "INKOS_LLM_MODEL=gpt-oss:20b",
+          "",
+        ].join("\n"), "utf-8");
+
+        const { stdout } = runStderr(["doctor"], {
+          env: { INKOS_LLM_API_KEY: "" },
+        });
+        expect(stdout).toContain("LLM API Key");
+        expect(stdout).toContain("Optional for local/self-hosted endpoint");
+        expect(stdout).toContain("LLM Config");
+        expect(stdout).not.toContain("No LLM config available");
+      } finally {
+        await writeFile(configPath, originalConfig, "utf-8");
+        await writeFile(envPath, originalEnv, "utf-8");
+      }
+    });
   });
 
   describe("inkos analytics", () => {

+ 26 - 6
packages/cli/src/commands/doctor.ts

@@ -75,15 +75,31 @@ export const doctorCommand = new Command("doctor")
 
     // 5. Check LLM API key (global + project .env)
     {
+      const { loadConfig } = await import("../utils.js");
       const { config: loadDotenv } = await import("dotenv");
       loadDotenv({ path: GLOBAL_ENV_PATH });
       loadDotenv({ path: join(root, ".env"), override: true });
+      const { isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
+      let provider = process.env.INKOS_LLM_PROVIDER;
+      let baseUrl = process.env.INKOS_LLM_BASE_URL;
+      try {
+        const config = await loadConfig({ requireApiKey: false });
+        provider = config.llm.provider;
+        baseUrl = config.llm.baseUrl;
+      } catch {
+        // Fall back to raw env inspection only.
+      }
       const apiKey = process.env.INKOS_LLM_API_KEY;
-      const hasKey = !!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here";
+      const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
+      const hasKey = apiKeyOptional || (!!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here");
       checks.push({
         name: "LLM API Key",
         ok: hasKey,
-        detail: hasKey ? "Configured" : "Missing — run 'inkos config set-global' or add to project .env",
+        detail: apiKeyOptional
+          ? "Optional for local/self-hosted endpoint"
+          : hasKey
+            ? "Configured"
+            : "Missing — run 'inkos config set-global' or add to project .env",
       });
     }
 
@@ -103,7 +119,7 @@ export const doctorCommand = new Command("doctor")
 
     // 6. API connectivity test
     try {
-      const { createLLMClient, chatCompletion, LLMConfigSchema } = await import("@actalk/inkos-core");
+      const { createLLMClient, chatCompletion, LLMConfigSchema, isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
       const { loadConfig } = await import("../utils.js");
 
       let llmConfig;
@@ -115,11 +131,15 @@ export const doctorCommand = new Command("doctor")
         const { config: loadDotenv } = await import("dotenv");
         loadDotenv({ path: GLOBAL_ENV_PATH });
         const env = process.env;
-        if (env.INKOS_LLM_API_KEY && env.INKOS_LLM_BASE_URL && env.INKOS_LLM_MODEL) {
+        const apiKeyOptional = isApiKeyOptionalForEndpoint({
+          provider: env.INKOS_LLM_PROVIDER,
+          baseUrl: env.INKOS_LLM_BASE_URL,
+        });
+        if ((env.INKOS_LLM_API_KEY || apiKeyOptional) && env.INKOS_LLM_BASE_URL && env.INKOS_LLM_MODEL) {
           llmConfig = LLMConfigSchema.parse({
             provider: env.INKOS_LLM_PROVIDER ?? "custom",
             baseUrl: env.INKOS_LLM_BASE_URL,
-            apiKey: env.INKOS_LLM_API_KEY,
+            apiKey: env.INKOS_LLM_API_KEY ?? "",
             model: env.INKOS_LLM_MODEL,
           });
         }
@@ -158,7 +178,7 @@ export const doctorCommand = new Command("doctor")
         hints.push("baseUrl 可能不正确,检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)");
       }
       if (errMsg.includes("400")) {
-        hints.push("尝试在 inkos.json 中设置 \"stream\": false");
+        hints.push("检查提供方文档,确认该接口要求 stream=true、stream=false,还是根本不支持 stream");
         hints.push("检查模型名称是否正确(INKOS_LLM_MODEL)");
       }
       if (errMsg.includes("401")) {

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

@@ -0,0 +1,80 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { loadProjectConfig } from "../utils/config-loader.js";
+
+const ENV_KEYS = [
+  "INKOS_LLM_PROVIDER",
+  "INKOS_LLM_BASE_URL",
+  "INKOS_LLM_MODEL",
+  "INKOS_LLM_API_KEY",
+  "INKOS_LLM_TEMPERATURE",
+  "INKOS_LLM_MAX_TOKENS",
+  "INKOS_LLM_THINKING_BUDGET",
+  "INKOS_LLM_API_FORMAT",
+] as const;
+
+describe("loadProjectConfig local provider auth", () => {
+  let root = "";
+  const previousEnv = new Map<string, string | undefined>();
+
+  afterEach(async () => {
+    for (const key of ENV_KEYS) {
+      const previous = previousEnv.get(key);
+      if (previous === undefined) delete process.env[key];
+      else process.env[key] = previous;
+    }
+    previousEnv.clear();
+
+    if (root) {
+      await rm(root, { recursive: true, force: true });
+      root = "";
+    }
+  });
+
+  it("allows missing API keys for localhost OpenAI-compatible endpoints", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-local-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "local-project",
+      version: "0.1.0",
+      llm: {
+        provider: "openai",
+        baseUrl: "http://127.0.0.1:11434/v1",
+        model: "gpt-oss:20b",
+      },
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), "", "utf-8");
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.baseUrl).toBe("http://127.0.0.1:11434/v1");
+    expect(config.llm.model).toBe("gpt-oss:20b");
+    expect(config.llm.apiKey).toBe("");
+  });
+
+  it("still requires API keys for remote hosted endpoints", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-remote-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "remote-project",
+      version: "0.1.0",
+      llm: {
+        provider: "openai",
+        baseUrl: "https://api.openai.com/v1",
+        model: "gpt-5.4",
+      },
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), "", "utf-8");
+    await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i);
+  });
+});

+ 26 - 0
packages/core/src/__tests__/hook-health.test.ts

@@ -112,4 +112,30 @@ describe("analyzeHookHealth", () => {
 
     expect(issues.some((issue) => issue.description.includes("Opened 2 new hooks"))).toBe(true);
   });
+
+  it("does not count absorbed duplicate-family upserts as genuinely new hooks", () => {
+    const issues = analyzeHookHealth({
+      language: "en",
+      chapterNumber: 20,
+      hooks: [
+        createHook({ hookId: "old-debt", lastAdvancedChapter: 20 }),
+      ],
+      delta: createDelta({
+        chapter: 20,
+        hookOps: {
+          upsert: [
+            createHook({ hookId: "duplicate-restated", lastAdvancedChapter: 20 }),
+            createHook({ hookId: "second-duplicate", lastAdvancedChapter: 20 }),
+          ],
+          mention: [],
+          resolve: [],
+          defer: [],
+        },
+      }),
+      existingHookIds: ["old-debt"],
+      newHookBurstThreshold: 2,
+    });
+
+    expect(issues.some((issue) => issue.description.includes("Opened 2 new hooks"))).toBe(false);
+  });
 });

+ 56 - 52
packages/core/src/__tests__/state-reducer.test.ts

@@ -245,59 +245,63 @@ describe("applyRuntimeStateDelta", () => {
     ]);
   });
 
-  it("rejects duplicate restated hook families when a new hook upsert overlaps an active hook", () => {
-    expect(() =>
-      applyRuntimeStateDelta({
-        snapshot: {
-          manifest: {
-            schemaVersion: 2,
-            language: "en",
-            lastAppliedChapter: 11,
-            projectionVersion: 1,
-            migrationWarnings: [],
-          },
-          currentState: {
-            chapter: 11,
-            facts: [],
-          },
-          hooks: {
-            hooks: [
-              {
-                hookId: "anonymous-source-scope",
-                startChapter: 3,
-                type: "source-risk",
-                status: "open",
-                lastAdvancedChapter: 8,
-                expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
-                notes: "Still unresolved anonymous source knowledge question.",
-              },
-            ],
-          },
-          chapterSummaries: {
-            rows: [],
-          },
+  it("merges duplicate restated hook families into the matched active hook", () => {
+    const result = applyRuntimeStateDelta({
+      snapshot: {
+        manifest: {
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 11,
+          projectionVersion: 1,
+          migrationWarnings: [],
         },
-        delta: RuntimeStateDeltaSchema.parse({
-          chapter: 12,
-          hookOps: {
-            upsert: [
-              {
-                hookId: "anonymous-source-restated",
-                startChapter: 12,
-                type: "source-risk",
-                status: "open",
-                lastAdvancedChapter: 12,
-                expectedPayoff: "Reveal how much the anonymous source already knew about the route.",
-                notes: "Anonymous source knowledge question restated with slightly different wording.",
-              },
-            ],
-            mention: [],
-            resolve: [],
-            defer: [],
-          },
-          notes: [],
-        }),
+        currentState: {
+          chapter: 11,
+          facts: [],
+        },
+        hooks: {
+          hooks: [
+            {
+              hookId: "anonymous-source-scope",
+              startChapter: 3,
+              type: "source-risk",
+              status: "open",
+              lastAdvancedChapter: 8,
+              expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
+              notes: "Still unresolved anonymous source knowledge question.",
+            },
+          ],
+        },
+        chapterSummaries: {
+          rows: [],
+        },
+      },
+      delta: RuntimeStateDeltaSchema.parse({
+        chapter: 12,
+        hookOps: {
+          upsert: [
+            {
+              hookId: "anonymous-source-restated",
+              startChapter: 12,
+              type: "source-risk",
+              status: "open",
+              lastAdvancedChapter: 12,
+              expectedPayoff: "Reveal how much the anonymous source already knew about the route.",
+              notes: "Anonymous source knowledge question restated with slightly different wording.",
+            },
+          ],
+          mention: [],
+          resolve: [],
+          defer: [],
+        },
+        notes: [],
       }),
-    ).toThrow(/duplicate active hook/i);
+    });
+
+    expect(result.hooks.hooks).toHaveLength(1);
+    expect(result.hooks.hooks[0]).toEqual(expect.objectContaining({
+      hookId: "anonymous-source-scope",
+      lastAdvancedChapter: 12,
+    }));
   });
 });

+ 1 - 1
packages/core/src/index.ts

@@ -101,7 +101,7 @@ export { loadRuntimeStateSnapshot, buildRuntimeStateArtifacts, saveRuntimeStateS
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
-export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/config-loader.js";
+export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, isApiKeyOptionalForEndpoint } from "./utils/config-loader.js";
 export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
 export {
   collectStaleHookDebt,

+ 39 - 1
packages/core/src/state/state-reducer.ts

@@ -4,6 +4,7 @@ import {
   HooksStateSchema,
   RuntimeStateDeltaSchema,
   StateManifestSchema,
+  type HookRecord,
   type ChapterSummariesState,
   type CurrentStateState,
   type HooksState,
@@ -88,7 +89,13 @@ function applyHookOps(hooksState: HooksState, delta: RuntimeStateDelta): HooksSt
       });
 
       if (!admission.admit && admission.reason === "duplicate_family") {
-        throw new Error(`duplicate active hook family: ${hook.hookId} overlaps ${admission.matchedHookId}`);
+        const matchedHookId = admission.matchedHookId;
+        const existing = matchedHookId ? hooksById.get(matchedHookId) : undefined;
+        if (!existing) {
+          throw new Error(`duplicate active hook family: ${hook.hookId} overlaps ${admission.matchedHookId}`);
+        }
+        hooksById.set(existing.hookId, mergeDuplicateHookFamily(existing, hook));
+        continue;
       }
     }
 
@@ -128,6 +135,37 @@ function applyHookOps(hooksState: HooksState, delta: RuntimeStateDelta): HooksSt
   };
 }
 
+function mergeDuplicateHookFamily(existing: HookRecord, incoming: HookRecord): HookRecord {
+  const expectedPayoff = preferRicherText(existing.expectedPayoff, incoming.expectedPayoff);
+  const notes = preferRicherText(existing.notes, incoming.notes);
+  const advanced = Math.max(existing.lastAdvancedChapter, incoming.lastAdvancedChapter);
+  const progressed = advanced > existing.lastAdvancedChapter;
+
+  return {
+    ...existing,
+    startChapter: Math.min(existing.startChapter, incoming.startChapter),
+    type: preferRicherText(existing.type, incoming.type),
+    status: progressed
+      ? "progressing"
+      : existing.status === "progressing" || incoming.status === "progressing"
+        ? "progressing"
+        : existing.status,
+    lastAdvancedChapter: advanced,
+    expectedPayoff,
+    notes,
+  };
+}
+
+function preferRicherText(primary: string, fallback: string): string {
+  const left = primary.trim();
+  const right = fallback.trim();
+
+  if (!left) return right;
+  if (!right) return left;
+  if (left === right) return left;
+  return right.length > left.length ? right : left;
+}
+
 function applyCurrentStatePatch(
   currentState: CurrentStateState,
   language: "zh" | "en",

+ 46 - 1
packages/core/src/utils/config-loader.ts

@@ -6,6 +6,35 @@ import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
 
+export function isApiKeyOptionalForEndpoint(params: {
+  readonly provider?: string | undefined;
+  readonly baseUrl?: string | undefined;
+}): boolean {
+  if (params.provider === "anthropic") {
+    return false;
+  }
+  if (!params.baseUrl) {
+    return false;
+  }
+
+  try {
+    const url = new URL(params.baseUrl);
+    const hostname = url.hostname.toLowerCase();
+
+    return (
+      hostname === "localhost"
+      || hostname === "127.0.0.1"
+      || hostname === "::1"
+      || hostname === "0.0.0.0"
+      || hostname === "host.docker.internal"
+      || hostname.endsWith(".local")
+      || isPrivateIpv4(hostname)
+    );
+  } catch {
+    return false;
+  }
+}
+
 /**
  * Load project config from inkos.json with .env overrides.
  * Shared by CLI and Studio — single source of truth for config loading.
@@ -73,7 +102,11 @@ export async function loadProjectConfig(
 
   // API key ONLY from env — never stored in inkos.json
   const apiKey = env.INKOS_LLM_API_KEY;
-  if (!apiKey && options?.requireApiKey !== false) {
+  const provider = typeof llm.provider === "string" ? llm.provider : undefined;
+  const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
+  const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
+
+  if (!apiKey && options?.requireApiKey !== false && !apiKeyOptional) {
     throw new Error(
       "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
     );
@@ -93,3 +126,15 @@ export async function loadProjectConfig(
 
   return ProjectConfigSchema.parse(config);
 }
+
+function isPrivateIpv4(hostname: string): boolean {
+  const parts = hostname.split(".").map((segment) => Number.parseInt(segment, 10));
+  if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
+    return false;
+  }
+
+  if (parts[0] === 10) return true;
+  if (parts[0] === 192 && parts[1] === 168) return true;
+  if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
+  return false;
+}

+ 2 - 1
packages/core/src/utils/hook-health.ts

@@ -78,9 +78,10 @@ export function analyzeHookHealth(params: {
 
   if (params.delta) {
     const existingHookIds = new Set(params.existingHookIds ?? []);
+    const resultingHookIds = new Set(params.hooks.map((hook) => hook.hookId));
     const newHookIds = params.delta.hookOps.upsert
       .map((hook) => hook.hookId)
-      .filter((hookId) => !existingHookIds.has(hookId));
+      .filter((hookId) => !existingHookIds.has(hookId) && resultingHookIds.has(hookId));
 
     if (newHookIds.length >= newHookBurstThreshold && params.delta.hookOps.resolve.length === 0) {
       issues.push(warning(