Преглед изворни кода

fix: retry transient llm transport failures

Ma пре 6 месеци
родитељ
комит
72173ea258
2 измењених фајлова са 89 додато и 5 уклоњено
  1. 12 0
      packages/core/src/__tests__/provider.test.ts
  2. 77 5
      packages/core/src/llm/provider.ts

+ 12 - 0
packages/core/src/__tests__/provider.test.ts

@@ -205,6 +205,18 @@ describe("chatCompletion via pi-ai", () => {
     expect(error.message).toContain("无法连接到 API 服务");
   });
 
+  it("retries transient socket termination errors before failing the chapter pipeline", async () => {
+    mockStreamSimple
+      .mockReturnValueOnce(makeErrorStream("terminated: UND_ERR_SOCKET other side closed"))
+      .mockReturnValueOnce(makeTextStream("recovered"));
+
+    const client = makeClient();
+    const result = await chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]);
+
+    expect(result.content).toBe("recovered");
+    expect(mockStreamSimple).toHaveBeenCalledTimes(2);
+  });
+
   it("passes temperature and maxTokens to streamSimple", async () => {
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
 

+ 77 - 5
packages/core/src/llm/provider.ts

@@ -33,6 +33,7 @@ export type OnStreamProgress = (progress: StreamProgress) => void;
 
 const INKOS_USER_AGENT = "InkOS/1.3.5";
 const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
+const TRANSIENT_LLM_RETRIES = 2;
 
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
@@ -366,7 +367,17 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
       `API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
     );
   }
-  if (msg.includes("Connection error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
+  if (
+    msg.includes("Connection error")
+    || msg.includes("ECONNREFUSED")
+    || msg.includes("ENOTFOUND")
+    || msg.includes("fetch failed")
+    || msg.includes("terminated")
+    || msg.includes("UND_ERR_SOCKET")
+    || msg.includes("ECONNRESET")
+    || msg.includes("ETIMEDOUT")
+    || msg.includes("EPIPE")
+  ) {
     return new Error(
       `无法连接到 API 服务。可能原因:\n` +
       `  1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
@@ -399,6 +410,61 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
   return error instanceof Error ? error : new Error(msg);
 }
 
+function collectErrorText(error: unknown, depth = 0): string {
+  if (depth > 4 || error === null || error === undefined) return "";
+  const parts = [String(error)];
+  if (error instanceof Error) {
+    parts.push(error.name, error.message);
+    const cause = (error as Error & { cause?: unknown }).cause;
+    if (cause) parts.push(collectErrorText(cause, depth + 1));
+  } else if (typeof error === "object") {
+    const err = error as { code?: unknown; cause?: unknown; message?: unknown; name?: unknown };
+    if (err.name) parts.push(String(err.name));
+    if (err.message) parts.push(String(err.message));
+    if (err.code) parts.push(String(err.code));
+    if (err.cause) parts.push(collectErrorText(err.cause, depth + 1));
+  }
+  return parts.join("\n");
+}
+
+function isTransientLLMTransportError(error: unknown): boolean {
+  const text = collectErrorText(error);
+  return [
+    "terminated",
+    "UND_ERR_SOCKET",
+    "ECONNRESET",
+    "ETIMEDOUT",
+    "EPIPE",
+    "socket hang up",
+    "other side closed",
+    "network socket disconnected",
+  ].some((needle) => text.includes(needle));
+}
+
+async function withTransientLLMRetry<T>(
+  run: () => Promise<T>,
+  options?: { readonly enabled?: boolean },
+): Promise<T> {
+  const enabled = options?.enabled ?? true;
+  let lastError: unknown;
+  for (let attempt = 0; attempt <= TRANSIENT_LLM_RETRIES; attempt++) {
+    try {
+      return await run();
+    } catch (error) {
+      lastError = error;
+      if (
+        !enabled
+        || attempt >= TRANSIENT_LLM_RETRIES
+        || error instanceof PartialResponseError
+        || !isTransientLLMTransportError(error)
+      ) {
+        throw error;
+      }
+    }
+  }
+  throw lastError;
+}
+
 function shouldUseNativeCustomTransport(client: LLMClient): boolean {
   return client.configSource === "studio"
     && client.service === "custom"
@@ -904,10 +970,16 @@ export async function chatCompletion(
   const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
 
   try {
-    if (shouldUseNativeCustomTransport(client)) {
-      return await chatCompletionViaCustomOpenAICompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
-    }
-    return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
+    return await withTransientLLMRetry(
+      async () => {
+        if (shouldUseNativeCustomTransport(client)) {
+          return chatCompletionViaCustomOpenAICompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
+        }
+        return chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
+      },
+      // Retrying after UI text deltas have been emitted can duplicate visible text.
+      { enabled: !onTextDelta },
+    );
   } catch (error) {
     // Stream interrupted but partial content is usable — return truncated response
     if (error instanceof PartialResponseError) {