// lib/providers.mjs — 可插拔 provider 注册表 // // 设计原则(务必遵守): // 1. 每个 provider 显式声明 available —— 通道没开通就是 false,绝不伪装成能用。 // 2. probe() 会真的去探活(默认轻量:查 /models 列表;deep 模式才真提交任务)。 // 3. 调用未开通通道时抛 ProviderUnavailableError,附「怎么换通道 / 怎么对接」的指引。 // 4. 用户可以用环境变量或 providers.json 覆盖任何 provider 的模型名与端点, // 不需要改代码 —— 这是「可插拔」的落点。 // // 能力现状以本文件为唯一事实来源;references/capability-matrix.md 与之保持同步。 import { loadConfig, requireToken } from './config.mjs'; import { request, ProviderUnavailableError, GatewayError, runAsyncTask, chat } from './api.mjs'; import * as JM from './jimeng.mjs'; const cfg = () => loadConfig(); /** 读取 provider 覆盖配置(环境变量 / 可选 providers.json) */ function overrides(kind) { const c = loadConfig(); const o = {}; // 环境变量覆盖:FEME__MODEL / FEME__ENDPOINT const K = kind.toUpperCase().replace(/[^A-Z0-9]/g, '_'); if (process.env[`FEME_${K}_MODEL`]) o.model = process.env[`FEME_${K}_MODEL`]; if (process.env[`FEME_${K}_ENDPOINT`]) o.endpoint = process.env[`FEME_${K}_ENDPOINT`]; // 身份文件里的 providers 段 const p = c.identity?.providers?.[kind]; if (p && typeof p === 'object') Object.assign(o, p); return o; } /** 通用 /models 探活:模型是否在网关模型列表里 */ async function probeModelList(model) { try { const res = await request('/models', { timeout: 20_000, retries: 0 }); const ids = (res.body?.data || []).map((m) => m.id); const hit = ids.find((x) => x === model) || ids.find((x) => x.includes(model)) || null; return { available: !!hit, detail: hit ? `模型 ${hit} 在网关模型列表中` : `模型 ${model} 不在网关模型列表中(当前共 ${ids.length} 个模型)`, models: ids, }; } catch (e) { return { available: false, detail: `探活失败:${e.message.split('\n')[0]}` }; } } /** * 判断一个端点是否「真的存在」。 * 关键陷阱:网关根域对未知路径会返回 SPA 的 index.html(HTTP 200 + text/html), * 不能只看状态码,必须看 Content-Type 和响应体形状。 */ async function probeEndpoint(pathOrUrl, { method = 'POST', body, timeout = 20_000, base } = {}) { const b = base || cfg().rootUrl; const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${b}${pathOrUrl}`; let res; try { res = await fetch(url, { method, headers: { Authorization: `Bearer ${requireToken()}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeout), }); } catch (e) { return { exists: false, status: 0, detail: `无法连接(${e.message})` }; } const ct = (res.headers.get('content-type') || '').toLowerCase(); const text = (await res.text().catch(() => '')).slice(0, 400); // SPA 兜底页 → 端点不存在 if (ct.includes('text/html') || /^\s* overrides('chat').model || process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash', modelCandidates: ['deepseek-v4-flash', 'doubao-seed-2-1-pro', 'deepseek-v4-pro', 'doubao-seed-2-0-pro-260215'], async probe({ deep = false } = {}) { const model = this.defaultModel(); const r = await probeModelList(model); if (!r.available || !deep) return { ...r, model }; const t0 = Date.now(); try { await chat({ messages: [{ role: 'user', content: 'ping' }], max_tokens: 4, model }); return { available: true, model, detail: `真实调用成功(${Date.now() - t0}ms)` }; } catch (e) { return { available: false, model, detail: `真实调用失败:${e.message.split('\n')[0]}` }; } }, async call({ messages, system, temperature, max_tokens, json }) { return chat({ messages, system, temperature, max_tokens, json, model: this.defaultModel() }); }, }, // ---------- 图像:场景 / 人物关键帧 ---------- image: { id: 'image', kind: 'image', label: '文生图(场景 & 人物关键帧)', available: true, note: 'POST /v1/images/generations,实测可用(返回 b64_json)', defaultModel: () => overrides('image').model || process.env.FEME_IMAGE_MODEL || 'gpt-image-2.5-sunburst', modelCandidates: ['gpt-image-2.5-sunburst', 'doubao-seedream-4-0-250828'], async probe({ deep = false } = {}) { const model = this.defaultModel(); const r = await probeModelList(model); // 图像探活不做 deep(每张都要计费),改为说明 return { ...r, model, detail: r.available ? `${r.detail}(deep 会真的生成图片并计费,故默认跳过)` : r.detail }; }, async call({ prompt, size = '1024x1024', n = 1, refImage }) { const { generateImage } = await import('./api.mjs'); const model = this.defaultModel(); return generateImage({ prompt, size, n, refImage, model }); }, }, // ---------- 音乐:AI 作曲 ---------- music: { id: 'music', kind: 'music', label: 'AI 音乐生成(Suno 兼容通道)', available: false, // ← 实测:渠道未开通 note: 'POST /suno/submit/music + POST /suno/fetch —— 端点存在,渠道未开通(返回 Task request failed)', defaultModel: () => overrides('music').model || process.env.FEME_MUSIC_MODEL || 'chirp-v3-5', endpoint: () => overrides('music').endpoint || '/suno/submit/music', fetchPath: () => overrides('music').fetchPath || '/suno/fetch', hint: '音乐通道未开通。可选方案:\n' + ' A) 等平台开通 Suno 渠道后,本命令无需改动即可用;\n' + ' B) 自建/对接其它音乐服务,用 FEME_MUSIC_ENDPOINT 指向兼容端点;\n' + ' C) 手工把成品音频放到 work/audio/ 下,再用 --from 跳过本步继续走后续流程。', async probe({ deep = true } = {}) { const r = await probeEndpoint(this.endpoint(), { method: 'GET', timeout: 15_000, }); if (!r.exists) return { available: false, detail: r.detail, hint: this.hint }; if (!deep) return { available: null, detail: r.detail + '(未做真实提交;--deep 才真提交)' }; const post = await probeEndpoint(this.endpoint(), { method: 'POST', body: { prompt: 'probe', mv: this.defaultModel(), custom_mode: false, make_instrumental: true }, timeout: 30_000, }); if (post.exists && !post.noChannel && post.status < 400) { return { available: true, detail: '提交成功,渠道已开通' }; } return { available: false, detail: post.noChannel ? post.detail : `提交失败:${post.detail}`, hint: this.hint, }; }, async call({ prompt, lyrics, instrumental = false, title }) { if (!this.available && !process.env.FEME_FORCE_UNAVAILABLE_CHANNELS) { // 先真试一次,渠道若被开通则照常走通;失败再给指引 } const base = cfg().rootUrl; const payload = { prompt: prompt || '', // 曲风 / 风格描述 mv: this.defaultModel(), // 版本(chirp-v3-5 等) title: title || undefined, custom_mode: !!lyrics, make_instrumental: !!instrumental, }; if (lyrics) { // Suno 自定义模式:歌词 + 标题 payload.lyrics = typeof lyrics === 'string' ? lyrics : String(lyrics); payload.prompt = prompt || ''; } try { const { taskId, result } = await runAsyncTask({ submitPath: this.endpoint(), listPath: this.fetchPath(), submitBase: base, fetchBase: base, payload, taskLabel: '音乐生成', interval: 6000, maxWait: 480_000, pick: (raw) => ({ status: raw?.status || 'running', progress: raw?.progress, error: raw?.fail_reason || raw?.error, result: raw, }), }); return { taskId, result }; } catch (e) { if (e instanceof ProviderUnavailableError || /channel|401|403|404|503|502/i.test(String(e.message))) { throw new ProviderUnavailableError(`音乐通道调用失败:${e.message.split('\n')[0]}`, { endpoint: `${base}${this.endpoint()}`, status: e.status, hint: this.hint, cause: e, }); } throw e; } }, }, // ---------- 视频:图生视频(旧网关通道,已被 jimeng 取代) ---------- video: { id: 'video', kind: 'video', label: '图生视频(旧网关通道 /v1/video/generations)', available: false, // ← 实测:模型在列,渠道未开通;已改用 jimeng 通道 note: '已废弃:POST /v1/video/generations 实测 503 No available channel。请用 `jimeng.img2video`(即梦视频3.0,✅ 实测可用)。', supersededBy: 'jimeng', defaultModel: () => overrides('video').model || process.env.FEME_VIDEO_MODEL || 'doubao-seedance-1-0-pro', modelCandidates: ['doubao-seedance-1-0-pro', 'doubao-seedance-1-0-lite', 'kling-v1'], endpoint: () => overrides('video').endpoint || '/video/generations', fetchPath: () => overrides('video').fetchPath || '/video/generations', hint: '旧图生视频通道未开通(模型在清单里但无可用渠道)。\n' + ' → 本技能默认已切到即梦通道:`fmode-mv video --provider jimeng`(实测可用,720p ¥0.28/秒)。\n' + ' A) 平台开通旧通道后仍可用;\n' + ' B) FEME_VIDEO_MODEL 换一个已开通渠道的视频模型;\n' + ' C) --fallback-still 用「静帧 + Ken Burns 运镜」兜底(产物带 .fallback 标记)。', async probe({ deep = false } = {}) { const model = this.defaultModel(); const list = await probeModelList(model); const r = await probeEndpoint(this.endpoint(), { method: 'POST', body: { model, prompt: 'probe', duration: 4 }, timeout: 25_000, }); if (!r.exists) { return { available: false, model, detail: `视频端点不存在:${r.detail}`, hint: this.hint }; } if (r.noChannel) { return { available: false, model, detail: `模型 ${model} 无可用渠道:${r.detail.replace(/^端点存在但渠道未开通(HTTP \d+):/, '')}`, hint: this.hint, }; } if (r.status >= 400) { return { available: false, model, detail: `提交失败(HTTP ${r.status})`, hint: this.hint }; } return { available: true, model, detail: '提交成功,渠道已开通' }; }, async call({ image, prompt, duration = 5, size = '1280x720' }) { const model = this.defaultModel(); const payload = { model, prompt, duration, size, image }; const { taskId, result } = await runAsyncTask({ submitPath: this.endpoint(), listPath: this.fetchPath(), payload, taskLabel: '图生视频', interval: 8000, maxWait: 900_000, pick: (raw) => ({ status: raw?.status || 'running', progress: raw?.progress, error: raw?.fail_reason || raw?.error?.message || raw?.error, result: raw, }), }); return { taskId, result }; }, }, // ---------- ★ 即梦:唯一实测可用的真实生成通道 ---------- // // 这是本技能仓的主力通道:文生图 / 图生图 / 图生视频 / 动作模仿 / 数字人 / 超清。 // 与其他 provider 的差别:它不吃 /v1 网关那套,走的是 server.fmode.cn 的 // `/api/volcengine/jimeng` 代理,鉴权用 sessionToken(不是 newapi_token)。 jimeng: { id: 'jimeng', kind: 'jimeng', label: '即梦(火山引擎)文生图 / 图生图 / 图生视频 / 动作模仿 / 数字人', available: true, note: 'POST {base}/ → workId → getDataByTask02 轮询 → ImagineWork 取直链。实测 2026-10-13 全链路可用。', base: JM.JIMENG.base, cost: JM.ENDPOINTS, defaultModel: () => overrides('jimeng').model || process.env.FMODE_JIMENG_MODEL || 'jimeng-3.0', hint: '即梦通道需要 sessionToken(缺失会返回 403 请输入API_KEY或用户sessionToken)。\n' + ' A) export FMODE_SESSION_TOKEN=<你的sessionToken>\n' + ' B) 在 /opt/data/fmode-identity.json 写入 session_token 字段\n' + '本技能仓不会把 token 写入任何文件、日志或提交。', /** 轻量探活:只查 workId 不存在时的报错形状,不产生任何费用 */ async probe({ deep = false } = {}) { if (!JM.jimengAvailable()) { return { available: false, detail: '未找到 sessionToken(FMODE_SESSION_TOKEN / fmode-identity.json)', hint: this.hint }; } try { const res = await fetch(`${JM.JIMENG.base}/getDataByTask02`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workId: '__fmode_mv_probe__', routerName: 'getImgV4', token: `Bearer ${JM.requireJimengToken()}`, }), signal: AbortSignal.timeout(20_000), }); const text = await res.text(); if (/^\s*(= height ? '16:9' : '9:16', '2k'); process.stderr.write( ` ! ${e.message}\n → 已自动改用推荐尺寸 ${fallback.width}×${fallback.height}(${ep})\n` ); size = JM.assertValidSize(ep, fallback); } const est = JM.estimateCost(ep); if (ledger) ledger.printEstimate(ep, `${size.width}×${size.height}`, est); const r = await JM.runTask(ep, { prompt, sizeDate: size }, { kind: 'image', outPaths: outFile ? [outFile] : [], outDir, prefix, estimateCost: est, }); if (ledger) ledger.record({ kind: ep, detail: `${size.width}×${size.height}`, estimate: est, actual: r.cost, workId: r.workId, file: r.files[0], ms: r.elapsed }); return r.files[0]; }, /** 图生图(多图参考)→ 本地图片路径。内部自动压缩 + 上传七牛。 */ async img2img({ prompt, refImagePaths = [], width = 1024, height = 1024, scale = 0.6, endpoint = 'imgV4', outFile, outDir, prefix = 'img2img', ledger } = {}) { const refs = Array.isArray(refImagePaths) ? refImagePaths : [refImagePaths]; let size; try { size = JM.assertValidSize(endpoint, { width, height }); } catch (e) { const fallback = JM.resolveSize(endpoint, width >= height ? '16:9' : '9:16', '2k'); process.stderr.write(` ! ${e.message}\n → 已自动改用推荐尺寸 ${fallback.width}×${fallback.height}(${endpoint})\n`); size = JM.assertValidSize(endpoint, fallback); } const urls = []; for (const p of refs.filter(Boolean)) { process.stderr.write(` → 上传参考图到公网:${p}\n`); urls.push(await JM.prepareImageUrl(p, { width: Math.max(size.width, 1280), height: Math.max(size.height, 720) })); } const est = JM.estimateCost(endpoint); if (ledger) ledger.printEstimate(endpoint, `参考 ${urls.length} 图 ${size.width}×${size.height}`, est); const r = await JM.runTask(endpoint, { prompt, image_urls: urls, sizeDate: size, scale, force_single: true }, { kind: 'image', outPaths: outFile ? [outFile] : [], outDir, prefix, estimateCost: est, }); if (ledger) ledger.record({ kind: endpoint, detail: `参考${urls.length}图`, estimate: est, actual: r.cost, workId: r.workId, file: r.files[0], ms: r.elapsed }); return r.files[0]; }, /** * 图生视频(即梦视频 3.0)→ 本地 mp4 路径。 * method: '2'=首帧(需 firstImagePath);'3'=首尾帧(需 lastImagePath)。 * method='3' 失败会自动降级为 '2'(照抄 KB 的降级逻辑),降级会在返回值里留痕。 */ async img2video({ prompt, firstImagePath, lastImagePath, seconds = 5, resolution = '720p', method = '2', outFile, outDir, prefix = 'clip', ledger, } = {}) { if (!firstImagePath) throw new GatewayError('img2video 需要 firstImagePath'); const ep = resolution === '1080p' ? 'video1080' : resolution === 'pro' ? 'videoPro' : 'video720'; // 本地先自检帧数合法性:接口要求 frames ∈ [121,241] 且 ≡ 1 (mod 24)。 // 不在本地拦住的话,要白等一轮轮询才拿到 50200 的报错。 const frames = JM.assertValidFrames(JM.secondsToFrames(seconds)); const realSeconds = JM.framesToSeconds(frames); const urls = [await JM.prepareImageUrl(firstImagePath)]; let wantLast = method === '3'; if (wantLast) { if (!lastImagePath) throw new GatewayError('method=3(首尾帧)需要 lastImagePath'); urls.push(await JM.prepareImageUrl(lastImagePath)); } const est = JM.estimateCost(ep, { seconds: realSeconds }); if (ledger) ledger.printEstimate(ep, `${realSeconds.toFixed(1)}s/${frames}帧 method=${wantLast ? 3 : 2}`, est); const attempt = async (m, imageUrls) => { const r = await JM.runTask(ep, { prompt, frames, method: String(m), config: { image_urls: imageUrls } }, { kind: 'video', outPaths: outFile ? [outFile] : [], outDir, prefix, estimateCost: est, timeoutMs: 900_000, }); return r; }; let res; let degraded = false; if (wantLast) { try { res = await attempt('3', urls); } catch (e) { process.stderr.write(` ! method=3(首尾帧)失败:${String(e.message).split('\n')[0]}\n`); process.stderr.write(' → 自动降级为 method=2(首帧模式),照 KB 的降级预案\n'); degraded = true; wantLast = false; res = await attempt('2', [urls[0]]); } } else { res = await attempt('2', [urls[0]]); } if (ledger) ledger.record({ kind: ep, detail: `${realSeconds.toFixed(1)}s method=${wantLast ? 3 : 2}`, estimate: est, actual: res.cost, workId: res.workId, file: res.files[0], ms: res.elapsed }); res.degraded = degraded; res.frames = frames; res.method = wantLast ? '3' : '2'; res.seconds = realSeconds; return res; }, /** 动作模仿 → 本地 mp4 */ async actor({ imagePath, templateVideoUrl, outFile, version = 2, ledger } = {}) { const ep = version === 1 ? 'actor' : 'actorV2'; const est = JM.estimateCost(ep, { seconds: 8 }); if (ledger) ledger.printEstimate(ep, '按 8s 估算(最低 3.2 元)', est); const r = await JM.actorFlow({ imagePath, templateVideoUrl, outFile, version }); if (ledger) ledger.record({ kind: ep, detail: '动作模仿', estimate: est, actual: r.cost, workId: r.workId, file: r.file, ms: r.elapsed }); return r.file; }, /** 数字人(三步流程)→ 本地 mp4 */ async digitalHuman({ imagePath, audioPath, prompt = '', outFile, ledger } = {}) { const est = JM.estimateCost('omniHuman', { seconds: 8 }); if (ledger) ledger.printEstimate('omniHuman', '按 8s 估算', est); const r = await JM.digitalHumanFlow({ imagePath, audioPath, prompt, outFile }); if (ledger) ledger.record({ kind: 'omniHuman', detail: '数字人', estimate: est, actual: r.cost, workId: r.workId, file: r.file, ms: r.elapsed }); return r.file; }, /** 智能超清 → 本地图片 */ async upscale({ imagePath, resolution = '4k', scale = 50, outFile, outDir, prefix = 'upscale', ledger } = {}) { const url = await JM.prepareImageUrl(imagePath); const est = JM.estimateCost('superRes'); if (ledger) ledger.printEstimate('superRes', resolution, est); const r = await JM.runTask('superRes', { image_urls: [url], resolution, scale }, { kind: 'image', outPaths: outFile ? [outFile] : [], outDir, prefix, estimateCost: est, }); if (ledger) ledger.record({ kind: 'superRes', detail: resolution, estimate: est, actual: r.cost, workId: r.workId, file: r.files[0], ms: r.elapsed }); return r.files[0]; }, /** 供 CLI 直调(`fmode-mv jimeng ...`)用的原始任务入口 */ async call(o = {}) { const { endpoint, body, kind = 'image', ...rest } = o; return JM.runTask(endpoint, body || {}, { kind, ...rest }); }, }, // ---------- 唇形驱动 ---------- lipsync: { id: 'lipsync', kind: 'lipsync', label: '唇形驱动(口型对齐人声)', available: false, // ← 平台暂无通道 note: '平台暂无通道,留接口 + 外部服务对接说明', defaultModel: () => overrides('lipsync').model || process.env.FEME_LIPSYNC_MODEL || '', endpoint: () => overrides('lipsync').endpoint || '/lipsync/generations', hint: '唇形驱动通道平台暂无。可选方案:\n' + ' A) 自建/对接 SadTalker / Wav2Lip / LatentSync,用 FEME_LIPSYNC_ENDPOINT 指向你的服务;\n' + ' B) 用「演唱镜头」替代:关键帧 + 图生视频做表情/头部微动,不严格对口型(成片观感可接受);\n' + ' C) 只走空镜 B-roll + 字幕,完全不做唇形(纯音乐 MV 常见做法)。', async probe() { const ep = this.endpoint(); const r = await probeEndpoint(ep, { method: 'POST', body: { image: 'probe', audio: 'probe' }, timeout: 20_000 }); if (!r.exists) { return { available: false, detail: `平台无此通道:${r.detail}`, hint: this.hint }; } if (r.noChannel || r.status >= 400) { return { available: false, detail: `端点存在但不可用:${r.detail}`, hint: this.hint }; } return { available: true, detail: '端点可用' }; }, async call() { throw new ProviderUnavailableError('唇形驱动通道不可用', { endpoint: this.endpoint(), hint: this.hint }); }, }, // ---------- TTS:口播配音 ---------- tts: { id: 'tts', kind: 'tts', label: '语音合成(口播配音)', available: false, // ← 平台暂无通道 note: '平台暂无通道,留接口 + 外部服务对接说明', defaultVoice: () => overrides('tts').voice || process.env.FEME_TTS_VOICE || 'zh-CN-XiaoxiaoNeural', defaultModel: () => overrides('tts').model || process.env.FEME_TTS_MODEL || 'tts-1', endpoint: () => overrides('tts').endpoint || '/audio/speech', hint: 'TTS 通道平台暂无。可选方案:\n' + ' A) 对接 OpenAI 兼容 /v1/audio/speech:设置 FEME_TTS_ENDPOINT 与 FEME_TTS_MODEL;\n' + ' B) 本地 edge-tts(免费、中文音色多):pip install edge-tts,本技能会自动探测并调用;\n' + ' C) 自备配音音频放入 work/audio/voiceover.mp3,用 --from 跳过本步。', async probe() { // 优先探测本地 edge-tts(免费、中文音色多),再探网关端点 const local = await probeLocalTTS(); if (local.available) { return { available: true, provider: 'edge-tts(local)', detail: local.detail }; } const r = await probeEndpoint(this.endpoint(), { method: 'POST', body: { model: this.defaultModel(), input: 'probe', voice: this.defaultVoice() }, timeout: 20_000, }); if (!r.exists) { return { available: false, provider: '平台网关', detail: `平台无 TTS 通道(${r.detail});本地 edge-tts 亦未安装`, hint: this.hint }; } if (r.noChannel || r.status >= 400) { return { available: false, provider: '平台网关', detail: `TTS 端点不可用:${r.detail}`, hint: this.hint }; } return { available: true, provider: '平台网关', detail: '端点可用' }; }, async call({ text, outPath, voice }) { const local = await probeLocalTTS(); if (local.available) return synthLocalTTS({ text, outPath, voice: voice || this.defaultVoice() }); throw new ProviderUnavailableError('TTS 通道不可用', { endpoint: this.endpoint(), hint: this.hint }); }, }, }; // ==================== 本地 TTS 探测(edge-tts)==================== async function probeLocalTTS() { const { spawnSync } = await import('node:child_process'); try { const r = spawnSync('edge-tts', ['--version'], { encoding: 'utf8', timeout: 10_000 }); if (r.status === 0) { return { available: true, provider: 'edge-tts(local)', detail: `本地 edge-tts 可用:${(r.stdout || '').trim().split('\n')[0]}` }; } } catch { /* 未安装 */ } return { available: false, provider: 'edge-tts(local)', detail: '未检测到本地 edge-tts' }; } async function synthLocalTTS({ text, outPath, voice }) { const { spawn } = await import('node:child_process'); const args = ['--voice', voice, '--text', text, '--write-media', outPath]; await new Promise((resolve, reject) => { const p = spawn('edge-tts', args); let err = ''; p.stderr.on('data', (d) => (err += d)); p.on('error', reject); p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`edge-tts 退出码 ${code}: ${err.slice(0, 300)}`)))); }); return { outPath, voice, provider: 'edge-tts(local)' }; } // ==================== 注册表 API ==================== export const KINDS = ['chat', 'image', 'jimeng', 'music', 'video', 'lipsync', 'tts']; export function getProvider(kind) { const p = PROVIDERS[kind]; if (!p) { throw new Error(`未知 provider 类型「${kind}」。可用:${KINDS.join(' / ')}`); } return p; } /** 探活全部 provider(并行),返回结构化能力矩阵 */ export async function probeAll({ deep = false } = {}) { const results = await Promise.all( KINDS.map(async (k) => { const p = PROVIDERS[k]; try { const r = await p.probe({ deep }); return { kind: k, label: p.label, available: r.available, detail: r.detail, model: r.model || p.defaultModel?.(), hint: r.hint || (r.available ? undefined : p.hint) }; } catch (e) { return { kind: k, label: p.label, available: false, detail: `探活异常:${e.message.split('\n')[0]}`, hint: p.hint }; } }) ); return results; } /** * 统一调用入口。未开通通道会抛 ProviderUnavailableError(带 hint), * 绝不会「假装成功」。 */ export async function invoke(kind, params) { const p = getProvider(kind); return p.call(params); }