Jelajahi Sumber

feat(core): add verifyService two-step validation (probe + chat hello)

fanghanjun 6 bulan lalu
induk
melakukan
0b2cc25533

+ 61 - 0
packages/core/src/__tests__/verify-service.test.ts

@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { verifyService } from "../llm/providers/verify.js";
+
+describe("verifyService (B9)", () => {
+  const originalFetch = global.fetch;
+
+  afterEach(() => {
+    global.fetch = originalFetch;
+    vi.restoreAllMocks();
+  });
+
+  it("probe 成功 → probe.ok=true + chat 字段非 null(chat 步骤被执行)", async () => {
+    global.fetch = vi.fn()
+      // probe /models 成功
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({ data: [{ id: "gpt-4o" }, { id: "gpt-4o-mini" }] }),
+      } as any)
+      // 后续 chat 请求失败(测试环境没真实 openai SDK 支持),但这 OK —— 只要 chat 被尝试了就行
+      .mockRejectedValue(new Error("test: no real chat backend"));
+
+    const result = await verifyService("openai", "sk-test");
+    expect(result.probe.ok).toBe(true);
+    expect(result.probe.models).toBe(2);
+    // chat 字段不为 null 说明 checkModel 存在 + chat 步骤被执行(ok=true/false 都可以)
+    expect(result.chat).not.toBeNull();
+    expect(typeof result.chat?.ok).toBe("boolean");
+    expect(result.chat?.latencyMs).toBeGreaterThanOrEqual(0);
+  });
+
+  it("probe 401 → probe.ok=false, error 带 401", async () => {
+    global.fetch = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 401,
+      statusText: "Unauthorized",
+    } as any) as typeof fetch;
+
+    const result = await verifyService("openai", "wrong-key");
+    expect(result.probe.ok).toBe(false);
+    expect(result.probe.error).toContain("401");
+  });
+
+  it("probe 网络挂 → probe.ok=false, error 是 fetch 错误", async () => {
+    global.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as typeof fetch;
+    const result = await verifyService("openai", "sk-test");
+    expect(result.probe.ok).toBe(false);
+    expect(result.probe.error).toContain("ECONNREFUSED");
+  });
+
+  it("provider 没 checkModel(custom baseUrl 空)→ chat 字段返回 null,不发 chat 请求", async () => {
+    // custom 没 checkModel,verifyService 跳过 chat step
+    const result = await verifyService("custom", "sk-x");
+    expect(result.chat).toBeNull();
+  });
+
+  it("未知 service → probe 报 '无 baseUrl'", async () => {
+    const result = await verifyService("nonexistent-xyz", "sk-test");
+    expect(result.probe.ok).toBe(false);
+    expect(result.probe.error).toContain("baseUrl");
+  });
+});

+ 95 - 0
packages/core/src/llm/providers/verify.ts

@@ -0,0 +1,95 @@
+import { getProvider } from "./index.js";
+import { resolveServiceModelsBaseUrl } from "../service-presets.js";
+
+export interface VerifyResult {
+  readonly probe: {
+    readonly ok: boolean;
+    readonly models: number;
+    readonly error?: string;
+  };
+  readonly chat: {
+    readonly ok: boolean;
+    readonly latencyMs?: number;
+    readonly error?: string;
+  } | null;
+}
+
+/**
+ * Step 1: 用 apikey 调 /models endpoint 探测。
+ * 返回 models 条数 + ok 标志;失败原因落到 error 字段。
+ */
+async function probe(
+  service: string,
+  apiKey: string,
+  baseUrl?: string,
+): Promise<VerifyResult["probe"]> {
+  const provider = getProvider(service);
+  const probeBaseUrl = baseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
+  if (!probeBaseUrl) {
+    return { ok: false, models: 0, error: "无 baseUrl 可探测(custom / newapi / higress 需要用户填)" };
+  }
+  try {
+    const url = probeBaseUrl.replace(/\/$/, "") + "/models";
+    const res = await fetch(url, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(10_000),
+    });
+    if (!res.ok) {
+      return { ok: false, models: 0, error: `HTTP ${res.status} ${res.statusText}` };
+    }
+    const json = (await res.json()) as { data?: Array<{ id: string }> };
+    const count = json.data?.length ?? 0;
+    return { ok: count > 0, models: count };
+  } catch (error) {
+    return {
+      ok: false,
+      models: 0,
+      error: error instanceof Error ? error.message : String(error),
+    };
+  }
+}
+
+/**
+ * verifyService: 两步验证。
+ * Step 1 probe /models。
+ * Step 2 chat hello 到 checkModel(命中 provider.checkModel 才做;custom / newapi / higress 没有 checkModel,chat 字段返回 null)。
+ */
+export async function verifyService(
+  service: string,
+  apiKey: string,
+  opts?: { checkModel?: string; baseUrl?: string },
+): Promise<VerifyResult> {
+  const probeResult = await probe(service, apiKey, opts?.baseUrl);
+
+  const provider = getProvider(service);
+  const checkModel = opts?.checkModel ?? provider?.checkModel;
+  if (!checkModel) {
+    return { probe: probeResult, chat: null };
+  }
+
+  const start = Date.now();
+  try {
+    const { createLLMClient, chatCompletion } = await import("../provider.js");
+    const { LLMConfigSchema } = await import("../../models/project.js");
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: provider?.api === "anthropic-messages" ? "anthropic" : "openai",
+      service,
+      model: checkModel,
+      apiKey,
+      baseUrl: opts?.baseUrl ?? provider?.baseUrl ?? "",
+      configSource: "studio",
+      stream: false,
+    }));
+    await chatCompletion(client, checkModel, [{ role: "user", content: "hi" }], { maxTokens: 10 });
+    return { probe: probeResult, chat: { ok: true, latencyMs: Date.now() - start } };
+  } catch (error) {
+    return {
+      probe: probeResult,
+      chat: {
+        ok: false,
+        latencyMs: Date.now() - start,
+        error: error instanceof Error ? error.message : String(error),
+      },
+    };
+  }
+}