import { createServer, type Server } from "node:http"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts"; import { createPiModule } from "../../src/modules/pi/index.ts"; import { mergeModels } from "../../src/modules/pi/models.ts"; import { Browser, bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; import { DEV_KEY, json } from "../helpers.ts"; // PI-2 获取模型列表:mock 一个 OpenAI 兼容 /models(Bearer 校验),覆盖成功存库、合并去重、401 与网络失败回退静态表、 // 不支持列表的 provider、新建前试拉(不落库)、改 Base URL 后作废已存列表。 const hasPg = await pgAvailable(); const GOOD = "sk-good-models-key-1234"; describe("mergeModels:远程优先,缺的字段用静态补", () => { it("同 id 合并,远程在前,静态独有的标 static", () => { const base = { displayName: null, contextWindow: null, supportsVision: null, supportsTools: null, pricing: null }; const out = mergeModels( [ { ...base, id: "a", supportsTools: true }, { ...base, id: "b", contextWindow: 9000 }, ], [ { ...base, id: "b", displayName: "B 静态", contextWindow: 1000, supportsVision: true }, { ...base, id: "c" }, ], ); expect(out.map((m) => [m.id, m.origin])).toEqual([ ["a", "remote"], ["b", "remote"], ["c", "static"], ]); expect(out[1]).toMatchObject({ displayName: "B 静态", contextWindow: 9000, supportsVision: true }); }); }); describe.skipIf(!hasPg)("获取模型列表(真实 PG + mock /models)", () => { let h: Harness; let admin: Browser; let mock: Server; let mockUrl = ""; let deadUrl = ""; const hits: string[] = []; beforeAll(async () => { h = await createHarness({ extraModules: [createPiModule({ refreshIntervalMs: 0 })] }); const token = await ensureSetupToken(h.ctx); admin = new Browser(h.app); expect( ( await admin.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: "correct horse battery", }) ).status, ).toBe(200); mock = createServer((req, res) => { hits.push(`${req.method} ${req.url} ${req.headers.authorization ?? ""}`); if (req.url !== "/v1/models") { res.writeHead(404).end(); return; } if (req.headers.authorization !== `Bearer ${GOOD}`) { res.writeHead(401, { "content-type": "application/json" }).end('{"error":"invalid api key"}'); return; } res.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ object: "list", data: [ { id: "vision-pro", name: "Vision Pro", context_length: 200000, architecture: { input_modalities: ["text", "image"] }, supported_parameters: ["tools", "temperature"], pricing: { prompt: "0.000003", completion: "0.000015" }, }, { id: "text-mini", object: "model", owned_by: "me" }, { id: "text-embedding-3-small", object: "model" }, { id: "text-mini", object: "model" }, ], }), ); }); await new Promise((r) => mock.listen(0, "127.0.0.1", r)); mockUrl = `http://127.0.0.1:${(mock.address() as { port: number }).port}/v1`; // 取一个空闲端口后立刻关掉,当"连不上"的地址 const tmp = createServer(); await new Promise((r) => tmp.listen(0, "127.0.0.1", r)); deadUrl = `http://127.0.0.1:${(tmp.address() as { port: number }).port}/v1`; await new Promise((r) => tmp.close(r)); }, 120_000); afterAll(async () => { await new Promise((r) => mock?.close(r)); await h?.close(); }); const req = async (method: string, path: string, body?: unknown) => { const res = await admin.req(method, path, body); return { status: res.status, body: await json(res) }; }; const create = async (body: Record) => { const r = await req("POST", "/pi/upstreams", body); expect(r.status, JSON.stringify(r.body)).toBe(200); return r.body.data as { id: string }; }; const custom = (name: string, baseUrl: string, apiKey: string) => create({ name, provider: "openai-compatible", authKind: "api_key", apiKey, baseUrl, defaultModel: "my-default" }); it("成功:归一化(视觉/工具/价格/上下文)、去掉嵌入模型、去重、存库;读取返回远程 + 静态合并", async () => { const u = await custom("兼容端点 A", mockUrl, GOOD); const before = (await req("GET", `/pi/upstreams/${u.id}/models`)).body.data; expect(before).toMatchObject({ source: "static", fetchedAt: null, remoteSupported: true, error: null }); expect(before.models.map((m: { id: string }) => m.id)).toEqual(["my-default"]); const r = await req("POST", `/pi/upstreams/${u.id}/models/refresh`); expect(r.status).toBe(200); const d = r.body.data; expect(d).toMatchObject({ source: "remote", error: null, errorKind: null }); expect(d.fetchedAt).toBeTruthy(); expect(d.models.map((m: { id: string; origin: string }) => [m.id, m.origin])).toEqual([ ["vision-pro", "remote"], ["text-mini", "remote"], ["my-default", "static"], ]); expect(d.models[0]).toMatchObject({ displayName: "Vision Pro", contextWindow: 200000, supportsVision: true, supportsTools: true, pricing: { input: 3, output: 15 }, }); expect(hits.at(-1)).toBe(`GET /v1/models Bearer ${GOOD}`); const row = await h.db .selectFrom("pi_upstream") .select(["models", "models_fetched_at"]) .where("id", "=", u.id) .executeTakeFirstOrThrow(); expect((row.models as unknown[]).length).toBe(2); expect(row.models_fetched_at).toBeTruthy(); const again = (await req("GET", `/pi/upstreams/${u.id}/models`)).body.data; expect(again).toMatchObject({ source: "remote", fetchedAt: d.fetchedAt }); expect(again.models).toHaveLength(3); // Bearer admin key 只能读,获取(会用凭证出站)要控制台会话 expect((await bearer(h.app, DEV_KEY)("POST", `/pi/upstreams/${u.id}/models/refresh`)).status).toBe(403); expect((await bearer(h.app, DEV_KEY)("GET", `/pi/upstreams/${u.id}/models`)).status).toBe(200); // 改 Base URL:已存列表作废,回到静态表 await req("PATCH", `/pi/upstreams/${u.id}`, { baseUrl: `${mockUrl}/` }); expect((await req("GET", `/pi/upstreams/${u.id}/models`)).body.data.source).toBe("static"); }); it("401:回退静态表,errorKind=auth,消息可读且不含 key", async () => { const u = await custom("兼容端点 坏 key", mockUrl, "sk-bad-models-key-9999"); const d = (await req("POST", `/pi/upstreams/${u.id}/models/refresh`)).body.data; expect(d).toMatchObject({ source: "static", fetchedAt: null, errorKind: "auth" }); expect(d.error).toMatch(/拒绝了凭证.*401/); expect(d.error).not.toContain("sk-bad"); expect(d.models.map((m: { id: string }) => m.id)).toEqual(["my-default"]); }); it("网络失败:回退静态表,errorKind=network", async () => { const u = await custom("兼容端点 连不上", deadUrl, GOOD); const d = (await req("POST", `/pi/upstreams/${u.id}/models/refresh`)).body.data; expect(d).toMatchObject({ source: "static", errorKind: "network" }); expect(d.error).toMatch(/连不上 127\.0\.0\.1/); }); it("没有列表接口的 provider(订阅登录 / fake):unsupported,返回静态表", async () => { const f = await create({ name: "fake 列表", provider: "fake", authKind: "none" }); const d = (await req("POST", `/pi/upstreams/${f.id}/models/refresh`)).body.data; expect(d).toMatchObject({ source: "static", errorKind: "unsupported", remoteSupported: false }); expect(d.models.map((m: { id: string }) => m.id)).toEqual(["fake-model"]); const codex = await create({ name: "codex 列表", provider: "openai-codex", authKind: "oauth" }); const c = (await req("POST", `/pi/upstreams/${codex.id}/models/refresh`)).body.data; expect(c).toMatchObject({ source: "static", errorKind: "unsupported" }); expect(c.models.length).toBeGreaterThan(3); }); it("新建前试拉:用填的 key 拉一次,不落库;401 回退", async () => { const ok = ( await req("POST", "/pi/models/probe", { provider: "openai-compatible", authKind: "api_key", apiKey: GOOD, baseUrl: mockUrl, }) ).body.data; expect(ok).toMatchObject({ source: "remote", error: null }); expect(ok.models.map((m: { id: string }) => m.id)).toEqual(["vision-pro", "text-mini"]); const bad = ( await req("POST", "/pi/models/probe", { provider: "openai-compatible", authKind: "api_key", apiKey: "sk-nope", baseUrl: mockUrl, }) ).body.data; expect(bad).toMatchObject({ source: "static", errorKind: "auth", models: [] }); // OpenAI 没填 key:直接提示,不出站 const n = hits.length; const noKey = (await req("POST", "/pi/models/probe", { provider: "openai", authKind: "api_key" })).body.data; expect(noKey).toMatchObject({ source: "static", errorKind: "auth" }); expect(noKey.models.length).toBeGreaterThan(10); expect(hits.length).toBe(n); expect(h.lines.join("\n")).not.toContain(GOOD); }); });