Skip to content
SlashClone

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.

SkillFinancev1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1// Shared usage schema and price table helpers. Node built-ins only.
  2. 2// Normalized usage columns:
  3. 3// timestamp, customer, task, run_id, provider, model, input_tokens, output_tokens,
  4. 4// cache_read_tokens, cache_write_tokens, cost_usd, outcome
  5. 5// input_tokens means uncached input only. cache_read_tokens and cache_write_tokens are separate.
  6. 6import { DAY_MS, fail, num, num0, parseDate, readCsv } from "./lib.mjs";
  7. 7
  8. 8export const USAGE_COLUMNS = ["timestamp", "customer", "task", "run_id", "provider", "model", "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "cost_usd", "outcome"];
  9. 9
  10. 10/** Loads a price table: provider, model, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, checked_on. */
  11. 11export function loadPrices(path) {
  12. 12 const rows = readCsv(path, "price table", ["model", "input_per_mtok", "output_per_mtok"]);
  13. 13 const prices = rows
  14. 14 .filter((r) => r.model && !r.model.startsWith("#"))
  15. 15 .map((r) => ({
  16. 16 provider: r.provider || "",
  17. 17 model: r.model.toLowerCase(),
  18. 18 input: num0(r.input_per_mtok),
  19. 19 output: num0(r.output_per_mtok),
  20. 20 cacheRead: Number.isFinite(num(r.cache_read_per_mtok)) ? num(r.cache_read_per_mtok) : num0(r.input_per_mtok),
  21. 21 cacheWrite: Number.isFinite(num(r.cache_write_per_mtok)) ? num(r.cache_write_per_mtok) : num0(r.input_per_mtok),
  22. 22 cacheWrite1h: Number.isFinite(num(r.cache_write_1h_per_mtok)) ? num(r.cache_write_1h_per_mtok) : NaN,
  23. 23 checkedOn: parseDate(r.checked_on),
  24. 24 }));
  25. 25 // Longest model name first, so "gpt-5-mini" wins over "gpt-5" for "gpt-5-mini-2025-08-07".
  26. 26 prices.sort((a, b) => b.model.length - a.model.length);
  27. 27 return prices;
  28. 30export function findPrice(prices, model) {
  29. 31 const m = String(model || "").toLowerCase();
  30. 32 return prices.find((p) => m === p.model) || prices.find((p) => m.startsWith(p.model)) || null;
  31. 35/** Cost in USD for one usage row, from the price table. Returns null when the model has no price. */
  32. 36export function priceRow(prices, r) {
  33. 37 const p = findPrice(prices, r.model);
  34. 38 if (!p) return null;
  35. 39 return (r.input_tokens * p.input + r.output_tokens * p.output + r.cache_read_tokens * p.cacheRead + r.cache_write_tokens * p.cacheWrite) / 1e6;
  36. 42/** Warns on stderr when prices were never checked or were checked more than maxAgeDays ago. */
  37. 43export function warnStalePrices(prices, path, maxAgeDays = 60) {
  38. 44 const now = Date.now();
  39. 45 const unchecked = prices.filter((p) => !p.checkedOn).map((p) => p.model);
  40. 46 const stale = prices.filter((p) => p.checkedOn && now - p.checkedOn.getTime() > maxAgeDays * DAY_MS).map((p) => p.model);
  41. 47 if (unchecked.length || stale.length)
  42. 48 process.stderr.write(
  43. 49 `Prices in ${path} need checking against each provider's current pricing page:${unchecked.length ? ` never checked: ${unchecked.join(", ")};` : ""}${stale.length ? ` older than ${maxAgeDays} days: ${stale.join(", ")};` : ""} update checked_on when done.\n`,
  44. 50 );
  45. 53/**
  46. 54 * Reads a normalized usage CSV. When cost_usd is blank and a price table is given, cost is computed.
  47. 55 * Returns { rows, unpriced } where unpriced counts rows with no cost.
  48. 56 */
  49. 57export function readUsage(path, prices = null) {
  50. 58 const raw = readCsv(path, "usage CSV", ["timestamp", "customer", "model"]);
  51. 59 const rows = [];
  52. 60 let unpriced = 0;
  53. 61 let skipped = 0;
  54. 62 const unpricedModels = new Set();
  55. 63 raw.forEach((r) => {
  56. 64 const d = parseDate(r.timestamp);
  57. 65 if (!d) {
  58. 66 skipped++;
  59. 67 return;
  60. 68 }
  61. 69 const row = {
  62. 70 t: d.getTime(),
  63. 71 date: d,
  64. 72 customer: r.customer || "(unassigned)",
  65. 73 task: r.task || "(none)",
  66. 74 run_id: r.run_id || "",
  67. 75 provider: r.provider || "",
  68. 76 model: r.model,
  69. 77 input_tokens: num0(r.input_tokens),
  70. 78 output_tokens: num0(r.output_tokens),
  71. 79 cache_read_tokens: num0(r.cache_read_tokens),
  72. 80 cache_write_tokens: num0(r.cache_write_tokens),
  73. 81 cost: num(r.cost_usd),
  74. 82 outcome: (r.outcome || "").toLowerCase(),
  75. 83 };
  76. 84 if (!Number.isFinite(row.cost) && prices) {
  77. 85 const c = priceRow(prices, row);
  78. 86 if (c !== null) row.cost = c;
  79. 87 }
  80. 88 if (!Number.isFinite(row.cost)) {
  81. 89 unpriced++;
  82. 90 unpricedModels.add(row.model);
  83. 91 row.cost = 0;
  84. 92 }
  85. 93 rows.push(row);
  86. 94 });
  87. 95 if (!rows.length) fail(`no usable rows in ${path}.`);
  88. 96 if (unpriced) process.stderr.write(`Warning: ${unpriced} row(s) have no cost_usd and no price for model(s) ${[...unpricedModels].join(", ")}. They count as $0 until you add prices.\n`);
  89. 97 if (skipped) process.stderr.write(`Skipped ${skipped} row(s) with a bad timestamp.\n`);
  90. 98 return { rows, unpriced, unpricedModels: [...unpricedModels], skipped };
  91. 101/** Groups rows into runs (by run_id, or one run per row). A run succeeded if any row says success. */
  92. 102export function toRuns(rows) {
  93. 103 const runs = new Map();
  94. 104 rows.forEach((r, i) => {
  95. 105 const id = r.run_id || `row-${i}`;
  96. 106 const run = runs.get(id) || { id, customer: r.customer, task: r.task, t: r.t, cost: 0, calls: 0, outcome: "" };
  97. 107 run.cost += r.cost;
  98. 108 run.calls++;
  99. 109 run.t = Math.min(run.t, r.t);
  100. 110 if (r.outcome === "success") run.outcome = "success";
  101. 111 else if (r.outcome && run.outcome !== "success") run.outcome = r.outcome;
  102. 112 runs.set(id, run);
  103. 113 });
  104. 114 return [...runs.values()];