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:
1 parent
e5abfa6217
commit
0765264a47
16 files changed
+1178
-52
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user