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