chore(guardrails): add data validator, wire into CI, reproducible plan generation

- scripts/validate-data.mjs enforces plan schema + AGENTS invariants
  (non-empty limits/models, positive token budgets on paid tiers,
  conservative <= midpoint <= optimistic, annual price math, id/filename)
- validate-data + lint now run in deploy and update-data workflows
- generate_plans.py: relative output path, single LAST_VERIFIED constant
- fix zero-token paid tiers (kilo-code Teams, opencode Zen, tabnine) with
  documented platform-fee-equivalent estimates so stacking no longer drops them
This commit is contained in:
John Smith
2026-09-19 00:54:33 -04:00
parent 5452588dda
commit c7b191067f
10 changed files with 278 additions and 71 deletions
+6
View File
@@ -30,6 +30,12 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Validate data invariants
run: npm run validate-data
- name: Build site
run: npm run build
+3
View File
@@ -30,6 +30,9 @@ jobs:
AA_API_KEY: ${{ secrets.AA_API_KEY }}
run: npm run update-data
- name: Validate data invariants
run: npm run validate-data
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
+5 -3
View File
@@ -33,9 +33,11 @@
"Kilo Gateway (500+ models)"
],
"estimatedTokenBudget": {
"description": "Platform fee only; inference at provider list rates",
"estimatedMillionTokens": 0,
"assumptions": "Token cost = provider rates + 5% gateway fee (or BYOK, zero fee)"
"description": "Platform fee; ~15M fee-equivalent tokens at open-model rates",
"estimatedMillionTokens": 15,
"midpointEstimate": 30,
"optimisticEstimate": 45,
"assumptions": "Platform-fee plan: no token bundle included; inference billed separately at provider rates + 5% gateway fee (BYOK zero fee). Figures are the fee-equivalent value of $15 at ~$1.00/M (conservative) to ~$0.33/M (optimistic open-model gateway rates) for cross-plan comparison only"
},
"notes": "Security/governance features included"
},
+5 -3
View File
@@ -40,9 +40,11 @@
"Free stealth models"
],
"estimatedTokenBudget": {
"description": "Per-token billing at curated gateway rates; min $20",
"estimatedMillionTokens": 0,
"assumptions": "Balance-based; cost equals published per-model rates"
"description": "Prepaid balance (~10M fee-equivalent tokens)",
"estimatedMillionTokens": 10,
"midpointEstimate": 20,
"optimisticEstimate": 40,
"assumptions": "$20 minimum balance at zero markup buys $20 of inference at curated gateway rates; conservative 10M at ~$2.00/M frontier blend, midpoint 20M at ~$1.00/M, optimistic 40M at ~$0.50/M open-model rates; excludes the $1.23 card fee"
},
"notes": "US-hosted, zero retention"
},
+10 -6
View File
@@ -17,9 +17,11 @@
"Tabnine Protected"
],
"estimatedTokenBudget": {
"description": "Uncapped volumes; token cost = LLM provider list price + 5% service fee",
"estimatedMillionTokens": 0,
"assumptions": "No fixed monthly token bundle; supply your own LLM for unlimited usage, or reserve token quota at actual provider rates"
"description": "Platform fee; ~19.5M fee-equivalent tokens",
"estimatedMillionTokens": 19.5,
"midpointEstimate": 39,
"optimisticEstimate": 78,
"assumptions": "Platform-fee plan: no token bundle included; Tabnine-provided LLM access is billed at provider list price + 5% handling fee, BYO LLM endpoint is unlimited. Figures are fee-equivalent at ~$2.00/M (conservative), ~$1.00/M (midpoint) and ~$0.50/M (optimistic) for cross-plan comparison only"
},
"notes": "Completions + grounded chat in IDEs; Jira integration; SOC 2/ISO 27001"
},
@@ -35,9 +37,11 @@
"Tabnine Protected"
],
"estimatedTokenBudget": {
"description": "Uncapped; provider list price + 5% handling fee",
"estimatedMillionTokens": 0,
"assumptions": "No fixed token bundle; value is the platform, agents and context engine rather than quota"
"description": "Platform fee; ~29.5M fee-equivalent tokens",
"estimatedMillionTokens": 29.5,
"midpointEstimate": 59,
"optimisticEstimate": 118,
"assumptions": "Platform-fee plan: no token bundle included; same LLM economics as Code Assistant. Figures are fee-equivalent at ~$2.00/M / ~$1.00/M / ~$0.50/M for cross-plan comparison only"
},
"notes": "Adds autonomous agents, Coaching Guidelines, Jira/Confluence/database MCP access"
},
+55 -46
View File
@@ -5,7 +5,8 @@ null = None
false = False
true = True
target_dir = "/home/john/Projects/Token-Max/data/coding-plans"
LAST_VERIFIED = "2026-09-18"
target_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "coding-plans")
os.makedirs(target_dir, exist_ok=True)
@@ -24,7 +25,7 @@ write_json(
"name": "Cursor",
"category": "coding-ide",
"url": "https://cursor.com/docs/account/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Hobby",
@@ -142,7 +143,7 @@ write_json(
"name": "GitHub Copilot",
"category": "coding-ide",
"url": "https://github.com/features/copilot/plans",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -284,7 +285,7 @@ write_json(
"name": "Claude Code",
"category": "coding-ide",
"url": "https://claude.com/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Pro",
@@ -365,7 +366,7 @@ write_json(
"name": "OpenAI Codex (ChatGPT)",
"category": "coding-ide",
"url": "https://openai.com/chatgpt/pricing/",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -457,7 +458,7 @@ write_json(
"name": "Google Antigravity",
"category": "coding-ide",
"url": "https://antigravity.google/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Individual",
@@ -584,7 +585,7 @@ write_json(
"name": "Meta Muse Code",
"category": "coding-ide",
"url": "https://developer.meta.com/ai/products/muse-code",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Everyday",
@@ -657,7 +658,7 @@ write_json(
"name": "Kiro",
"category": "coding-ide",
"url": "https://kiro.dev/pricing/",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -784,7 +785,7 @@ write_json(
"name": "Kilo Code",
"category": "coding-router",
"url": "https://kilo.ai/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Individual",
@@ -810,9 +811,11 @@ write_json(
},
"models": ["Kilo Gateway (500+ models)"],
"estimatedTokenBudget": {
"description": "Platform fee only; inference at provider list rates",
"estimatedMillionTokens": 0,
"assumptions": "Token cost = provider rates + 5% gateway fee (or BYOK, zero fee)",
"description": "Platform fee; ~15M fee-equivalent tokens at open-model rates",
"estimatedMillionTokens": 15,
"midpointEstimate": 30,
"optimisticEstimate": 45,
"assumptions": "Platform-fee plan: no token bundle included; inference billed separately at provider rates + 5% gateway fee (BYOK zero fee). Figures are the fee-equivalent value of $15 at ~$1.00/M (conservative) to ~$0.33/M (optimistic open-model gateway rates) for cross-plan comparison only",
},
"notes": "Security/governance features included",
},
@@ -849,7 +852,7 @@ write_json(
"name": "Lovable",
"category": "coding-ide",
"url": "https://lovable.dev/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -921,7 +924,7 @@ write_json(
"name": "Kimi Code",
"category": "coding-router",
"url": "https://www.kimi.com/coding-plan/",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Moderato",
@@ -1004,7 +1007,7 @@ write_json(
"name": "Windsurf (Cognition)",
"category": "coding-ide",
"url": "https://windsurf.com/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -1111,7 +1114,7 @@ write_json(
"name": "Augment Code",
"category": "coding-ide",
"url": "https://www.augmentcode.com/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Standard",
@@ -1180,7 +1183,7 @@ write_json(
"name": "Replit",
"category": "coding-ide",
"url": "https://replit.com/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Core",
@@ -1249,7 +1252,7 @@ write_json(
"name": "Amazon Q Developer",
"category": "coding-ide",
"url": "https://aws.amazon.com/q/developer/pricing/",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free Tier",
@@ -1303,7 +1306,7 @@ write_json(
"name": "Tabnine",
"category": "coding-ide",
"url": "https://www.tabnine.com/pricing/",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Code Assistant",
@@ -1315,9 +1318,11 @@ write_json(
},
"models": ["Tabnine Protected"],
"estimatedTokenBudget": {
"description": "Uncapped volumes; token cost = LLM provider list price + 5% service fee",
"estimatedMillionTokens": 0,
"assumptions": "No fixed monthly token bundle; supply your own LLM for unlimited usage, or reserve token quota at actual provider rates",
"description": "Platform fee; ~19.5M fee-equivalent tokens",
"estimatedMillionTokens": 19.5,
"midpointEstimate": 39,
"optimisticEstimate": 78,
"assumptions": "Platform-fee plan: no token bundle included; Tabnine-provided LLM access is billed at provider list price + 5% handling fee, BYO LLM endpoint is unlimited. Figures are fee-equivalent at ~$2.00/M (conservative), ~$1.00/M (midpoint) and ~$0.50/M (optimistic) for cross-plan comparison only",
},
"notes": "Completions + grounded chat in IDEs; Jira integration; SOC 2/ISO 27001",
},
@@ -1331,9 +1336,11 @@ write_json(
},
"models": ["Tabnine Protected"],
"estimatedTokenBudget": {
"description": "Uncapped; provider list price + 5% handling fee",
"estimatedMillionTokens": 0,
"assumptions": "No fixed token bundle; value is the platform, agents and context engine rather than quota",
"description": "Platform fee; ~29.5M fee-equivalent tokens",
"estimatedMillionTokens": 29.5,
"midpointEstimate": 59,
"optimisticEstimate": 118,
"assumptions": "Platform-fee plan: no token bundle included; same LLM economics as Code Assistant. Figures are fee-equivalent at ~$2.00/M / ~$1.00/M / ~$0.50/M for cross-plan comparison only",
},
"notes": "Adds autonomous agents, Coaching Guidelines, Jira/Confluence/database MCP access",
},
@@ -1375,7 +1382,7 @@ write_json(
"name": "Aider",
"category": "coding-ide",
"url": "https://aider.chat",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free / BYOK",
@@ -1421,7 +1428,7 @@ write_json(
"name": "CommandCode",
"category": "coding-router",
"url": "https://commandcode.ai/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Go",
@@ -1530,7 +1537,7 @@ write_json(
"name": "OpenCode",
"category": "coding-router",
"url": "https://opencode.ai/zen",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "OpenSource CLI",
@@ -1565,9 +1572,11 @@ write_json(
"Free stealth models",
],
"estimatedTokenBudget": {
"description": "Per-token billing at curated gateway rates; min $20",
"estimatedMillionTokens": 0,
"assumptions": "Balance-based; cost equals published per-model rates",
"description": "Prepaid balance (~10M fee-equivalent tokens)",
"estimatedMillionTokens": 10,
"midpointEstimate": 20,
"optimisticEstimate": 40,
"assumptions": "$20 minimum balance at zero markup buys $20 of inference at curated gateway rates; conservative 10M at ~$2.00/M frontier blend, midpoint 20M at ~$1.00/M, optimistic 40M at ~$0.50/M open-model rates; excludes the $1.23 card fee",
},
"notes": "US-hosted, zero retention",
},
@@ -1612,7 +1621,7 @@ write_json(
"name": "OpenRouter",
"category": "coding-router",
"url": "https://openrouter.ai",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -1668,7 +1677,7 @@ write_json(
"name": "BytePlus ModelArk Coding Plan",
"category": "api-provider",
"url": "https://www.byteplus.com/en/activity/arkcodingplan",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Lite",
@@ -1734,7 +1743,7 @@ write_json(
"name": "Alibaba Cloud",
"category": "api-provider",
"url": "https://www.alibabacloud.com/en/campaign/ai-landing-page-token",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Personal Lite",
@@ -1849,7 +1858,7 @@ write_json(
"name": "MiniMax Token Plan",
"category": "api-provider",
"url": "https://platform.minimax.io/docs/guides/pricing-token-plan",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Plus",
@@ -1932,7 +1941,7 @@ write_json(
"name": "OpenAI API",
"category": "api-provider",
"url": "https://platform.openai.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "PAYG",
@@ -1981,7 +1990,7 @@ write_json(
"name": "Anthropic API",
"category": "api-provider",
"url": "https://console.anthropic.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "PAYG",
@@ -2030,7 +2039,7 @@ write_json(
"name": "Google AI Studio",
"category": "api-provider",
"url": "https://aistudio.google.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -2119,7 +2128,7 @@ write_json(
"name": "DeepSeek API",
"category": "api-provider",
"url": "https://platform.deepseek.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "PAYG (Flash)",
@@ -2174,7 +2183,7 @@ write_json(
"name": "Groq API",
"category": "api-provider",
"url": "https://console.groq.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -2228,7 +2237,7 @@ write_json(
"name": "Mistral API",
"category": "api-provider",
"url": "https://mistral.ai/pricing",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free (Le Plan Studio)",
@@ -2289,7 +2298,7 @@ write_json(
"name": "Together.ai",
"category": "api-provider",
"url": "https://together.ai",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -2345,7 +2354,7 @@ write_json(
"name": "Fireworks.ai",
"category": "api-provider",
"url": "https://fireworks.ai",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -2398,7 +2407,7 @@ write_json(
"name": "Meta Llama API",
"category": "api-provider",
"url": "https://meta.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Standard",
@@ -2454,7 +2463,7 @@ write_json(
"name": "Ollama Cloud",
"category": "api-provider",
"url": "https://ollama.com",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Free",
@@ -2544,7 +2553,7 @@ write_json(
"name": "Z.ai GLM Coding Plan",
"category": "api-provider",
"url": "https://z.ai/subscribe",
"lastVerified": "2026-09-18",
"lastVerified": LAST_VERIFIED,
"tiers": [
{
"name": "Lite",
+1
View File
@@ -10,6 +10,7 @@
"fetch-benchmarks": "node scripts/fetch-benchmarks.mjs",
"build-data": "node scripts/build-data.mjs",
"update-data": "node scripts/fetch-models.mjs && node scripts/fetch-benchmarks.mjs && node scripts/build-data.mjs",
"validate-data": "node scripts/validate-data.mjs",
"lint": "oxlint",
"preview": "vite preview"
},
+1 -1
View File
@@ -1,3 +1,3 @@
{
"timestamp": "2026-09-19T04:11:37.228Z"
"timestamp": "2026-09-19T04:54:16.160Z"
}
+20 -12
View File
@@ -1283,9 +1283,11 @@
"Kilo Gateway (500+ models)"
],
"estimatedTokenBudget": {
"description": "Platform fee only; inference at provider list rates",
"estimatedMillionTokens": 0,
"assumptions": "Token cost = provider rates + 5% gateway fee (or BYOK, zero fee)"
"description": "Platform fee; ~15M fee-equivalent tokens at open-model rates",
"estimatedMillionTokens": 15,
"midpointEstimate": 30,
"optimisticEstimate": 45,
"assumptions": "Platform-fee plan: no token bundle included; inference billed separately at provider rates + 5% gateway fee (BYOK zero fee). Figures are the fee-equivalent value of $15 at ~$1.00/M (conservative) to ~$0.33/M (optimistic open-model gateway rates) for cross-plan comparison only"
},
"notes": "Security/governance features included"
},
@@ -2177,9 +2179,11 @@
"Free stealth models"
],
"estimatedTokenBudget": {
"description": "Per-token billing at curated gateway rates; min $20",
"estimatedMillionTokens": 0,
"assumptions": "Balance-based; cost equals published per-model rates"
"description": "Prepaid balance (~10M fee-equivalent tokens)",
"estimatedMillionTokens": 10,
"midpointEstimate": 20,
"optimisticEstimate": 40,
"assumptions": "$20 minimum balance at zero markup buys $20 of inference at curated gateway rates; conservative 10M at ~$2.00/M frontier blend, midpoint 20M at ~$1.00/M, optimistic 40M at ~$0.50/M open-model rates; excludes the $1.23 card fee"
},
"notes": "US-hosted, zero retention"
},
@@ -2359,9 +2363,11 @@
"Tabnine Protected"
],
"estimatedTokenBudget": {
"description": "Uncapped volumes; token cost = LLM provider list price + 5% service fee",
"estimatedMillionTokens": 0,
"assumptions": "No fixed monthly token bundle; supply your own LLM for unlimited usage, or reserve token quota at actual provider rates"
"description": "Platform fee; ~19.5M fee-equivalent tokens",
"estimatedMillionTokens": 19.5,
"midpointEstimate": 39,
"optimisticEstimate": 78,
"assumptions": "Platform-fee plan: no token bundle included; Tabnine-provided LLM access is billed at provider list price + 5% handling fee, BYO LLM endpoint is unlimited. Figures are fee-equivalent at ~$2.00/M (conservative), ~$1.00/M (midpoint) and ~$0.50/M (optimistic) for cross-plan comparison only"
},
"notes": "Completions + grounded chat in IDEs; Jira integration; SOC 2/ISO 27001"
},
@@ -2377,9 +2383,11 @@
"Tabnine Protected"
],
"estimatedTokenBudget": {
"description": "Uncapped; provider list price + 5% handling fee",
"estimatedMillionTokens": 0,
"assumptions": "No fixed token bundle; value is the platform, agents and context engine rather than quota"
"description": "Platform fee; ~29.5M fee-equivalent tokens",
"estimatedMillionTokens": 29.5,
"midpointEstimate": 59,
"optimisticEstimate": 118,
"assumptions": "Platform-fee plan: no token bundle included; same LLM economics as Code Assistant. Figures are fee-equivalent at ~$2.00/M / ~$1.00/M / ~$0.50/M for cross-plan comparison only"
},
"notes": "Adds autonomous agents, Coaching Guidelines, Jira/Confluence/database MCP access"
},
+172
View File
@@ -0,0 +1,172 @@
import fs from 'fs/promises';
import path from 'path';
const PLANS_DIR = path.join(process.cwd(), 'data/coding-plans');
const PUBLIC_PLANS = path.join(process.cwd(), 'public/data/plans.json');
const CATEGORIES = new Set(['coding-ide', 'coding-router', 'api-provider']);
const STACKING_POLICIES = new Set(['allowed', 'silent', 'prohibited', 'unknown']);
const errors = [];
function err(msg) {
errors.push(msg);
}
function isIsoDate(value) {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value));
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function validatePlan(plan, filename) {
const where = `${filename}`;
if (!plan || typeof plan !== 'object') {
err(`${where}: not a JSON object`);
return;
}
if (typeof plan.id !== 'string' || plan.id.length === 0) err(`${where}: missing id`);
const idFromFile = filename.replace(/\.json$/, '');
if (plan.id !== idFromFile) err(`${where}: id "${plan.id}" does not match filename`);
if (typeof plan.name !== 'string' || plan.name.trim() === '') err(`${where}: missing name`);
if (!CATEGORIES.has(plan.category)) err(`${where}: category "${plan.category}" is not one of ${[...CATEGORIES].join(', ')}`);
if (typeof plan.url !== 'string' || !/^https?:\/\//.test(plan.url)) err(`${where}: url must be an http(s) URL`);
if (!isIsoDate(plan.lastVerified)) err(`${where}: lastVerified must be YYYY-MM-DD`);
if (typeof plan.dataTraining !== 'string' || plan.dataTraining.trim() === '') err(`${where}: dataTraining must be a non-empty string`);
if (typeof plan.ipIndemnity !== 'boolean' && (typeof plan.ipIndemnity !== 'string' || plan.ipIndemnity.trim() === '')) {
err(`${where}: ipIndemnity must be a boolean or non-empty string`);
}
if (!Array.isArray(plan.gotchas) || plan.gotchas.length === 0) err(`${where}: gotchas must be a non-empty array`);
if (!Array.isArray(plan.tosHighlights)) err(`${where}: tosHighlights must be an array`);
if (plan.stackingPolicy !== undefined && !STACKING_POLICIES.has(plan.stackingPolicy)) {
err(`${where}: stackingPolicy "${plan.stackingPolicy}" must be one of ${[...STACKING_POLICIES].join(', ')}`);
}
if (!Array.isArray(plan.tiers) || plan.tiers.length === 0) {
err(`${where}: tiers must be a non-empty array`);
return;
}
const seenTierNames = new Set();
for (const tier of plan.tiers) {
const t = `${where} [${tier?.name ?? '?'}]`;
if (!isPlainObject(tier)) {
err(`${t}: tier is not an object`);
continue;
}
if (typeof tier.name !== 'string' || tier.name.trim() === '') err(`${t}: missing tier name`);
if (seenTierNames.has(tier.name)) err(`${t}: duplicate tier name`);
seenTierNames.add(tier.name);
if (tier.monthlyPrice !== null && (typeof tier.monthlyPrice !== 'number' || tier.monthlyPrice < 0 || !Number.isFinite(tier.monthlyPrice))) {
err(`${t}: monthlyPrice must be a non-negative number or null`);
}
if (tier.annualPrice !== undefined && tier.annualPrice !== null) {
if (typeof tier.annualPrice !== 'number' || tier.annualPrice <= 0) {
err(`${t}: annualPrice must be a positive number or null`);
} else if (typeof tier.monthlyPrice === 'number' && tier.monthlyPrice > 0 && tier.annualPrice / 12 >= tier.monthlyPrice) {
err(`${t}: annualPrice/12 (${(tier.annualPrice / 12).toFixed(2)}) must be lower than monthlyPrice ${tier.monthlyPrice}`);
}
}
if (!isPlainObject(tier.limits) || Object.keys(tier.limits).length === 0) err(`${t}: limits must be a non-empty object`);
if (!Array.isArray(tier.models) || tier.models.length === 0) err(`${t}: models must be a non-empty array`);
if (Array.isArray(tier.models)) {
const dupes = tier.models.filter((m, i) => tier.models.indexOf(m) !== i);
if (dupes.length > 0) err(`${t}: duplicate model entries: ${[...new Set(dupes)].join(', ')}`);
}
const tb = tier.estimatedTokenBudget;
if (!isPlainObject(tb)) {
err(`${t}: estimatedTokenBudget must be an object (never null)`);
continue;
}
if (typeof tb.description !== 'string' || tb.description.trim() === '') err(`${t}: estimatedTokenBudget.description required`);
if (typeof tb.assumptions !== 'string' || tb.assumptions.trim() === '') err(`${t}: estimatedTokenBudget.assumptions required`);
if (typeof tb.estimatedMillionTokens !== 'number' || !Number.isFinite(tb.estimatedMillionTokens)) {
err(`${t}: estimatedTokenBudget.estimatedMillionTokens must be a number`);
continue;
}
const paid = typeof tier.monthlyPrice === 'number' && tier.monthlyPrice > 0;
if (paid && tb.estimatedMillionTokens <= 0) {
err(`${t}: paid tier ($${tier.monthlyPrice}) must have estimatedMillionTokens > 0 (stacking drops zero budgets)`);
}
if (tb.estimatedMillionTokens < 0) err(`${t}: estimatedMillionTokens must not be negative`);
if (tb.midpointEstimate !== undefined && tb.midpointEstimate !== null) {
if (typeof tb.midpointEstimate !== 'number' || tb.midpointEstimate < tb.estimatedMillionTokens) {
err(`${t}: midpointEstimate must be a number >= estimatedMillionTokens`);
}
}
if (tb.optimisticEstimate !== undefined && tb.optimisticEstimate !== null) {
const floor = tb.midpointEstimate ?? tb.estimatedMillionTokens;
if (typeof tb.optimisticEstimate !== 'number' || tb.optimisticEstimate < floor) {
err(`${t}: optimisticEstimate must be a number >= midpoint/conservative estimate`);
}
}
}
}
async function main() {
let files = [];
try {
files = (await fs.readdir(PLANS_DIR)).filter(f => f.endsWith('.json') && !f.startsWith('_')).sort();
} catch (e) {
console.error(`Cannot read ${PLANS_DIR}: ${e.message}`);
process.exit(1);
}
if (files.length === 0) {
console.error('No plan files found.');
process.exit(1);
}
const plans = new Map();
for (const file of files) {
const raw = await fs.readFile(path.join(PLANS_DIR, file), 'utf-8');
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
err(`${file}: invalid JSON: ${e.message}`);
continue;
}
validatePlan(parsed, file);
plans.set(parsed.id, parsed);
}
// Cross-check the consolidated artifact the frontend consumes.
let consolidated = null;
try {
consolidated = JSON.parse(await fs.readFile(PUBLIC_PLANS, 'utf-8'));
} catch (e) {
err(`public/data/plans.json: cannot read: ${e.message}`);
}
if (Array.isArray(consolidated)) {
const consolidatedIds = new Set(consolidated.map(p => p.id));
for (const id of plans.keys()) {
if (!consolidatedIds.has(id)) err(`public/data/plans.json: missing plan "${id}" (stale build artifact?)`);
}
for (const p of consolidated) {
if (!plans.has(p.id)) {
err(`public/data/plans.json: contains unknown plan "${p.id}"`);
continue;
}
const source = plans.get(p.id);
if (JSON.stringify(source) !== JSON.stringify(p)) {
err(`public/data/plans.json: "${p.id}" differs from data/coding-plans/${p.id}.json — run npm run build-data`);
}
}
}
if (errors.length > 0) {
console.error(`\nValidation FAILED with ${errors.length} error(s):`);
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log(`\nValidated ${plans.size} plan files against schema invariants. OK`);
}
main().catch(e => {
console.error(e);
process.exit(1);
});