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

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

This reverts commit 43724a4cfba5342e1e76d3f60e71ec344ed2cc75.
Ma 6 месяцев назад
Родитель
Сommit
020d0eb448

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

@@ -160,32 +160,4 @@ 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");
-  });
 });
 });

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

@@ -60,7 +60,6 @@ const SubAgentParams = Type.Object({
   instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
   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" })),
   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." })),
   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 {
 function deriveBookIdFromTitle(title: string): string {
@@ -91,7 +90,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
     ): Promise<AgentToolResult<undefined>> {
-      const { agent, instruction, bookId, title, chapterNumber } = params;
+      const { agent, instruction, bookId, title } = params;
 
 
       const progress = (msg: string) => {
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
         onUpdate?.(textResult(msg));
@@ -131,8 +130,8 @@ export function createSubAgentTool(
 
 
           case "auditor": {
           case "auditor": {
             if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
             if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
-            progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
-            const audit = await pipeline.auditDraft(bookId, chapterNumber);
+            progress(`Auditing draft for "${bookId}"...`);
+            const audit = await pipeline.auditDraft(bookId);
             progress(`Audit complete for "${bookId}".`);
             progress(`Audit complete for "${bookId}".`);
             const issueCount = audit.issues?.length ?? 0;
             const issueCount = audit.issues?.length ?? 0;
             return textResult(
             return textResult(
@@ -151,8 +150,8 @@ export function createSubAgentTool(
                 : /rework|返工/.test(instruction)
                 : /rework|返工/.test(instruction)
                   ? "rework"
                   ? "rework"
                   : "spot-fix";
                   : "spot-fix";
-            progress(`Revising chapter ${chapterNumber ?? "latest"} for "${bookId}" in ${mode} mode...`);
-            await pipeline.reviseDraft(bookId, chapterNumber, mode);
+            progress(`Revising "${bookId}" in ${mode} mode...`);
+            await pipeline.reviseDraft(bookId, undefined, mode);
             progress(`Revision complete for "${bookId}".`);
             progress(`Revision complete for "${bookId}".`);
             return textResult(`Revision (${mode}) complete for "${bookId}".`);
             return textResult(`Revision (${mode}) complete for "${bookId}".`);
           }
           }

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

@@ -9,7 +9,6 @@ export interface ServicePreset {
   readonly temperatureHint?: string;
   readonly temperatureHint?: string;
   readonly piProvider?: string;
   readonly piProvider?: string;
   readonly modelsBaseUrl?: string;
   readonly modelsBaseUrl?: string;
-  readonly knownModels?: readonly string[];
 }
 }
 
 
 export const SERVICE_PRESETS: Record<string, ServicePreset> = {
 export const SERVICE_PRESETS: Record<string, ServicePreset> = {
@@ -17,7 +16,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" },
   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" },
   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" },
   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", knownModels: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2"] },
+  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" },
   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" },
   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" },
   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: "硅基流动" },
   siliconflow: { providerFamily: "openai",    api: "openai-completions", baseUrl: "https://api.siliconflow.cn/v1",                      label: "硅基流动" },
@@ -96,15 +95,6 @@ export async function listModelsForService(service: string, apiKey?: string): Pr
   const preset = SERVICE_PRESETS[service];
   const preset = SERVICE_PRESETS[service];
   if (!preset || service === "custom") return [];
   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
   // 1) 尝试动态获取:调用 GET {modelsBaseUrl || baseUrl}/models
   const modelsBaseUrl = resolveServiceModelsBaseUrl(service);
   const modelsBaseUrl = resolveServiceModelsBaseUrl(service);
   if (apiKey && modelsBaseUrl) {
   if (apiKey && modelsBaseUrl) {

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

@@ -488,17 +488,6 @@ describe("createStudioServer daemon lifecycle", () => {
       },
       },
     };
     };
     loadProjectConfigMock.mockResolvedValue(freshConfig);
     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 { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
     const app = createStudioServer(startupConfig as never, root);
@@ -506,14 +495,15 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/doctor");
     const response = await app.request("http://localhost/api/v1/doctor");
 
 
     expect(response.status).toBe(200);
     expect(response.status).toBe(200);
+    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
+      model: "fresh-model",
+      baseUrl: "https://fresh.example.com/v1",
+    }));
     expect(chatCompletionMock).toHaveBeenCalledWith(
     expect(chatCompletionMock).toHaveBeenCalledWith(
-      expect.objectContaining({
-        model: "fresh-model",
-        baseUrl: "https://fresh.example.com/v1",
-      }),
+      expect.anything(),
       "fresh-model",
       "fresh-model",
       expect.any(Array),
       expect.any(Array),
-      expect.objectContaining({ maxTokens: 2048 }),
+      expect.objectContaining({ maxTokens: 5 }),
     );
     );
   });
   });
 
 
@@ -529,15 +519,9 @@ describe("createStudioServer daemon lifecycle", () => {
       },
       },
     };
     };
     loadProjectConfigMock.mockResolvedValue(freshConfig);
     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);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
-    chatCompletionMock.mockImplementation(async (client: any, _model: string, _messages: any, options: any) => {
-      if (client.stream === false && options?.maxTokens === 2048) {
+    chatCompletionMock.mockImplementation(async (client: any) => {
+      if (client.stream === false) {
         return {
         return {
           content: "pong",
           content: "pong",
           usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
           usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
@@ -554,24 +538,14 @@ describe("createStudioServer daemon lifecycle", () => {
     await expect(response.json()).resolves.toMatchObject({
     await expect(response.json()).resolves.toMatchObject({
       llmConnected: true,
       llmConnected: true,
     });
     });
-    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 }),
-    );
+    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
+      stream: true,
+      apiFormat: "chat",
+    }));
+    expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
+      stream: false,
+      apiFormat: "chat",
+    }));
   });
   });
 
 
   it("reloads latest llm config for radar scans without restarting the studio server", async () => {
   it("reloads latest llm config for radar scans without restarting the studio server", async () => {
@@ -900,10 +874,9 @@ describe("createStudioServer daemon lifecycle", () => {
         services: [
         services: [
           { service: "minimax", apiFormat: "chat", stream: false },
           { service: "minimax", apiFormat: "chat", stream: false },
         ],
         ],
-        defaultModel: "kimi-k2.5",
+        defaultModel: "MiniMax-M2.7",
       },
       },
     }, null, 2), "utf-8");
     }, null, 2), "utf-8");
-    SERVICE_PRESETS_MOCK.minimax.knownModels = ["MiniMax-M2.7", "MiniMax-M2.5"];
 
 
     const fetchMock = vi.fn().mockResolvedValue({
     const fetchMock = vi.fn().mockResolvedValue({
       ok: false,
       ok: false,
@@ -912,11 +885,8 @@ describe("createStudioServer daemon lifecycle", () => {
     });
     });
     vi.stubGlobal("fetch", fetchMock as typeof fetch);
     vi.stubGlobal("fetch", fetchMock as typeof fetch);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
-    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) {
+    chatCompletionMock.mockImplementation(async (client: any, model: string) => {
+      if (client.provider === "anthropic" && client.baseUrl === "https://api.minimaxi.com/anthropic" && model === "MiniMax-M2.7") {
         return {
         return {
           content: "pong",
           content: "pong",
           usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
           usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
@@ -948,37 +918,6 @@ describe("createStudioServer daemon lifecycle", () => {
         baseUrl: "https://api.minimaxi.com/anthropic",
         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 () => {
   it("uses the preset models baseUrl when listing Bailian models", async () => {

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

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

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

@@ -20,15 +20,6 @@ interface DetectedConfig {
   readonly modelsSource?: "api" | "fallback";
   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
 // Unified page state
 type ConnectionStatus =
 type ConnectionStatus =
   | { state: "idle" }               // No action taken yet
   | { state: "idle" }               // No action taken yet
@@ -38,115 +29,6 @@ type ConnectionStatus =
   | { state: "saving" }             // Save in progress
   | { state: "saving" }             // Save in progress
   | { state: "saved" }              // Save succeeded
   | { 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() {
 function DetailSkeleton() {
   return (
   return (
     <div className="max-w-xl mx-auto space-y-6 animate-pulse">
     <div className="max-w-xl mx-auto space-y-6 animate-pulse">
@@ -187,9 +69,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
 
 
   // -- Unified connection status --
   // -- Unified connection status --
   const [status, setStatus] = useState<ConnectionStatus>({ state: "idle" });
   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(() => {
   useEffect(() => {
     let cancelled = false;
     let cancelled = false;
@@ -217,32 +96,24 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     return () => { cancelled = true; };
     return () => { cancelled = true; };
   }, [isCustom, persistedCustomName, serviceId]);
   }, [isCustom, persistedCustomName, serviceId]);
 
 
-  // Validate existing key on mount via /test, not just /models
+  // Load models on mount if connected
   useEffect(() => {
   useEffect(() => {
-    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]);
+    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);
 
 
   useEffect(() => {
   useEffect(() => {
     let cancelled = false;
     let cancelled = false;
@@ -278,14 +149,26 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     setApiKey(trimmedKey);
     setApiKey(trimmedKey);
     setStatus({ state: "testing" });
     setStatus({ state: "testing" });
     try {
     try {
-      const result = await probeServiceForDetail(fetchJson, {
-        effectiveServiceId,
-        apiKey: trimmedKey,
-        apiFormat,
-        stream,
-        isCustom,
-        baseUrl,
-      });
+      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() } : {}),
+          }),
+        },
+      );
       if (result.ok) {
       if (result.ok) {
         const models = result.models ?? [];
         const models = result.models ?? [];
         if (result.detected?.apiFormat) setApiFormat(result.detected.apiFormat);
         if (result.detected?.apiFormat) setApiFormat(result.detected.apiFormat);
@@ -313,28 +196,63 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     }
     }
     setStatus({ state: "saving" });
     setStatus({ state: "saving" });
     try {
     try {
-      const { probe: validatedResult } = await saveServiceConfigWithValidation(fetchJson, {
-        effectiveServiceId,
-        serviceId,
-        isCustom,
-        resolvedCustomName,
-        baseUrl,
-        apiKey: trimmedKey,
-        apiFormat,
-        stream,
-        temperature,
-        maxTokens,
-        defaultModel: detectedModel || undefined,
+      // Save key (empty = delete)
+      await fetchJson(`/services/${encodeURIComponent(effectiveServiceId)}/secret`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ apiKey: trimmedKey }),
       });
       });
-
-      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 });
+      // 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 : "连接失败" });
+        }
       } else {
       } else {
         clearStoreModels(effectiveServiceId);
         clearStoreModels(effectiveServiceId);
         setStatus({ state: "saved" });
         setStatus({ state: "saved" });

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

@@ -1,84 +0,0 @@
-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",
-    }));
-  });
-});