/** * m3.mjs — new-api M3 客户端(openai 协议 + function-calling) * owner:amodel-gen spike | 消费方:gen.mjs(ReAct 循环每轮调 chat) * * 单一职责:发一轮 chat/completions(带 tools)→ 返 {message, usage, finishReason}。 * 含超时 + 重试(429/5xx/IO 重试;4xx 不重试)。模型固定 MiniMax-M3(founder 定)。 * 密钥(§9):优先 env NEWAPI_KEY,否则从 docs/内网凭据与端点.md(内网允许入仓)解析——doc 是单一事实源。 */ 'use strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { REPO_ROOT } from './tools.mjs'; const BASE = (process.env.NEWAPI_BASE_URL || 'http://100.64.0.8:3000').replace(/\/+$/, ''); const MODEL = process.env.AMGEN_MODEL || 'MiniMax-M3'; // founder:只用 M3 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 取网关 token:env 优先,否则解析项目密钥文档(§9 单一事实源)。 */ function getKey() { if (process.env.NEWAPI_KEY) return process.env.NEWAPI_KEY; try { const doc = readFileSync(join(REPO_ROOT, 'docs/内网凭据与端点.md'), 'utf8'); const m = doc.match(/NEWAPI_KEY[^\n]*?(sk-[A-Za-z0-9]+)/); if (m) return m[1]; } catch { /* fall through */ } throw new Error('找不到 NEWAPI_KEY(请设 env 或确认 docs/内网凭据与端点.md 存在)'); } const KEY = getKey(); /** * 发一轮带 tools 的 chat/completions。 * @param {Array} messages openai 消息数组(含 system/user/assistant/tool) * @param {Array} tools function-calling 工具定义 * @param {{maxTokens?:number, temperature?:number, timeoutMs?:number}} [opt] * @returns {Promise<{message:object, usage:object, finishReason:string}>} */ export async function chat(messages, tools, opt = {}) { const { maxTokens = 16000, temperature = 0.3, timeoutMs = 180000 } = opt; const body = { model: MODEL, messages, tools, tool_choice: 'auto', max_tokens: maxTokens, temperature }; let lastErr; for (let attempt = 0; attempt < 3; attempt++) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const r = await fetch(`${BASE}/v1/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: ctrl.signal, }); clearTimeout(timer); if (!r.ok) { const txt = await r.text().catch(() => ''); // 4xx(除 429)= 请求本身错,不重试,直接抛 if (r.status !== 429 && r.status < 500) throw new Error(`HTTP ${r.status}: ${txt.slice(0, 300)}`); lastErr = new Error(`HTTP ${r.status}: ${txt.slice(0, 200)}`); await sleep(1500 * (attempt + 1)); continue; } const d = await r.json(); const ch = d.choices && d.choices[0]; if (!ch) throw new Error('响应无 choices: ' + JSON.stringify(d).slice(0, 300)); return { message: ch.message, usage: d.usage || {}, finishReason: ch.finish_reason }; } catch (e) { clearTimeout(timer); lastErr = e; await sleep(1500 * (attempt + 1)); // 超时/IO 重试 } } throw lastErr || new Error('M3 chat 失败(重试耗尽)'); } export { MODEL, BASE };