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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:26:07 +08:00
1 parent e5abfa6217
commit 0765264a47
16 files changed
+1178 -52

No files matched your search

+471
View File
@@ -0,0 +1,471 @@
import {
classifyUpstreamError,
PiUpstreamClient,
providerInfo,
redactString,
type UpstreamCredential,
type UpstreamCredentialStore,
type UpstreamSpec,
} from "@umt/agent";
import type {
PiListedModel,
PiModelFetchErrorKind,
PiModelInfo,
PiModelsProbeRequest,
PiUpstreamModels,
} from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { clientFor } from "./service.ts";
import { dbCredentialStore, getRow, parseExtra, specOf } from "./store.ts";
// PI-2 上游模型列表:「获取模型列表」按 provider 调模型列表接口(用该上游的凭证;出站走进程的 HTTP(S)_PROXY),
// 归一化后存 pi_upstream.models + models_fetched_at。读取时与随包静态目录合并去重(同 id 远程优先,缺的字段用静态补)。
// pi-ai 0.87.1 只有 Radius 自带 refreshModels,其它 provider 按通用规则:
// OpenAI 兼容 GET {base}/models(Bearer);Anthropic GET /v1/models(x-api-key + anthropic-version);
// Google GET /v1beta/models?key=;OpenRouter GET /api/v1/models;订阅登录、云凭证等没有公开接口的 → 静态表。
type Shape = "openai" | "anthropic" | "google";
interface ListEndpoint {
shape: Shape;
url: URL;
headers: Record<string, string>;
}
type Plan = { kind: "http"; endpoint: ListEndpoint } | { kind: "pi" } | { kind: "unsupported"; reason: string };
class FetchError extends Error {
constructor(
readonly kind: PiModelFetchErrorKind,
message: string,
) {
super(message);
}
}
/** 模型列表接口是 OpenAI 形状(GET {base}/models)的内置 provider */
const OPENAI_SHAPED = new Set([
"ant-ling",
"baseten",
"cerebras",
"deepseek",
"fireworks",
"groq",
"huggingface",
"meta",
"mistral",
"moonshotai",
"moonshotai-cn",
"nvidia",
"openai",
"opencode",
"opencode-go",
"openrouter",
"qwen-token-plan",
"qwen-token-plan-cn",
"qwen-token-plan-individual",
"together",
"vercel-ai-gateway",
"xai",
"xiaomi",
"xiaomi-token-plan-ams",
"xiaomi-token-plan-cn",
"xiaomi-token-plan-sgp",
"zai",
"zai-coding-cn",
]);
const TIMEOUT_MS = 15_000;
/** 明显不是对话模型的 id(嵌入、语音、审核、重排),列表里去掉 */
const NON_CHAT = /embed|whisper|tts|dall-e|moderation|transcri|rerank/i;
const trimSlash = (u: string) => u.replace(/\/+$/, "");
/** base 以 /v1、/v4、/v1beta 这类版本段结尾 → {base}/models,否则 {base}/v1/models */
const openaiModelsUrl = (base: string) =>
new URL(/\/v\d+[a-z0-9]*$/i.test(trimSlash(base)) ? `${trimSlash(base)}/models` : `${trimSlash(base)}/v1/models`);
function keyOf(cred: UpstreamCredential | undefined): string | null {
if (cred?.type === "api_key") return cred.key || null;
if (cred?.type === "oauth") return cred.access || null;
return null;
}
/** 按 provider / 认证方式挑模型列表接口 */
function planFor(spec: UpstreamSpec, cred: UpstreamCredential | undefined): Plan {
const extra = spec.extra ?? {};
if (spec.provider === "fake") return { kind: "unsupported", reason: "Fake 上游没有远程模型列表" };
if (spec.provider === "radius") return { kind: "pi" };
const custom = spec.provider === "openai-compatible";
// 订阅登录只有 OpenRouter 换到的是普通 API Key,其它订阅 token 不能调公开的模型列表接口
if (spec.authKind === "oauth" && spec.provider !== "openrouter")
return { kind: "unsupported", reason: "订阅登录的上游没有公开的模型列表接口,用内置模型表" };
const key = keyOf(cred);
if (spec.authKind !== "none" && !key && !custom) throw new FetchError("auth", "还没有配置 API Key");
let shape: Shape | null = null;
if (custom) {
const api = extra.api ?? "openai-completions";
shape = api === "anthropic-messages" ? "anthropic" : api === "google-generative-ai" ? "google" : "openai";
} else if (spec.provider === "anthropic") shape = "anthropic";
else if (spec.provider === "google") shape = "google";
else if (OPENAI_SHAPED.has(spec.provider)) shape = "openai";
if (!shape) return { kind: "unsupported", reason: "这个 provider 没有模型列表接口,用内置模型表" };
const base = spec.baseUrl || providerInfo(spec.provider)?.defaultBaseUrl;
if (!base) throw new FetchError("config", "没有 Base URL");
const headers: Record<string, string> = { accept: "application/json", ...(custom ? (extra.headers ?? {}) : {}) };
let url: URL;
try {
if (shape === "anthropic") {
url = new URL(`${trimSlash(base).replace(/\/v1$/, "")}/v1/models`);
url.searchParams.set("limit", "1000");
if (key) headers["x-api-key"] = key;
headers["anthropic-version"] = "2023-06-01";
} else if (shape === "google") {
const b = trimSlash(base);
url = new URL(/\/v1(beta)?$/.test(b) ? `${b}/models` : `${b}/v1beta/models`);
url.searchParams.set("pageSize", "1000");
if (key) url.searchParams.set("key", key);
} else {
url = openaiModelsUrl(base);
if (key) headers.authorization = `Bearer ${key}`;
}
} catch {
throw new FetchError("config", `Base URL 不是合法地址:${base}`);
}
return { kind: "http", endpoint: { shape, url, headers } };
}
// ── 归一化 ──
const num = (v: unknown): number | null => {
const n = typeof v === "string" ? Number(v) : v;
return typeof n === "number" && Number.isFinite(n) ? n : null;
};
const int = (v: unknown): number | null => {
const n = num(v);
return n !== null && n > 0 ? Math.round(n) : null;
};
const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim().slice(0, 300) : null);
const obj = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
const has = (v: unknown, x: string) => Array.isArray(v) && v.includes(x);
type Remote = Omit<PiListedModel, "origin">;
function normalizeOpenAI(item: Record<string, unknown>): Remote | null {
const id = str(item.id);
if (!id) return null;
const arch = obj(item.architecture);
const caps = obj(item.capabilities);
const top = obj(item.top_provider);
const pricing = obj(item.pricing);
const params = item.supported_parameters;
const inputs = arch.input_modalities ?? item.input_modalities ?? item.modalities;
const vision = Array.isArray(inputs)
? has(inputs, "image")
: typeof caps.vision === "boolean"
? caps.vision
: typeof item.supports_vision === "boolean"
? item.supports_vision
: null;
const tools = Array.isArray(params)
? has(params, "tools")
: typeof caps.function_calling === "boolean"
? caps.function_calling
: typeof caps.tools === "boolean"
? caps.tools
: typeof item.supports_tools === "boolean"
? item.supports_tools
: null;
// OpenRouter:pricing.prompt/completion 是每 token 美元(字符串);Together 等:pricing.input/output 已是每百万
let price: Remote["pricing"] = null;
if (pricing.prompt !== undefined || pricing.completion !== undefined) {
const p = num(pricing.prompt);
const c = num(pricing.completion);
price = { input: p === null ? null : +(p * 1e6).toFixed(4), output: c === null ? null : +(c * 1e6).toFixed(4) };
} else if (pricing.input !== undefined || pricing.output !== undefined)
price = { input: num(pricing.input), output: num(pricing.output) };
return {
id,
displayName: str(item.name) ?? str(item.display_name),
contextWindow:
int(item.context_length) ??
int(item.context_window) ??
int(item.max_context_length) ??
int(item.max_model_len) ??
int(item.max_input_tokens) ??
int(top.context_length),
supportsVision: vision,
supportsTools: tools,
pricing: price,
};
}
function normalizeAnthropic(item: Record<string, unknown>): Remote | null {
const id = str(item.id);
if (!id) return null;
return {
id,
displayName: str(item.display_name),
contextWindow: int(item.max_input_tokens) ?? int(item.context_window),
supportsVision: null,
supportsTools: null,
pricing: null,
};
}
function normalizeGoogle(item: Record<string, unknown>): Remote | null {
const name = str(item.name);
if (!name) return null;
const methods = item.supportedGenerationMethods;
if (Array.isArray(methods) && !methods.includes("generateContent")) return null;
return {
id: name.replace(/^models\//, ""),
displayName: str(item.displayName),
contextWindow: int(item.inputTokenLimit),
supportsVision: null,
supportsTools: null,
pricing: null,
};
}
function parseList(shape: Shape, body: unknown): Remote[] {
const b = obj(body);
const raw = Array.isArray(body) ? body : Array.isArray(b.data) ? b.data : Array.isArray(b.models) ? b.models : null;
if (!raw) throw new FetchError("other", "上游返回的不是模型列表");
const norm = shape === "anthropic" ? normalizeAnthropic : shape === "google" ? normalizeGoogle : normalizeOpenAI;
const out: Remote[] = [];
const seen = new Set<string>();
for (const it of raw) {
const m = norm(obj(it));
if (!m || seen.has(m.id) || NON_CHAT.test(m.id)) continue;
seen.add(m.id);
out.push(m);
}
return out;
}
async function fetchList(ep: ListEndpoint): Promise<Remote[]> {
const host = ep.url.host;
let res: Response;
try {
res = await fetch(ep.url, { headers: ep.headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (err) {
const e = err as Error;
// 错误信息里可能带完整 URL(Google 的 key 在查询串里),只留主机名
const why =
e.name === "TimeoutError"
? `${TIMEOUT_MS / 1000} 秒没有响应`
: redactString(e.message.replaceAll(ep.url.toString(), host)).slice(0, 160);
throw new FetchError("network", `连不上 ${host}:${why}`);
}
if (res.status === 401 || res.status === 403) {
await res.body?.cancel();
throw new FetchError("auth", `${host} 拒绝了凭证(HTTP ${res.status})`);
}
if (res.status === 404 || res.status === 405) {
await res.body?.cancel();
throw new FetchError("unsupported", `${host} 没有模型列表接口(HTTP ${res.status})`);
}
if (!res.ok) {
await res.body?.cancel();
throw new FetchError("other", `${host} 返回 HTTP ${res.status}`);
}
let body: unknown;
try {
body = await res.json();
} catch {
throw new FetchError("other", `${host} 返回的不是 JSON`);
}
const list = parseList(ep.shape, body);
if (!list.length) throw new FetchError("other", `${host} 返回的模型列表是空的`);
return list;
}
// ── 静态目录与合并 ──
const fromInfo = (m: PiModelInfo): Remote => ({
id: m.id,
displayName: m.name && m.name !== m.id ? m.name : null,
contextWindow: m.contextWindow || null,
supportsVision: m.input.includes("image"),
supportsTools: null,
pricing: null,
});
/** 随包静态目录(已配置凭证时按 provider 过滤,如 Copilot);自定义端点只有已填的模型 */
async function staticList(client: PiUpstreamClient | null): Promise<Remote[]> {
if (!client) return [];
try {
const r = await client.listModels({ refresh: false });
return r.items.map(fromInfo);
} catch {
return [];
}
}
/** 远程在前、同 id 远程优先(缺的字段用静态补),然后是只在静态目录里的 */
export function mergeModels(remote: Remote[] | null, stat: Remote[]): PiListedModel[] {
const byId = new Map(stat.map((m) => [m.id, m]));
const out: PiListedModel[] = [];
const seen = new Set<string>();
for (const r of remote ?? []) {
if (seen.has(r.id)) continue;
seen.add(r.id);
const s = byId.get(r.id);
out.push({
id: r.id,
displayName: r.displayName ?? s?.displayName ?? null,
contextWindow: r.contextWindow ?? s?.contextWindow ?? null,
supportsVision: r.supportsVision ?? s?.supportsVision ?? null,
supportsTools: r.supportsTools ?? s?.supportsTools ?? null,
pricing: r.pricing ?? s?.pricing ?? null,
origin: "remote",
});
}
for (const s of stat) {
if (seen.has(s.id)) continue;
seen.add(s.id);
out.push({ ...s, origin: "static" });
}
return out;
}
function storedRemote(row: PiUpstreamRow): Remote[] | null {
if (!row.models || !row.models_fetched_at || !Array.isArray(row.models)) return null;
return (row.models as unknown[]).map((m) => obj(m) as unknown as Remote).filter((m) => typeof m.id === "string");
}
function remoteSupported(spec: UpstreamSpec): boolean {
try {
return planFor(spec, { type: "api_key", key: "x" }).kind !== "unsupported";
} catch {
return true;
}
}
/** GET …/models:拉过远程列表 → 远程 + 静态合并;否则静态目录 */
export async function readUpstreamModels(ctx: ServerContext, row: PiUpstreamRow): Promise<PiUpstreamModels> {
const remote = storedRemote(row);
const stat = await staticList(safeClient(() => clientFor(ctx, row)));
return {
source: remote ? "remote" : "static",
fetchedAt: remote && row.models_fetched_at ? row.models_fetched_at.toISOString() : null,
models: mergeModels(remote, stat),
remoteSupported: remoteSupported(specOf(row)),
error: null,
errorKind: null,
};
}
function safeClient(make: () => PiUpstreamClient): PiUpstreamClient | null {
try {
return make();
} catch {
return null;
}
}
/** 拉一次远程列表(不落库);失败抛 FetchError */
async function fetchRemote(
spec: UpstreamSpec,
cred: UpstreamCredential | undefined,
client: PiUpstreamClient | null,
): Promise<Remote[]> {
const plan = planFor(spec, cred);
if (plan.kind === "unsupported") throw new FetchError("unsupported", plan.reason);
if (plan.kind === "http") return await fetchList(plan.endpoint);
// Radius:pi-ai 自带的动态刷新
if (!client) throw new FetchError("config", "上游配置不完整");
const r = await client.listModels({ refresh: true });
if (!r.refreshed) throw toFetchError(new Error(r.refreshError ?? "刷新失败"));
return r.items.map(fromInfo);
}
function toFetchError(err: unknown): FetchError {
if (err instanceof FetchError) return err;
const e = classifyUpstreamError(err);
const kind: PiModelFetchErrorKind =
e.kind === "auth" || e.kind === "oauth"
? "auth"
: e.kind === "network"
? "network"
: e.kind === "config"
? "config"
: "other";
return new FetchError(kind, e.message);
}
/** POST …/models/refresh:成功存库;失败不动已存的列表,返回当前列表(没拉过就是静态表)并带上原因 */
export async function refreshUpstreamModels(ctx: ServerContext, row: PiUpstreamRow): Promise<PiUpstreamModels> {
const client = safeClient(() => clientFor(ctx, row));
const spec = { ...specOf(row), extra: parseExtra(row.extra) };
try {
const cred = await dbCredentialStore(ctx, row.id).read();
const remote = await fetchRemote(spec, cred, client);
await ctx.db
.updateTable("pi_upstream")
.set({ models: JSON.stringify(remote), models_fetched_at: new Date() })
.where("id", "=", row.id)
.execute();
return await readUpstreamModels(ctx, (await getRow(ctx, row.id)) ?? row);
} catch (err) {
const e = toFetchError(err);
if (e.kind !== "unsupported")
ctx.logger.info({ upstreamId: row.id, provider: row.provider, kind: e.kind, msg: e.message }, "获取模型列表失败");
return { ...(await readUpstreamModels(ctx, row)), error: e.message, errorKind: e.kind };
}
}
/** 只在内存里的凭证(试拉用,不写库) */
function memoryStore(cred: UpstreamCredential | undefined): UpstreamCredentialStore {
let c = cred;
return {
read: async () => c,
modify: async (fn) => {
const next = await fn(c);
if (next !== undefined) c = next;
return c;
},
delete: async () => {
c = undefined;
},
};
}
/** POST /pi/models/probe:新建上游前试拉(不落库) */
export async function probeModels(body: PiModelsProbeRequest): Promise<PiUpstreamModels> {
const spec: UpstreamSpec = {
id: "probe",
provider: body.provider,
authKind: body.authKind,
baseUrl: body.baseUrl ?? null,
model: null,
extra: body.extra ?? {},
};
const cred: UpstreamCredential | undefined = body.apiKey ? { type: "api_key", key: body.apiKey } : undefined;
const client = safeClient(() => new PiUpstreamClient(spec, memoryStore(cred)));
const stat = await staticList(client);
const base = { remoteSupported: remoteSupported(spec) };
try {
const remote = await fetchRemote(spec, cred, client);
return {
...base,
source: "remote",
fetchedAt: new Date().toISOString(),
models: mergeModels(remote, stat),
error: null,
errorKind: null,
};
} catch (err) {
const e = toFetchError(err);
return {
...base,
source: "static",
fetchedAt: null,
models: mergeModels(null, stat),
error: e.message,
errorKind: e.kind,
};
}
}
+14 -8
View File
@@ -5,6 +5,7 @@ import {
Api,
apiResponse,
type EndpointDef,
type PiModelsProbeRequest,
type PiOAuthCompleteRequest,
type PiOAuthStartRequest,
type PiUpstreamCreate,
@@ -13,8 +14,8 @@ import {
} from "@umt/contracts";
import type { z } from "zod";
import { requireAdminSession, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { probeModels, readUpstreamModels, refreshUpstreamModels } from "./models.ts";
import type { OAuthSessions } from "./oauth.ts";
import { requireRow } from "./oauth.ts";
import { refreshUpstream, resolveExpiredDecision } from "./refresh.ts";
@@ -125,16 +126,21 @@ export function registerPiRoutes(
return { ...out, mode, latencyMs: Date.now() - started, upstream: await toView(ctx, (await getRow(ctx, row.id))!) };
});
route(app, Api.pi.models, async ({ params }) => {
route(app, Api.pi.models, async ({ params }) => await readUpstreamModels(ctx, await requireRow(ctx, params.id)));
route(app, Api.pi.modelsRefresh, async ({ params, actor }) => {
const row = await requireRow(ctx, params.id);
try {
return await clientFor(ctx, row, deps.client).listModels();
} catch (err) {
if (err instanceof UpstreamError) throw new ApiException("validation_failed", err.message);
throw err;
}
const r = await refreshUpstreamModels(ctx, row);
await auditPi(ctx, actor, "pi_upstream.models_refresh", row.id, {
source: r.source,
count: r.models.length,
errorKind: r.errorKind,
});
return r;
});
route(app, Api.pi.modelsProbe, async ({ body }) => await probeModels(body as PiModelsProbeRequest));
route(app, Api.pi.oauthStart, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
const r = await deps.oauth().start(row, body as PiOAuthStartRequest);
+4
View File
@@ -159,6 +159,10 @@ export async function updateUpstream(
...(body.thinkingLevel !== undefined ? { thinking_level: body.thinkingLevel } : {}),
...(body.extra !== undefined ? { extra: JSON.stringify(body.extra) } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
// 端点变了,之前拉到的模型列表作废(回到静态表,等下次获取)
...(baseUrl !== row.base_url || (body.extra !== undefined && body.extra.api !== parseExtra(row.extra).api)
? { models: null, models_fetched_at: null }
: {}),
})
.where("id", "=", row.id)
.execute()
+3
View File
@@ -81,6 +81,9 @@ describe("Api 注册表鉴权标注", () => {
"POST /pi/upstreams/{id}/oauth/start",
"POST /pi/upstreams/{id}/oauth/complete",
"POST /pi/upstreams/{id}/oauth/logout",
// PI-2 获取模型列表(会用上游凭证出站)
"POST /pi/upstreams/{id}/models/refresh",
"POST /pi/models/probe",
// W7-C 定时任务看板:全部只接受控制台会话
"GET /schedules",
"POST /schedules",
+213
View File
@@ -0,0 +1,213 @@
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);
});
});
+2 -2
View File
@@ -164,8 +164,8 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
expect(tb).toMatchObject({ ok: true, mode: "auth", upstream: { status: "error", lastVerifiedAt: null } });
expect(tb.message).toMatch(/最小补全/);
const models = (await req("GET", `/pi/upstreams/${a.id}/models`)).body.data;
expect(models.items.length).toBeGreaterThan(10);
expect(models.refreshed).toBe(false);
expect(models.models.length).toBeGreaterThan(10);
expect(models).toMatchObject({ source: "static", fetchedAt: null, remoteSupported: true });
// 删除默认上游 → 默认清空,secret 一并删除
const secretRef = (