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// Small helpers shared by this kit's scripts. Node built-ins only.
  2. 2import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
  3. 3import { dirname } from "node:path";
  4. 4
  5. 5export function fail(message) {
  6. 6 process.stderr.write(`Error: ${message}\n`);
  7. 7 process.exit(1);
  8. 8}
  9. 9
  10. 10/** Parses argv into { _: [positional], flag: value }. Flags without a value become true. */
  11. 11export function parseArgs(argv, booleans = []) {
  12. 12 const out = { _: [] };
  13. 13 for (let i = 0; i < argv.length; i++) {
  14. 14 const a = argv[i];
  15. 15 if (a.startsWith("--")) {
  16. 16 const eq = a.indexOf("=");
  17. 17 if (eq !== -1) out[a.slice(2, eq)] = a.slice(eq + 1);
  18. 18 else if (booleans.includes(a.slice(2))) out[a.slice(2)] = true;
  19. 19 else {
  20. 20 const next = argv[i + 1];
  21. 21 if (next === undefined || next.startsWith("--")) out[a.slice(2)] = true;
  22. 22 else {
  23. 23 out[a.slice(2)] = next;
  24. 24 i++;
  25. 25 }
  26. 26 }
  27. 27 } else out._.push(a);
  28. 28 }
  29. 29 return out;
  30. 32export function readText(path, what = "file") {
  31. 33 if (!path) fail(`no ${what} given.`);
  32. 34 if (!existsSync(path)) fail(`${what} not found: ${path}`);
  33. 35 return readFileSync(path, "utf8").replace(/^\uFEFF/, "");
  34. 38export function readJson(path, what = "JSON file") {
  35. 39 const text = readText(path, what);
  36. 40 try {
  37. 41 return JSON.parse(text);
  38. 42 } catch (e) {
  39. 43 return fail(`${path} is not valid JSON (${e.message}).`);
  40. 44 }
  41. 47/** RFC 4180 CSV parser. Returns { header, rows } where rows are objects keyed by trimmed header. */
  42. 48export function parseCsv(text) {
  43. 49 const raw = [];
  44. 50 let row = [];
  45. 51 let field = "";
  46. 52 let quoted = false;
  47. 53 const pushRow = () => {
  48. 54 row.push(field);
  49. 55 field = "";
  50. 56 if (row.some((v) => v.trim() !== "")) raw.push(row);
  51. 57 row = [];
  52. 58 };
  53. 59 for (let i = 0; i < text.length; i++) {
  54. 60 const ch = text[i];
  55. 61 if (quoted) {
  56. 62 if (ch === '"' && text[i + 1] === '"') {
  57. 63 field += '"';
  58. 64 i++;
  59. 65 } else if (ch === '"') quoted = false;
  60. 66 else field += ch;
  61. 67 } else if (ch === '"') quoted = true;
  62. 68 else if (ch === ",") {
  63. 69 row.push(field);
  64. 70 field = "";
  65. 71 } else if (ch === "\n" || ch === "\r") {
  66. 72 if (ch === "\r" && text[i + 1] === "\n") i++;
  67. 73 pushRow();
  68. 74 } else field += ch;
  69. 75 }
  70. 76 if (field !== "" || row.length) pushRow();
  71. 77 const [head = [], ...body] = raw;
  72. 78 const header = head.map((h) => h.trim());
  73. 79 const rows = body.map((r) => {
  74. 80 const rec = {};
  75. 81 header.forEach((h, i) => {
  76. 82 rec[h] = (r[i] ?? "").trim();
  77. 83 });
  78. 84 return rec;
  79. 85 });
  80. 86 return { header, rows };
  81. 89export function readCsv(path, what = "CSV file", required = []) {
  82. 90 const { header, rows } = parseCsv(readText(path, what));
  83. 91 const lower = header.map((h) => h.toLowerCase());
  84. 92 const missing = required.filter((c) => !lower.includes(c.toLowerCase()));
  85. 93 if (missing.length)
  86. 94 fail(
  87. 95 `${path} is missing column(s): ${missing.join(", ")}. Found: ${header.join(", ") || "(no header)"}.`,
  88. 96 );
  89. 97 // Normalize keys to lowercase so "Amount" and "amount" both work.
  90. 98 return rows.map((r) => {
  91. 99 const o = {};
  92. 100 for (const [k, v] of Object.entries(r)) o[k.toLowerCase()] = v;
  93. 101 return o;
  94. 102 });
  95. 105export function csvCell(v) {
  96. 106 const s = v === null || v === undefined ? "" : String(v);
  97. 107 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  98. 110export function toCsv(rows, columns) {
  99. 111 const lines = [columns.map(csvCell).join(",")];
  100. 112 for (const r of rows) lines.push(columns.map((c) => csvCell(r[c])).join(","));
  101. 113 return lines.join("\n") + "\n";
  102. 116/** "$1,234.50", "(12.00)", "-3", "" -> number. Empty or junk -> NaN. */
  103. 117export function num(v) {
  104. 118 if (v === null || v === undefined) return NaN;
  105. 119 let s = String(v).trim();
  106. 120 if (s === "") return NaN;
  107. 121 let neg = false;
  108. 122 if (/^\(.*\)$/.test(s)) {
  109. 123 neg = true;
  110. 124 s = s.slice(1, -1);
  111. 125 }
  112. 126 s = s.replace(/[$€£,\s%]/g, "");
  113. 127 if (s.endsWith("-")) {
  114. 128 neg = !neg;
  115. 129 s = s.slice(0, -1);
  116. 130 }
  117. 131 const n = Number(s);
  118. 132 if (!Number.isFinite(n)) return NaN;
  119. 133 return neg ? -n : n;
  120. 136export function num0(v) {
  121. 137 const n = num(v);
  122. 138 return Number.isNaN(n) ? 0 : n;
  123. 141export function round(n, digits = 2) {
  124. 142 const f = 10 ** digits;
  125. 143 return Math.round((n + Number.EPSILON) * f) / f;
  126. 146export function money(n, digits = 0) {
  127. 147 if (!Number.isFinite(n)) return "n/a";
  128. 148 const s = Math.abs(n).toLocaleString("en-US", {
  129. 149 minimumFractionDigits: digits,
  130. 150 maximumFractionDigits: digits,
  131. 151 });
  132. 152 return `${n < 0 ? "-" : ""}$${s}`;
  133. 155export function pct(n, digits = 1) {
  134. 156 if (!Number.isFinite(n)) return "n/a";
  135. 157 return `${(n * 100).toFixed(digits)}%`;
  136. 160export function writeOut(path, content) {
  137. 161 mkdirSync(dirname(path), { recursive: true });
  138. 162 writeFileSync(path, content);
  139. 165/** Accepts 2026-10-03, 2026-10-03T12:00:00Z, 10/03/2026, or unix seconds. Returns a Date (UTC) or null. */
  140. 166export function parseDate(v) {
  141. 167 if (v === null || v === undefined) return null;
  142. 168 const s = String(v).trim();
  143. 169 if (!s) return null;
  144. 170 if (/^\d{9,10}$/.test(s)) return new Date(Number(s) * 1000);
  145. 171 if (/^\d{12,13}$/.test(s)) return new Date(Number(s));
  146. 172 let m = s.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?$/);
  147. 173 if (m) return new Date(Date.UTC(+m[1], +m[2] - 1, m[3] ? +m[3] : 1));
  148. 174 m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
  149. 175 if (m) {
  150. 176 const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
  151. 177 return new Date(Date.UTC(y, +m[1] - 1, +m[2]));
  152. 178 }
  153. 179 const d = new Date(s);
  154. 180 return Number.isNaN(d.getTime()) ? null : d;
  155. 183export const DAY_MS = 86400000;
  156. 185export function dayKey(d) {
  157. 186 return d.toISOString().slice(0, 10);
  158. 189export function monthKey(d) {
  159. 190 return d.toISOString().slice(0, 7);
  160. 193/** Monday of the ISO week containing d, as YYYY-MM-DD. */
  161. 194export function weekKey(d) {
  162. 195 const day = (d.getUTCDay() + 6) % 7;
  163. 196 return dayKey(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - day)));
  164. 199export function addMonths(key, n) {
  165. 200 const [y, m] = key.split("-").map(Number);
  166. 201 const d = new Date(Date.UTC(y, m - 1 + n, 1));
  167. 202 return monthKey(d);
  168. 205export function monthsBetween(a, b) {
  169. 206 const [ya, ma] = a.split("-").map(Number);
  170. 207 const [yb, mb] = b.split("-").map(Number);
  171. 208 return (yb - ya) * 12 + (mb - ma);
  172. 211export function median(values) {
  173. 212 const v = values.filter(Number.isFinite).sort((a, b) => a - b);
  174. 213 if (!v.length) return NaN;
  175. 214 const mid = Math.floor(v.length / 2);
  176. 215 return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
  177. 218export function sum(values) {
  178. 219 return values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0);
  179. 222export function mdTable(columns, rows) {
  180. 223 const head = `| ${columns.join(" | ")} |`;
  181. 224 const sep = `| ${columns.map(() => "---").join(" | ")} |`;
  182. 225 const body = rows.map((r) => `| ${r.map((c) => String(c).replace(/\|/g, "/")).join(" | ")} |`);
  183. 226 return [head, sep, ...body].join("\n");
  184. 229export function escapeHtml(s) {
  185. 230 return String(s)
  186. 231 .replace(/&/g, "&amp;")
  187. 232 .replace(/</g, "&lt;")
  188. 233 .replace(/>/g, "&gt;")
  189. 234 .replace(/"/g, "&quot;");