← back to Model Wars
server.js
223 lines
// server.js — MODEL WARS backend.
// Serves the arena, proxies REAL model API calls, runs AI judges, and keeps a
// persistent medieval ELO ladder. No route ever fabricates a model response.
import express from 'express';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
CHAMPIONS, championWithAvailability, callChampion,
judgeCall, judgeProvider,
} from './providers.js';
// Minimal .env loader (no dependency) — reads KEY=value lines into process.env.
const __dirname = dirname(fileURLToPath(import.meta.url));
try {
const envPath = join(__dirname, '.env');
if (existsSync(envPath)) {
for (const line of (await readFile(envPath, 'utf8')).split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
} catch { /* no .env — exhibition mode */ }
const app = express();
app.use(express.json({ limit: '256kb' }));
app.use(express.static(join(__dirname, 'public')));
const DATA_DIR = join(__dirname, 'data');
const LB_PATH = join(DATA_DIR, 'leaderboard.json');
// ── ELO ladder persistence ────────────────────────────────────────────
const START_ELO = 1500;
function freshRecord(champ) {
return {
id: champ.id, name: champ.name, title: champ.title, provider: champ.provider,
color: champ.color, crest: champ.crest,
elo: SEED_ELO[champ.id] ?? START_ELO,
wins: 0, losses: 0, draws: 0, streak: 0,
catWins: {}, // { joust: 3, archery: 1, ... }
totalRespMs: 0, battles: 0, totalCost: 0,
custom: !!champ.custom,
};
}
// Flavor seeds so the ladder opens with a believable spread (from the brief).
const SEED_ELO = { gpt: 1847, claude: 1821, gemini: 1789, grok: 1744, deepseek: 1712, llama: 1688 };
async function loadLadder() {
try {
const raw = JSON.parse(await readFile(LB_PATH, 'utf8'));
return raw;
} catch {
const seed = {};
for (const c of CHAMPIONS) seed[c.id] = freshRecord(c);
await saveLadder(seed);
return seed;
}
}
async function saveLadder(ladder) {
if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true });
await writeFile(LB_PATH, JSON.stringify(ladder, null, 2));
}
// Serialize every read-modify-write on the ladder so concurrent battles /
// champion adds can't clobber each other's writes (last-write-wins corruption).
let _lock = Promise.resolve();
function serialize(task) {
const run = _lock.then(task, task);
_lock = run.then(() => {}, () => {});
return run;
}
// The full roster = static champions + any custom champions registered on the
// ladder. This is the single source of truth for selectors AND battles, so a
// summoned champion actually appears and can fight.
async function allChampions() {
const ladder = await loadLadder();
const custom = Object.values(ladder)
.filter((r) => r.custom && !CHAMPIONS.some((c) => c.id === r.id))
.map((r) => ({ id: r.id, name: r.name, title: r.title, provider: r.provider, color: r.color, crest: r.crest, custom: true }));
return [...CHAMPIONS, ...custom];
}
function expectedScore(a, b) { return 1 / (1 + 10 ** ((b - a) / 400)); }
// Apply a result to the ladder. outcome: 1 = A wins, 0 = B wins, 0.5 = draw.
function applyResult(args) { return serialize(() => _applyResult(args)); }
async function _applyResult({ aId, bId, outcome, category, aStats, bStats }) {
const ladder = await loadLadder();
const A = ladder[aId], B = ladder[bId];
if (!A || !B) return null;
const K = 32;
const eA = expectedScore(A.elo, B.elo);
const eB = 1 - eA;
const beforeA = A.elo, beforeB = B.elo;
A.elo = Math.round(A.elo + K * (outcome - eA));
B.elo = Math.round(B.elo + K * ((1 - outcome) - eB));
const bump = (rec, isWin, isDraw, cat, stats) => {
if (isDraw) { rec.draws++; rec.streak = 0; }
else if (isWin) { rec.wins++; rec.streak = rec.streak >= 0 ? rec.streak + 1 : 1; if (cat) rec.catWins[cat] = (rec.catWins[cat] || 0) + 1; }
else { rec.losses++; rec.streak = rec.streak <= 0 ? rec.streak - 1 : -1; }
if (stats) { rec.totalRespMs += stats.total || 0; rec.battles++; rec.totalCost += stats.cost || 0; }
};
const draw = outcome === 0.5;
bump(A, outcome === 1, draw, category, aStats);
bump(B, outcome === 0, draw, category, bStats);
await saveLadder(ladder);
return { ladder, deltas: { [aId]: A.elo - beforeA, [bId]: B.elo - beforeB } };
}
function strongestCategory(rec) {
const e = Object.entries(rec.catWins || {});
if (!e.length) return '—';
return e.sort((x, y) => y[1] - x[1])[0][0];
}
function ladderRows(ladder) {
const rows = Object.values(ladder).map((r) => {
const games = r.wins + r.losses + r.draws;
return {
...r,
games,
winPct: games ? Math.round((r.wins / games) * 100) : 0,
avgRespMs: r.battles ? Math.round(r.totalRespMs / r.battles) : null,
costPerBattle: r.battles ? r.totalCost / r.battles : null,
strongest: strongestCategory(r),
};
});
rows.sort((a, b) => b.elo - a.elo);
rows.forEach((r, i) => (r.rank = i + 1));
return rows;
}
// ── Routes ────────────────────────────────────────────────────────────
app.get('/api/champions', async (req, res) => {
const champs = await allChampions();
res.json({ champions: champs.map(championWithAvailability), judge: judgeProvider() });
});
app.get('/api/leaderboard', async (req, res) => {
res.json({ rows: ladderRows(await loadLadder()) });
});
// Register a new champion dynamically (brief: "Allow new models to be added").
app.post('/api/champions', async (req, res) => {
const { id, name, title, provider = 'openai', color = '#c9a961', crest = '⚜' } = req.body || {};
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
const ladder = await loadLadder();
if (!ladder[id]) {
ladder[id] = freshRecord({ id, name, title: title || 'The Challenger', provider, color, crest, custom: true });
await saveLadder(ladder);
}
res.json({ ok: true, champion: ladder[id] });
});
// REAL battle: send the identical prompt to both champions simultaneously.
app.post('/api/battle', async (req, res) => {
const { prompt, a, b } = req.body || {};
if (!prompt || !a || !b) return res.status(400).json({ error: 'prompt, a, b required' });
const roster = await allChampions(); // include custom champions
const champA = roster.find((c) => c.id === a);
const champB = roster.find((c) => c.id === b);
if (!champA || !champB) return res.status(400).json({ error: 'unknown champion' });
const run = async (champ) => {
try { return { ok: true, ...(await callChampion(champ, prompt)) }; }
catch (e) { return { ok: false, error: String(e.message || e) }; }
};
const [ra, rb] = await Promise.all([run(champA), run(champB)]);
res.json({ a: ra, b: rb });
});
// AI judge: scores both answers 0–100 across the six dimensions.
app.post('/api/judge', async (req, res) => {
const { prompt, aText, bText, aName = 'A', bName = 'B' } = req.body || {};
const provider = judgeProvider();
if (!provider) return res.status(503).json({ error: 'no judge available (no API keys configured)' });
if (!prompt || aText == null || bText == null) return res.status(400).json({ error: 'prompt, aText, bText required' });
const rubric = `Judge two answers to the SAME prompt. Score EACH answer 0-100 on: correctness, reasoning, relevance, completeness, instruction_adherence, and hallucination_risk (higher hallucination_risk = MORE hallucination = worse). Also give a one-sentence rationale per answer.
PROMPT:
"""${prompt.slice(0, 4000)}"""
ANSWER_A (${aName}):
"""${(aText || '').slice(0, 6000)}"""
ANSWER_B (${bName}):
"""${(bText || '').slice(0, 6000)}"""
Return JSON exactly: {"a":{"correctness":0,"reasoning":0,"relevance":0,"completeness":0,"instruction_adherence":0,"hallucination_risk":0,"rationale":""},"b":{...same...}}`;
try {
const scored = await judgeCall(provider, rubric);
res.json({ provider, scored });
} catch (e) {
res.status(502).json({ error: String(e.message || e) });
}
});
// Record a completed battle onto the ELO ladder.
app.post('/api/result', async (req, res) => {
const { aId, bId, outcome, category, aStats, bStats } = req.body || {};
if (!aId || !bId || outcome == null) return res.status(400).json({ error: 'aId, bId, outcome required' });
const r = await applyResult({ aId, bId, outcome: Number(outcome), category, aStats, bStats });
if (!r) return res.status(400).json({ error: 'unknown champion' });
res.json({ ok: true, deltas: r.deltas, rows: ladderRows(r.ladder) });
});
// Reset the ladder to seeds (admin / demo convenience).
app.post('/api/leaderboard/reset', async (req, res) => {
const seed = {};
for (const c of CHAMPIONS) seed[c.id] = freshRecord(c);
await saveLadder(seed);
res.json({ ok: true, rows: ladderRows(seed) });
});
const PORT = process.env.PORT || 9911;
app.listen(PORT, () => {
const live = CHAMPIONS.map(championWithAvailability).filter((c) => c.available).map((c) => c.name);
console.log(`⚔️ MODEL WARS live at http://localhost:${PORT}`);
console.log(live.length ? ` Live champions: ${live.join(', ')}` : ' No API keys — EXHIBITION mode (simulated battles, clearly labeled).');
});