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#!/usr/bin/env node
  2. 2// Convert Anthropic and OpenAI usage exports (or app logs) into one usage schema, priced from an editable table.
  3. 3// Usage: node scripts/normalize.mjs <export.csv> [<export2.csv> ...] --prices prices.csv
  4. 4// [--source auto|anthropic|openai] [--customer-map map.csv] [--customer-key api_key_id] [--task default]
  5. 5// [--batch-discount 0.5] [--out out/usage-normalized]
  6. 6// Output columns: timestamp, customer, task, run_id, provider, model, input_tokens (uncached), output_tokens,
  7. 7// cache_read_tokens, cache_write_tokens, cost_usd, outcome
  8. 8import { fail, mdTable, money, num, num0, parseArgs, parseDate, readCsv, round, toCsv, writeOut } from "./lib.mjs";
  9. 9import { USAGE_COLUMNS, findPrice, loadPrices, warnStalePrices } from "./usage.mjs";
  10. 11const args = parseArgs(process.argv.slice(2));
  11. 12const inputs = args._;
  12. 13if (!inputs.length || !args.prices) fail("usage: node scripts/normalize.mjs <export.csv> [...] --prices prices.csv [--customer-map map.csv]");
  13. 14const outPrefix = args.out || "out/usage-normalized";
  14. 15const prices = loadPrices(args.prices);
  15. 16warnStalePrices(prices, args.prices);
  16. 17const batchDiscount = Number(args["batch-discount"] ?? 0.5);
  17. 18const defaultTask = args.task || "";
  18. 20// Column aliases, first match wins. Lowercased; dots allowed for flattened JSON exports.
  19. 21const A = {
  20. 22 timestamp: ["timestamp", "starting_at", "start_time", "start_time_iso", "date", "created_at", "time", "bucket_start"],
  21. 23 model: ["model", "model_id", "model_name"],
  22. 24 // Anthropic: uncached input. OpenAI: total input including cached.
  23. 25 input: ["uncached_input_tokens", "input_tokens", "prompt_tokens", "n_context_tokens_total"],
  24. 26 output: ["output_tokens", "completion_tokens", "n_generated_tokens_total"],
  25. 27 cacheRead: ["cache_read_input_tokens", "input_cached_tokens", "cached_tokens", "prompt_tokens_details.cached_tokens", "input_tokens_details.cached_tokens", "cache_read_tokens"],
  26. 28 cacheWrite5m: ["cache_creation_input_tokens", "cache_creation.ephemeral_5m_input_tokens", "ephemeral_5m_input_tokens", "cache_write_tokens"],
  27. 29 cacheWrite1h: ["cache_creation.ephemeral_1h_input_tokens", "ephemeral_1h_input_tokens"],
  28. 30 batch: ["batch", "service_tier", "is_batch"],
  29. 31 customer: ["customer", "customer_id", "account_id", "tenant"],
  30. 32 task: ["task", "workflow", "feature", "agent"],
  31. 33 run: ["run_id", "trace_id", "session_id", "conversation_id"],
  32. 34 outcome: ["outcome", "status", "result"],
  33. 35};
  34. 36const KEYS = ["api_key_id", "api_key", "workspace_id", "workspace", "project_id", "project", "user_id", "metadata.user_id"];
  35. 38let map = null;
  36. 39if (args["customer-map"]) {
  37. 40 map = new Map();
  38. 41 for (const r of readCsv(args["customer-map"], "customer map", ["key", "customer"])) map.set(r.key, { customer: r.customer, task: r.task || "" });
  39. 44function detect(cols, path) {
  40. 45 if (args.source && args.source !== "auto") return args.source;
  41. 46 if (cols.some((c) => ["uncached_input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"].includes(c) || c.startsWith("cache_creation."))) return "anthropic";
  42. 47 if (cols.some((c) => ["input_cached_tokens", "prompt_tokens", "completion_tokens", "num_model_requests"].includes(c) || c.includes("cached_tokens"))) return "openai";
  43. 48 if (cols.includes("cache_read_tokens") && cols.includes("input_tokens")) return "normalized";
  44. 49 fail(`could not tell whether ${path} is an Anthropic or OpenAI export. Pass --source anthropic or --source openai. Columns: ${cols.join(", ")}`);
  45. 52const out = [];
  46. 53const report = [];
  47. 54const unmapped = new Map();
  48. 55const unpriced = new Map();
  49. 56for (const path of inputs) {
  50. 57 const rows = readCsv(path, "usage export", []);
  51. 58 if (!rows.length) {
  52. 59 report.push({ file: path, source: "empty", rows: 0, cost: 0 });
  53. 60 continue;
  54. 61 }
  55. 62 const cols = Object.keys(rows[0]);
  56. 63 const source = detect(cols, path);
  57. 64 const col = (k) => A[k].find((a) => cols.includes(a));
  58. 65 const c = Object.fromEntries(Object.keys(A).map((k) => [k, col(k)]));
  59. 66 const keyCol = args["customer-key"] ? args["customer-key"].toLowerCase() : KEYS.find((k) => cols.includes(k));
  60. 67 if (!c.timestamp || !c.model || !c.input || !c.output) fail(`${path}: need timestamp, model, input, and output token columns. Found: ${cols.join(", ")}`);
  61. 68 let cost = 0;
  62. 69 let n = 0;
  63. 70 for (const r of rows) {
  64. 71 const d = parseDate(r[c.timestamp]);
  65. 72 if (!d) continue;
  66. 73 const rawIn = num0(r[c.input]);
  67. 74 const cacheRead = c.cacheRead ? num0(r[c.cacheRead]) : 0;
  68. 75 const w5 = c.cacheWrite5m ? num0(r[c.cacheWrite5m]) : 0;
  69. 76 const w1 = c.cacheWrite1h ? num0(r[c.cacheWrite1h]) : 0;
  70. 77 // OpenAI-style input counts cached tokens too; take them out so input means uncached everywhere.
  71. 78 const uncached = source === "openai" ? Math.max(0, rawIn - cacheRead) : rawIn;
  72. 79 const outputTokens = num0(r[c.output]);
  73. 80 const key = keyCol ? r[keyCol] : "";
  74. 81 let customer = c.customer ? r[c.customer] : "";
  75. 82 let task = c.task ? r[c.task] : "";
  76. 83 if (map && key && map.has(key)) {
  77. 84 customer = customer || map.get(key).customer;
  78. 85 task = task || map.get(key).task;
  79. 86 }
  80. 87 if (!customer) {
  81. 88 customer = key ? `unmapped:${key}` : "(unassigned)";
  82. 89 if (key) unmapped.set(key, (unmapped.get(key) || 0) + 1);
  83. 90 }
  84. 91 const model = r[c.model];
  85. 92 const p = findPrice(prices, model);
  86. 93 let rowCost = num(r.cost_usd);
  87. 94 if (!Number.isFinite(rowCost)) {
  88. 95 if (p) {
  89. 96 const w1Price = Number.isFinite(p.cacheWrite1h) ? p.cacheWrite1h : p.input * 2;
  90. 97 rowCost = (uncached * p.input + outputTokens * p.output + cacheRead * p.cacheRead + w5 * p.cacheWrite + w1 * w1Price) / 1e6;
  91. 98 const b = c.batch ? String(r[c.batch]).toLowerCase() : "";
  92. 99 if (b === "true" || b === "1" || b === "batch" || b === "yes") rowCost *= 1 - batchDiscount;
  93. 100 } else {
  94. 101 unpriced.set(model, (unpriced.get(model) || 0) + 1);
  95. 102 rowCost = null;
  96. 103 }
  97. 104 }
  98. 105 cost += rowCost || 0;
  99. 106 n++;
  100. 107 out.push({
  101. 108 timestamp: d.toISOString(),
  102. 109 customer,
  103. 110 task: task || defaultTask,
  104. 111 run_id: c.run ? r[c.run] : "",
  105. 112 provider: source === "normalized" ? r.provider || "" : source,
  106. 113 model,
  107. 114 input_tokens: uncached,
  108. 115 output_tokens: outputTokens,
  109. 116 cache_read_tokens: cacheRead,
  110. 117 cache_write_tokens: w5 + w1,
  111. 118 cost_usd: rowCost === null ? "" : round(rowCost, 6),
  112. 119 outcome: c.outcome ? r[c.outcome] : "",
  113. 120 });
  114. 121 }
  115. 122 report.push({ file: path, source, rows: n, cost: round(cost, 2), customer_key: keyCol || "(none)", input_column: c.input, cache_read_column: c.cacheRead || "(none)" });
  116. 124out.sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
  117. 125writeOut(`${outPrefix}.csv`, toCsv(out, USAGE_COLUMNS));
  118. 127const total = out.reduce((a, r) => a + (Number(r.cost_usd) || 0), 0);
  119. 128const byCustomer = new Map();
  120. 129for (const r of out) byCustomer.set(r.customer, (byCustomer.get(r.customer) || 0) + (Number(r.cost_usd) || 0));
  121. 130const summary = {
  122. 131 files: report,
  123. 132 rows: out.length,
  124. 133 total_cost: round(total, 2),
  125. 134 by_customer: Object.fromEntries([...byCustomer.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => [k, round(v, 2)])),
  126. 135 unmapped_keys: Object.fromEntries(unmapped),
  127. 136 unpriced_models: Object.fromEntries(unpriced),
  128. 137 prices_file: args.prices,
  129. 138};
  130. 139writeOut(`${outPrefix}-report.json`, JSON.stringify(summary, null, 2) + "\n");
  131. 140const md = [
  132. 141 "# Usage normalization report",
  133. 142 "",
  134. 143 mdTable(["File", "Detected as", "Rows", "Cost", "Customer key", "Input column"], report.map((r) => [r.file, r.source, r.rows, money(r.cost, 2), r.customer_key || "", r.input_column || ""])),
  135. 144 "",
  136. 145 `Total ${money(total, 2)} across ${out.length} rows, priced with \`${args.prices}\`. Prices are only as good as that file: check them against each provider's current pricing.`,
  137. 146 "",
  138. 147 "## Cost by customer",
  139. 148 "",
  140. 149 mdTable(["Customer", "Cost"], Object.entries(summary.by_customer).map(([k, v]) => [k, money(v, 2)])),
  141. 150 "",
  142. 151 unmapped.size ? `## Keys with no customer\n\nAdd these to the customer map:\n\n${[...unmapped.entries()].map(([k, n]) => `- \`${k}\` (${n} row${n === 1 ? "" : "s"})`).join("\n")}\n` : "Every row has a customer.",
  143. 152 "",
  144. 153 unpriced.size ? `## Models with no price\n\nAdd these to the price table (cost left blank until then):\n\n${[...unpriced.entries()].map(([k, n]) => `- \`${k}\` (${n} row${n === 1 ? "" : "s"})`).join("\n")}\n` : "Every model has a price.",
  145. 154 "",
  146. 155].join("\n");
  147. 156writeOut(`${outPrefix}-report.md`, md);
  148. 158process.stdout.write(`Wrote ${outPrefix}.csv (${out.length} rows) and ${outPrefix}-report.md\n`);
  149. 159process.stdout.write(`Total ${money(total, 2)}. ${unmapped.size} unmapped key(s), ${unpriced.size} unpriced model(s).\n`);