- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|