#!/usr/bin/env node /** * model-shootout.mjs — 给 opencode 的免费模型跑同一套可判分题,挑出免费档里最强的那个。 * * 用法: * node model-shootout.mjs # 跑默认题组 × 全部免费模型 * node model-shootout.mjs --models a,b # 只跑指定模型 * node model-shootout.mjs --timeout 240 # 每个模型超时(秒) * * 判分是客观字符串/数值匹配,不打印象分。结果同时打印成表并写入 shootout-result.json。 */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_MODELS = [ 'opencode/big-pickle', 'opencode/muse-spark-1.3-contributor-free', 'opencode/muse-spark-1.2-contributor-free', 'opencode/nemotron-3-ultra-free', 'opencode/nemotron-3.5-lightning-free', 'opencode/ling-3.0-flash-fin-free', 'opencode/mimo-v2.5-free', ]; const argv = process.argv.slice(2); const getFlag = (name, def) => { const i = argv.indexOf(`--${name}`); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; }; const models = String(getFlag('models', '')).trim() ? String(getFlag('models')).split(',').map((s) => s.trim()).filter(Boolean) : DEFAULT_MODELS; const timeoutSec = Number(getFlag('timeout', 300)); /* --------------------------------------------------------------- 题组 */ // 长文本里埋一个只出现一次的事实,考长上下文里的定位能力。 const HAYSTACK = (() => { const facts = [ '仓库代号是 KESTREL-7。', '构建编号是 4412。', '负责人是林工。', '发布窗口在周四凌晨。', '数据库用的是 SQLite。', ]; const filler = [ '这条记录只用于填充上下文,不包含任何需要记忆的信息。', '例行巡检没有发现异常,指标都在阈值内。', '文档结构保持不变,仅调整了少量措辞。', '监控面板显示过去一小时流量平稳。', '值班同学已交接,交接内容无特殊事项。', ]; const lines = []; for (let i = 0; i < 60; i++) { lines.push(filler[i % filler.length]); if (i === 37) lines.push(`【关键】本段的唯一标识是 ${facts[1]} 备用代号 ${facts[0]}`); } return lines.join(''); })(); const TASKS = [ { id: 'Q1', q: '球拍和球一共 1.10 元,球拍比球贵 1.00 元。球多少钱?', // 接受 0.05 / .05 / 五分 / 5分 —— 中文作答也算对(第一版只认 0.05,把「五分」误判成错) grade: (t) => (/(^|[^\d.])0?\.05([^\d]|$)/.test(t) || /[五5]\s*分/.test(t) ? 1 : 0), expect: '0.05(或「五分」/「5分」)', }, { id: 'Q2', q: '小于 1000 的正整数里,有多少个满足「除以 7 余 3」?', grade: (t) => (/(^|[^\d])143([^\d]|$)/.test(t) ? 1 : 0), expect: '143', }, { id: 'Q3', q: 'Python 表达式 len(list(range(10))[2:5]) 的值是多少?', grade: (t) => (/(^|[^\d])3([^\d]|$)/.test(t) ? 1 : 0), expect: '3', }, { id: 'Q4', q: '已知「所有 A 都是 B」且「有些 B 是 C」。能否必然推出「有些 A 是 C」?只回答 能 或 不能。', grade: (t) => (/不能/.test(t) && !/能[^不]/.test(t.replace(/不能/g, '')) ? 1 : 0), expect: '不能', }, { id: 'Q5', q: '下面这段文本里出现过一次「构建编号」,它的数字是多少?\n\n' + HAYSTACK, grade: (t) => (/(^|[^\d])4412([^\d]|$)/.test(t) ? 1 : 0), expect: '4412', }, ]; const PROMPT = [ '按顺序回答下面的问题。严格要求输出格式:每题一行,形如 `Q1) 答案`,', '答案要极短(数字或两个字),不要解释、不要复述题目、不要用 markdown 代码块。', '', ...TASKS.map((t) => `${t.id}. ${t.q}`), ].join('\n'); /* --------------------------------------------------------------- 运行 */ function opencodeExe() { const cands = [ path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'), path.join(os.homedir(), '.opencode', 'bin', 'opencode'), ]; for (const c of cands) if (c && fs.existsSync(c)) return c; return 'opencode'; } function extract(text, id) { const re = new RegExp(`${id}\\s*[).::]\\s*([^\\n]+)`, 'i'); const m = text.match(re); return m ? m[1].trim() : text; } function runModel(model) { const started = Date.now(); const r = spawnSync(opencodeExe(), ['run', '-m', model, PROMPT], { encoding: 'utf8', windowsHide: true, timeout: timeoutSec * 1000, maxBuffer: 16 * 1024 * 1024, cwd: os.tmpdir(), env: { ...process.env, NO_COLOR: '1', AIGROUP_NO_WAKE: '1' }, }); const ms = Date.now() - started; const stdout = (r.stdout || '').replace(/\u001b\[[0-9;]*m/g, '').trim(); const stderr = (r.stderr || '').trim(); if (r.error || r.status !== 0) { return { model, ok: false, ms, error: r.error?.message || `exit ${r.status}`, stderr: stderr.slice(-400), raw: stdout.slice(0, 400) }; } const per = {}; let score = 0; for (const t of TASKS) { const ans = extract(stdout, t.id); const g = t.grade(ans) || t.grade(stdout); per[t.id] = { answer: ans.slice(0, 60), expect: t.expect, pass: Boolean(g) }; if (g) score++; } return { model, ok: true, ms, score, total: TASKS.length, per, raw: stdout.slice(0, 1200) }; } /* --------------------------------------------------------------- main */ const results = []; for (const model of models) { process.stderr.write(`… 跑 ${model}\n`); const res = runModel(model); results.push(res); process.stderr.write(` → ${res.ok ? `${res.score}/${res.total} (${(res.ms / 1000).toFixed(1)}s)` : `失败 ${res.error}`}\n`); } results.sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || (a.ms ?? 1e9) - (b.ms ?? 1e9)); const report = { at: new Date().toISOString(), tasks: TASKS.map((t) => ({ id: t.id, expect: t.expect, q: t.q.slice(0, 80) })), results, }; fs.writeFileSync(path.join(HERE, 'shootout-result.json'), JSON.stringify(report, null, 2), 'utf8'); const head = ['排名', '模型', '得分', '耗时', ...TASKS.map((t) => t.id)]; const rows = results.map((r, i) => [ String(i + 1), r.model.replace('opencode/', ''), r.ok ? `${r.score}/${r.total}` : 'ERR', r.ok ? `${(r.ms / 1000).toFixed(1)}s` : '-', ...TASKS.map((t) => (r.ok ? (r.per[t.id].pass ? '✔' : `✘(${r.per[t.id].answer})`) : '-')), ]); const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length))); const line = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' '); process.stdout.write(`\n${line(head)}\n${line(widths.map((w) => '─'.repeat(w)))}\n`); for (const r of rows) process.stdout.write(`${line(r)}\n`); const winner = results.find((r) => r.ok); process.stdout.write(`\n最强免费档(本次同题实测):${winner ? winner.model : '(全部失败)'}\n`); process.stdout.write(`结果已写入 ${path.join(HERE, 'shootout-result.json')}\n`);