Files

110 lines
4.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}