// Generates docs/screenshots/free-tier-budget-card.svg from the per-model catalog.
// Run: node scripts/research/gen-budget-card-svg.mjs
import fs from "node:fs";
const txt = fs.readFileSync("open-sse/config/freeModelCatalog.data.ts", "utf8");
const recs = [
...txt.matchAll(
/\{ provider: "([^"]+)", modelId: "([^"]+)", displayName: "([^"]+)", monthlyTokens: (\d+), creditTokens: (\d+), freeType: "([^"]+)", poolKey: (null|"[^"]+"), tos: "([^"]+)" \}/g
),
].map((m) => ({
provider: m[1],
modelId: m[2],
displayName: m[3],
monthlyTokens: +m[4],
creditTokens: +m[5],
freeType: m[6],
poolKey: m[7] === "null" ? null : m[7].slice(1, -1),
tos: m[8],
}));
const fmt = (n) =>
n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : n >= 1e6 ? Math.round(n / 1e6) + "M" : Math.round(n / 1e3) + "K";
const poolMap = new Map();
for (const r of recs) {
if (!["recurring-daily", "recurring-monthly", "keyless"].includes(r.freeType)) continue;
const k = r.poolKey || `${r.provider}:${r.modelId}`;
const cur = poolMap.get(k);
if (!cur || r.monthlyTokens > cur.monthlyTokens) poolMap.set(k, r);
}
const pools = [...poolMap.values()].filter((r) => r.monthlyTokens > 0).sort((a, b) => b.monthlyTokens - a.monthlyTokens);
const steady = pools.reduce((s, r) => s + r.monthlyTokens, 0);
const otMap = new Map();
for (const r of recs) {
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0) continue;
const k = r.poolKey || r.provider;
otMap.set(k, { provider: r.provider, v: Math.max(otMap.get(k)?.v || 0, r.creditTokens) });
}
const oneTime = [...otMap.values()].sort((a, b) => b.v - a.v);
const oneTimeSum = oneTime.reduce((s, r) => s + r.v, 0);
const firstMonth = steady + oneTimeSum;
const avoidProviders = [...new Set(recs.filter((r) => r.tos === "avoid").map((r) => r.provider))].length;
const uncappedProviders = [...new Set(recs.filter((r) => r.freeType === "recurring-uncapped").map((r) => r.provider))];
const GRID = pools.slice(0, 28);
const STRIP = oneTime.slice(0, 9);
const PAL = ["#6c5ce7","#00b894","#0984e3","#e17055","#fdcb6e","#e84393","#00cec9","#d63031","#a29bfe","#55efc4","#74b9ff","#ffeaa7","#fab1a0","#81ecec"];
const color = (i) => PAL[i % PAL.length];
const cleanName = (r) => (r.displayName || r.provider).replace(/\s*\(.*$/, "").replace(/ —.*$/, "").slice(0, 24);
// bar segments (min width so every pool shows)
const BAR_X = 32, BAR_W = 836, MIN = 7;
const extra = BAR_W - MIN * GRID.length;
let bx = BAR_X;
const segs = GRID.map((r, i) => {
const w = MIN + (r.monthlyTokens / steady) * extra;
const s = { x: bx, w, c: color(i) };
bx += w;
return s;
});
const B = []; // body elements
// title
B.push(`Monthly free-token budget`);
B.push(`${pools.length} free pools · ${recs.length} models · one endpoint`);
// stats
const stat = (sx, label, val, vc) => {
B.push(`${label}`);
B.push(`${val}`);
};
stat(32, "Steady / month", `~${fmt(steady)}`, "#e6edf3");
stat(330, "First month (+ signup credits)", `~${fmt(firstMonth)}`, "#3fb950");
stat(700, "ToS-flagged (you decide)", `${avoidProviders} providers`, "#d29922");
// bar
B.push(``);
B.push(``);
for (const s of segs) B.push(``);
B.push(``);
B.push(`Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.`);
// model grid 4 cols
const COLS = 4, COLW = 213, GX = 32, GY = 200, RH = 30;
GRID.forEach((r, i) => {
const col = i % COLS, row = (i / COLS) | 0;
const cx = GX + col * COLW, cy = GY + row * RH;
B.push(``);
B.push(`${cleanName(r)} ${fmt(r.monthlyTokens)}`);
});
let y = GY + Math.ceil(GRID.length / COLS) * RH + 6;
// first-month strip (wrapping)
B.push(``);
y += 26;
B.push(`+ First month: one-time signup credits (~${fmt(oneTimeSum)})`);
y += 24;
let sxp = 32;
for (const r of STRIP) {
const label = `${r.provider} ${fmt(r.v)}`;
const w = 16 + label.length * 6.7;
if (sxp + w > 862) { sxp = 32; y += 30; }
B.push(``);
B.push(`${label}`);
sxp += w + 8;
}
y += 26;
// ToS note (softened)
B.push(``);
B.push(`Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.`);
B.push(`+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +24M/mo.`);
y += 34;
const H = y + 24; // card content bottom
const CANVAS = H + 16;
const out = [];
out.push(``);
fs.writeFileSync("docs/screenshots/free-tier-budget-card.svg", out.join("\n") + "\n");
console.log(`SVG: ${GRID.length} models, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} oneTime=${fmt(oneTimeSum)}`);