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

fix(core,studio): tighten service probe and chapter-targeted agent params

Ma 6 месяцев назад
Родитель
Сommit
43724a4cfb

+ 28 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -160,4 +160,32 @@ describe("agent deterministic writing tools", () => {
       }),
     );
   });
+
+  it("passes explicit chapterNumber through sub_agent auditor and reviser", async () => {
+    const pipeline = {
+      initBook: vi.fn(async () => undefined),
+      auditDraft: vi.fn(async () => ({
+        chapterNumber: 5,
+        issues: [],
+      })),
+      reviseDraft: vi.fn(async () => undefined),
+    };
+    const tool = createSubAgentTool(pipeline as never, "harbor");
+
+    await tool.execute("tool-7", {
+      agent: "auditor",
+      bookId: "harbor",
+      chapterNumber: 5,
+      instruction: "审计第5章",
+    });
+    await tool.execute("tool-8", {
+      agent: "reviser",
+      bookId: "harbor",
+      chapterNumber: 6,
+      instruction: "重写第6章",
+    });
+
+    expect(pipeline.auditDraft).toHaveBeenCalledWith("harbor", 5);
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 6, "rewrite");
+  });
 });

+ 6 - 5
packages/core/src/agent/agent-tools.ts

@@ -60,6 +60,7 @@ const SubAgentParams = Type.Object({
   instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
   bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
   title: Type.Optional(Type.String({ description: "Architect only: explicit book title. Required when creating a book." })),
+  chapterNumber: Type.Optional(Type.Number({ description: "Auditor/reviser only: target chapter number. Omit to use the latest chapter." })),
 });
 
 function deriveBookIdFromTitle(title: string): string {
@@ -90,7 +91,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
-      const { agent, instruction, bookId, title } = params;
+      const { agent, instruction, bookId, title, chapterNumber } = params;
 
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
@@ -130,8 +131,8 @@ export function createSubAgentTool(
 
           case "auditor": {
             if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
-            progress(`Auditing draft for "${bookId}"...`);
-            const audit = await pipeline.auditDraft(bookId);
+            progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
+            const audit = await pipeline.auditDraft(bookId, chapterNumber);
             progress(`Audit complete for "${bookId}".`);
             const issueCount = audit.issues?.length ?? 0;
             return textResult(
@@ -150,8 +151,8 @@ export function createSubAgentTool(
                 : /rework|返工/.test(instruction)
                   ? "rework"
                   : "spot-fix";
-            progress(`Revising "${bookId}" in ${mode} mode...`);
-            await pipeline.reviseDraft(bookId, undefined, mode);
+            progress(`Revising chapter ${chapterNumber ?? "latest"} for "${bookId}" in ${mode} mode...`);
+            await pipeline.reviseDraft(bookId, chapterNumber, mode);
             progress(`Revision complete for "${bookId}".`);
             return textResult(`Revision (${mode}) complete for "${bookId}".`);
           }

+ 11 - 1
packages/core/src/llm/service-presets.ts

@@ -9,6 +9,7 @@ export interface ServicePreset {
   readonly temperatureHint?: string;
   readonly piProvider?: string;
   readonly modelsBaseUrl?: string;
+  readonly knownModels?: readonly string[];
 }
 
 export const SERVICE_PRESETS: Record<string, ServicePreset> = {
@@ -16,7 +17,7 @@ export const SERVICE_PRESETS: Record<string, ServicePreset> = {
   anthropic:   { providerFamily: "anthropic", api: "anthropic-messages", baseUrl: "https://api.anthropic.com",                          label: "Anthropic",       temperatureRange: [0, 1], defaultTemperature: 1.0, writingTemperature: 1.0, temperatureHint: "不要同时改 temperature 和 top_p" },
   deepseek:    { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.deepseek.com",                           label: "DeepSeek",        temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
   moonshot:    { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.moonshot.cn/v1",                         label: "Moonshot (Kimi)", temperatureRange: [0, 1], defaultTemperature: 0.3, writingTemperature: 1.0, temperatureHint: "kimi-k2.5 推荐 temperature=1.0" },
-  minimax:     { providerFamily: "anthropic", api: "anthropic-messages", baseUrl: "https://api.minimaxi.com/anthropic",                 label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9, piProvider: "anthropic" },
+  minimax:     { providerFamily: "anthropic", api: "anthropic-messages", baseUrl: "https://api.minimaxi.com/anthropic",                 label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9, piProvider: "anthropic", knownModels: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2"] },
   bailian:     { providerFamily: "anthropic", api: "anthropic-messages", baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",      modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)", temperatureRange: [0, 2], defaultTemperature: 0.7, writingTemperature: 1.0, piProvider: "anthropic" },
   zhipu:       { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://open.bigmodel.cn/api/paas/v4",               label: "智谱 GLM",        temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95, piProvider: "zai" },
   siliconflow: { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.siliconflow.cn/v1",                      label: "硅基流动" },
@@ -95,6 +96,15 @@ export async function listModelsForService(service: string, apiKey?: string): Pr
   const preset = SERVICE_PRESETS[service];
   if (!preset || service === "custom") return [];
 
+  if (preset.knownModels && preset.knownModels.length > 0) {
+    return preset.knownModels.map((id) => ({
+      id,
+      name: id,
+      reasoning: false,
+      contextWindow: 0,
+    }));
+  }
+
   // 1) 尝试动态获取:调用 GET {modelsBaseUrl || baseUrl}/models
   const modelsBaseUrl = resolveServiceModelsBaseUrl(service);
   if (apiKey && modelsBaseUrl) {

+ 80 - 19
packages/studio/src/api/server.test.ts

@@ -488,6 +488,17 @@ describe("createStudioServer daemon lifecycle", () => {
       },
     };
     loadProjectConfigMock.mockResolvedValue(freshConfig);
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "404 page not found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockResolvedValue({
+      content: "pong",
+      usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+    });
 
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
@@ -495,15 +506,14 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/doctor");
 
     expect(response.status).toBe(200);
-    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
-      model: "fresh-model",
-      baseUrl: "https://fresh.example.com/v1",
-    }));
     expect(chatCompletionMock).toHaveBeenCalledWith(
-      expect.anything(),
+      expect.objectContaining({
+        model: "fresh-model",
+        baseUrl: "https://fresh.example.com/v1",
+      }),
       "fresh-model",
       expect.any(Array),
-      expect.objectContaining({ maxTokens: 5 }),
+      expect.objectContaining({ maxTokens: 2048 }),
     );
   });
 
@@ -519,9 +529,15 @@ describe("createStudioServer daemon lifecycle", () => {
       },
     };
     loadProjectConfigMock.mockResolvedValue(freshConfig);
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "404 page not found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
-    chatCompletionMock.mockImplementation(async (client: any) => {
-      if (client.stream === false) {
+    chatCompletionMock.mockImplementation(async (client: any, _model: string, _messages: any, options: any) => {
+      if (client.stream === false && options?.maxTokens === 2048) {
         return {
           content: "pong",
           usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
@@ -538,14 +554,24 @@ describe("createStudioServer daemon lifecycle", () => {
     await expect(response.json()).resolves.toMatchObject({
       llmConnected: true,
     });
-    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
-      stream: true,
-      apiFormat: "chat",
-    }));
-    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
-      stream: false,
-      apiFormat: "chat",
-    }));
+    expect(chatCompletionMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        stream: true,
+        apiFormat: "chat",
+      }),
+      "claude-sonnet-4-6",
+      expect.any(Array),
+      expect.objectContaining({ maxTokens: 2048 }),
+    );
+    expect(chatCompletionMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        stream: false,
+        apiFormat: "chat",
+      }),
+      "claude-sonnet-4-6",
+      expect.any(Array),
+      expect.objectContaining({ maxTokens: 2048 }),
+    );
   });
 
   it("reloads latest llm config for radar scans without restarting the studio server", async () => {
@@ -874,9 +900,10 @@ describe("createStudioServer daemon lifecycle", () => {
         services: [
           { service: "minimax", apiFormat: "chat", stream: false },
         ],
-        defaultModel: "MiniMax-M2.7",
+        defaultModel: "kimi-k2.5",
       },
     }, null, 2), "utf-8");
+    SERVICE_PRESETS_MOCK.minimax.knownModels = ["MiniMax-M2.7", "MiniMax-M2.5"];
 
     const fetchMock = vi.fn().mockResolvedValue({
       ok: false,
@@ -885,8 +912,11 @@ describe("createStudioServer daemon lifecycle", () => {
     });
     vi.stubGlobal("fetch", fetchMock as typeof fetch);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
-    chatCompletionMock.mockImplementation(async (client: any, model: string) => {
-      if (client.provider === "anthropic" && client.baseUrl === "https://api.minimaxi.com/anthropic" && model === "MiniMax-M2.7") {
+    chatCompletionMock.mockImplementation(async (client: any, model: string, _messages: any, options: any) => {
+      if (client.provider === "anthropic"
+        && client.baseUrl === "https://api.minimaxi.com/anthropic"
+        && model === "MiniMax-M2.7"
+        && options?.maxTokens === 2048) {
         return {
           content: "pong",
           usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
@@ -918,6 +948,37 @@ describe("createStudioServer daemon lifecycle", () => {
         baseUrl: "https://api.minimaxi.com/anthropic",
       },
     });
+    SERVICE_PRESETS_MOCK.minimax.knownModels = [];
+  });
+
+  it("short-circuits the probe when /models returns 401 or 403", async () => {
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 401,
+      text: async () => "unauthorized",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/openai/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "sk-bad",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: "API Key 无效,请检查后重试",
+    });
+    expect(chatCompletionMock).not.toHaveBeenCalled();
   });
 
   it("uses the preset models baseUrl when listing Bailian models", async () => {

+ 17 - 5
packages/studio/src/api/server.ts

@@ -384,7 +384,7 @@ async function fetchModelsFromServiceBaseUrl(
   serviceId: string,
   baseUrl: string,
   apiKey: string,
-): Promise<{ models: Array<{ id: string; name: string }>; error?: string }> {
+): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
   const modelsBaseUrl = isCustomServiceId(serviceId)
     ? baseUrl
     : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl;
@@ -396,7 +396,11 @@ async function fetchModelsFromServiceBaseUrl(
     });
     if (!res.ok) {
       const body = await res.text().catch(() => "");
-      return { models: [], error: `服务商返回 ${res.status}: ${body.slice(0, 200)}` };
+      return {
+        models: [],
+        error: `服务商返回 ${res.status}: ${body.slice(0, 200)}`,
+        authFailed: res.status === 401 || res.status === 403,
+      };
     }
     const json = await res.json() as { data?: Array<{ id: string }> };
     return {
@@ -430,9 +434,17 @@ async function probeServiceCapabilities(args: {
 
   const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
   const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey);
+  if (modelsResponse.authFailed) {
+    return {
+      ok: false,
+      models: [],
+      error: "API Key 无效,请检查后重试",
+    };
+  }
   const discoveredModels = modelsResponse.models;
+  const serviceFirstModel = resolveServicePreset(baseService)?.knownModels?.[0];
   const modelCandidates = buildModelCandidates({
-    preferredModel: args.preferredModel,
+    preferredModel: args.preferredModel ?? serviceFirstModel,
     configModel: typeof llm.defaultModel === "string" ? llm.defaultModel : typeof llm.model === "string" ? llm.model : undefined,
     envModel,
     discoveredModels,
@@ -458,14 +470,14 @@ async function probeServiceCapabilities(args: {
         apiKey: args.apiKey.trim(),
         model,
         temperature: 0.7,
-        maxTokens: 64,
+        maxTokens: 2048,
         thinkingBudget: 0,
         apiFormat: plan.apiFormat,
         stream: plan.stream,
       } as ProjectConfig["llm"]);
 
       try {
-        await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 5 });
+        await chatCompletion(client, model, [{ role: "user", content: "hi" }], { maxTokens: 2048 });
         const models = discoveredModels.length > 0
           ? discoveredModels
           : [{ id: model, name: model }];

+ 175 - 93
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -20,6 +20,15 @@ interface DetectedConfig {
   readonly modelsSource?: "api" | "fallback";
 }
 
+interface ServiceProbeResponse {
+  readonly ok: boolean;
+  readonly models?: ModelInfo[];
+  readonly modelCount?: number;
+  readonly selectedModel?: string;
+  readonly detected?: DetectedConfig;
+  readonly error?: string;
+}
+
 // Unified page state
 type ConnectionStatus =
   | { state: "idle" }               // No action taken yet
@@ -29,6 +38,115 @@ type ConnectionStatus =
   | { state: "saving" }             // Save in progress
   | { state: "saved" }              // Save succeeded
 
+export async function probeServiceForDetail(
+  fetcher: typeof fetchJson,
+  args: {
+    readonly effectiveServiceId: string;
+    readonly apiKey: string;
+    readonly apiFormat: "chat" | "responses";
+    readonly stream: boolean;
+    readonly isCustom: boolean;
+    readonly baseUrl: string;
+  },
+): Promise<ServiceProbeResponse> {
+  return fetcher<ServiceProbeResponse>(
+    `/services/${encodeURIComponent(args.effectiveServiceId)}/test`,
+    {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: args.apiKey,
+        apiFormat: args.apiFormat,
+        stream: args.stream,
+        ...(args.isCustom ? { baseUrl: args.baseUrl.trim() } : {}),
+      }),
+    },
+  );
+}
+
+export async function rehydrateServiceConnectionStatus(
+  fetcher: typeof fetchJson,
+  args: {
+    readonly connected: boolean;
+    readonly effectiveServiceId: string;
+    readonly apiFormat: "chat" | "responses";
+    readonly stream: boolean;
+    readonly isCustom: boolean;
+    readonly baseUrl: string;
+  },
+): Promise<ServiceProbeResponse | null> {
+  if (!args.connected) return null;
+  const secret = await fetcher<{ apiKey?: string }>(`/services/${encodeURIComponent(args.effectiveServiceId)}/secret`);
+  const savedKey = secret.apiKey?.trim();
+  if (!savedKey) return null;
+  return probeServiceForDetail(fetcher, {
+    effectiveServiceId: args.effectiveServiceId,
+    apiKey: savedKey,
+    apiFormat: args.apiFormat,
+    stream: args.stream,
+    isCustom: args.isCustom,
+    baseUrl: args.baseUrl,
+  });
+}
+
+export async function saveServiceConfigWithValidation(
+  fetcher: typeof fetchJson,
+  args: {
+    readonly effectiveServiceId: string;
+    readonly serviceId: string;
+    readonly isCustom: boolean;
+    readonly resolvedCustomName: string;
+    readonly baseUrl: string;
+    readonly apiKey: string;
+    readonly apiFormat: "chat" | "responses";
+    readonly stream: boolean;
+    readonly temperature: string;
+    readonly maxTokens: string;
+    readonly defaultModel?: string;
+  },
+): Promise<{ probe: ServiceProbeResponse | null }> {
+  const trimmedKey = args.apiKey.trim();
+  let probe: ServiceProbeResponse | null = null;
+  if (trimmedKey) {
+    probe = await probeServiceForDetail(fetcher, {
+      effectiveServiceId: args.effectiveServiceId,
+      apiKey: trimmedKey,
+      apiFormat: args.apiFormat,
+      stream: args.stream,
+      isCustom: args.isCustom,
+      baseUrl: args.baseUrl,
+    });
+    if (!probe.ok) {
+      throw new Error(probe.error ?? "连接失败");
+    }
+  }
+
+  await fetcher(`/services/${encodeURIComponent(args.effectiveServiceId)}/secret`, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ apiKey: trimmedKey }),
+  });
+  await fetcher("/services/config", {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({
+      service: args.effectiveServiceId,
+      ...(args.defaultModel ? { defaultModel: args.defaultModel } : {}),
+      services: [
+        {
+          service: args.isCustom ? "custom" : args.serviceId,
+          temperature: parseFloat(args.temperature),
+          maxTokens: parseInt(args.maxTokens, 10),
+          apiFormat: args.apiFormat,
+          stream: args.stream,
+          ...(args.isCustom ? { name: args.resolvedCustomName, baseUrl: args.baseUrl.trim() } : {}),
+        },
+      ],
+    }),
+  });
+  return { probe };
+}
+
 function DetailSkeleton() {
   return (
     <div className="max-w-xl mx-auto space-y-6 animate-pulse">
@@ -69,6 +187,9 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
 
   // -- Unified connection status --
   const [status, setStatus] = useState<ConnectionStatus>({ state: "idle" });
+  const resolvedCustomName = persistedCustomName || customName.trim() || "Custom";
+  const effectiveServiceId = isCustom ? `custom:${resolvedCustomName}` : serviceId;
+  const label = isCustom ? (customName || persistedCustomName || "自定义服务") : (svc?.label ?? serviceId);
 
   useEffect(() => {
     let cancelled = false;
@@ -96,24 +217,32 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     return () => { cancelled = true; };
   }, [isCustom, persistedCustomName, serviceId]);
 
-  // Load models on mount if connected
+  // Validate existing key on mount via /test, not just /models
   useEffect(() => {
-    if (svc?.connected) {
-      setStatus({ state: "testing" });
-      fetchJson<{ models: ModelInfo[] }>(`/services/${encodeURIComponent(serviceId)}/models`)
-        .then((data) => {
-          const models = data.models ?? [];
-          setStatus(models.length > 0
-            ? { state: "connected", models }
-            : { state: "idle" });
-        })
-        .catch(() => setStatus({ state: "idle" }));
-    }
-  }, [svc?.connected, serviceId]);
-
-  const resolvedCustomName = persistedCustomName || customName.trim() || "Custom";
-  const effectiveServiceId = isCustom ? `custom:${resolvedCustomName}` : serviceId;
-  const label = isCustom ? (customName || persistedCustomName || "自定义服务") : (svc?.label ?? serviceId);
+    let cancelled = false;
+    if (!svc?.connected) return;
+    setStatus({ state: "testing" });
+    void rehydrateServiceConnectionStatus(fetchJson, {
+      connected: svc.connected,
+      effectiveServiceId,
+      apiFormat,
+      stream,
+      isCustom,
+      baseUrl,
+    })
+      .then((result) => {
+        if (cancelled || !result) return;
+        if (result.ok && result.models) {
+          setStatus({ state: "connected", models: result.models });
+        } else {
+          setStatus({ state: "idle" });
+        }
+      })
+      .catch(() => {
+        if (!cancelled) setStatus({ state: "idle" });
+      });
+    return () => { cancelled = true; };
+  }, [svc?.connected, effectiveServiceId, apiFormat, stream, isCustom, baseUrl]);
 
   useEffect(() => {
     let cancelled = false;
@@ -149,26 +278,14 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     setApiKey(trimmedKey);
     setStatus({ state: "testing" });
     try {
-      const result = await fetchJson<{
-        ok: boolean;
-        models?: ModelInfo[];
-        modelCount?: number;
-        selectedModel?: string;
-        detected?: DetectedConfig;
-        error?: string;
-      }>(
-        `/services/${encodeURIComponent(effectiveServiceId)}/test`,
-        {
-          method: "POST",
-          headers: { "Content-Type": "application/json" },
-          body: JSON.stringify({
-            apiKey: trimmedKey,
-            apiFormat,
-            stream,
-            ...(isCustom ? { baseUrl: baseUrl.trim() } : {}),
-          }),
-        },
-      );
+      const result = await probeServiceForDetail(fetchJson, {
+        effectiveServiceId,
+        apiKey: trimmedKey,
+        apiFormat,
+        stream,
+        isCustom,
+        baseUrl,
+      });
       if (result.ok) {
         const models = result.models ?? [];
         if (result.detected?.apiFormat) setApiFormat(result.detected.apiFormat);
@@ -196,63 +313,28 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     }
     setStatus({ state: "saving" });
     try {
-      // Save key (empty = delete)
-      await fetchJson(`/services/${encodeURIComponent(effectiveServiceId)}/secret`, {
-        method: "PUT",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ apiKey: trimmedKey }),
+      const { probe: validatedResult } = await saveServiceConfigWithValidation(fetchJson, {
+        effectiveServiceId,
+        serviceId,
+        isCustom,
+        resolvedCustomName,
+        baseUrl,
+        apiKey: trimmedKey,
+        apiFormat,
+        stream,
+        temperature,
+        maxTokens,
+        defaultModel: detectedModel || undefined,
       });
-      // Save config (temperature, maxTokens, etc.)
-      await fetchJson("/services/config", {
-        method: "PUT",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({
-          service: effectiveServiceId,
-          ...(detectedModel ? { defaultModel: detectedModel } : {}),
-          services: [
-            {
-              service: isCustom ? "custom" : serviceId,
-              temperature: parseFloat(temperature),
-              maxTokens: parseInt(maxTokens, 10),
-              apiFormat,
-              stream,
-              ...(isCustom ? { name: resolvedCustomName, baseUrl: baseUrl.trim() } : {}),
-            },
-          ],
-        }),
-      });
-      if (trimmedKey) {
-        try {
-          const result = await fetchJson<{
-            ok: boolean;
-            models?: ModelInfo[];
-            selectedModel?: string;
-            detected?: DetectedConfig;
-            error?: string;
-          }>(`/services/${encodeURIComponent(effectiveServiceId)}/test`, {
-            method: "POST",
-            headers: { "Content-Type": "application/json" },
-            body: JSON.stringify({
-              apiKey: trimmedKey,
-              apiFormat,
-              stream,
-              ...(isCustom ? { baseUrl: baseUrl.trim() } : {}),
-            }),
-          });
-          if (result.ok && result.models) {
-            if (result.detected?.apiFormat) setApiFormat(result.detected.apiFormat);
-            if (typeof result.detected?.stream === "boolean") setStream(result.detected.stream);
-            if (isCustom && result.detected?.baseUrl) setBaseUrl(result.detected.baseUrl);
-            setDetectedModel(result.selectedModel ?? detectedModel);
-            setDetectedConfig(result.detected ?? detectedConfig);
-            setStoreModels(effectiveServiceId, result.models);
-            setStatus({ state: "connected", models: result.models });
-          } else {
-            setStatus({ state: "error", message: result.error ?? "连接失败" });
-          }
-        } catch (e) {
-          setStatus({ state: "error", message: e instanceof Error ? e.message : "连接失败" });
-        }
+
+      if (trimmedKey && validatedResult?.models) {
+        if (validatedResult.detected?.apiFormat) setApiFormat(validatedResult.detected.apiFormat);
+        if (typeof validatedResult.detected?.stream === "boolean") setStream(validatedResult.detected.stream);
+        if (isCustom && validatedResult.detected?.baseUrl) setBaseUrl(validatedResult.detected.baseUrl);
+        setDetectedModel(validatedResult.selectedModel ?? detectedModel);
+        setDetectedConfig(validatedResult.detected ?? detectedConfig);
+        setStoreModels(effectiveServiceId, validatedResult.models);
+        setStatus({ state: "connected", models: validatedResult.models });
       } else {
         clearStoreModels(effectiveServiceId);
         setStatus({ state: "saved" });

+ 84 - 0
packages/studio/src/pages/service-detail-state.test.ts

@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+  probeServiceForDetail,
+  rehydrateServiceConnectionStatus,
+  saveServiceConfigWithValidation,
+} from "./ServiceDetailPage";
+
+describe("service detail state helpers", () => {
+  it("rehydrates connection status by validating via /test instead of /models", async () => {
+    const fetcher = vi.fn()
+      .mockResolvedValueOnce({ apiKey: "sk-live" })
+      .mockResolvedValueOnce({ ok: true, models: [{ id: "gpt-5.4" }] });
+
+    const result = await rehydrateServiceConnectionStatus(fetcher as any, {
+      connected: true,
+      effectiveServiceId: "openai",
+      apiFormat: "chat",
+      stream: false,
+      isCustom: false,
+      baseUrl: "",
+    });
+
+    expect(result).toMatchObject({ ok: true, models: [{ id: "gpt-5.4" }] });
+    expect(fetcher).toHaveBeenNthCalledWith(1, "/services/openai/secret");
+    expect(fetcher).toHaveBeenNthCalledWith(2, "/services/openai/test", expect.objectContaining({
+      method: "POST",
+    }));
+  });
+
+  it("validates API key before persisting service config", async () => {
+    const fetcher = vi.fn()
+      .mockResolvedValueOnce({ ok: true, models: [{ id: "MiniMax-M2.7" }], selectedModel: "MiniMax-M2.7" })
+      .mockResolvedValueOnce({ ok: true })
+      .mockResolvedValueOnce({ ok: true });
+
+    await saveServiceConfigWithValidation(fetcher as any, {
+      effectiveServiceId: "minimax",
+      serviceId: "minimax",
+      isCustom: false,
+      resolvedCustomName: "",
+      baseUrl: "",
+      apiKey: "sk-live",
+      apiFormat: "chat",
+      stream: false,
+      temperature: "0.7",
+      maxTokens: "4096",
+      defaultModel: "MiniMax-M2.7",
+    });
+
+    expect(fetcher).toHaveBeenNthCalledWith(1, "/services/minimax/test", expect.objectContaining({
+      method: "POST",
+    }));
+    expect(fetcher).toHaveBeenNthCalledWith(2, "/services/minimax/secret", expect.objectContaining({
+      method: "PUT",
+    }));
+    expect(fetcher).toHaveBeenNthCalledWith(3, "/services/config", expect.objectContaining({
+      method: "PUT",
+    }));
+  });
+
+  it("does not persist anything when validation fails", async () => {
+    const fetcher = vi.fn()
+      .mockResolvedValueOnce({ ok: false, error: "API Key 无效,请检查后重试" });
+
+    await expect(saveServiceConfigWithValidation(fetcher as any, {
+      effectiveServiceId: "openai",
+      serviceId: "openai",
+      isCustom: false,
+      resolvedCustomName: "",
+      baseUrl: "",
+      apiKey: "sk-bad",
+      apiFormat: "chat",
+      stream: false,
+      temperature: "0.7",
+      maxTokens: "4096",
+      defaultModel: undefined,
+    })).rejects.toThrow("API Key 无效,请检查后重试");
+
+    expect(fetcher).toHaveBeenCalledTimes(1);
+    expect(fetcher).toHaveBeenCalledWith("/services/openai/test", expect.objectContaining({
+      method: "POST",
+    }));
+  });
+});