mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
merge(release/v3.8.51): refresh #12605 onto current tip
This commit is contained in:
@@ -218,12 +218,16 @@ function readCodeFacts() {
|
||||
"const t=computeFreeModelTotals();const cli=Object.values(CLI_TOOLS);",
|
||||
"const by=(c)=>cli.filter(x=>x.category===c).length;",
|
||||
// "Free forever" = every provider whose free access renews or needs no key at all.
|
||||
// one-time-initial (signup credits) and discontinued pools are excluded on purpose.
|
||||
// one-time-initial (signup credits) and discontinued pools are excluded on purpose,
|
||||
// and so is every eligibility-gated row: a provider nobody can sign up for without
|
||||
// clearing a gate is not "free forever" for the reader of the headline.
|
||||
"const FOREVER=new Set(['recurring-monthly','recurring-daily','recurring-uncapped',",
|
||||
"'recurring-credit','keyless']);",
|
||||
"const ff=new Set();for(const m of t.perModel)if(FOREVER.has(m.freeType))ff.add(m.provider);",
|
||||
"const ff=new Set();for(const m of t.perModel)",
|
||||
"if(FOREVER.has(m.freeType)&&!m.eligibilityGate)ff.add(m.provider);",
|
||||
'console.log("@@"+JSON.stringify({freeSteady:t.steadyRecurringTokens,entries:t.perModel.length,',
|
||||
"freeFirst:t.firstMonthRealisticTokens,freePools:t.poolCount,engines:ENGINE_IDS.length,",
|
||||
"freeFirst:t.firstMonthRealisticTokens,freeGated:t.gatedRecurringTokens,",
|
||||
"freePools:t.poolCount,engines:ENGINE_IDS.length,",
|
||||
"cliTotal:cli.length,cliCode:by('code'),cliAgent:by('agent'),",
|
||||
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size,freeForever:ff.size,",
|
||||
"modePacks:Object.keys(MODE_PACKS),",
|
||||
@@ -271,6 +275,20 @@ export function extractHeadlineClaims(content) {
|
||||
return claims;
|
||||
}
|
||||
|
||||
// The eligibility-gated figure ("+~6M behind regional identity verification") is validated
|
||||
// with its own anchor so it can neither drift nor be silently dropped once it exists.
|
||||
const GATED_ANCHOR = /^\s*behind regional identity verification/i;
|
||||
|
||||
export function extractGatedClaims(content) {
|
||||
const claims = [];
|
||||
for (const m of content.matchAll(/\+?~?(\d+(?:\.\d+)?)([BM])\b/g)) {
|
||||
const after = content.slice(m.index + m[0].length, m.index + m[0].length + 60);
|
||||
if (!GATED_ANCHOR.test(after)) continue;
|
||||
claims.push({ tokens: Number(m[1]) * (m[2] === "B" ? 1e9 : 1e6), unit: m[2], text: m[0] });
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function checkFreeTierHeadline(content, totals) {
|
||||
const claims = extractHeadlineClaims(content);
|
||||
if (!claims.length) return { ok: true, detail: "no aggregate free-tier headline in this file" };
|
||||
@@ -279,14 +297,31 @@ export function checkFreeTierHeadline(content, totals) {
|
||||
const stale = claims.filter(
|
||||
(c) => Math.abs(c.value - steady) >= 0.05 && Math.abs(c.value - first) >= 0.05
|
||||
);
|
||||
if (!stale.length)
|
||||
return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` };
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
const problems = [];
|
||||
if (stale.length) {
|
||||
problems.push(
|
||||
`stale headline ${[...new Set(stale.map((c) => c.text))].join(", ")} — live catalog ` +
|
||||
`computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`,
|
||||
};
|
||||
`computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`
|
||||
);
|
||||
}
|
||||
if (totals.g != null && totals.g > 0) {
|
||||
const gated = extractGatedClaims(content);
|
||||
const tol = (c) => (c.unit === "B" ? 0.05e9 : 0.5e6);
|
||||
const gatedStale = gated.filter((c) => Math.abs(c.tokens - totals.g) >= tol(c));
|
||||
if (!gated.length) {
|
||||
problems.push(
|
||||
`missing gated figure — live catalog computes ${Math.round(totals.g / 1e6)}M behind regional identity verification`
|
||||
);
|
||||
} else if (gatedStale.length) {
|
||||
problems.push(
|
||||
`stale gated figure ${[...new Set(gatedStale.map((c) => c.text))].join(", ")} — live catalog ` +
|
||||
`computes ${Math.round(totals.g / 1e6)}M behind regional identity verification`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!problems.length)
|
||||
return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` };
|
||||
return { ok: false, detail: problems.join("; ") };
|
||||
}
|
||||
|
||||
// PURE: docs prose that names the product version ("OmniRoute v3.8.50 ·",
|
||||
@@ -599,12 +634,12 @@ export function buildChecks() {
|
||||
},
|
||||
{
|
||||
label: "Free-tier headline (live catalog)",
|
||||
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools`,
|
||||
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools / ${Math.round(f.freeGated / 1e6)}M gated`,
|
||||
docKey: "free-tier headline",
|
||||
strict: true,
|
||||
files: ["README.md", "docs/reference/FREE_TIERS.md"],
|
||||
validate: (content) =>
|
||||
checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst }),
|
||||
checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst, g: f.freeGated }),
|
||||
},
|
||||
claim(
|
||||
f.engines,
|
||||
|
||||
@@ -1,56 +1,81 @@
|
||||
// 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");
|
||||
if (outIdx >= 0 && !process.argv[outIdx + 1]) throw new Error("--out requires a path");
|
||||
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) {
|
||||
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0) continue;
|
||||
for (const r of t.perModel) {
|
||||
// Gated rows are excluded here too — they are absent from firstMonthRealisticTokens.
|
||||
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0 || r.eligibilityGate) 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 +85,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 +99,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)}`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user