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