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; } 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 = { 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 => v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; const has = (v: unknown, x: string) => Array.isArray(v) && v.includes(x); type Remote = Omit; function normalizeOpenAI(item: Record): 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): 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): 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(); 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 { 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 { 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(); 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 { 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 { 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 { 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 { 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, }; } }