138 lines
6.7 KiB
JavaScript
138 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* model-probe.mjs — 把各渠道的候选免费模型全量探一遍,并按并发压测。
|
||
*
|
||
* 用法:
|
||
* node tools/model-probe.mjs # 全量探测(并发 4)
|
||
* node tools/model-probe.mjs --concurrency 6 # 改并发
|
||
* node tools/model-probe.mjs --only opencode # 只探某个渠道
|
||
*
|
||
* 输出:表格 + tools/model-probe-result.json(含每个模型的 ok / 耗时 / 错误)
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { spawn } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const SCRIPTS = path.resolve(HERE, '..', 'scripts');
|
||
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 CONC = Number(flag('concurrency', 4));
|
||
const ONLY = flag('only', '');
|
||
const HOME = os.homedir();
|
||
|
||
const QODER = path.join(HOME, '.qoderwake-cn', 'bin', 'qodercli', 'qodercli-cn-wake.exe');
|
||
const QODER_CFG = path.join(HOME, '.qoderwake-cn');
|
||
const CBC_BIN = 'H:/workbuddy/resources/app.asar.unpacked/cli/bin/codebuddy';
|
||
const CBC_NODE = (() => {
|
||
const base = path.join(HOME, '.workbuddy', 'binaries', 'node', 'versions');
|
||
try {
|
||
const v = fs.readdirSync(base).filter((x) => /^\d/.test(x)).sort().reverse()[0];
|
||
return path.join(base, v, 'node.exe');
|
||
} catch { return process.execPath; }
|
||
})();
|
||
const OC = path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
|
||
|
||
/** 候选清单:渠道 / 模型 id / 怎么调 */
|
||
const CANDIDATES = [
|
||
// --- opencode Zen 的免费档 ---
|
||
...['big-pickle', 'nemotron-3.5-lightning-free', 'nemotron-3-ultra-free', 'ling-3.0-flash-fin-free', 'mimo-v2.5-free']
|
||
.map((m) => ({ channel: 'opencode', model: m, cmd: OC, args: ['run', '--format', 'json', '-m', `opencode/${m}`] })),
|
||
// --- Qoder CLI ---
|
||
...['Qwen3.8-Flash', 'Qwen3.8-Max']
|
||
.map((m) => ({ channel: 'qoder', model: m, cmd: QODER, args: ['--config-dir', QODER_CFG, '-p', '-o', 'json', '-m', m] })),
|
||
// --- WorkBuddy 随附的 CodeBuddy CLI ---
|
||
...['hy4-preview-f', 'hy3', 'hy4-preview']
|
||
.map((m) => ({ channel: 'codebuddy', model: m, cmd: CBC_NODE, args: [CBC_BIN, '-p', '--output-format', 'json', '--model', m] })),
|
||
// --- ZCode 的 BigModel API(enabled: true 的那些) ---
|
||
...['GLM-4-Flash-250414', 'GLM-4-FlashX-250414', 'GLM-4.1V-Thinking-Flash', 'GLM-4V-Flash', 'codegeex-4']
|
||
.map((m) => ({
|
||
channel: 'bigmodel', model: m, cmd: process.execPath,
|
||
args: [path.join(SCRIPTS, 'api-call.mjs'), '--base', 'https://open.bigmodel.cn/api/paas/v4', '--model', m,
|
||
'--key-file', path.join(HOME, '.zcode', 'v2', 'provider_config.json'),
|
||
'--key-path', 'config.providerConfigRules.providerRules.0.config.access.apiKey'],
|
||
})),
|
||
];
|
||
|
||
const list = ONLY ? CANDIDATES.filter((c) => c.channel === ONLY) : CANDIDATES;
|
||
|
||
/** 从各家输出里把"这次花了多少钱"抠出来——判"完全免费"只认模型自己报的数,不认印象。 */
|
||
function extractCost(channel, text) {
|
||
const t = text.replace(/\u001b\[[0-9;]*m/g, '');
|
||
if (channel === 'opencode') {
|
||
let sum = 0, seen = false;
|
||
for (const line of t.split('\n')) {
|
||
const s = line.trim();
|
||
if (!s.startsWith('{')) continue;
|
||
try {
|
||
const ev = JSON.parse(s);
|
||
if (ev?.type === 'step_finish' && typeof ev.part?.cost === 'number') { sum += ev.part.cost; seen = true; }
|
||
} catch { /* 跳过 */ }
|
||
}
|
||
return seen ? sum : null;
|
||
}
|
||
// qoder / codebuddy:Claude Code 风格的 result 对象(或数组)
|
||
for (const chunk of [t, ...t.split('\n')]) {
|
||
const s = chunk.trim();
|
||
if (!s.startsWith('{') && !s.startsWith('[')) continue;
|
||
try {
|
||
const j = JSON.parse(s);
|
||
const objs = Array.isArray(j) ? j.reverse() : [j];
|
||
for (const o of objs) if (o && typeof o.total_cost_usd === 'number') return o.total_cost_usd;
|
||
} catch { /* 继续 */ }
|
||
}
|
||
return null; // bigmodel 这类只回 usage,没有价钱字段
|
||
}
|
||
|
||
function runOne(c) {
|
||
const prompt = `只回复四个字:${c.model} 就绪`;
|
||
const t0 = Date.now();
|
||
return new Promise((resolve) => {
|
||
const child = spawn(c.cmd, [...c.args, prompt], { windowsHide: true, cwd: os.tmpdir(), env: { ...process.env, NO_COLOR: '1' } });
|
||
let out = '', err = '';
|
||
const timer = setTimeout(() => { try { child.kill(); } catch { /* 忽略 */ } }, 180000);
|
||
child.stdout.on('data', (d) => { out += d; });
|
||
child.stderr.on('data', (d) => { err += d; });
|
||
child.on('close', (code) => {
|
||
clearTimeout(timer);
|
||
const ms = Date.now() - t0;
|
||
const text = (out + '\n' + err).replace(/\u001b\[[0-9;]*m/g, '');
|
||
const ok = code === 0 && !/not available in your country|No auth|not logged in|HTTP 4\d\d|Error:/i.test(text);
|
||
const cost = ok ? extractCost(c.channel, out + '\n' + err) : null;
|
||
resolve({ ...c, ok, code, ms, cost, sample: text.replace(/\s+/g, ' ').slice(0, 110) });
|
||
});
|
||
child.on('error', (e) => { clearTimeout(timer); resolve({ ...c, ok: false, code: -1, ms: Date.now() - t0, cost: null, sample: String(e.message) }); });
|
||
});
|
||
}
|
||
|
||
async function pool(items, size, fn) {
|
||
const out = new Array(items.length);
|
||
let i = 0;
|
||
await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
|
||
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]); }
|
||
}));
|
||
return out;
|
||
}
|
||
|
||
const t0 = Date.now();
|
||
const results = await pool(list, CONC, runOne);
|
||
const totalMs = Date.now() - t0;
|
||
|
||
results.sort((a, b) => Number(b.ok) - Number(a.ok) || a.ms - b.ms);
|
||
const fmtCost = (c) => (c === null ? '未报' : c === 0 ? '0 ✔' : String(c));
|
||
const head = ['渠道', '模型', '结果', '耗时', '这次花费'];
|
||
const rows = results.map((r) => [r.channel, r.model, r.ok ? 'OK' : `FAIL(${r.code})`, `${(r.ms / 1000).toFixed(1)}s`, r.ok ? fmtCost(r.cost) : '-']);
|
||
const w = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
|
||
const line = (c) => c.map((x, i) => String(x).padEnd(w[i])).join(' ');
|
||
console.log(`\n并发 ${CONC} · ${list.length} 个候选 · 总耗时 ${(totalMs / 1000).toFixed(1)}s\n`);
|
||
console.log(line(head)); console.log(line(w.map((n) => '─'.repeat(n))));
|
||
for (const r of rows) console.log(line(r));
|
||
const bad = results.filter((r) => !r.ok);
|
||
if (bad.length) { console.log('\n失败详情:'); for (const b of bad) console.log(` ${b.channel}/${b.model}:${b.sample}`); }
|
||
const okCount = results.filter((r) => r.ok).length;
|
||
console.log(`\n可用 ${okCount}/${results.length}`);
|
||
fs.writeFileSync(path.join(HERE, 'model-probe-result.json'), JSON.stringify({ at: new Date().toISOString(), concurrency: CONC, totalMs, results }, null, 2), 'utf8');
|