Files
UHD_Media_Tool/apps/server/src/modules/pi/models.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

472 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}
}