mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
fix(docs): budget card reads computeFreeModelTotals() instead of regex-parsing the catalog
This commit is contained in:
@@ -1,94 +1,106 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="536" viewBox="0 0 900 536" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<rect width="900" height="536" rx="16" fill="#0d1117"/>
|
||||
<rect x="16" y="16" width="868" height="520" rx="13" fill="#161b22" stroke="#30363d"/>
|
||||
<text x="868" y="528" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="566" viewBox="0 0 900 566" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<rect width="900" height="566" rx="16" fill="#0d1117"/>
|
||||
<rect x="16" y="16" width="868" height="550" rx="13" fill="#161b22" stroke="#30363d"/>
|
||||
<text x="868" y="558" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>
|
||||
<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>
|
||||
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">20 free pools · 446 models · one endpoint</text>
|
||||
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">22 free pools · 451 models · one endpoint</text>
|
||||
<text x="32" y="84" fill="#7d8590" font-size="11.5">Steady / month</text>
|
||||
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.51B</text>
|
||||
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.50B</text>
|
||||
<text x="330" y="84" fill="#7d8590" font-size="11.5">First month (+ signup credits)</text>
|
||||
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.13B</text>
|
||||
<text x="700" y="84" fill="#7d8590" font-size="11.5">ToS-flagged (you decide)</text>
|
||||
<text x="700" y="114" fill="#d29922" font-size="27" font-weight="800">13 providers</text>
|
||||
<clipPath id="bar"><rect x="32" y="132" width="836" height="16" rx="8"/></clipPath>
|
||||
<g clip-path="url(#bar)"><rect x="32" y="132" width="836" height="16" fill="#21262d"/>
|
||||
<rect x="32.0" y="132" width="492.8" height="16" fill="#6c5ce7"/>
|
||||
<rect x="524.2" y="132" width="80.4" height="16" fill="#00b894"/>
|
||||
<rect x="604.0" y="132" width="80.4" height="16" fill="#0984e3"/>
|
||||
<rect x="683.8" y="132" width="36.7" height="16" fill="#e17055"/>
|
||||
<rect x="719.9" y="132" width="22.2" height="16" fill="#fdcb6e"/>
|
||||
<rect x="741.5" y="132" width="19.2" height="16" fill="#e84393"/>
|
||||
<rect x="760.1" y="132" width="17.3" height="16" fill="#00cec9"/>
|
||||
<rect x="776.8" y="132" width="11.1" height="16" fill="#d63031"/>
|
||||
<rect x="787.3" y="132" width="10.5" height="16" fill="#a29bfe"/>
|
||||
<rect x="797.2" y="132" width="9.9" height="16" fill="#55efc4"/>
|
||||
<rect x="806.6" y="132" width="9.8" height="16" fill="#74b9ff"/>
|
||||
<rect x="815.7" y="132" width="9.3" height="16" fill="#ffeaa7"/>
|
||||
<rect x="824.5" y="132" width="8.2" height="16" fill="#fab1a0"/>
|
||||
<rect x="832.1" y="132" width="8.0" height="16" fill="#81ecec"/>
|
||||
<rect x="839.5" y="132" width="7.8" height="16" fill="#6c5ce7"/>
|
||||
<rect x="846.7" y="132" width="7.8" height="16" fill="#00b894"/>
|
||||
<rect x="853.9" y="132" width="7.7" height="16" fill="#0984e3"/>
|
||||
<rect x="861.0" y="132" width="7.6" height="16" fill="#e17055"/>
|
||||
<rect x="32.0" y="132" width="461.3" height="16" fill="#6c5ce7"/>
|
||||
<rect x="492.7" y="132" width="102.9" height="16" fill="#00b894"/>
|
||||
<rect x="595.0" y="132" width="75.7" height="16" fill="#0984e3"/>
|
||||
<rect x="670.0" y="132" width="21.2" height="16" fill="#e17055"/>
|
||||
<rect x="690.6" y="132" width="21.2" height="16" fill="#fdcb6e"/>
|
||||
<rect x="711.2" y="132" width="18.5" height="16" fill="#e84393"/>
|
||||
<rect x="729.1" y="132" width="10.9" height="16" fill="#00cec9"/>
|
||||
<rect x="739.4" y="132" width="10.3" height="16" fill="#d63031"/>
|
||||
<rect x="749.1" y="132" width="10.3" height="16" fill="#a29bfe"/>
|
||||
<rect x="758.8" y="132" width="10.3" height="16" fill="#55efc4"/>
|
||||
<rect x="768.6" y="132" width="10.3" height="16" fill="#74b9ff"/>
|
||||
<rect x="778.3" y="132" width="10.3" height="16" fill="#ffeaa7"/>
|
||||
<rect x="788.0" y="132" width="10.3" height="16" fill="#fab1a0"/>
|
||||
<rect x="797.7" y="132" width="9.8" height="16" fill="#81ecec"/>
|
||||
<rect x="806.9" y="132" width="9.6" height="16" fill="#6c5ce7"/>
|
||||
<rect x="815.9" y="132" width="9.2" height="16" fill="#00b894"/>
|
||||
<rect x="824.6" y="132" width="8.1" height="16" fill="#0984e3"/>
|
||||
<rect x="832.1" y="132" width="8.0" height="16" fill="#e17055"/>
|
||||
<rect x="839.5" y="132" width="7.8" height="16" fill="#fdcb6e"/>
|
||||
<rect x="846.7" y="132" width="7.8" height="16" fill="#e84393"/>
|
||||
<rect x="853.9" y="132" width="7.7" height="16" fill="#00cec9"/>
|
||||
<rect x="861.0" y="132" width="7.6" height="16" fill="#d63031"/>
|
||||
</g>
|
||||
<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>
|
||||
<circle cx="37" cy="196" r="5" fill="#6c5ce7"/>
|
||||
<text x="48" y="200" fill="#c9d1d9" font-size="12.5">Mistral Large 3 <tspan fill="#7d8590">1.00B</tspan></text>
|
||||
<circle cx="250" cy="196" r="5" fill="#00b894"/>
|
||||
<text x="261" y="200" fill="#c9d1d9" font-size="12.5">GPT-4o mini <tspan fill="#7d8590">150M</tspan></text>
|
||||
<text x="261" y="200" fill="#c9d1d9" font-size="12.5">Agnes 2.0 Flash <tspan fill="#7d8590">210M</tspan></text>
|
||||
<circle cx="463" cy="196" r="5" fill="#0984e3"/>
|
||||
<text x="474" y="200" fill="#c9d1d9" font-size="12.5">Tencent Hy3 <tspan fill="#7d8590">150M</tspan></text>
|
||||
<text x="474" y="200" fill="#c9d1d9" font-size="12.5">GPT-4o mini <tspan fill="#7d8590">150M</tspan></text>
|
||||
<circle cx="676" cy="196" r="5" fill="#e17055"/>
|
||||
<text x="687" y="200" fill="#c9d1d9" font-size="12.5">Gemini 2.5 Flash <tspan fill="#7d8590">60M</tspan></text>
|
||||
<text x="687" y="200" fill="#c9d1d9" font-size="12.5">GLM 4.7 <tspan fill="#7d8590">30M</tspan></text>
|
||||
<circle cx="37" cy="226" r="5" fill="#fdcb6e"/>
|
||||
<text x="48" y="230" fill="#c9d1d9" font-size="12.5">Llama 3.3 70B <tspan fill="#7d8590">30M</tspan></text>
|
||||
<circle cx="250" cy="226" r="5" fill="#e84393"/>
|
||||
<text x="261" y="230" fill="#c9d1d9" font-size="12.5">Grok-3 <tspan fill="#7d8590">24M</tspan></text>
|
||||
<circle cx="463" cy="226" r="5" fill="#00cec9"/>
|
||||
<text x="474" y="230" fill="#c9d1d9" font-size="12.5">DeepSeek V4 Pro <tspan fill="#7d8590">20M</tspan></text>
|
||||
<text x="474" y="230" fill="#c9d1d9" font-size="12.5">GPT-4o <tspan fill="#7d8590">7M</tspan></text>
|
||||
<circle cx="676" cy="226" r="5" fill="#d63031"/>
|
||||
<text x="687" y="230" fill="#c9d1d9" font-size="12.5">GPT-4o <tspan fill="#7d8590">7M</tspan></text>
|
||||
<text x="687" y="230" fill="#c9d1d9" font-size="12.5">GPT-OSS 120B <tspan fill="#7d8590">6M</tspan></text>
|
||||
<circle cx="37" cy="256" r="5" fill="#a29bfe"/>
|
||||
<text x="48" y="260" fill="#c9d1d9" font-size="12.5">MiniMax-M2.7 <tspan fill="#7d8590">6M</tspan></text>
|
||||
<text x="48" y="260" fill="#c9d1d9" font-size="12.5">GPT-OSS 20B <tspan fill="#7d8590">6M</tspan></text>
|
||||
<circle cx="250" cy="256" r="5" fill="#55efc4"/>
|
||||
<text x="261" y="260" fill="#c9d1d9" font-size="12.5">Arcee Trinity Large Prev <tspan fill="#7d8590">5M</tspan></text>
|
||||
<text x="261" y="260" fill="#c9d1d9" font-size="12.5">GPT-OSS Safeguard 20B <tspan fill="#7d8590">6M</tspan></text>
|
||||
<circle cx="463" cy="256" r="5" fill="#74b9ff"/>
|
||||
<text x="474" y="260" fill="#c9d1d9" font-size="12.5">NavyAI free pool <tspan fill="#7d8590">5M</tspan></text>
|
||||
<text x="474" y="260" fill="#c9d1d9" font-size="12.5">Qwen3.6 27B <tspan fill="#7d8590">6M</tspan></text>
|
||||
<circle cx="676" cy="256" r="5" fill="#ffeaa7"/>
|
||||
<text x="687" y="260" fill="#c9d1d9" font-size="12.5">Auto Free <tspan fill="#7d8590">4M</tspan></text>
|
||||
<text x="687" y="260" fill="#c9d1d9" font-size="12.5">Qwen3.8 27B <tspan fill="#7d8590">6M</tspan></text>
|
||||
<circle cx="37" cy="286" r="5" fill="#fab1a0"/>
|
||||
<text x="48" y="290" fill="#c9d1d9" font-size="12.5">Auto <tspan fill="#7d8590">1M</tspan></text>
|
||||
<text x="48" y="290" fill="#c9d1d9" font-size="12.5">MiniMax-M2.7 <tspan fill="#7d8590">6M</tspan></text>
|
||||
<circle cx="250" cy="286" r="5" fill="#81ecec"/>
|
||||
<text x="261" y="290" fill="#c9d1d9" font-size="12.5">Command A Reasoning <tspan fill="#7d8590">800K</tspan></text>
|
||||
<text x="261" y="290" fill="#c9d1d9" font-size="12.5">Arcee Trinity Large Prev <tspan fill="#7d8590">5M</tspan></text>
|
||||
<circle cx="463" cy="286" r="5" fill="#6c5ce7"/>
|
||||
<text x="474" y="290" fill="#c9d1d9" font-size="12.5">ERNIE 4.5 VL 424B A47B B <tspan fill="#7d8590">500K</tspan></text>
|
||||
<text x="474" y="290" fill="#c9d1d9" font-size="12.5">NavyAI free pool <tspan fill="#7d8590">5M</tspan></text>
|
||||
<circle cx="676" cy="286" r="5" fill="#00b894"/>
|
||||
<text x="687" y="290" fill="#c9d1d9" font-size="12.5">morph-v3-large <tspan fill="#7d8590">400K</tspan></text>
|
||||
<text x="687" y="290" fill="#c9d1d9" font-size="12.5">Auto Free <tspan fill="#7d8590">4M</tspan></text>
|
||||
<circle cx="37" cy="316" r="5" fill="#0984e3"/>
|
||||
<text x="48" y="320" fill="#c9d1d9" font-size="12.5">Llama 3.1 8B <tspan fill="#7d8590">200K</tspan></text>
|
||||
<text x="48" y="320" fill="#c9d1d9" font-size="12.5">Auto <tspan fill="#7d8590">1M</tspan></text>
|
||||
<circle cx="250" cy="316" r="5" fill="#e17055"/>
|
||||
<text x="261" y="320" fill="#c9d1d9" font-size="12.5">Claude Sonnet 4.5 <tspan fill="#7d8590">25K</tspan></text>
|
||||
<line x1="32" y1="356" x2="868" y2="356" stroke="#30363d"/>
|
||||
<text x="32" y="382" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~626M)</text>
|
||||
<rect x="32" y="391" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="77" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">vertex 300M</text>
|
||||
<rect x="130" y="391" width="123" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="191" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">agentrouter 200M</text>
|
||||
<rect x="261" y="391" width="103" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="312" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">predibase 25M</text>
|
||||
<rect x="372" y="391" width="96" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="420" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">together 25M</text>
|
||||
<rect x="476" y="391" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="518" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">glm-cn 20M</text>
|
||||
<rect x="567" y="391" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="609" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">doubao 15M</text>
|
||||
<rect x="658" y="391" width="70" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="693" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">ai21 10M</text>
|
||||
<rect x="736" y="391" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="781" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">longcat 10M</text>
|
||||
<text x="261" y="320" fill="#c9d1d9" font-size="12.5">Command A Reasoning <tspan fill="#7d8590">800K</tspan></text>
|
||||
<circle cx="463" cy="316" r="5" fill="#fdcb6e"/>
|
||||
<text x="474" y="320" fill="#c9d1d9" font-size="12.5">ERNIE 4.5 VL 424B A47B B <tspan fill="#7d8590">500K</tspan></text>
|
||||
<circle cx="676" cy="316" r="5" fill="#e84393"/>
|
||||
<text x="687" y="320" fill="#c9d1d9" font-size="12.5">morph-v3-large <tspan fill="#7d8590">400K</tspan></text>
|
||||
<circle cx="37" cy="346" r="5" fill="#00cec9"/>
|
||||
<text x="48" y="350" fill="#c9d1d9" font-size="12.5">Llama 3.1 8B <tspan fill="#7d8590">200K</tspan></text>
|
||||
<circle cx="250" cy="346" r="5" fill="#d63031"/>
|
||||
<text x="261" y="350" fill="#c9d1d9" font-size="12.5">Claude Sonnet 4.5 <tspan fill="#7d8590">25K</tspan></text>
|
||||
<line x1="32" y1="386" x2="868" y2="386" stroke="#30363d"/>
|
||||
<text x="32" y="412" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~626M)</text>
|
||||
<rect x="32" y="421" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="77" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">deepseek 5M</text>
|
||||
<rect x="32" y="462" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>
|
||||
<text x="46" y="476" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>
|
||||
<text x="46" y="490" fill="#7d8590" font-size="11.5">+ 12 permanently-free, no-cap providers (e.g. baidu, glm-cn, opencode-zen) · OpenRouter $10 → +24M/mo.</text>
|
||||
<text x="77" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">vertex 300M</text>
|
||||
<rect x="130" y="421" width="123" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="191" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">agentrouter 200M</text>
|
||||
<rect x="261" y="421" width="103" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="312" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">predibase 25M</text>
|
||||
<rect x="372" y="421" width="96" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="420" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">together 25M</text>
|
||||
<rect x="476" y="421" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="518" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">glm-cn 20M</text>
|
||||
<rect x="567" y="421" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="609" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">doubao 15M</text>
|
||||
<rect x="658" y="421" width="70" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="693" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">ai21 10M</text>
|
||||
<rect x="736" y="421" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="781" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">longcat 10M</text>
|
||||
<rect x="32" y="451" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
|
||||
<text x="77" y="466" fill="#7ee787" font-size="11.5" text-anchor="middle">deepseek 5M</text>
|
||||
<rect x="32" y="492" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>
|
||||
<text x="46" y="506" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>
|
||||
<text x="46" y="520" fill="#7d8590" font-size="11.5">+ 15 permanently-free, no-cap providers (e.g. agnes, ainative, aion) · OpenRouter $10 → +24M/mo.</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -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(`<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>`);
|
||||
B.push(`<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">${pools.length} free pools · ${recs.length} models · one endpoint</text>`);
|
||||
// stats
|
||||
const B = [];
|
||||
B.push(
|
||||
`<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">${pools.length} free pools · ${t.modelCount} models · one endpoint</text>`
|
||||
);
|
||||
const stat = (sx, label, val, vc) => {
|
||||
B.push(`<text x="${sx}" y="84" fill="#7d8590" font-size="11.5">${label}</text>`);
|
||||
B.push(`<text x="${sx}" y="114" fill="${vc}" font-size="27" font-weight="800">${val}</text>`);
|
||||
@@ -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(`<clipPath id="bar"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" rx="8"/></clipPath>`);
|
||||
B.push(`<g clip-path="url(#bar)"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" fill="#21262d"/>`);
|
||||
for (const s of segs) B.push(`<rect x="${s.x.toFixed(1)}" y="132" width="${(s.w + 0.6).toFixed(1)}" height="16" fill="${s.c}"/>`);
|
||||
B.push(
|
||||
`<clipPath id="bar"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" rx="8"/></clipPath>`
|
||||
);
|
||||
B.push(
|
||||
`<g clip-path="url(#bar)"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" fill="#21262d"/>`
|
||||
);
|
||||
for (const s of segs)
|
||||
B.push(
|
||||
`<rect x="${s.x.toFixed(1)}" y="132" width="${(s.w + 0.6).toFixed(1)}" height="16" fill="${s.c}"/>`
|
||||
);
|
||||
B.push(`</g>`);
|
||||
B.push(`<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>`);
|
||||
// model grid 4 cols
|
||||
const COLS = 4, COLW = 213, GX = 32, GY = 200, RH = 30;
|
||||
B.push(
|
||||
`<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>`
|
||||
);
|
||||
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(`<circle cx="${cx + 5}" cy="${cy - 4}" r="5" fill="${color(i)}"/>`);
|
||||
B.push(`<text x="${cx + 16}" y="${cy}" fill="#c9d1d9" font-size="12.5">${cleanName(r)} <tspan fill="#7d8590">${fmt(r.monthlyTokens)}</tspan></text>`);
|
||||
B.push(
|
||||
`<text x="${cx + 16}" y="${cy}" fill="#c9d1d9" font-size="12.5">${cleanName(r)} <tspan fill="#7d8590">${fmt(r.monthlyTokens)}</tspan></text>`
|
||||
);
|
||||
});
|
||||
let y = GY + Math.ceil(GRID.length / COLS) * RH + 6;
|
||||
// first-month strip (wrapping)
|
||||
B.push(`<line x1="32" y1="${y}" x2="868" y2="${y}" stroke="#30363d"/>`);
|
||||
y += 26;
|
||||
B.push(`<text x="32" y="${y}" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~${fmt(oneTimeSum)})</text>`);
|
||||
B.push(
|
||||
`<text x="32" y="${y}" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~${fmt(oneTimeSum)})</text>`
|
||||
);
|
||||
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(`<rect x="${sxp.toFixed(0)}" y="${(y - 15).toFixed(0)}" width="${w.toFixed(0)}" height="22" rx="11" fill="#13311f" stroke="#238636"/>`);
|
||||
B.push(`<text x="${(sxp + w / 2).toFixed(0)}" y="${y.toFixed(0)}" fill="#7ee787" font-size="11.5" text-anchor="middle">${label}</text>`);
|
||||
if (sxp + w > 862) {
|
||||
sxp = 32;
|
||||
y += 30;
|
||||
}
|
||||
B.push(
|
||||
`<rect x="${sxp.toFixed(0)}" y="${(y - 15).toFixed(0)}" width="${w.toFixed(0)}" height="22" rx="11" fill="#13311f" stroke="#238636"/>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="${(sxp + w / 2).toFixed(0)}" y="${y.toFixed(0)}" fill="#7ee787" font-size="11.5" text-anchor="middle">${label}</text>`
|
||||
);
|
||||
sxp += w + 8;
|
||||
}
|
||||
y += 26;
|
||||
// ToS note (softened)
|
||||
B.push(`<rect x="32" y="${y}" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>`);
|
||||
B.push(`<text x="46" y="${(y + 14).toFixed(0)}" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>`);
|
||||
B.push(`<text x="46" y="${(y + 28).toFixed(0)}" fill="#7d8590" font-size="11.5">+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +24M/mo.</text>`);
|
||||
y += 34;
|
||||
const H = y + 24; // card content bottom
|
||||
const noteH = gated > 0 ? 48 : 34;
|
||||
B.push(
|
||||
`<rect x="32" y="${y}" width="836" height="${noteH}" rx="8" fill="#1c2230" stroke="#30363d"/>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="46" y="${(y + 14).toFixed(0)}" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="46" y="${(y + 28).toFixed(0)}" fill="#7d8590" font-size="11.5">+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +${fmt(t.boostMonthlyTokens)}/mo.</text>`
|
||||
);
|
||||
if (gated > 0) {
|
||||
B.push(
|
||||
`<text x="46" y="${(y + 42).toFixed(0)}" fill="#d29922" font-size="11.5">+ ~${fmt(gated)} behind regional identity verification (${t.gatedProviders.join(", ")}) — real quota, never in the headline.</text>`
|
||||
);
|
||||
}
|
||||
y += noteH;
|
||||
const H = y + 24;
|
||||
const CANVAS = H + 16;
|
||||
|
||||
const out = [];
|
||||
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="900" height="${CANVAS}" viewBox="0 0 900 ${CANVAS}" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">`);
|
||||
out.push(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="900" height="${CANVAS}" viewBox="0 0 900 ${CANVAS}" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">`
|
||||
);
|
||||
out.push(`<rect width="900" height="${CANVAS}" rx="16" fill="#0d1117"/>`);
|
||||
out.push(`<rect x="16" y="16" width="868" height="${H}" rx="13" fill="#161b22" stroke="#30363d"/>`);
|
||||
out.push(`<text x="868" y="${(H + 8).toFixed(0)}" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>`);
|
||||
out.push(
|
||||
`<text x="868" y="${(H + 8).toFixed(0)}" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>`
|
||||
);
|
||||
out.push(...B);
|
||||
out.push(`</svg>`);
|
||||
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)}`
|
||||
);
|
||||
|
||||
27
tests/unit/gen-budget-card-svg.test.ts
Normal file
27
tests/unit/gen-budget-card-svg.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user