110 lines
4.6 KiB
JavaScript
110 lines
4.6 KiB
JavaScript
#!/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 <VAR> / --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);
|
||
}
|