Files
ai-group-chat/tools/model-probe.mjs
T

138 lines
6.7 KiB
JavaScript
Raw 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
/**
* 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');