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

fix: clarify streaming provider fallback errors

Ma 7 месяцев назад
Родитель
Сommit
a7f7d6c147
2 измененных файлов с 109 добавлено и 1 удалено
  1. 79 0
      packages/core/src/__tests__/provider.test.ts
  2. 30 1
      packages/core/src/llm/provider.ts

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

@@ -8,6 +8,15 @@ const ZERO_USAGE = {
   total_tokens: 18,
   total_tokens: 18,
 } as const;
 } as const;
 
 
+async function captureError(task: Promise<unknown>): Promise<Error> {
+  try {
+    await task;
+  } catch (error) {
+    return error as Error;
+  }
+  throw new Error("Expected promise to reject");
+}
+
 describe("chatCompletion stream fallback", () => {
 describe("chatCompletion stream fallback", () => {
   it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
   it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
     const create = vi.fn()
     const create = vi.fn()
@@ -54,4 +63,74 @@ describe("chatCompletion stream fallback", () => {
     expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
     expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
     expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
     expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
   });
   });
+
+  it("does not blindly suggest stream false for generic 400 errors", async () => {
+    const create = vi.fn().mockRejectedValue(new Error("400 Bad Request"));
+
+    const client: LLMClient = {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      _openai: {
+        chat: {
+          completions: {
+            create,
+          },
+        },
+      } as unknown as OpenAI,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 512,
+        thinkingBudget: 0, maxTokensCap: null,
+        extra: {},
+      },
+    };
+
+    const error = await captureError(chatCompletion(client, "test-model", [
+      { role: "user", content: "ping" },
+    ]));
+
+    expect(error.message).toContain("API 返回 400");
+    expect(error.message).not.toContain("\"stream\": false");
+    expect(error.message).toContain("检查提供方文档");
+  });
+
+  it("reports when sync fallback is rejected because provider requires streaming", async () => {
+    const create = vi.fn()
+      .mockResolvedValueOnce({
+        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
+          return;
+        },
+      })
+      .mockRejectedValueOnce(new Error("400 {\"detail\":\"Stream must be set to true\"}"));
+
+    const client: LLMClient = {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: true,
+      _openai: {
+        chat: {
+          completions: {
+            create,
+          },
+        },
+      } as unknown as OpenAI,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 512,
+        thinkingBudget: 0, maxTokensCap: null,
+        extra: {},
+      },
+    };
+
+    const error = await captureError(chatCompletion(client, "test-model", [
+      { role: "user", content: "ping" },
+    ]));
+
+    expect(create).toHaveBeenCalledTimes(2);
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
+    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
+    expect(error.message).toContain("stream:true");
+    expect(error.message).not.toContain("\"stream\": false");
+  });
 });
 });

+ 30 - 1
packages/core/src/llm/provider.ts

@@ -183,7 +183,7 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
       `  1. 模型名称不正确(检查 INKOS_LLM_MODEL)\n` +
       `  1. 模型名称不正确(检查 INKOS_LLM_MODEL)\n` +
       `  2. 提供方不支持某些参数(如 max_tokens、stream)\n` +
       `  2. 提供方不支持某些参数(如 max_tokens、stream)\n` +
       `  3. 消息格式不兼容(部分提供方不支持 system role)\n` +
       `  3. 消息格式不兼容(部分提供方不支持 system role)\n` +
-      `  建议:在 inkos.json 中设置 "stream": false 试试,或检查提供方文档${ctxLine}`,
+      `  建议:检查提供方文档,确认该接口要求流式开启、流式关闭,还是根本不支持 stream${ctxLine}`,
     );
     );
   }
   }
   if (msg.includes("403")) {
   if (msg.includes("403")) {
@@ -217,6 +217,23 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
   return error instanceof Error ? error : new Error(msg);
   return error instanceof Error ? error : new Error(msg);
 }
 }
 
 
+function wrapStreamRequiredError(
+  streamError: unknown,
+  syncError: unknown,
+  context?: { readonly baseUrl?: string; readonly model?: string },
+): Error {
+  const ctxLine = context
+    ? `\n  (baseUrl: ${context.baseUrl}, model: ${context.model})`
+    : "";
+  return new Error(
+    `API 提供方要求使用流式请求(stream:true),不能回退到同步模式。` +
+    `\n  这次失败不是模型名错误,而是前一次流式请求先失败了,随后同步回退又被提供方拒绝。` +
+    `\n  建议:保持 stream:true,并检查该提供方/代理的 SSE 流是否稳定。` +
+    `\n  原始流式错误:${String(streamError)}` +
+    `\n  同步回退错误:${String(syncError)}${ctxLine}`,
+  );
+}
+
 // === Simple Chat (used by all agents via BaseAgent.chat()) ===
 // === Simple Chat (used by all agents via BaseAgent.chat()) ===
 
 
 export async function chatCompletion(
 export async function chatCompletion(
@@ -276,6 +293,9 @@ export async function chatCompletion(
           }
           }
           return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
           return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
         } catch (syncError) {
         } catch (syncError) {
+          if (isStreamRequiredError(syncError)) {
+            throw wrapStreamRequiredError(error, syncError, errorCtx);
+          }
           throw wrapLLMError(syncError, errorCtx);
           throw wrapLLMError(syncError, errorCtx);
         }
         }
       }
       }
@@ -303,6 +323,15 @@ function isLikelyStreamError(error: unknown): boolean {
   );
   );
 }
 }
 
 
+function isStreamRequiredError(error: unknown): boolean {
+  const msg = String(error).toLowerCase();
+  return (
+    msg.includes("stream must be set to true") ||
+    (msg.includes("stream") && msg.includes("must be set to true")) ||
+    (msg.includes("stream") && msg.includes("required"))
+  );
+}
+
 // === Tool-calling Chat (used by agent loop) ===
 // === Tool-calling Chat (used by agent loop) ===
 
 
 export async function chatWithTools(
 export async function chatWithTools(