Files
rootandClaude Opus 5.5 0765264a47 feat(pi): fetch and refresh upstream model lists, searchable model picker
- 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]>
2026-09-29 01:26:07 +08:00

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);
});
});