Skip to content
SlashClone

Llm usage normalizer

@agentcosts/llm-usage-normalizer

Convert Anthropic and OpenAI usage exports, or your own app's token logs, into one usage schema with customers, tasks, cached and uncached tokens, and cost from a price table you edit. Fixes the classic mistake of counting cached tokens twice, and lists unmapped keys and unpriced models.

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;");