diff --git a/docs/screenshots/free-tier-budget-card.svg b/docs/screenshots/free-tier-budget-card.svg index c56452439a..b0552a61ad 100644 --- a/docs/screenshots/free-tier-budget-card.svg +++ b/docs/screenshots/free-tier-budget-card.svg @@ -1,94 +1,106 @@ - - - -OmniRoute · /dashboard/free-tiers · preview mockup + + + +OmniRoute · /dashboard/free-tiers · preview mockup Monthly free-token budget -20 free pools · 446 models · one endpoint +22 free pools · 451 models · one endpoint Steady / month -~1.51B +~1.50B First month (+ signup credits) ~2.13B ToS-flagged (you decide) 13 providers - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid. Mistral Large 3 1.00B -GPT-4o mini 150M +Agnes 2.0 Flash 210M -Tencent Hy3 150M +GPT-4o mini 150M -Gemini 2.5 Flash 60M +GLM 4.7 30M Llama 3.3 70B 30M Grok-3 24M -DeepSeek V4 Pro 20M +GPT-4o 7M -GPT-4o 7M +GPT-OSS 120B 6M -MiniMax-M2.7 6M +GPT-OSS 20B 6M -Arcee Trinity Large Prev 5M +GPT-OSS Safeguard 20B 6M -NavyAI free pool 5M +Qwen3.6 27B 6M -Auto Free 4M +Qwen3.8 27B 6M -Auto 1M +MiniMax-M2.7 6M -Command A Reasoning 800K +Arcee Trinity Large Prev 5M -ERNIE 4.5 VL 424B A47B B 500K +NavyAI free pool 5M -morph-v3-large 400K +Auto Free 4M -Llama 3.1 8B 200K +Auto 1M -Claude Sonnet 4.5 25K - -+ First month: one-time signup credits (~626M) - -vertex 300M - -agentrouter 200M - -predibase 25M - -together 25M - -glm-cn 20M - -doubao 15M - -ai21 10M - -longcat 10M +Command A Reasoning 800K + +ERNIE 4.5 VL 424B A47B B 500K + +morph-v3-large 400K + +Llama 3.1 8B 200K + +Claude Sonnet 4.5 25K + ++ First month: one-time signup credits (~626M) -deepseek 5M - -Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide. -+ 12 permanently-free, no-cap providers (e.g. baidu, glm-cn, opencode-zen) · OpenRouter $10 → +24M/mo. +vertex 300M + +agentrouter 200M + +predibase 25M + +together 25M + +glm-cn 20M + +doubao 15M + +ai21 10M + +longcat 10M + +deepseek 5M + +Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide. ++ 15 permanently-free, no-cap providers (e.g. agnes, ainative, aion) · OpenRouter $10 → +24M/mo. diff --git a/scripts/research/gen-budget-card-svg.mjs b/scripts/research/gen-budget-card-svg.mjs index f253ded074..bf1a520f38 100644 --- a/scripts/research/gen-budget-card-svg.mjs +++ b/scripts/research/gen-budget-card-svg.mjs @@ -1,56 +1,79 @@ -// Generates docs/screenshots/free-tier-budget-card.svg from the per-model catalog. -// Run: node scripts/research/gen-budget-card-svg.mjs +#!/usr/bin/env node +// Generates the free-tier budget card from the per-model catalog, through the +// same function the docs gate and the dashboard use — never by parsing the data +// file with a regex (that silently skipped every row carrying an extra field). +// Run from the repo root: +// node --import tsx/esm scripts/research/gen-budget-card-svg.mjs [--out path.svg] import fs from "node:fs"; +import { computeFreeModelTotals } from "../../open-sse/config/freeModelCatalog.ts"; -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 outIdx = process.argv.indexOf("--out"); +const OUT = outIdx >= 0 ? process.argv[outIdx + 1] : "docs/screenshots/free-tier-budget-card.svg"; +const t = computeFreeModelTotals(); +const STEADY_TYPES = new Set(["recurring-daily", "recurring-monthly", "keyless"]); const fmt = (n) => - n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : n >= 1e6 ? Math.round(n / 1e6) + "M" : Math.round(n / 1e3) + "K"; + n >= 1e9 + ? (n / 1e9).toFixed(2) + "B" + : n >= 1e6 + ? Math.round(n / 1e6) + "M" + : Math.round(n / 1e3) + "K"; +// One bar segment per steady pool (largest member), gated rows excluded like the headline. const poolMap = new Map(); -for (const r of recs) { - if (!["recurring-daily", "recurring-monthly", "keyless"].includes(r.freeType)) continue; +for (const r of t.perModel) { + if (!STEADY_TYPES.has(r.freeType) || r.eligibilityGate) 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 pools = [...poolMap.values()] + .filter((r) => r.monthlyTokens > 0) + .sort((a, b) => b.monthlyTokens - a.monthlyTokens); +const steady = t.steadyRecurringTokens; +const firstMonth = t.firstMonthRealisticTokens; +const gated = t.gatedRecurringTokens; const otMap = new Map(); -for (const r of recs) { +for (const r of t.perModel) { 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 avoidProviders = new Set(t.perModel.filter((r) => r.tos === "avoid").map((r) => r.provider)) + .size; +const uncappedProviders = t.uncappedProviders; 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 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); +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 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) => { @@ -60,11 +83,13 @@ const segs = GRID.map((r, i) => { 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 B = []; +B.push( + `Monthly free-token budget` +); +B.push( + `${pools.length} free pools · ${t.modelCount} models · one endpoint` +); const stat = (sx, label, val, vc) => { B.push(`${label}`); B.push(`${val}`); @@ -72,50 +97,90 @@ const stat = (sx, label, val, vc) => { 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( + `` +); +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; +B.push( + `Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.` +); +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; + 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)}`); + 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)})`); +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}`); + 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 noteH = gated > 0 ? 48 : 34; +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 → +${fmt(t.boostMonthlyTokens)}/mo.` +); +if (gated > 0) { + B.push( + `+ ~${fmt(gated)} behind regional identity verification (${t.gatedProviders.join(", ")}) — real quota, never in the headline.` + ); +} +y += noteH; +const H = y + 24; const CANVAS = H + 16; const out = []; -out.push(``); +out.push( + `` +); out.push(``); out.push(``); -out.push(`OmniRoute · /dashboard/free-tiers · preview mockup`); +out.push( + `OmniRoute · /dashboard/free-tiers · preview mockup` +); out.push(...B); 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)}`); +fs.writeFileSync(OUT, out.join("\n") + "\n"); +console.log( + `SVG → ${OUT}: ${GRID.length} pools, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} gated=${fmt(gated)} oneTime=${fmt(oneTimeSum)}` +); diff --git a/tests/unit/gen-budget-card-svg.test.ts b/tests/unit/gen-budget-card-svg.test.ts new file mode 100644 index 0000000000..8046311dc0 --- /dev/null +++ b/tests/unit/gen-budget-card-svg.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { computeFreeModelTotals } from "@omniroute/open-sse/config/freeModelCatalog.ts"; + +const fmt = (n: number) => (n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : Math.round(n / 1e6) + "M"); + +test("the budget card prints the catalog's own totals (no regex-parsed subset)", () => { + const out = path.join(mkdtempSync(path.join(os.tmpdir(), "budget-card-")), "card.svg"); + execFileSync( + process.execPath, + ["--import", "tsx/esm", "scripts/research/gen-budget-card-svg.mjs", "--out", out], + { stdio: "pipe" } + ); + const svg = readFileSync(out, "utf8"); + const t = computeFreeModelTotals(); + assert.ok(svg.includes(`~${fmt(t.steadyRecurringTokens)}`), "steady figure"); + assert.ok(svg.includes(`~${fmt(t.firstMonthRealisticTokens)}`), "first-month figure"); + assert.ok(svg.includes(`${t.uncappedProviders.length} permanently-free`), "uncapped count"); + if (t.gatedRecurringTokens > 0) { + assert.ok(svg.includes("behind regional identity verification"), "gated line"); + } +});