Przeglądaj źródła

fix(studio): surface upstream chat failures and validate service probes

Ma 6 miesięcy temu
rodzic
commit
cbf89bbda4

+ 6 - 0
packages/core/src/llm/provider.ts

@@ -490,6 +490,9 @@ async function chatCompletionViaPiAi(
 
   if (!client.stream) {
     const response = await piCompleteSimple(piModel, context, streamOpts);
+    if (response.stopReason === "error" && response.errorMessage) {
+      throw new Error(response.errorMessage);
+    }
     const content = response.content
       .filter((block): block is { type: "text"; text: string } => block.type === "text")
       .map((block) => block.text)
@@ -583,6 +586,9 @@ async function chatWithToolsViaPiAi(
 
   if (!client.stream) {
     const response = await piComplete(piModel, context, streamOpts);
+    if (response.stopReason === "error" && response.errorMessage) {
+      throw new Error(response.errorMessage);
+    }
     const content = response.content
       .filter((block): block is { type: "text"; text: string } => block.type === "text")
       .map((block) => block.text)

+ 84 - 0
packages/studio/src/api/server.test.ts

@@ -905,6 +905,64 @@ describe("createStudioServer daemon lifecycle", () => {
     );
   });
 
+  it("allows /api/agent to use explicit service+model when Studio config has no defaultModel", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        configSource: "studio",
+        services: [
+          { service: "custom", name: "CodexForMe", baseUrl: "https://api-vip.codex-for.me/v1", apiFormat: "responses", stream: false },
+        ],
+      },
+    }, null, 2), "utf-8");
+    loadProjectConfigMock.mockImplementation(async () => {
+      const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
+      return {
+        ...cloneProjectConfig(),
+        ...raw,
+        llm: {
+          ...cloneProjectConfig().llm,
+          ...((raw.llm ?? {}) as Record<string, unknown>),
+        },
+        daemon: {
+          ...cloneProjectConfig().daemon,
+          ...((raw.daemon ?? {}) as Record<string, unknown>),
+        },
+        modelOverrides: (raw.modelOverrides ?? {}) as Record<string, unknown>,
+        notify: (raw.notify ?? []) as unknown[],
+      };
+    });
+    resolveServiceModelMock.mockResolvedValue({
+      model: { id: "gpt-5.4", provider: "custom", api: "openai-responses" },
+      apiKey: "sk-test",
+    });
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "你好,我在。",
+      messages: [
+        { role: "user", content: "nihao" },
+        { role: "assistant", content: "你好,我在。" },
+      ],
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        instruction: "nihao",
+        service: "custom:CodexForMe",
+        model: "gpt-5.4",
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      response: "你好,我在。",
+    });
+  });
+
   it("returns 500 with an error payload when the agent session fails", async () => {
     runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
 
@@ -926,6 +984,32 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("probes the upstream when the agent returns empty text and surfaces the real error", async () => {
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "",
+      messages: [{ role: "user", content: "nihao" }],
+    });
+    chatCompletionMock.mockRejectedValueOnce(new Error("quota exhausted"));
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(502);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "AGENT_EMPTY_RESPONSE",
+        message: "quota exhausted",
+      },
+      response: "quota exhausted",
+    });
+  });
+
   it("returns the shared interaction session state", async () => {
     loadProjectSessionMock.mockResolvedValue({
       sessionId: "session-2",

+ 73 - 4
packages/studio/src/api/server.ts

@@ -25,6 +25,7 @@ import {
   saveSecrets,
   getServiceApiKey,
   listModelsForService,
+  chatCompletion,
   GLOBAL_ENV_PATH,
   type ResolvedModel,
   type PipelineConfig,
@@ -371,9 +372,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   }
 
   async function buildPipelineConfig(
-    overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">>,
+    overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
+      readonly currentConfig?: ProjectConfig;
+    },
   ): Promise<PipelineConfig> {
-    const currentConfig = await loadCurrentProjectConfig();
+    const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
     const logger = createLogger({ tag: "studio", sinks: [sseSink, consoleSink] });
     return {
       client: overrides?.client ?? createLLMClient(currentConfig.llm),
@@ -770,7 +773,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   app.post("/api/v1/services/:service/test", async (c) => {
     const service = c.req.param("service");
-    const { apiKey, baseUrl } = await c.req.json<{ apiKey: string; baseUrl?: string }>();
+    const { apiKey, baseUrl, apiFormat, stream } = await c.req.json<{
+      apiKey: string;
+      baseUrl?: string;
+      apiFormat?: "chat" | "responses";
+      stream?: boolean;
+    }>();
 
     if (!apiKey?.trim()) {
       return c.json({ ok: false, error: "API Key 不能为空" }, 400);
@@ -804,6 +812,35 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         return c.json({ ok: false, error: "连接成功但未返回可用模型" }, 400);
       }
 
+      const sampleModel = models[0]?.id;
+      if (sampleModel) {
+        const client = createLLMClient({
+          provider: service === "anthropic" ? "anthropic" : "openai",
+          service: isCustomServiceId(service) ? "custom" : service,
+          configSource: "studio",
+          baseUrl: resolvedBaseUrl,
+          apiKey: apiKey.trim(),
+          model: sampleModel,
+          temperature: 0.7,
+          maxTokens: 64,
+          thinkingBudget: 0,
+          apiFormat: apiFormat ?? "chat",
+          stream: stream ?? true,
+        } as ProjectConfig["llm"]);
+
+        try {
+          await chatCompletion(
+            client,
+            sampleModel,
+            [{ role: "user", content: "ping" }],
+            { maxTokens: 5 },
+          );
+        } catch (error) {
+          const message = error instanceof Error ? error.message : String(error);
+          return c.json({ ok: false, error: `模型列表可读,但生成测试失败:${message}` }, 400);
+        }
+      }
+
       return c.json({ ok: true, modelCount: models.length, models: models.slice(0, 50) });
     } catch (err: any) {
       if (err?.name === "TimeoutError" || err?.name === "AbortError") {
@@ -1056,7 +1093,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
     try {
       // Load config + create LLM client (pipeline created after model resolution)
-      const config = await loadCurrentProjectConfig({ requireApiKey: true });
+      const config = await loadCurrentProjectConfig({ requireApiKey: false });
       const client = createLLMClient(config.llm);
 
       // Resolve or create BookSession for history
@@ -1178,6 +1215,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       const pipeline = new PipelineRunner(await buildPipelineConfig({
         client: pipelineClient,
         model: reqModel ?? config.llm.model,
+        currentConfig: config,
       }));
 
       // Run pi-agent session
@@ -1271,6 +1309,37 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           timestamp: Date.now() + 1,
         });
       }
+      if (!result.responseText) {
+        try {
+          const probeClient = createLLMClient({
+            ...config.llm,
+            service: configuredEntry?.service ?? reqService ?? config.llm.service,
+            model: reqModel ?? config.llm.model,
+            apiKey: agentApiKey ?? config.llm.apiKey,
+            baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
+            ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
+            ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
+          } as ProjectConfig["llm"]);
+          await chatCompletion(
+            probeClient,
+            reqModel ?? config.llm.model,
+            [{ role: "user", content: "ping" }],
+            { maxTokens: 5 },
+          );
+        } catch (probeError) {
+          const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
+          return c.json({
+            error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
+            response: probeMessage,
+          }, 502);
+        }
+
+        const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
+        return c.json({
+          error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
+          response: emptyMessage,
+        }, 502);
+      }
       await persistBookSession(root, bookSession);
 
       broadcast("agent:complete", { instruction, activeBookId });

+ 2 - 0
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -134,6 +134,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify({
             apiKey: trimmedKey,
+            apiFormat,
+            stream,
             ...(isCustom ? { baseUrl: baseUrl.trim() } : {}),
           }),
         },

+ 9 - 2
packages/studio/src/store/chat/slices/message/action.ts

@@ -414,7 +414,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
 
       streamEs.close();
 
-      const finalContent = data.details?.draftRaw || data.response || "Acknowledged.";
+      const finalContent = data.details?.draftRaw || data.response || "";
       const toolCall = data.details?.toolCall ?? undefined;
       const hasStream = get().messages.some((m) => m.timestamp === streamTs);
 
@@ -424,7 +424,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
         } else {
           get().addErrorMessage(extractErrorMessage(data.error));
         }
-      } else {
+      } else if (finalContent) {
         if (hasStream) {
           get().finalizeStream(streamTs, finalContent, toolCall);
         } else {
@@ -437,6 +437,13 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
         if (toolCall?.name === "create_book") {
           get().setPendingBookArgs({ ...toolCall.arguments });
         }
+      } else {
+        const emptyMsg = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
+        if (hasStream) {
+          get().replaceStreamWithError(streamTs, emptyMsg);
+        } else {
+          get().addErrorMessage(emptyMsg);
+        }
       }
     } catch (e) {
       streamEs.close();