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

fix(core): stream auto-fallback and enhanced error diagnostics

- chatCompletion auto-retries with sync when streaming fails
  (covers proxies/relays that don't support SSE)
- isLikelyStreamError heuristic: detects stream-related 400s,
  SSE parse errors, premature close, connection resets
- wrapLLMError now includes baseUrl/model context in all messages
- New 400 and Connection error diagnostic messages with actionable
  suggestions (check baseUrl, try stream:false, check model name)
- inkos doctor: shows LLM config summary before connectivity test,
  adds targeted hints on failure (baseUrl, stream, apiKey)
Ma 7 месяцев назад
Родитель
Сommit
776ee16c25
2 измененных файлов с 98 добавлено и 11 удалено
  1. 27 1
      packages/cli/src/commands/doctor.ts
  2. 71 10
      packages/core/src/llm/provider.ts

+ 27 - 1
packages/cli/src/commands/doctor.ts

@@ -107,6 +107,12 @@ export const doctorCommand = new Command("doctor")
           detail: "No LLM config available (no project config or global .env)",
         });
       } else {
+        checks.push({
+          name: "LLM Config",
+          ok: true,
+          detail: `provider=${llmConfig.provider} model=${llmConfig.model} stream=${llmConfig.stream ?? true} baseUrl=${llmConfig.baseUrl}`,
+        });
+
         const client = createLLMClient(llmConfig);
         log("\n  [..] Testing API connectivity...");
         const response = await chatCompletion(client, llmConfig.model, [
@@ -120,11 +126,31 @@ export const doctorCommand = new Command("doctor")
         });
       }
     } catch (e) {
+      const errMsg = String(e);
+      const hints: string[] = [];
+
+      if (errMsg.includes("Connection error") || errMsg.includes("ECONNREFUSED") || errMsg.includes("fetch failed")) {
+        hints.push("baseUrl 可能不正确,检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)");
+      }
+      if (errMsg.includes("400")) {
+        hints.push("尝试在 inkos.json 中设置 \"stream\": false");
+        hints.push("检查模型名称是否正确(INKOS_LLM_MODEL)");
+      }
+      if (errMsg.includes("401")) {
+        hints.push("API Key 无效,检查 INKOS_LLM_API_KEY");
+      }
+
       checks.push({
         name: "API Connectivity",
         ok: false,
-        detail: String(e).split("\n")[0]!,
+        detail: errMsg.split("\n")[0]!,
       });
+
+      if (hints.length > 0) {
+        for (const hint of hints) {
+          checks.push({ name: "  Hint", ok: false, detail: hint });
+        }
+      }
     }
 
     // Output

+ 71 - 10
packages/core/src/llm/provider.ts

@@ -156,25 +156,47 @@ const MIN_SALVAGEABLE_CHARS = 500;
 
 // === Error Wrapping ===
 
-function wrapLLMError(error: unknown): Error {
+function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; readonly model?: string }): Error {
   const msg = String(error);
+  const ctxLine = context
+    ? `\n  (baseUrl: ${context.baseUrl}, model: ${context.model})`
+    : "";
+
+  if (msg.includes("400")) {
+    return new Error(
+      `API 返回 400 (请求参数错误)。可能原因:\n` +
+      `  1. 模型名称不正确(检查 INKOS_LLM_MODEL)\n` +
+      `  2. 提供方不支持某些参数(如 max_tokens、stream)\n` +
+      `  3. 消息格式不兼容(部分提供方不支持 system role)\n` +
+      `  建议:在 inkos.json 中设置 "stream": false 试试,或检查提供方文档${ctxLine}`,
+    );
+  }
   if (msg.includes("403")) {
     return new Error(
       `API 返回 403 (请求被拒绝)。可能原因:\n` +
       `  1. API Key 无效或过期\n` +
       `  2. API 提供方的内容审查拦截了请求(公益/免费 API 常见)\n` +
       `  3. 账户余额不足\n` +
-      `  建议:用 inkos doctor 测试 API 连通性,或换一个不限制内容的 API 提供方`,
+      `  建议:用 inkos doctor 测试 API 连通性,或换一个不限制内容的 API 提供方${ctxLine}`,
     );
   }
   if (msg.includes("401")) {
     return new Error(
-      `API 返回 401 (未授权)。请检查 .env 中的 INKOS_LLM_API_KEY 是否正确。`,
+      `API 返回 401 (未授权)。请检查 .env 中的 INKOS_LLM_API_KEY 是否正确。${ctxLine}`,
     );
   }
   if (msg.includes("429")) {
     return new Error(
-      `API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。`,
+      `API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
+    );
+  }
+  if (msg.includes("Connection error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
+    return new Error(
+      `无法连接到 API 服务。可能原因:\n` +
+      `  1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
+      `  2. 网络不通或被防火墙拦截\n` +
+      `  3. API 服务暂时不可用\n` +
+      `  建议:检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)`,
     );
   }
   return error instanceof Error ? error : new Error(msg);
@@ -193,12 +215,14 @@ export async function chatCompletion(
     readonly onStreamProgress?: OnStreamProgress;
   },
 ): Promise<LLMResponse> {
+  const resolved = {
+    temperature: options?.temperature ?? client.defaults.temperature,
+    maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
+  };
+  const onStreamProgress = options?.onStreamProgress;
+  const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
+
   try {
-    const resolved = {
-      temperature: options?.temperature ?? client.defaults.temperature,
-      maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
-    };
-    const onStreamProgress = options?.onStreamProgress;
     if (client.provider === "anthropic") {
       return client.stream
         ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress)
@@ -220,10 +244,47 @@ export async function chatCompletion(
         usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
       };
     }
-    throw wrapLLMError(error);
+
+    // Auto-fallback: if streaming failed, retry with sync (many proxies don't support SSE)
+    if (client.stream) {
+      const isStreamRelated = isLikelyStreamError(error);
+      if (isStreamRelated) {
+        try {
+          if (client.provider === "anthropic") {
+            return await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
+          }
+          if (client.apiFormat === "responses") {
+            return await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
+          }
+          return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
+        } catch (syncError) {
+          throw wrapLLMError(syncError, errorCtx);
+        }
+      }
+    }
+
+    throw wrapLLMError(error, errorCtx);
   }
 }
 
+function isLikelyStreamError(error: unknown): boolean {
+  const msg = String(error).toLowerCase();
+  // Common indicators that streaming specifically is the problem:
+  // - SSE parse errors, chunked transfer issues, content-type mismatches
+  // - Some proxies return 400/415 when stream=true
+  // - "stream" mentioned in error, or generic network errors during streaming
+  return (
+    msg.includes("stream") ||
+    msg.includes("text/event-stream") ||
+    msg.includes("chunked") ||
+    msg.includes("unexpected end") ||
+    msg.includes("premature close") ||
+    msg.includes("terminated") ||
+    msg.includes("econnreset") ||
+    (msg.includes("400") && !msg.includes("content"))
+  );
+}
+
 // === Tool-calling Chat (used by agent loop) ===
 
 export async function chatWithTools(