Usage pricing simulator
@agentcosts/usage-pricing-simulator
Test pricing models for an AI product on your real usage before you change a price: per seat, per task, credits, and hybrid (platform fee plus included credits plus overage). Shows revenue, gross margin after LLM cost, and which customers' bills would jump or drop under each model.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Try pricing models (per seat, per task, credits, hybrid) on real usage and see revenue, margin, and bill changes.
- 3// Usage: node scripts/simulate.mjs <usage.csv> --customers customers.csv --pricing pricing.json [--prices prices.csv] [--out out/pricing-sim]
- 4// customers.csv: customer, seats, mrr (what they pay today per month), optional plan.
- 5// pricing.json: see examples/pricing.json.
- 6import { fail, mdTable, money, monthKey, num, parseArgs, pct, readCsv, readJson, round, toCsv, writeOut } from "./lib.mjs";
- 7import { loadPrices, readUsage, toRuns, warnStalePrices } from "./usage.mjs";
- 9const args = parseArgs(process.argv.slice(2));
- 10const input = args._[0];
- 11if (!input || !args.customers || !args.pricing) fail("usage: node scripts/simulate.mjs <usage.csv> --customers customers.csv --pricing pricing.json [--prices prices.csv]");
- 12const outPrefix = args.out || "out/pricing-sim";
- 13const cfg = readJson(args.pricing, "pricing file");
- 14if (!Array.isArray(cfg.models) || !cfg.models.length) fail(`${args.pricing}: "models" must list at least one pricing model.`);
- 15const target = cfg.target_margin ?? 0.7;
- 16const billOn = cfg.bill_on || "success";
- 17const bigChange = cfg.big_change ?? 0.25;
- 18const rates = cfg.credit_rates || {};
- 19const prices = args.prices ? loadPrices(args.prices) : null;
- 20if (prices) warnStalePrices(prices, args.prices);
- 22const { rows } = readUsage(input, prices);
- 23const months = [...new Set(rows.map((r) => monthKey(r.date)))].sort();
- 24const nMonths = months.length;
- 25const runs = toRuns(rows);
- 27const customers = new Map();
- 28for (const c of readCsv(args.customers, "customers CSV", ["customer"])) customers.set(c.customer, { customer: c.customer, seats: Number.isFinite(num(c.seats)) ? num(c.seats) : 0, mrr: Number.isFinite(num(c.mrr)) ? num(c.mrr) : 0, plan: c.plan || "" });
- 29const unknown = [...new Set(runs.map((r) => r.customer))].filter((c) => !customers.has(c));
- 30if (unknown.length) process.stderr.write(`Usage for customers not in ${args.customers} is left out: ${unknown.join(", ")}.\n`);
- 32const pick = (table, task) => (table && task in table ? table[task] : table?.["*"] ?? 0);
- 33// Per customer: monthly averages of cost and billable runs per task.
- 34const profiles = [...customers.values()].map((c) => {
- 35 const cr = runs.filter((r) => r.customer === c.customer);
- 36 const billable = cr.filter((r) => billOn === "all" || r.outcome === "success");
- 37 const byTask = {};
- 38 for (const r of billable) byTask[r.task] = (byTask[r.task] || 0) + 1 / nMonths;
- 39 const credits = Object.entries(byTask).reduce((a, [t, n]) => a + n * pick(rates, t), 0);
- 40 return { ...c, cost: cr.reduce((a, r) => a + r.cost, 0) / nMonths, runs: cr.length / nMonths, billable_runs: billable.length / nMonths, by_task: byTask, credits };
- 41});
- 43function bill(model, p) {
- 44 switch (model.type) {
- 45 case "per_seat":
- 46 return Math.max(model.minimum ?? 0, p.seats * model.price_per_seat);
- 47 case "per_task":
- 48 return Math.max(model.minimum ?? 0, Object.entries(p.by_task).reduce((a, [t, n]) => a + n * pick(model.prices, t), 0));
- 49 case "credits":
- 50 return Math.max(model.minimum ?? 0, p.credits * model.credit_price);
- 51 case "hybrid": {
- 52 const over = Math.max(0, p.credits - (model.included_credits ?? 0));
- 53 return (model.platform_fee ?? 0) + (model.price_per_seat ?? 0) * p.seats + over * (model.overage_credit_price ?? 0);
- 54 }
- 55 case "current":
- 56 return p.mrr;
- 57 default:
- 58 fail(`unknown model type "${model.type}" (use per_seat, per_task, credits, hybrid, or current).`);
- 59 }
- 60}
- 62const models = [{ name: "Current", type: "current" }, ...cfg.models];
- 63const detail = [];
- 64const summaries = models.map((m) => {
- 65 const per = profiles.map((p) => {
- 66 const revenue = bill(m, p);
- 67 const margin = revenue ? (revenue - p.cost) / revenue : null;
- 68 const change = p.mrr ? (revenue - p.mrr) / p.mrr : null;
- 69 const row = { model: m.name, customer: p.customer, revenue: round(revenue, 2), llm_cost: round(p.cost, 2), gross_margin: margin === null ? null : round(margin, 4), current_mrr: p.mrr, change_vs_current: change === null ? null : round(change, 4) };
- 70 detail.push(row);
- 71 return row;
- 72 });
- 73 const revenue = per.reduce((a, r) => a + r.revenue, 0);
- 74 const cost = per.reduce((a, r) => a + r.llm_cost, 0);
- 75 return {
- 76 model: m.name,
- 77 type: m.type,
- 78 revenue: round(revenue, 2),
- 79 llm_cost: round(cost, 2),
- 80 gross_margin: revenue ? round((revenue - cost) / revenue, 4) : null,
- 81 below_target: per.filter((r) => r.gross_margin !== null && r.gross_margin < target).map((r) => r.customer),
- 82 negative: per.filter((r) => r.gross_margin !== null && r.gross_margin < 0).map((r) => r.customer),
- 83 big_increases: per.filter((r) => r.change_vs_current !== null && r.change_vs_current > bigChange).map((r) => r.customer),
- 84 big_decreases: per.filter((r) => r.change_vs_current !== null && r.change_vs_current < -bigChange).map((r) => r.customer),
- 85 revenue_change: profiles.reduce((a, p) => a + p.mrr, 0) ? round(revenue / profiles.reduce((a, p) => a + p.mrr, 0) - 1, 4) : null,
- 86 };
- 87});
- 89writeOut(`${outPrefix}.csv`, toCsv(detail, ["model", "customer", "revenue", "llm_cost", "gross_margin", "current_mrr", "change_vs_current"]));
- 90writeOut(
- 91 `${outPrefix}.json`,
- 92 JSON.stringify(
- 93 {
- 94 months,
- 95 bill_on: billOn,
- 96 target_margin: target,
- 97 customers: profiles.map((p) => ({ customer: p.customer, seats: p.seats, mrr: p.mrr, llm_cost: round(p.cost, 2), runs: round(p.runs, 1), billable_runs: round(p.billable_runs, 1), credits: round(p.credits, 1), by_task: Object.fromEntries(Object.entries(p.by_task).map(([k, v]) => [k, round(v, 1)])) })),
- 98 models: summaries,
- 99 },
- 100 null,
- 101 2,
- 102 ) + "\n",
- 103);
- 105const md = [
- 106 `# Pricing simulation`,
- 107 "",
- 108 `Usage from ${months[0]}${nMonths > 1 ? ` to ${months[nMonths - 1]} (monthly averages over ${nMonths} months)` : ""}. Billable runs: ${billOn === "all" ? "every run" : "successful runs only"}. Target gross margin ${pct(target, 0)} (LLM cost only). "Big change" means a bill moving more than ${pct(bigChange, 0)}.`,
- 109 "",
- 110 mdTable(
- 111 ["Model", "Monthly revenue", "vs today", "LLM cost", "Gross margin", "Under target", "Negative", "Bills up a lot", "Bills down a lot"],
- 112 summaries.map((s) => [s.model, money(s.revenue), s.revenue_change === null ? "" : `${s.revenue_change >= 0 ? "+" : ""}${pct(s.revenue_change, 0)}`, money(s.llm_cost), s.gross_margin === null ? "n/a" : pct(s.gross_margin), s.below_target.length, s.negative.length, s.big_increases.join(", ") || "none", s.big_decreases.join(", ") || "none"]),
- 113 ),
- 114 "",
- 115 "## Monthly bill per customer",
- 116 "",
- 117 mdTable(
- 118 ["Customer", "Seats", "LLM cost", ...summaries.map((s) => s.model)],
- 119 profiles.map((p) => [p.customer, p.seats, money(p.cost, 2), ...summaries.map((s) => {
- 120 const d = detail.find((x) => x.model === s.model && x.customer === p.customer);
- 121 return `${money(d.revenue)} (${d.gross_margin === null ? "n/a" : pct(d.gross_margin, 0)})`;
- 122 })]),
- 123 ),
- 124 "",
- 125 "Each cell: monthly bill (gross margin after LLM cost).",
- 126 "",
- 127].join("\n");
- 128writeOut(`${outPrefix}.md`, md);
- 130process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json\n`);
- 131for (const s of summaries) process.stdout.write(`${s.model}: ${money(s.revenue)}/mo, margin ${s.gross_margin === null ? "n/a" : pct(s.gross_margin)}, ${s.negative.length} negative, ${s.big_increases.length} big increase(s).\n`);
