- POST /pi/upstreams/{id}/models/refresh calls the provider's model list
endpoint (OpenAI-shaped /models, Anthropic /v1/models, Google
/v1beta/models, OpenRouter /api/v1/models; Radius via pi-ai refresh),
normalizes {id, displayName, contextWindow, supportsVision,
supportsTools, pricing} and stores it in pi_upstream.models (0044)
- GET .../models returns {source, fetchedAt, models[]}: remote merged
with the static catalog (remote first); failures (401/network/
unsupported) fall back to static with a one-line error
- POST /pi/models/probe for trying credentials before creating
- wizard and edit drawer: fetch button, searchable dropdown with
vision/tool tags, manual id, source line; default upstream needs a model
- tests: mock /models, 401, network failure; smoke step; runbook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
214 lines
9.3 KiB
TypeScript
214 lines
9.3 KiB
TypeScript
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<void>((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<void>((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<string, unknown>) => {
|
|
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);
|
|
});
|
|
});
|