#!/usr/bin/env node /** * api-call.mjs — 把任意 OpenAI 兼容的 chat/completions 端点,包装成一个"无头 CLI"。 * * 为什么要有它:群聊里每个成员都是"一个能被 spawn 的命令"。有的成员没有 CLI(例如直接用 * 智谱 BigModel API 的 GLM-4-Flash),那就给它一个只做一件事的小包装:发一次请求、把回答打到 stdout。 * * 用法(提示词必须是**最后一个**位置参数,aig 会把提示词追加在最后): * node api-call.mjs --base https://open.bigmodel.cn/api/paas/v4 --model GLM-4-Flash-250414 \ * --key-file ~/.zcode/v2/provider_config.json --key-path config.a.b.0.key "你的提示词" * * 密钥三种来源(按优先级):--key-env / --key-file + --key-path / --key <明文> * 输出:一行 JSON {type:"result", result:"…", model, usage},方便 aig 结构化取答复。 */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const argv = process.argv.slice(2); const flag = (n, d) => { const i = argv.indexOf('--' + n); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; }; const BASE = String(flag('base', '')).replace(/\/+$/, ''); const MODEL = String(flag('model', '')); const TIMEOUT = Number(flag('timeout', 180)) * 1000; const SYSTEM = flag('system', ''); const prompt = argv[argv.length - 1] && !argv[argv.length - 1].startsWith('--') ? argv[argv.length - 1] : ''; const posArgs = argv.filter((a) => !a.startsWith('--')); if (!BASE || !MODEL || !prompt) { process.stderr.write('api-call: 需要 --base、--model 和非空提示词\n'); process.exit(2); } function resolveKey() { const envName = flag('key-env', ''); if (envName) return process.env[envName] || ''; const file = flag('key-file', ''); if (file) { const p = file.replace(/^~(?=\/|\\)/, os.homedir()); let obj; try { obj = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { throw new Error(`读不到密钥文件 ${p}:${e.message}`); } const dotted = String(flag('key-path', '')); if (dotted) { let cur = obj; for (const seg of dotted.split('.')) { if (cur == null) break; cur = Array.isArray(cur) ? cur[Number(seg)] : cur[seg]; } if (typeof cur === 'string' && cur) return cur; throw new Error(`key-path "${dotted}" 在 ${p} 里没找到字符串`); } // 没给路径就宽容地找第一个像 key 的字段 const found = (function walk(o, depth) { if (depth > 6 || o == null) return null; if (typeof o === 'string') return null; for (const [k, v] of Object.entries(o)) { if (/^(api[_-]?key|apikey|token)$/i.test(k) && typeof v === 'string' && v.length > 8) return v; const deep = walk(v, depth + 1); if (deep) return deep; } return null; })(obj, 0); if (found) return found; throw new Error(`没在 ${p} 里找到 api key`); } return String(flag('key', '')); } let key; try { key = resolveKey(); } catch (e) { process.stderr.write(`api-call: ${e.message}\n`); process.exit(3); } if (!key) { process.stderr.write('api-call: 没拿到密钥(--key-env / --key-file / --key 都没给有效的)\n'); process.exit(3); } const messages = []; if (SYSTEM) messages.push({ role: 'system', content: SYSTEM }); messages.push({ role: 'user', content: prompt }); const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), TIMEOUT); try { const res = await fetch(`${BASE}/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify({ model: MODEL, messages, stream: false }), signal: ctl.signal, }); const text = await res.text(); clearTimeout(timer); if (!res.ok) { process.stderr.write(`api-call: HTTP ${res.status} ${text.slice(0, 400)}\n`); process.exit(4); } let json; try { json = JSON.parse(text); } catch { process.stderr.write(`api-call: 返回不是 JSON:${text.slice(0, 300)}\n`); process.exit(5); } const reply = json.choices?.[0]?.message?.content; if (typeof reply !== 'string') { process.stderr.write(`api-call: 拿不到 choices[0].message.content:${text.slice(0, 300)}\n`); process.exit(6); } process.stdout.write(`${JSON.stringify({ type: 'result', subtype: 'success', model: json.model || MODEL, result: reply.trim(), usage: json.usage || null, finish_reason: json.choices?.[0]?.finish_reason || null, })}\n`); } catch (e) { clearTimeout(timer); process.stderr.write(`api-call: ${e.name === 'AbortError' ? '请求超时' : e.message}\n`); process.exit(7); }