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

fix(core,studio): validate service keys before save and on load

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

+ 122 - 1
packages/core/src/agent/agent-tools.ts

@@ -188,7 +188,128 @@ export function createSubAgentTool(
 }
 
 // ---------------------------------------------------------------------------
-// 2. Read Tool
+// 2. Deterministic writing tools
+// ---------------------------------------------------------------------------
+
+const ReviseChapterParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
+  chapterNumber: Type.Number({ description: "Chapter number to revise." }),
+  mode: Type.Optional(Type.Union([
+    Type.Literal("spot-fix"),
+    Type.Literal("polish"),
+    Type.Literal("rewrite"),
+    Type.Literal("rework"),
+    Type.Literal("anti-detect"),
+  ])),
+});
+
+export function createReviseChapterTool(
+  pipeline: PipelineRunner,
+  activeBookId: string | null,
+): AgentTool<typeof ReviseChapterParams> {
+  return {
+    name: "revise_chapter",
+    description: "Revise a specific chapter through the deterministic revision pipeline.",
+    label: "Revise Chapter",
+    parameters: ReviseChapterParams,
+    async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
+      const bookId = resolveToolBookId("revise_chapter", params.bookId, activeBookId);
+      const mode = params.mode ?? DEFAULT_REVISE_MODE;
+      await pipeline.reviseDraft(bookId, params.chapterNumber, mode);
+      return textResult(`Revision (${mode}) complete for "${bookId}" chapter ${params.chapterNumber}.`);
+    },
+  };
+}
+
+const WriteTruthFileParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
+  fileName: Type.String({ description: "Truth file name under story/, e.g. story_bible.md or current_focus.md." }),
+  content: Type.String({ description: "Full replacement content for the truth file." }),
+});
+
+export function createWriteTruthFileTool(
+  pipeline: PipelineRunner,
+  projectRoot: string,
+  activeBookId: string | null,
+): AgentTool<typeof WriteTruthFileParams> {
+  const tools = createDeterministicInteractionTools(pipeline, projectRoot);
+  return {
+    name: "write_truth_file",
+    description: "Replace a truth/control file under story/ using deterministic project tools.",
+    label: "Write Truth File",
+    parameters: WriteTruthFileParams,
+    async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
+      const bookId = resolveToolBookId("write_truth_file", params.bookId, activeBookId);
+      await tools.writeTruthFile(bookId, params.fileName, params.content);
+      return textResult(`Updated "${params.fileName}" for "${bookId}".`);
+    },
+  };
+}
+
+const RenameEntityParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
+  oldValue: Type.String({ description: "Current entity name." }),
+  newValue: Type.String({ description: "New entity name." }),
+});
+
+export function createRenameEntityTool(
+  pipeline: PipelineRunner,
+  projectRoot: string,
+  activeBookId: string | null,
+): AgentTool<typeof RenameEntityParams> {
+  const tools = createDeterministicInteractionTools(pipeline, projectRoot);
+  return {
+    name: "rename_entity",
+    description: "Rename an entity across truth files and chapters using deterministic edit control.",
+    label: "Rename Entity",
+    parameters: RenameEntityParams,
+    async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
+      const bookId = resolveToolBookId("rename_entity", params.bookId, activeBookId);
+      const result = await tools.renameEntity(bookId, params.oldValue, params.newValue) as {
+        readonly __interaction?: { readonly responseText?: string };
+      };
+      const summary = result.__interaction?.responseText ?? `Renamed "${params.oldValue}" to "${params.newValue}" in "${bookId}".`;
+      return textResult(summary);
+    },
+  };
+}
+
+const PatchChapterTextParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
+  chapterNumber: Type.Number({ description: "Chapter number to patch." }),
+  targetText: Type.String({ description: "Exact text to replace." }),
+  replacementText: Type.String({ description: "Replacement text." }),
+});
+
+export function createPatchChapterTextTool(
+  pipeline: PipelineRunner,
+  projectRoot: string,
+  activeBookId: string | null,
+): AgentTool<typeof PatchChapterTextParams> {
+  const tools = createDeterministicInteractionTools(pipeline, projectRoot);
+  return {
+    name: "patch_chapter_text",
+    description: "Apply a deterministic local text patch to a chapter and mark it for review.",
+    label: "Patch Chapter",
+    parameters: PatchChapterTextParams,
+    async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
+      const bookId = resolveToolBookId("patch_chapter_text", params.bookId, activeBookId);
+      const result = await tools.patchChapterText(
+        bookId,
+        params.chapterNumber,
+        params.targetText,
+        params.replacementText,
+      ) as {
+        readonly __interaction?: { readonly responseText?: string };
+      };
+      const summary = result.__interaction?.responseText ?? `Patched chapter ${params.chapterNumber} for "${bookId}".`;
+      return textResult(summary);
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 3. Read Tool
 // ---------------------------------------------------------------------------
 
 const ReadParams = Type.Object({

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

@@ -867,6 +867,36 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("short-circuits service probe on 401/403 from /models", 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-invalid",
+        apiFormat: "responses",
+        stream: false,
+      }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: expect.stringContaining("401"),
+    });
+    expect(chatCompletionMock).not.toHaveBeenCalled();
+  });
+
   it("uses the MiniMax preset provider family during service probe", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,

+ 15 - 4
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,6 +434,13 @@ 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: modelsResponse.error ?? "API Key 无效或无权访问模型列表。",
+    };
+  }
   const discoveredModels = modelsResponse.models;
   const modelCandidates = buildModelCandidates({
     preferredModel: args.preferredModel,
@@ -458,14 +469,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: "ping" }], { maxTokens: 2048 });
         const models = discoveredModels.length > 0
           ? discoveredModels
           : [{ id: model, name: model }];

+ 71 - 120
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -3,32 +3,19 @@ import { fetchJson } from "../hooks/use-api";
 import { useServiceStore } from "../store/service";
 import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
 import { ServiceConfigSourceCard } from "../components/ServiceConfigSourceCard";
+import {
+  probeServiceForDetail,
+  rehydrateServiceConnectionStatus,
+  saveServiceConfigWithValidation,
+  type ServiceDetailConnectionStatus as ConnectionStatus,
+  type ServiceDetailDetectedConfig as DetectedConfig,
+  type ServiceDetailModelInfo as ModelInfo,
+} from "./service-detail-state";
 
 interface Nav {
   toServices: () => void;
 }
 
-interface ModelInfo {
-  readonly id: string;
-  readonly name?: string;
-}
-
-interface DetectedConfig {
-  readonly apiFormat?: "chat" | "responses";
-  readonly stream?: boolean;
-  readonly baseUrl?: string;
-  readonly modelsSource?: "api" | "fallback";
-}
-
-// Unified page state
-type ConnectionStatus =
-  | { state: "idle" }               // No action taken yet
-  | { state: "testing" }            // Test in progress
-  | { state: "connected"; models: ModelInfo[] }  // Test succeeded
-  | { state: "error"; message: string }          // Test failed
-  | { state: "saving" }             // Save in progress
-  | { state: "saved" }              // Save succeeded
-
 function DetailSkeleton() {
   return (
     <div className="max-w-xl mx-auto space-y-6 animate-pulse">
@@ -96,37 +83,50 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     return () => { cancelled = true; };
   }, [isCustom, persistedCustomName, serviceId]);
 
-  // Load models on mount if connected
-  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);
 
   useEffect(() => {
     let cancelled = false;
-    void fetchJson<{ apiKey?: string }>(`/services/${encodeURIComponent(effectiveServiceId)}/secret`)
-      .then((data) => {
+    if (svc?.connected) {
+      setStatus({ state: "testing" });
+    }
+    void rehydrateServiceConnectionStatus({
+      effectiveServiceId,
+      shouldVerify: Boolean(svc?.connected),
+      isCustom,
+      baseUrl,
+      apiFormat,
+      stream,
+    })
+      .then((result) => {
         if (cancelled) return;
-        if (typeof data.apiKey === "string") {
-          setApiKey(data.apiKey);
+        setApiKey(result.apiKey);
+        setDetectedModel(result.detectedModel);
+        setDetectedConfig(result.detectedConfig);
+        setStatus(result.status);
+        if (result.status.state === "connected") {
+          setStoreModels(effectiveServiceId, result.status.models);
+        } else {
+          clearStoreModels(effectiveServiceId);
         }
       })
-      .catch(() => {});
+      .catch(() => {
+        if (cancelled) return;
+        setStatus({ state: "idle" });
+      });
     return () => { cancelled = true; };
-  }, [effectiveServiceId]);
+  }, [
+    apiFormat,
+    baseUrl,
+    clearStoreModels,
+    effectiveServiceId,
+    isCustom,
+    setStoreModels,
+    stream,
+    svc?.connected,
+  ]);
 
   if (loading) return <DetailSkeleton />;
 
@@ -149,26 +149,12 @@ 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(effectiveServiceId, {
+        apiKey: trimmedKey,
+        apiFormat,
+        stream,
+        ...(isCustom ? { baseUrl: baseUrl.trim() } : {}),
+      });
       if (result.ok) {
         const models = result.models ?? [];
         if (result.detected?.apiFormat) setApiFormat(result.detected.apiFormat);
@@ -196,68 +182,33 @@ 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 }),
-      });
-      // 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() } : {}),
-            },
-          ],
-        }),
+      const result = await saveServiceConfigWithValidation({
+        effectiveServiceId,
+        serviceId,
+        isCustom,
+        resolvedCustomName,
+        apiKey: trimmedKey,
+        baseUrl,
+        apiFormat,
+        stream,
+        temperature,
+        maxTokens,
+        detectedModel,
       });
-      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 (result.status.state === "connected") {
+        if (result.detectedConfig?.apiFormat) setApiFormat(result.detectedConfig.apiFormat);
+        if (typeof result.detectedConfig?.stream === "boolean") setStream(result.detectedConfig.stream);
+        if (isCustom && result.detectedConfig?.baseUrl) setBaseUrl(result.detectedConfig.baseUrl);
+        setDetectedModel(result.detectedModel);
+        setDetectedConfig(result.detectedConfig);
+        setStoreModels(effectiveServiceId, result.status.models);
+        setStatus(result.status);
       } else {
         clearStoreModels(effectiveServiceId);
-        setStatus({ state: "saved" });
+        setDetectedModel("");
+        setDetectedConfig(null);
+        setStatus(result.status);
       }
-
       await refreshServices();
       nav.toServices();
     } catch (e) {

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

@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+  rehydrateServiceConnectionStatus,
+  saveServiceConfigWithValidation,
+} from "./service-detail-state";
+
+describe("rehydrateServiceConnectionStatus", () => {
+  it("verifies the saved key via /test on page load instead of /models", async () => {
+    const fetchJsonImpl = vi.fn(async (path: string) => {
+      if (path === "/services/openai/secret") {
+        return { apiKey: "sk-live" };
+      }
+      if (path === "/services/openai/test") {
+        return {
+          ok: true,
+          models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
+          selectedModel: "gpt-5.4",
+          detected: { apiFormat: "responses", stream: false, modelsSource: "api" },
+        };
+      }
+      throw new Error(`unexpected path: ${path}`);
+    });
+
+    const result = await rehydrateServiceConnectionStatus({
+      effectiveServiceId: "openai",
+      shouldVerify: true,
+      isCustom: false,
+      baseUrl: "",
+      apiFormat: "chat",
+      stream: true,
+      fetchJsonImpl: fetchJsonImpl as never,
+    });
+
+    expect(fetchJsonImpl).toHaveBeenCalledTimes(2);
+    expect(fetchJsonImpl).toHaveBeenNthCalledWith(1, "/services/openai/secret");
+    expect(fetchJsonImpl).toHaveBeenNthCalledWith(
+      2,
+      "/services/openai/test",
+      expect.objectContaining({ method: "POST" }),
+    );
+    expect(result).toMatchObject({
+      apiKey: "sk-live",
+      detectedModel: "gpt-5.4",
+      detectedConfig: { apiFormat: "responses", stream: false, modelsSource: "api" },
+      status: {
+        state: "connected",
+        models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
+      },
+    });
+  });
+});
+
+describe("saveServiceConfigWithValidation", () => {
+  it("validates the key before persisting secrets/config", async () => {
+    const calls: string[] = [];
+    const fetchJsonImpl = vi.fn(async (path: string) => {
+      calls.push(path);
+      if (path === "/services/openai/test") {
+        return {
+          ok: true,
+          models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
+          selectedModel: "gpt-5.4",
+          detected: { apiFormat: "responses", stream: false },
+        };
+      }
+      if (path === "/services/openai/secret") return { ok: true };
+      if (path === "/services/config") return { ok: true };
+      throw new Error(`unexpected path: ${path}`);
+    });
+
+    const result = await saveServiceConfigWithValidation({
+      effectiveServiceId: "openai",
+      serviceId: "openai",
+      isCustom: false,
+      resolvedCustomName: "",
+      apiKey: "sk-live",
+      baseUrl: "",
+      apiFormat: "chat",
+      stream: true,
+      temperature: "0.7",
+      maxTokens: "4096",
+      detectedModel: "",
+      fetchJsonImpl: fetchJsonImpl as never,
+    });
+
+    expect(calls).toEqual([
+      "/services/openai/test",
+      "/services/openai/secret",
+      "/services/config",
+    ]);
+    expect(result).toMatchObject({
+      detectedModel: "gpt-5.4",
+      detectedConfig: { apiFormat: "responses", stream: false },
+      status: {
+        state: "connected",
+        models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
+      },
+    });
+  });
+
+  it("does not persist secrets/config when validation fails", async () => {
+    const calls: string[] = [];
+    const fetchJsonImpl = vi.fn(async (path: string) => {
+      calls.push(path);
+      if (path === "/services/openai/test") {
+        throw new Error("401 Unauthorized");
+      }
+      throw new Error(`unexpected path: ${path}`);
+    });
+
+    await expect(saveServiceConfigWithValidation({
+      effectiveServiceId: "openai",
+      serviceId: "openai",
+      isCustom: false,
+      resolvedCustomName: "",
+      apiKey: "sk-bad",
+      baseUrl: "",
+      apiFormat: "chat",
+      stream: true,
+      temperature: "0.7",
+      maxTokens: "4096",
+      detectedModel: "",
+      fetchJsonImpl: fetchJsonImpl as never,
+    })).rejects.toThrow("401 Unauthorized");
+
+    expect(calls).toEqual(["/services/openai/test"]);
+  });
+});

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

@@ -0,0 +1,208 @@
+import { fetchJson } from "../hooks/use-api";
+
+export interface ServiceDetailModelInfo {
+  readonly id: string;
+  readonly name?: string;
+}
+
+export interface ServiceDetailDetectedConfig {
+  readonly apiFormat?: "chat" | "responses";
+  readonly stream?: boolean;
+  readonly baseUrl?: string;
+  readonly modelsSource?: "api" | "fallback";
+}
+
+export type ServiceDetailConnectionStatus =
+  | { state: "idle" }
+  | { state: "testing" }
+  | { state: "connected"; models: ServiceDetailModelInfo[] }
+  | { state: "error"; message: string }
+  | { state: "saving" }
+  | { state: "saved" };
+
+type JsonFetcher = typeof fetchJson;
+
+interface ServiceProbeResponse {
+  readonly ok: boolean;
+  readonly models?: ServiceDetailModelInfo[];
+  readonly selectedModel?: string;
+  readonly detected?: ServiceDetailDetectedConfig;
+  readonly error?: string;
+}
+
+function toErrorMessage(error: unknown, fallback: string): string {
+  return error instanceof Error ? error.message : fallback;
+}
+
+export async function probeServiceForDetail(
+  serviceId: string,
+  body: {
+    readonly apiKey: string;
+    readonly apiFormat: "chat" | "responses";
+    readonly stream: boolean;
+    readonly baseUrl?: string;
+  },
+  deps?: { readonly fetchJsonImpl?: JsonFetcher },
+): Promise<ServiceProbeResponse> {
+  const fetchJsonImpl = deps?.fetchJsonImpl ?? fetchJson;
+  return await fetchJsonImpl<ServiceProbeResponse>(
+    `/services/${encodeURIComponent(serviceId)}/test`,
+    {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(body),
+    },
+  );
+}
+
+export async function rehydrateServiceConnectionStatus(args: {
+  readonly effectiveServiceId: string;
+  readonly shouldVerify: boolean;
+  readonly isCustom: boolean;
+  readonly baseUrl: string;
+  readonly apiFormat: "chat" | "responses";
+  readonly stream: boolean;
+  readonly fetchJsonImpl?: JsonFetcher;
+}): Promise<{
+  readonly apiKey: string;
+  readonly status: ServiceDetailConnectionStatus;
+  readonly detectedModel: string;
+  readonly detectedConfig: ServiceDetailDetectedConfig | null;
+}> {
+  const fetchJsonImpl = args.fetchJsonImpl ?? fetchJson;
+  const secret = await fetchJsonImpl<{ apiKey?: string }>(
+    `/services/${encodeURIComponent(args.effectiveServiceId)}/secret`,
+  );
+  const apiKey = String(secret.apiKey ?? "");
+
+  if (!args.shouldVerify || apiKey.trim().length === 0) {
+    return {
+      apiKey,
+      status: { state: "idle" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+
+  if (args.isCustom && args.baseUrl.trim().length === 0) {
+    return {
+      apiKey,
+      status: { state: "idle" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+
+  try {
+    const result = await probeServiceForDetail(
+      args.effectiveServiceId,
+      {
+        apiKey: apiKey.trim(),
+        apiFormat: args.apiFormat,
+        stream: args.stream,
+        ...(args.isCustom ? { baseUrl: args.baseUrl.trim() } : {}),
+      },
+      { fetchJsonImpl },
+    );
+    if (!result.ok) {
+      return {
+        apiKey,
+        status: { state: "error", message: result.error ?? "连接失败" },
+        detectedModel: "",
+        detectedConfig: null,
+      };
+    }
+    return {
+      apiKey,
+      status: { state: "connected", models: result.models ?? [] },
+      detectedModel: result.selectedModel ?? "",
+      detectedConfig: result.detected ?? null,
+    };
+  } catch (error) {
+    return {
+      apiKey,
+      status: { state: "error", message: toErrorMessage(error, "连接失败") },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+}
+
+export async function saveServiceConfigWithValidation(args: {
+  readonly effectiveServiceId: string;
+  readonly serviceId: string;
+  readonly isCustom: boolean;
+  readonly resolvedCustomName: string;
+  readonly apiKey: string;
+  readonly baseUrl: string;
+  readonly apiFormat: "chat" | "responses";
+  readonly stream: boolean;
+  readonly temperature: string;
+  readonly maxTokens: string;
+  readonly detectedModel: string;
+  readonly fetchJsonImpl?: JsonFetcher;
+}): Promise<{
+  readonly status: ServiceDetailConnectionStatus;
+  readonly detectedModel: string;
+  readonly detectedConfig: ServiceDetailDetectedConfig | null;
+}> {
+  const fetchJsonImpl = args.fetchJsonImpl ?? fetchJson;
+  const trimmedKey = args.apiKey.trim();
+  const trimmedBaseUrl = args.baseUrl.trim();
+
+  let probeResult: ServiceProbeResponse | null = null;
+  if (trimmedKey) {
+    probeResult = await probeServiceForDetail(
+      args.effectiveServiceId,
+      {
+        apiKey: trimmedKey,
+        apiFormat: args.apiFormat,
+        stream: args.stream,
+        ...(args.isCustom ? { baseUrl: trimmedBaseUrl } : {}),
+      },
+      { fetchJsonImpl },
+    );
+  }
+
+  await fetchJsonImpl(`/services/${encodeURIComponent(args.effectiveServiceId)}/secret`, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ apiKey: trimmedKey }),
+  });
+
+  await fetchJsonImpl("/services/config", {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({
+      service: args.effectiveServiceId,
+      ...(probeResult?.selectedModel ? { defaultModel: probeResult.selectedModel } : args.detectedModel ? { defaultModel: args.detectedModel } : {}),
+      services: [
+        {
+          service: args.isCustom ? "custom" : args.serviceId,
+          temperature: parseFloat(args.temperature),
+          maxTokens: parseInt(args.maxTokens, 10),
+          apiFormat: probeResult?.detected?.apiFormat ?? args.apiFormat,
+          stream: typeof probeResult?.detected?.stream === "boolean" ? probeResult.detected.stream : args.stream,
+          ...(args.isCustom ? {
+            name: args.resolvedCustomName,
+            baseUrl: probeResult?.detected?.baseUrl ?? trimmedBaseUrl,
+          } : {}),
+        },
+      ],
+    }),
+  });
+
+  if (!probeResult) {
+    return {
+      status: { state: "saved" },
+      detectedModel: "",
+      detectedConfig: null,
+    };
+  }
+
+  return {
+    status: { state: "connected", models: probeResult.models ?? [] },
+    detectedModel: probeResult.selectedModel ?? "",
+    detectedConfig: probeResult.detected ?? null,
+  };
+}