#!/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');