Skip to content
SlashClone

Agent cost dashboard

@agentcosts/agent-cost-dashboard

Turn LLM usage logs into one self-contained HTML cost dashboard for an agentic product: cost per customer, per task, per successful outcome, model mix, cache hit rate, and gross margin when you add revenue by customer. Prices blank costs from an editable price table.

SkillDatav1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Agent cost dashboard: normalized LLM usage CSV in, one self-contained HTML file out.
  3. 3// Usage: node scripts/cost-dashboard.mjs <usage.csv> [--prices prices.csv] [--revenue revenue.csv]
  4. 4// [--from 2026-10-01] [--to 2026-10-31] [--title "Agent costs"] [--out out/agent-costs.html]
  5. 5// Usage columns: timestamp, customer, task, run_id, provider, model, input_tokens, output_tokens,
  6. 6// cache_read_tokens, cache_write_tokens, cost_usd, outcome. Blank cost_usd is priced from --prices.
  7. 7// Revenue columns: customer, month (YYYY-MM), revenue.
  8. 8import { dayKey, fail, money, monthKey, num, parseArgs, parseDate, pct, readCsv, round, writeOut } from "./lib.mjs";
  9. 9import { loadPrices, readUsage, toRuns, warnStalePrices } from "./usage.mjs";
  10. 10import { SERIES, barChart, card, flag, lineChart, page, table, tiles } from "./html.mjs";
  11. 12const args = parseArgs(process.argv.slice(2));
  12. 13const input = args._[0];
  13. 14if (!input) fail("usage: node scripts/cost-dashboard.mjs <usage.csv> [--prices prices.csv] [--revenue revenue.csv] [--out out/agent-costs.html]");
  14. 15const outHtml = args.out || "out/agent-costs.html";
  15. 16const outJson = outHtml.replace(/\.html?$/i, "") + ".json";
  16. 17const prices = args.prices ? loadPrices(args.prices) : null;
  17. 18if (prices) warnStalePrices(prices, args.prices);
  18. 20let { rows, unpriced, unpricedModels } = readUsage(input, prices);
  19. 21const from = args.from ? parseDate(args.from)?.getTime() : null;
  20. 22const to = args.to ? parseDate(args.to)?.getTime() + 86399999 : null;
  21. 23if (from) rows = rows.filter((r) => r.t >= from);
  22. 24if (to) rows = rows.filter((r) => r.t <= to);
  23. 25if (!rows.length) fail("no usage rows in the chosen date range.");
  24. 26rows.sort((a, b) => a.t - b.t);
  25. 27const firstDay = dayKey(rows[0].date);
  26. 28const lastDay = dayKey(rows[rows.length - 1].date);
  27. 30const sumBy = (list, key, val = (r) => r.cost) => {
  28. 31 const m = new Map();
  29. 32 for (const r of list) m.set(key(r), (m.get(key(r)) || 0) + val(r));
  30. 33 return m;
  31. 34};
  32. 35const total = rows.reduce((a, r) => a + r.cost, 0);
  33. 36const tok = (k) => rows.reduce((a, r) => a + r[k], 0);
  34. 37const inputTok = tok("input_tokens");
  35. 38const outputTok = tok("output_tokens");
  36. 39const cacheRead = tok("cache_read_tokens");
  37. 40const cacheWrite = tok("cache_write_tokens");
  38. 41const cacheHit = inputTok + cacheRead + cacheWrite ? cacheRead / (inputTok + cacheRead + cacheWrite) : 0;
  39. 43const runs = toRuns(rows);
  40. 44const hasOutcome = runs.some((r) => r.outcome);
  41. 45const successes = runs.filter((r) => r.outcome === "success").length;
  42. 46const successRate = hasOutcome ? successes / runs.length : null;
  43. 47const costPerSuccess = successes ? total / successes : null;
  44. 49// Per customer
  45. 50const revenue = new Map();
  46. 51if (args.revenue) {
  47. 52 const months = new Set(rows.map((r) => monthKey(r.date)));
  48. 53 for (const r of readCsv(args.revenue, "revenue CSV", ["customer", "revenue"])) {
  49. 54 if (r.month && !months.has(monthKey(parseDate(r.month) || new Date(0)))) continue;
  50. 55 revenue.set(r.customer, (revenue.get(r.customer) || 0) + (Number.isFinite(num(r.revenue)) ? num(r.revenue) : 0));
  51. 56 }
  52. 58const customers = [...new Set(rows.map((r) => r.customer))].map((c) => {
  53. 59 const cr = runs.filter((r) => r.customer === c);
  54. 60 const cost = rows.filter((r) => r.customer === c).reduce((a, r) => a + r.cost, 0);
  55. 61 const s = cr.filter((r) => r.outcome === "success").length;
  56. 62 const rev = revenue.has(c) ? revenue.get(c) : null;
  57. 63 return { customer: c, cost: round(cost, 2), runs: cr.length, successes: s, cost_per_success: s ? round(cost / s, 4) : null, revenue: rev, gross_margin: rev ? round((rev - cost) / rev, 4) : null };
  58. 64});
  59. 65customers.sort((a, b) => b.cost - a.cost);
  60. 67// Per task
  61. 68const tasks = [...new Set(rows.map((r) => r.task))].map((t) => {
  62. 69 const tr = runs.filter((r) => r.task === t);
  63. 70 const cost = tr.reduce((a, r) => a + r.cost, 0);
  64. 71 const s = tr.filter((r) => r.outcome === "success").length;
  65. 72 return { task: t, cost: round(cost, 2), runs: tr.length, cost_per_run: round(cost / tr.length, 4), success_rate: hasOutcome ? round(s / tr.length, 4) : null, cost_per_success: s ? round(cost / s, 4) : null, avg_calls: round(tr.reduce((a, r) => a + r.calls, 0) / tr.length, 1) };
  66. 73});
  67. 74tasks.sort((a, b) => b.cost - a.cost);
  68. 76// Model mix
  69. 77const models = [...sumBy(rows, (r) => r.model).entries()].map(([m, c]) => {
  70. 78 const mr = rows.filter((r) => r.model === m);
  71. 79 const it = mr.reduce((a, r) => a + r.input_tokens + r.cache_read_tokens + r.cache_write_tokens, 0);
  72. 80 const ot = mr.reduce((a, r) => a + r.output_tokens, 0);
  73. 81 const hit = it ? mr.reduce((a, r) => a + r.cache_read_tokens, 0) / it : 0;
  74. 82 return { model: m, cost: round(c, 2), share: round(c / total, 4), calls: mr.length, input_tokens: it, output_tokens: ot, cache_hit_rate: round(hit, 4) };
  75. 83});
  76. 84models.sort((a, b) => b.cost - a.cost);
  77. 86// Daily cost
  78. 87const days = [];
  79. 88for (let t = Date.parse(firstDay); t <= Date.parse(lastDay); t += 86400000) days.push(dayKey(new Date(t)));
  80. 89const daily = sumBy(rows, (r) => dayKey(r.date));
  81. 90const dailySeries = days.map((d) => round(daily.get(d) || 0, 2));
  82. 91const topModels = models.slice(0, 4).map((m) => m.model);
  83. 92const dailyByModel = topModels.map((m) => {
  84. 93 const mm = sumBy(rows.filter((r) => r.model === m), (r) => dayKey(r.date));
  85. 94 return { name: m, values: days.map((d) => round(mm.get(d) || 0, 2)) };
  86. 95});
  87. 97const revTotal = [...revenue.values()].reduce((a, b) => a + b, 0);
  88. 98const summary = {
  89. 99 source: input,
  90. 100 from: firstDay,
  91. 101 to: lastDay,
  92. 102 calls: rows.length,
  93. 103 runs: runs.length,
  94. 104 total_cost: round(total, 2),
  95. 105 success_rate: successRate === null ? null : round(successRate, 4),
  96. 106 cost_per_run: round(total / runs.length, 4),
  97. 107 cost_per_success: costPerSuccess === null ? null : round(costPerSuccess, 4),
  98. 108 tokens: { input: inputTok, output: outputTok, cache_read: cacheRead, cache_write: cacheWrite },
  99. 109 cache_hit_rate: round(cacheHit, 4),
  100. 110 unpriced_rows: unpriced,
  101. 111 unpriced_models: unpricedModels,
  102. 112 revenue_total: args.revenue ? round(revTotal, 2) : null,
  103. 113 gross_margin_after_llm: args.revenue && revTotal ? round((revTotal - customers.filter((c) => c.revenue !== null).reduce((a, c) => a + c.cost, 0)) / revTotal, 4) : null,
  104. 114 customers,
  105. 115 tasks,
  106. 116 models,
  107. 117 daily: days.map((d, i) => ({ day: d, cost: dailySeries[i] })),
  108. 118};
  109. 119writeOut(outJson, JSON.stringify(summary, null, 2) + "\n");
  110. 121const usd = (v) => money(v, v < 10 ? 2 : 0);
  111. 122const usd4 = (v) => (v === null ? "n/a" : v < 1 ? `$${v.toFixed(3)}` : money(v, 2));
  112. 123const n0 = (v) => Math.round(v).toLocaleString("en-US");
  113. 124const marginFlag = (m) => (m === null ? "" : m < 0 ? flag("bad", pct(m, 0)) : m < 0.6 ? flag("warn", pct(m, 0)) : flag("good", pct(m, 0)));
  114. 125const body = [
  115. 126 tiles([
  116. 127 { label: "LLM cost", value: usd(total), hint: `${firstDay} to ${lastDay}` },
  117. 128 { label: "Runs", value: n0(runs.length), hint: `${n0(rows.length)} model calls` },
  118. 129 { label: "Success rate", value: successRate === null ? "n/a" : pct(successRate, 0), hint: hasOutcome ? `${n0(successes)} successful runs` : "no outcome column" },
  119. 130 { label: "Cost per successful outcome", value: usd4(summary.cost_per_success), hint: `cost per run ${usd4(summary.cost_per_run)}` },
  120. 131 { label: "Cache hit rate", value: pct(cacheHit, 0), hint: "cache reads / all input tokens" },
  121. 132 ...(args.revenue ? [{ label: "Gross margin after LLM cost", value: summary.gross_margin_after_llm === null ? "n/a" : pct(summary.gross_margin_after_llm, 0), hint: `revenue ${usd(revTotal)}` }] : []),
  122. 133 ]),
  123. 134 `<div class="grid">`,
  124. 135 card("Cost per day", lineChart(days.map((d) => d.slice(5)), [{ name: "Cost", values: dailySeries }], (v) => money(v, v < 10 ? 2 : 0))),
  125. 136 card("Cost per day by model", lineChart(days.map((d) => d.slice(5)), dailyByModel, (v) => money(v, v < 10 ? 2 : 0)), models.length > 4 ? "Top 4 models by cost." : ""),
  126. 137 `</div><div class="grid">`,
  127. 138 card("Cost by customer", barChart(customers.slice(0, 12).map((c) => ({ label: c.customer, value: c.cost })), usd)),
  128. 139 card("Model mix (share of cost)", barChart(models.map((m, i) => ({ label: m.model, value: m.share * 100, color: SERIES[i % SERIES.length] })), (v) => `${v.toFixed(0)}%`)),
  129. 140 `</div>`,
  130. 141 card(
  131. 142 "Customers",
  132. 143 table(
  133. 144 [{ label: "Customer" }, { label: "LLM cost", numeric: true }, { label: "Runs", numeric: true }, { label: "Cost / success", numeric: true }, ...(args.revenue ? [{ label: "Revenue", numeric: true }, { label: "Gross margin", numeric: true }] : [])],
  134. 145 customers.map((c) => [c.customer, usd(c.cost), n0(c.runs), usd4(c.cost_per_success), ...(args.revenue ? [c.revenue === null ? "no revenue row" : usd(c.revenue), marginFlag(c.gross_margin)] : [])]),
  135. 146 { html: true },
  136. 147 ),
  137. 148 args.revenue ? "Gross margin here counts LLM cost only. Use the gross-margin-by-customer kit to add hosting and other costs." : "",
  138. 149 ),
  139. 150 `<div style="height:16px"></div>`,
  140. 151 card(
  141. 152 "Tasks and workflows",
  142. 153 table(
  143. 154 [{ label: "Task" }, { label: "Cost", numeric: true }, { label: "Runs", numeric: true }, { label: "Calls / run", numeric: true }, { label: "Cost / run", numeric: true }, { label: "Success", numeric: true }, { label: "Cost / success", numeric: true }],
  144. 155 tasks.map((t) => [t.task, usd(t.cost), n0(t.runs), String(t.avg_calls), usd4(t.cost_per_run), t.success_rate === null ? "n/a" : pct(t.success_rate, 0), usd4(t.cost_per_success)]),
  145. 156 ),
  146. 157 "A failed run still costs money, so cost per success is the number to price and optimize against.",
  147. 158 ),
  148. 159 `<div style="height:16px"></div>`,
  149. 160 card(
  150. 161 "Models",
  151. 162 table(
  152. 163 [{ label: "Model" }, { label: "Cost", numeric: true }, { label: "Share", numeric: true }, { label: "Calls", numeric: true }, { label: "Input tokens", numeric: true }, { label: "Output tokens", numeric: true }, { label: "Cache hit", numeric: true }],
  153. 164 models.map((m) => [m.model, usd(m.cost), pct(m.share, 0), n0(m.calls), n0(m.input_tokens), n0(m.output_tokens), pct(m.cache_hit_rate, 0)]),
  154. 165 ),
  155. 166 "Input tokens include cache reads and writes. Cache hit = cache reads / input tokens.",
  156. 167 ),
  157. 168 `<p class="note">Source: ${input.replace(/[<>&"]/g, "")}.${prices ? ` Blank costs priced from ${args.prices.replace(/[<>&"]/g, "")}; check those prices against each provider's current pricing.` : ""}${unpriced ? ` ${unpriced} rows had no price and count as $0 (${unpricedModels.join(", ").replace(/[<>&"]/g, "")}).` : ""}</p>`,
  158. 169].join("\n");
  159. 170writeOut(outHtml, page(args.title || "Agent costs", `${firstDay} to ${lastDay}`, body));
  160. 172process.stdout.write(`Wrote ${outHtml} and ${outJson}\n`);
  161. 173process.stdout.write(`Cost ${usd(total)} over ${n0(runs.length)} runs; cost per success ${usd4(summary.cost_per_success)}; cache hit ${pct(cacheHit, 0)}${summary.gross_margin_after_llm !== null ? `; gross margin after LLM ${pct(summary.gross_margin_after_llm, 0)}` : ""}.\n`);