api.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. // lib/api.mjs — 网关 HTTP 封装(chat / images / 异步 task 轮询 / 文件下载)
  2. // 零依赖,仅用 Node 内置 fetch(Node >= 18)。
  3. import { loadConfig, requireToken, mask } from './config.mjs';
  4. /** 通道未开通/上游不可用时抛出,携带可操作指引 */
  5. export class ProviderUnavailableError extends Error {
  6. constructor(message, { endpoint, status, hint, cause } = {}) {
  7. super(message);
  8. this.name = 'ProviderUnavailableError';
  9. this.endpoint = endpoint;
  10. this.status = status;
  11. this.hint = hint;
  12. if (cause) this.cause = cause;
  13. }
  14. }
  15. /** 网关返回了业务错误(参数错/额度等) */
  16. export class GatewayError extends Error {
  17. constructor(message, { endpoint, status, body } = {}) {
  18. super(message);
  19. this.name = 'GatewayError';
  20. this.endpoint = endpoint;
  21. this.status = status;
  22. this.body = body;
  23. }
  24. }
  25. const DEFAULT_TIMEOUT = 120_000;
  26. function joinUrl(base, path) {
  27. return `${String(base).replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`;
  28. }
  29. /**
  30. * 底层请求。自动带 token、超时、把网关错误翻译成可读异常。
  31. * @returns {Promise<{status:number, headers:Headers, body:any, text:string}>}
  32. */
  33. export async function request(path, opts = {}) {
  34. const {
  35. method = 'GET',
  36. body,
  37. timeout = DEFAULT_TIMEOUT,
  38. base, // 覆盖 base(默认 config.baseUrl)
  39. headers = {},
  40. expectJson = true,
  41. raw = false,
  42. retries = 0,
  43. retryDelay = 1500,
  44. } = opts;
  45. const cfg = loadConfig();
  46. const token = requireToken();
  47. const url = /^https?:\/\//.test(path) ? path : joinUrl(base || cfg.baseUrl, path);
  48. let lastErr;
  49. for (let attempt = 0; attempt <= retries; attempt++) {
  50. if (attempt > 0) {
  51. await sleep(retryDelay * 2 ** (attempt - 1));
  52. }
  53. try {
  54. const res = await fetch(url, {
  55. method,
  56. headers: {
  57. Authorization: `Bearer ${token}`,
  58. Accept: 'application/json',
  59. ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
  60. ...headers,
  61. },
  62. body: body !== undefined ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
  63. signal: AbortSignal.timeout(timeout),
  64. });
  65. const text = raw ? '' : await res.text();
  66. let parsed = null;
  67. if (expectJson && text) {
  68. try {
  69. parsed = JSON.parse(text);
  70. } catch {
  71. parsed = null;
  72. }
  73. }
  74. if (!res.ok) {
  75. const err = classifyHttpError(res.status, text, url, parsed);
  76. // 5xx / 429 可重试
  77. if ((res.status >= 500 || res.status === 429) && attempt < retries) {
  78. lastErr = err;
  79. continue;
  80. }
  81. throw err;
  82. }
  83. return { status: res.status, headers: res.headers, body: parsed, text };
  84. } catch (e) {
  85. if (e instanceof ProviderUnavailableError || e instanceof GatewayError) throw e;
  86. lastErr = new GatewayError(`${e.name === 'TimeoutError' ? '请求超时' : '网络错误'}: ${e.message}`, {
  87. endpoint: url,
  88. });
  89. if (attempt >= retries) throw lastErr;
  90. }
  91. }
  92. throw lastErr;
  93. }
  94. /** 把 HTTP 状态码 / 网关错误体翻译成「能照着做」的错误 */
  95. function classifyHttpError(status, text, url, parsed) {
  96. const raw = (parsed && (parsed.error?.message || parsed.message)) || text.slice(0, 300);
  97. const lower = String(raw).toLowerCase();
  98. // 渠道未开通的典型特征
  99. const unavailable =
  100. lower.includes('no available channel') ||
  101. lower.includes('model_not_found') ||
  102. lower.includes('channel') && lower.includes('not') ||
  103. lower.includes('task request failed') ||
  104. lower.includes('upstream') && (status === 404 || status === 502 || status === 503) ||
  105. lower.includes('not implemented');
  106. const hint =
  107. status === 401 || status === 403
  108. ? '凭据无效或权限不足:检查 FEME_API_TOKEN / /opt/data/fmode-identity.json 的 newapi_token'
  109. : unavailable
  110. ? '该模型/通道在当前网关未开通。可用 `fmode-mv providers` 查看替代通道,或在 provider 配置里改换模型名。'
  111. : status === 429
  112. ? '触发限流,稍后重试或降低并发'
  113. : undefined;
  114. const message = `网关 ${status} @ ${url}\n ${raw}` + (hint ? `\n → ${hint}` : '');
  115. if (unavailable) {
  116. return new ProviderUnavailableError(message, { endpoint: url, status, hint });
  117. }
  118. return new GatewayError(message, { endpoint: url, status, body: raw });
  119. }
  120. export function sleep(ms) {
  121. return new Promise((r) => setTimeout(r, ms));
  122. }
  123. /** 下载远程文件为 Buffer */
  124. export async function download(url, { timeout = 180_000 } = {}) {
  125. const res = await fetch(url, { signal: AbortSignal.timeout(timeout) });
  126. if (!res.ok) throw new GatewayError(`下载失败 ${res.status}: ${url}`);
  127. return Buffer.from(await res.arrayBuffer());
  128. }
  129. // ==================== Chat ====================
  130. /**
  131. * 文本对话(作曲填词 / 分镜 / 口播稿都走这里)。
  132. * @param {{model?:string, messages:Array, system?:string, temperature?:number, max_tokens?:number, json?:boolean, timeout?:number}} o
  133. */
  134. export async function chat(o = {}) {
  135. const {
  136. model = process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
  137. messages = [],
  138. system,
  139. temperature = 0.8,
  140. max_tokens = 4096,
  141. json = false,
  142. timeout = 180_000,
  143. retries = 1,
  144. } = o;
  145. const msgs = system ? [{ role: 'system', content: system }, ...messages] : messages;
  146. const body = {
  147. model,
  148. messages: msgs,
  149. temperature,
  150. max_tokens,
  151. stream: false,
  152. };
  153. if (json) body.response_format = { type: 'json_object' };
  154. const res = await request('/chat/completions', {
  155. method: 'POST',
  156. body,
  157. timeout,
  158. retries,
  159. });
  160. const choice = res.body?.choices?.[0];
  161. // 部分推理模型(如 deepseek-v4)会把内容放在 reasoning_content,content 可能为空
  162. let content = choice?.message?.content;
  163. if ((content == null || content === '') && choice?.message?.reasoning_content) {
  164. if (json) {
  165. // 尝试从 reasoning 里抠出 JSON
  166. const m = String(choice.message.reasoning_content).match(/\{[\s\S]*\}/);
  167. content = m ? m[0] : choice.message.reasoning_content;
  168. } else {
  169. content = choice.message.reasoning_content;
  170. }
  171. }
  172. if (content == null) {
  173. throw new GatewayError(`chat 返回为空(model=${model})`, { body: res.body });
  174. }
  175. return {
  176. content: String(content),
  177. model: res.body?.model || model,
  178. usage: res.body?.usage,
  179. finishReason: choice?.finish_reason || null,
  180. truncated: choice?.finish_reason === 'length',
  181. };
  182. }
  183. /**
  184. * 让模型产出 JSON 并解析。
  185. *
  186. * 现实里有两类坑,这里都自动处理:
  187. * 1) 推理型模型(如 deepseek-v4-flash)会把大量 token 花在推理上,
  188. * 导致 max_tokens 被吃满、JSON 被截断 —— finish_reason === 'length'。
  189. * → 自动把 max_tokens 翻倍重试,最多升到上限。
  190. * 2) 输出带 markdown 围栏 / 前后废话 / 轻微格式错误。
  191. * → extractJSON 已能剥离围栏;仍失败则带着错误提示让模型重出一次。
  192. */
  193. export async function chatJSON(o = {}) {
  194. const {
  195. model = process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
  196. messages = [],
  197. system,
  198. temperature = 0.7,
  199. max_tokens = 4096,
  200. maxTokensCap = 32768,
  201. escalationSteps = 2,
  202. repairAttempts = 1,
  203. timeout = 300_000,
  204. } = o;
  205. const msgs = system ? [{ role: 'system', content: system }, ...messages] : [...messages];
  206. let budget = max_tokens;
  207. let lastText = '';
  208. let lastErr = null;
  209. for (let step = 0; step <= escalationSteps; step++) {
  210. const r = await chat({ model, messages: msgs, temperature, max_tokens: budget, json: true, timeout });
  211. lastText = r.content;
  212. const parsed = extractJSON(r.content);
  213. if (parsed !== null) return { ...r, json: parsed, maxTokensUsed: budget };
  214. // 截断 → 加大预算重来
  215. if (r.truncated && budget < maxTokensCap) {
  216. budget = Math.min(maxTokensCap, budget * 2);
  217. continue;
  218. }
  219. lastErr = r;
  220. break;
  221. }
  222. // 修复式重试:把失败输出回喂,要求只吐合法 JSON
  223. for (let i = 0; i < repairAttempts; i++) {
  224. const tail = lastText.slice(-1200);
  225. const r = await chat({
  226. model,
  227. messages: [
  228. ...msgs,
  229. { role: 'assistant', content: tail },
  230. {
  231. role: 'user',
  232. content:
  233. '上面的 JSON 不完整或格式非法,无法解析。请重新输出**完整且合法**的 JSON:' +
  234. '不要省略任何数组元素、不要写 markdown 围栏、不要在 JSON 前后添加任何解释文字。' +
  235. '如果内容太长,请压缩每个字段的文字长度,但必须保证 JSON 结构完整闭合。',
  236. },
  237. ],
  238. temperature: 0.3,
  239. max_tokens: maxTokensCap,
  240. json: true,
  241. timeout,
  242. });
  243. lastText = r.content;
  244. const parsed = extractJSON(r.content);
  245. if (parsed !== null) return { ...r, json: parsed, maxTokensUsed: maxTokensCap, repaired: true };
  246. }
  247. const hint =
  248. lastErr?.truncated || lastText.length > 3000
  249. ? '\n → 疑似输出被 max_tokens 截断。可提高 --max-tokens,或减少镜头数(--shots),或换用非推理型模型(FEME_CHAT_MODEL=doubao-seed-2-1-pro)。'
  250. : '';
  251. throw new GatewayError(
  252. `模型未返回可解析的 JSON(model=${model},已尝试升配与修复)。输出尾部片段:\n${lastText.slice(-400)}${hint}`
  253. );
  254. }
  255. /** 从可能带 ```json 围栏或前后缀说明的文本中抠出 JSON */
  256. export function extractJSON(text) {
  257. if (!text) return null;
  258. const s = String(text).trim();
  259. const tryParse = (t) => {
  260. try {
  261. return JSON.parse(t);
  262. } catch {
  263. return null;
  264. }
  265. };
  266. let v = tryParse(s);
  267. if (v !== null) return v;
  268. const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
  269. if (fence) {
  270. v = tryParse(fence[1].trim());
  271. if (v !== null) return v;
  272. }
  273. const obj = s.match(/[{[][\s\S]*[}\]]/);
  274. if (obj) {
  275. v = tryParse(obj[0]);
  276. if (v !== null) return v;
  277. }
  278. return null;
  279. }
  280. // ==================== Images ====================
  281. /**
  282. * 文生图 / 图生图(关键帧)。
  283. * @param {{prompt:string, model?:string, size?:string, n?:number, refImage?:string|Buffer, timeout?:number}} o
  284. * @returns {Promise<{buffers:Buffer[], model:string, size:string}>}
  285. */
  286. export async function generateImage(o = {}) {
  287. const {
  288. prompt,
  289. model = process.env.FEME_IMAGE_MODEL || 'gpt-image-2.5-sunburst',
  290. size = '1024x1024',
  291. n = 1,
  292. refImage, // 参考图(URL 或本地路径/base64)—— 用于人物一致性
  293. timeout = 300_000,
  294. retries = 1,
  295. } = o;
  296. if (!prompt) throw new GatewayError('generateImage 需要 prompt');
  297. const payload = { model, prompt, n, size };
  298. if (refImage) payload.image = await toImageInput(refImage); // 兼容 OpenAI 风格图生图
  299. const res = await request('/images/generations', {
  300. method: 'POST',
  301. body: payload,
  302. timeout,
  303. retries,
  304. });
  305. const items = res.body?.data || [];
  306. const buffers = [];
  307. for (const it of items) {
  308. if (it.b64_json) buffers.push(Buffer.from(it.b64_json, 'base64'));
  309. else if (it.url) buffers.push(await download(it.url));
  310. }
  311. if (!buffers.length) {
  312. throw new GatewayError(`图片通道未返回图像数据(model=${model})`, { body: JSON.stringify(res.body).slice(0, 300) });
  313. }
  314. return { buffers, model: res.body?.model || model, size };
  315. }
  316. async function toImageInput(ref) {
  317. if (Buffer.isBuffer(ref)) return `data:image/png;base64,${ref.toString('base64')}`;
  318. const s = String(ref);
  319. if (/^https?:\/\//.test(s) || s.startsWith('data:')) return s;
  320. const { readFileSync } = await import('node:fs');
  321. const buf = readFileSync(s);
  322. const ext = s.toLowerCase().endsWith('.jpg') || s.toLowerCase().endsWith('.jpeg') ? 'jpeg' : 'png';
  323. return `data:image/${ext};base64,${buf.toString('base64')}`;
  324. }
  325. // ==================== 异步任务(音乐 / 视频 / 唇形) ====================
  326. /**
  327. * 提交一个异步任务并轮询至完成。
  328. * 不同通道的字段名差异用 pick 函数吸收。
  329. *
  330. * @param {object} o
  331. * @param {string} o.submitPath 提交路径
  332. * @param {string} o.fetchPath 查询路径(返回单任务)
  333. * @param {string} o.listPath 查询路径(返回任务列表,多数通道用这个)
  334. * @param {object} o.payload 提交体
  335. * @param {number} o.interval 轮询间隔 ms
  336. * @param {number} o.maxWait 最长等待 ms
  337. * @param {(t:any)=>({status:string, progress?:number, result?:any, error?:string})} o.pick 状态解析
  338. */
  339. export async function runAsyncTask(o) {
  340. const {
  341. submitPath,
  342. fetchPath,
  343. listPath,
  344. payload,
  345. interval = 5000,
  346. maxWait = 600_000,
  347. pick,
  348. submitBase,
  349. fetchBase,
  350. taskLabel = 'task',
  351. onTick,
  352. } = o;
  353. const cfg = loadConfig();
  354. const submitRes = await request(submitPath, {
  355. method: 'POST',
  356. body: payload,
  357. base: submitBase || cfg.rootUrl,
  358. timeout: 60_000,
  359. retries: 0,
  360. });
  361. const taskId =
  362. submitRes.body?.data ??
  363. submitRes.body?.id ??
  364. submitRes.body?.task_id ??
  365. submitRes.body?.taskId ??
  366. submitRes.body?.data?.task_id ??
  367. submitRes.body?.data?.id;
  368. if (!taskId) {
  369. throw new GatewayError(`提交${taskLabel}未返回任务 ID`, {
  370. body: JSON.stringify(submitRes.body).slice(0, 300),
  371. });
  372. }
  373. const started = Date.now();
  374. let last = null;
  375. while (Date.now() - started < maxWait) {
  376. await sleep(interval);
  377. const id = typeof taskId === 'object' ? taskId.id : taskId;
  378. const res = listPath
  379. ? await request(listPath, {
  380. method: 'POST',
  381. body: { ids: [id] },
  382. base: fetchBase || cfg.rootUrl,
  383. timeout: 60_000,
  384. })
  385. : await request(`${fetchPath}?ids=${encodeURIComponent(id)}`, {
  386. base: fetchBase || cfg.rootUrl,
  387. timeout: 60_000,
  388. });
  389. const raw = listPath ? res.body?.data?.[0] ?? res.body?.data ?? res.body : res.body?.data ?? res.body;
  390. const st = pick ? pick(raw) : { status: normalStatus(raw?.status) };
  391. last = st;
  392. if (onTick) onTick({ ...st, taskId: id, elapsed: Date.now() - started });
  393. if (st.status === 'succeeded' || st.status === 'complete' || st.status === 'completed') {
  394. return { taskId: id, result: st.result ?? raw, elapsed: Date.now() - started };
  395. }
  396. if (st.status === 'failed' || st.status === 'error') {
  397. throw new GatewayError(`${taskLabel}执行失败:${st.error || '未知原因'}`, {
  398. body: JSON.stringify(raw).slice(0, 500),
  399. });
  400. }
  401. }
  402. throw new GatewayError(
  403. `${taskLabel}超时(${Math.round(maxWait / 1000)}s 未完成,最后状态 ${last?.status || 'unknown'},taskId=${last?.taskId || '-'})`
  404. );
  405. }
  406. function normalStatus(s) {
  407. const v = String(s || '').toLowerCase();
  408. if (['success', 'succeeded', 'succeed', 'complete', 'completed', 'done'].includes(v)) return 'succeeded';
  409. if (['fail', 'failed', 'error', 'cancelled', 'canceled'].includes(v)) return 'failed';
  410. if (!v) return 'unknown';
  411. return 'running';
  412. }