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

refactor(core): 抽出模型探针 helper

fanghanjun 6 месяцев назад
Родитель
Сommit
26398a756a

+ 64 - 0
packages/core/src/__tests__/probe.test.ts

@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { probeModelsFromUpstream } from "../llm/providers/probe.js";
+
+describe("probeModelsFromUpstream", () => {
+  const origFetch = globalThis.fetch;
+
+  beforeEach(() => {
+    globalThis.fetch = vi.fn();
+  });
+
+  afterEach(() => {
+    globalThis.fetch = origFetch;
+  });
+
+  it("正常响应返回 ProbedModel 数组", async () => {
+    (globalThis.fetch as any).mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "gpt-4" }, { id: "gpt-3.5" }] }),
+    });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([
+      { id: "gpt-4", name: "gpt-4", contextWindow: 0 },
+      { id: "gpt-3.5", name: "gpt-3.5", contextWindow: 0 },
+    ]);
+  });
+
+  it("非 2xx 返回空数组", async () => {
+    (globalThis.fetch as any).mockResolvedValue({ ok: false });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([]);
+  });
+
+  it("fetch 抛错返回空数组", async () => {
+    (globalThis.fetch as any).mockRejectedValue(new Error("network down"));
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([]);
+  });
+
+  it("响应 json.data 不是数组返回空数组", async () => {
+    (globalThis.fetch as any).mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: "not-an-array" }),
+    });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([]);
+  });
+
+  it("baseUrl 或 apiKey 空直接返回空数组,不发请求", async () => {
+    const r1 = await probeModelsFromUpstream("", "sk-test");
+    const r2 = await probeModelsFromUpstream("https://x.com", "");
+    expect(r1).toEqual([]);
+    expect(r2).toEqual([]);
+    expect(globalThis.fetch).not.toHaveBeenCalled();
+  });
+
+  it("过滤掉 id 非字符串的 entry", async () => {
+    (globalThis.fetch as any).mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "valid" }, { id: null }, { id: 123 }, {}] }),
+    });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([{ id: "valid", name: "valid", contextWindow: 0 }]);
+  });
+});

+ 33 - 0
packages/core/src/llm/providers/probe.ts

@@ -0,0 +1,33 @@
+/**
+ * 通用 OpenAI 兼容 /models 探针。
+ * 任何失败(网络错、超时、非 JSON、非 2xx)一律返回空数组,不抛异常。
+ */
+
+export interface ProbedModel {
+  readonly id: string;
+  readonly name: string;
+  readonly contextWindow: number;
+}
+
+export async function probeModelsFromUpstream(
+  baseUrl: string,
+  apiKey: string,
+  timeoutMs = 10_000,
+): Promise<ReadonlyArray<ProbedModel>> {
+  if (!baseUrl || !apiKey) return [];
+  try {
+    const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
+    const res = await fetch(modelsUrl, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(timeoutMs),
+    });
+    if (!res.ok) return [];
+    const json = (await res.json()) as { data?: Array<{ id: unknown }> };
+    if (!Array.isArray(json.data)) return [];
+    return json.data
+      .filter((m): m is { id: string } => typeof m.id === "string" && m.id.length > 0)
+      .map((m) => ({ id: m.id, name: m.id, contextWindow: 0 }));
+  } catch {
+    return [];
+  }
+}

+ 7 - 21
packages/core/src/llm/service-presets.ts

@@ -1,4 +1,5 @@
 import { getEndpoint } from "./providers/index.js";
+import { probeModelsFromUpstream } from "./providers/probe.js";
 
 export interface ServicePreset {
   readonly providerFamily: "openai" | "anthropic";
@@ -167,28 +168,13 @@ export async function listModelsForService(
   // 1) 先试 live /models probe
   const probeBaseUrl = liveBaseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
   if (apiKey && probeBaseUrl) {
-    try {
-      const modelsUrl = probeBaseUrl.replace(/\/$/, "") + "/models";
-      const res = await fetch(modelsUrl, {
-        headers: { Authorization: `Bearer ${apiKey}` },
-        signal: AbortSignal.timeout(10_000),
-      });
-      if (res.ok) {
-        const json = (await res.json()) as { data?: Array<{ id: string; owned_by?: string }> };
-        if (json.data) {
-          const { lookupModel } = await import("./providers/lookup.js");
-          for (const m of json.data) {
-            const card = lookupModel(service, m.id);
-            if (card) {
-              byId.set(m.id, toModelInfo(card));
-            } else {
-              byId.set(m.id, { id: m.id, name: m.id, contextWindow: 0 });
-            }
-          }
-        }
+    const probed = await probeModelsFromUpstream(probeBaseUrl, apiKey, 10_000);
+    if (probed.length > 0) {
+      const { lookupModel } = await import("./providers/lookup.js");
+      for (const m of probed) {
+        const card = lookupModel(service, m.id);
+        byId.set(m.id, card ? toModelInfo(card) : { id: m.id, name: m.name, contextWindow: m.contextWindow });
       }
-    } catch {
-      // live 不可用,下面 fallback
     }
   }