providers.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. // lib/providers.mjs — 可插拔 provider 注册表
  2. //
  3. // 设计原则(务必遵守):
  4. // 1. 每个 provider 显式声明 available —— 通道没开通就是 false,绝不伪装成能用。
  5. // 2. probe() 会真的去探活(默认轻量:查 /models 列表;deep 模式才真提交任务)。
  6. // 3. 调用未开通通道时抛 ProviderUnavailableError,附「怎么换通道 / 怎么对接」的指引。
  7. // 4. 用户可以用环境变量或 providers.json 覆盖任何 provider 的模型名与端点,
  8. // 不需要改代码 —— 这是「可插拔」的落点。
  9. //
  10. // 能力现状以本文件为唯一事实来源;references/capability-matrix.md 与之保持同步。
  11. import { loadConfig, requireToken } from './config.mjs';
  12. import { request, ProviderUnavailableError, GatewayError, runAsyncTask, chat } from './api.mjs';
  13. const cfg = () => loadConfig();
  14. /** 读取 provider 覆盖配置(环境变量 / 可选 providers.json) */
  15. function overrides(kind) {
  16. const c = loadConfig();
  17. const o = {};
  18. // 环境变量覆盖:FEME_<KIND>_MODEL / FEME_<KIND>_ENDPOINT
  19. const K = kind.toUpperCase().replace(/[^A-Z0-9]/g, '_');
  20. if (process.env[`FEME_${K}_MODEL`]) o.model = process.env[`FEME_${K}_MODEL`];
  21. if (process.env[`FEME_${K}_ENDPOINT`]) o.endpoint = process.env[`FEME_${K}_ENDPOINT`];
  22. // 身份文件里的 providers 段
  23. const p = c.identity?.providers?.[kind];
  24. if (p && typeof p === 'object') Object.assign(o, p);
  25. return o;
  26. }
  27. /** 通用 /models 探活:模型是否在网关模型列表里 */
  28. async function probeModelList(model) {
  29. try {
  30. const res = await request('/models', { timeout: 20_000, retries: 0 });
  31. const ids = (res.body?.data || []).map((m) => m.id);
  32. const hit = ids.find((x) => x === model) || ids.find((x) => x.includes(model)) || null;
  33. return {
  34. available: !!hit,
  35. detail: hit ? `模型 ${hit} 在网关模型列表中` : `模型 ${model} 不在网关模型列表中(当前共 ${ids.length} 个模型)`,
  36. models: ids,
  37. };
  38. } catch (e) {
  39. return { available: false, detail: `探活失败:${e.message.split('\n')[0]}` };
  40. }
  41. }
  42. /**
  43. * 判断一个端点是否「真的存在」。
  44. * 关键陷阱:网关根域对未知路径会返回 SPA 的 index.html(HTTP 200 + text/html),
  45. * 不能只看状态码,必须看 Content-Type 和响应体形状。
  46. */
  47. async function probeEndpoint(pathOrUrl, { method = 'POST', body, timeout = 20_000, base } = {}) {
  48. const b = base || cfg().rootUrl;
  49. const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${b}${pathOrUrl}`;
  50. let res;
  51. try {
  52. res = await fetch(url, {
  53. method,
  54. headers: {
  55. Authorization: `Bearer ${requireToken()}`,
  56. 'Content-Type': 'application/json',
  57. Accept: 'application/json',
  58. },
  59. body: body ? JSON.stringify(body) : undefined,
  60. signal: AbortSignal.timeout(timeout),
  61. });
  62. } catch (e) {
  63. return { exists: false, status: 0, detail: `无法连接(${e.message})` };
  64. }
  65. const ct = (res.headers.get('content-type') || '').toLowerCase();
  66. const text = (await res.text().catch(() => '')).slice(0, 400);
  67. // SPA 兜底页 → 端点不存在
  68. if (ct.includes('text/html') || /^\s*<!doctype html/i.test(text) || /^\s*<html/i.test(text)) {
  69. return { exists: false, status: res.status, spa: true, detail: `端点不存在(网关返回前端页面兜底,HTTP ${res.status})` };
  70. }
  71. let json = null;
  72. try { json = JSON.parse(text); } catch { /* 非 JSON */ }
  73. // 路由存在但方法不对(405)→ 端点存在
  74. if (res.status === 405) return { exists: true, status: 405, json, detail: '端点存在(405 方法不允许)' };
  75. // 明确的上游/渠道错误 → 端点存在但不可用
  76. const msg = json?.error?.message || json?.message || text;
  77. const lower = String(msg).toLowerCase();
  78. const noChannel =
  79. lower.includes('no available channel') || lower.includes('model_not_found') || lower.includes('task request failed');
  80. if (noChannel) return { exists: true, status: res.status, json, noChannel: true, detail: `端点存在但渠道未开通(HTTP ${res.status}):${String(msg).slice(0, 120)}` };
  81. if (res.status === 404) return { exists: false, status: 404, json, detail: `端点不存在(404)` };
  82. return { exists: true, status: res.status, json, detail: `端点存在(HTTP ${res.status})` };
  83. }
  84. // ==================== Provider 定义 ====================
  85. export const PROVIDERS = {
  86. // ---------- 文本:作曲填词 / 分镜 / 口播稿 ----------
  87. chat: {
  88. id: 'chat',
  89. kind: 'chat',
  90. label: '大语言模型(作曲填词 / 分镜 / 口播稿)',
  91. available: true,
  92. note: 'POST /v1/chat/completions,实测可用',
  93. defaultModel: () => overrides('chat').model || process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
  94. modelCandidates: ['deepseek-v4-flash', 'doubao-seed-2-1-pro', 'deepseek-v4-pro', 'doubao-seed-2-0-pro-260215'],
  95. async probe({ deep = false } = {}) {
  96. const model = this.defaultModel();
  97. const r = await probeModelList(model);
  98. if (!r.available || !deep) return { ...r, model };
  99. const t0 = Date.now();
  100. try {
  101. await chat({ messages: [{ role: 'user', content: 'ping' }], max_tokens: 4, model });
  102. return { available: true, model, detail: `真实调用成功(${Date.now() - t0}ms)` };
  103. } catch (e) {
  104. return { available: false, model, detail: `真实调用失败:${e.message.split('\n')[0]}` };
  105. }
  106. },
  107. async call({ messages, system, temperature, max_tokens, json }) {
  108. return chat({ messages, system, temperature, max_tokens, json, model: this.defaultModel() });
  109. },
  110. },
  111. // ---------- 图像:场景 / 人物关键帧 ----------
  112. image: {
  113. id: 'image',
  114. kind: 'image',
  115. label: '文生图(场景 & 人物关键帧)',
  116. available: true,
  117. note: 'POST /v1/images/generations,实测可用(返回 b64_json)',
  118. defaultModel: () => overrides('image').model || process.env.FEME_IMAGE_MODEL || 'gpt-image-2.5-sunburst',
  119. modelCandidates: ['gpt-image-2.5-sunburst', 'doubao-seedream-4-0-250828'],
  120. async probe({ deep = false } = {}) {
  121. const model = this.defaultModel();
  122. const r = await probeModelList(model);
  123. // 图像探活不做 deep(每张都要计费),改为说明
  124. return { ...r, model, detail: r.available ? `${r.detail}(deep 会真的生成图片并计费,故默认跳过)` : r.detail };
  125. },
  126. async call({ prompt, size = '1024x1024', n = 1, refImage }) {
  127. const { generateImage } = await import('./api.mjs');
  128. const model = this.defaultModel();
  129. return generateImage({ prompt, size, n, refImage, model });
  130. },
  131. },
  132. // ---------- 音乐:AI 作曲 ----------
  133. music: {
  134. id: 'music',
  135. kind: 'music',
  136. label: 'AI 音乐生成(Suno 兼容通道)',
  137. available: false, // ← 实测:渠道未开通
  138. note: 'POST /suno/submit/music + POST /suno/fetch —— 端点存在,渠道未开通(返回 Task request failed)',
  139. defaultModel: () => overrides('music').model || process.env.FEME_MUSIC_MODEL || 'chirp-v3-5',
  140. endpoint: () => overrides('music').endpoint || '/suno/submit/music',
  141. fetchPath: () => overrides('music').fetchPath || '/suno/fetch',
  142. hint:
  143. '音乐通道未开通。可选方案:\n' +
  144. ' A) 等平台开通 Suno 渠道后,本命令无需改动即可用;\n' +
  145. ' B) 自建/对接其它音乐服务,用 FEME_MUSIC_ENDPOINT 指向兼容端点;\n' +
  146. ' C) 手工把成品音频放到 work/audio/ 下,再用 --from 跳过本步继续走后续流程。',
  147. async probe({ deep = true } = {}) {
  148. const r = await probeEndpoint(this.endpoint(), {
  149. method: 'GET',
  150. timeout: 15_000,
  151. });
  152. if (!r.exists) return { available: false, detail: r.detail, hint: this.hint };
  153. if (!deep) return { available: null, detail: r.detail + '(未做真实提交;--deep 才真提交)' };
  154. const post = await probeEndpoint(this.endpoint(), {
  155. method: 'POST',
  156. body: { prompt: 'probe', mv: this.defaultModel(), custom_mode: false, make_instrumental: true },
  157. timeout: 30_000,
  158. });
  159. if (post.exists && !post.noChannel && post.status < 400) {
  160. return { available: true, detail: '提交成功,渠道已开通' };
  161. }
  162. return {
  163. available: false,
  164. detail: post.noChannel ? post.detail : `提交失败:${post.detail}`,
  165. hint: this.hint,
  166. };
  167. },
  168. async call({ prompt, lyrics, instrumental = false, title }) {
  169. if (!this.available && !process.env.FEME_FORCE_UNAVAILABLE_CHANNELS) {
  170. // 先真试一次,渠道若被开通则照常走通;失败再给指引
  171. }
  172. const base = cfg().rootUrl;
  173. const payload = {
  174. prompt: prompt || '', // 曲风 / 风格描述
  175. mv: this.defaultModel(), // 版本(chirp-v3-5 等)
  176. title: title || undefined,
  177. custom_mode: !!lyrics,
  178. make_instrumental: !!instrumental,
  179. };
  180. if (lyrics) {
  181. // Suno 自定义模式:歌词 + 标题
  182. payload.lyrics = typeof lyrics === 'string' ? lyrics : String(lyrics);
  183. payload.prompt = prompt || '';
  184. }
  185. try {
  186. const { taskId, result } = await runAsyncTask({
  187. submitPath: this.endpoint(),
  188. listPath: this.fetchPath(),
  189. submitBase: base,
  190. fetchBase: base,
  191. payload,
  192. taskLabel: '音乐生成',
  193. interval: 6000,
  194. maxWait: 480_000,
  195. pick: (raw) => ({
  196. status: raw?.status || 'running',
  197. progress: raw?.progress,
  198. error: raw?.fail_reason || raw?.error,
  199. result: raw,
  200. }),
  201. });
  202. return { taskId, result };
  203. } catch (e) {
  204. if (e instanceof ProviderUnavailableError || /channel|401|403|404|503|502/i.test(String(e.message))) {
  205. throw new ProviderUnavailableError(`音乐通道调用失败:${e.message.split('\n')[0]}`, {
  206. endpoint: `${base}${this.endpoint()}`,
  207. status: e.status,
  208. hint: this.hint,
  209. cause: e,
  210. });
  211. }
  212. throw e;
  213. }
  214. },
  215. },
  216. // ---------- 视频:图生视频 ----------
  217. video: {
  218. id: 'video',
  219. kind: 'video',
  220. label: '图生视频(B-roll 空镜 / 人物演唱镜头)',
  221. available: false, // ← 实测:模型在列,渠道未开通
  222. note: 'POST /v1/video/generations(异步 task_id)—— 实测返回 503 No available channel for model',
  223. defaultModel: () => overrides('video').model || process.env.FEME_VIDEO_MODEL || 'doubao-seedance-1-0-pro',
  224. modelCandidates: ['doubao-seedance-1-0-pro', 'doubao-seedance-1-0-lite', 'kling-v1'],
  225. endpoint: () => overrides('video').endpoint || '/video/generations',
  226. fetchPath: () => overrides('video').fetchPath || '/video/generations',
  227. hint:
  228. '视频通道未开通(模型在清单里但无可用渠道)。可选方案:\n' +
  229. ' A) 平台开通后本命令直接用;\n' +
  230. ' B) FEME_VIDEO_MODEL 换一个已开通渠道的视频模型;\n' +
  231. ' C) 用「静帧 + Ken Burns 运镜」兜底(fmode-mv video --fallback-still),\n' +
  232. ' 把关键帧做成有推拉摇移的动态镜头,先出片、后替换真视频。',
  233. async probe({ deep = false } = {}) {
  234. const model = this.defaultModel();
  235. const list = await probeModelList(model);
  236. const r = await probeEndpoint(this.endpoint(), {
  237. method: 'POST',
  238. body: { model, prompt: 'probe', duration: 4 },
  239. timeout: 25_000,
  240. });
  241. if (!r.exists) {
  242. return { available: false, model, detail: `视频端点不存在:${r.detail}`, hint: this.hint };
  243. }
  244. if (r.noChannel) {
  245. return {
  246. available: false,
  247. model,
  248. detail: `模型 ${model} 无可用渠道:${r.detail.replace(/^端点存在但渠道未开通(HTTP \d+):/, '')}`,
  249. hint: this.hint,
  250. };
  251. }
  252. if (r.status >= 400) {
  253. return { available: false, model, detail: `提交失败(HTTP ${r.status})`, hint: this.hint };
  254. }
  255. return { available: true, model, detail: '提交成功,渠道已开通' };
  256. },
  257. async call({ image, prompt, duration = 5, size = '1280x720' }) {
  258. const model = this.defaultModel();
  259. const payload = { model, prompt, duration, size, image };
  260. const { taskId, result } = await runAsyncTask({
  261. submitPath: this.endpoint(),
  262. listPath: this.fetchPath(),
  263. payload,
  264. taskLabel: '图生视频',
  265. interval: 8000,
  266. maxWait: 900_000,
  267. pick: (raw) => ({
  268. status: raw?.status || 'running',
  269. progress: raw?.progress,
  270. error: raw?.fail_reason || raw?.error?.message || raw?.error,
  271. result: raw,
  272. }),
  273. });
  274. return { taskId, result };
  275. },
  276. },
  277. // ---------- 唇形驱动 ----------
  278. lipsync: {
  279. id: 'lipsync',
  280. kind: 'lipsync',
  281. label: '唇形驱动(口型对齐人声)',
  282. available: false, // ← 平台暂无通道
  283. note: '平台暂无通道,留接口 + 外部服务对接说明',
  284. defaultModel: () => overrides('lipsync').model || process.env.FEME_LIPSYNC_MODEL || '',
  285. endpoint: () => overrides('lipsync').endpoint || '/lipsync/generations',
  286. hint:
  287. '唇形驱动通道平台暂无。可选方案:\n' +
  288. ' A) 自建/对接 SadTalker / Wav2Lip / LatentSync,用 FEME_LIPSYNC_ENDPOINT 指向你的服务;\n' +
  289. ' B) 用「演唱镜头」替代:关键帧 + 图生视频做表情/头部微动,不严格对口型(成片观感可接受);\n' +
  290. ' C) 只走空镜 B-roll + 字幕,完全不做唇形(纯音乐 MV 常见做法)。',
  291. async probe() {
  292. const ep = this.endpoint();
  293. const r = await probeEndpoint(ep, { method: 'POST', body: { image: 'probe', audio: 'probe' }, timeout: 20_000 });
  294. if (!r.exists) {
  295. return { available: false, detail: `平台无此通道:${r.detail}`, hint: this.hint };
  296. }
  297. if (r.noChannel || r.status >= 400) {
  298. return { available: false, detail: `端点存在但不可用:${r.detail}`, hint: this.hint };
  299. }
  300. return { available: true, detail: '端点可用' };
  301. },
  302. async call() {
  303. throw new ProviderUnavailableError('唇形驱动通道不可用', { endpoint: this.endpoint(), hint: this.hint });
  304. },
  305. },
  306. // ---------- TTS:口播配音 ----------
  307. tts: {
  308. id: 'tts',
  309. kind: 'tts',
  310. label: '语音合成(口播配音)',
  311. available: false, // ← 平台暂无通道
  312. note: '平台暂无通道,留接口 + 外部服务对接说明',
  313. defaultVoice: () => overrides('tts').voice || process.env.FEME_TTS_VOICE || 'zh-CN-XiaoxiaoNeural',
  314. defaultModel: () => overrides('tts').model || process.env.FEME_TTS_MODEL || 'tts-1',
  315. endpoint: () => overrides('tts').endpoint || '/audio/speech',
  316. hint:
  317. 'TTS 通道平台暂无。可选方案:\n' +
  318. ' A) 对接 OpenAI 兼容 /v1/audio/speech:设置 FEME_TTS_ENDPOINT 与 FEME_TTS_MODEL;\n' +
  319. ' B) 本地 edge-tts(免费、中文音色多):pip install edge-tts,本技能会自动探测并调用;\n' +
  320. ' C) 自备配音音频放入 work/audio/voiceover.mp3,用 --from 跳过本步。',
  321. async probe() {
  322. // 优先探测本地 edge-tts(免费、中文音色多),再探网关端点
  323. const local = await probeLocalTTS();
  324. if (local.available) {
  325. return { available: true, provider: 'edge-tts(local)', detail: local.detail };
  326. }
  327. const r = await probeEndpoint(this.endpoint(), {
  328. method: 'POST',
  329. body: { model: this.defaultModel(), input: 'probe', voice: this.defaultVoice() },
  330. timeout: 20_000,
  331. });
  332. if (!r.exists) {
  333. return { available: false, provider: '平台网关', detail: `平台无 TTS 通道(${r.detail});本地 edge-tts 亦未安装`, hint: this.hint };
  334. }
  335. if (r.noChannel || r.status >= 400) {
  336. return { available: false, provider: '平台网关', detail: `TTS 端点不可用:${r.detail}`, hint: this.hint };
  337. }
  338. return { available: true, provider: '平台网关', detail: '端点可用' };
  339. },
  340. async call({ text, outPath, voice }) {
  341. const local = await probeLocalTTS();
  342. if (local.available) return synthLocalTTS({ text, outPath, voice: voice || this.defaultVoice() });
  343. throw new ProviderUnavailableError('TTS 通道不可用', { endpoint: this.endpoint(), hint: this.hint });
  344. },
  345. },
  346. };
  347. // ==================== 本地 TTS 探测(edge-tts)====================
  348. async function probeLocalTTS() {
  349. const { spawnSync } = await import('node:child_process');
  350. try {
  351. const r = spawnSync('edge-tts', ['--version'], { encoding: 'utf8', timeout: 10_000 });
  352. if (r.status === 0) {
  353. return { available: true, provider: 'edge-tts(local)', detail: `本地 edge-tts 可用:${(r.stdout || '').trim().split('\n')[0]}` };
  354. }
  355. } catch {
  356. /* 未安装 */
  357. }
  358. return { available: false, provider: 'edge-tts(local)', detail: '未检测到本地 edge-tts' };
  359. }
  360. async function synthLocalTTS({ text, outPath, voice }) {
  361. const { spawn } = await import('node:child_process');
  362. const args = ['--voice', voice, '--text', text, '--write-media', outPath];
  363. await new Promise((resolve, reject) => {
  364. const p = spawn('edge-tts', args);
  365. let err = '';
  366. p.stderr.on('data', (d) => (err += d));
  367. p.on('error', reject);
  368. p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`edge-tts 退出码 ${code}: ${err.slice(0, 300)}`))));
  369. });
  370. return { outPath, voice, provider: 'edge-tts(local)' };
  371. }
  372. // ==================== 注册表 API ====================
  373. export const KINDS = ['chat', 'image', 'music', 'video', 'lipsync', 'tts'];
  374. export function getProvider(kind) {
  375. const p = PROVIDERS[kind];
  376. if (!p) {
  377. throw new Error(`未知 provider 类型「${kind}」。可用:${KINDS.join(' / ')}`);
  378. }
  379. return p;
  380. }
  381. /** 探活全部 provider(并行),返回结构化能力矩阵 */
  382. export async function probeAll({ deep = false } = {}) {
  383. const results = await Promise.all(
  384. KINDS.map(async (k) => {
  385. const p = PROVIDERS[k];
  386. try {
  387. const r = await p.probe({ deep });
  388. 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) };
  389. } catch (e) {
  390. return { kind: k, label: p.label, available: false, detail: `探活异常:${e.message.split('\n')[0]}`, hint: p.hint };
  391. }
  392. })
  393. );
  394. return results;
  395. }
  396. /**
  397. * 统一调用入口。未开通通道会抛 ProviderUnavailableError(带 hint),
  398. * 绝不会「假装成功」。
  399. */
  400. export async function invoke(kind, params) {
  401. const p = getProvider(kind);
  402. return p.call(params);
  403. }