Skip to content
SlashClone

Gross margin by customer

@agentcosts/gross-margin-by-customer

Gross margin per customer for an AI product: revenue minus LLM cost minus allocated hosting and other cost of revenue. Flags negative and below-target customers, names the task driving their cost, and gives the price each one would need to hit your target margin.

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()];