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