Consumption guardrails
@agentcosts/consumption-guardrails
Set and check consumption guardrails for an AI product: per-customer daily and monthly spend caps, month-end forecasts, spend spike rules, runaway agent runs, and failure rate spikes, plus a runbook for what to do when each alert fires. Runs on usage files; exit code 2 on critical alerts for cron or CI.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1// Shared usage schema and price table helpers. Node built-ins only.
- 2// Normalized usage columns:
- 3// timestamp, customer, task, run_id, provider, model, input_tokens, output_tokens,
- 4// cache_read_tokens, cache_write_tokens, cost_usd, outcome
- 5// input_tokens means uncached input only. cache_read_tokens and cache_write_tokens are separate.
- 6import { DAY_MS, fail, num, num0, parseDate, readCsv } from "./lib.mjs";
- 8export const USAGE_COLUMNS = ["timestamp", "customer", "task", "run_id", "provider", "model", "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "cost_usd", "outcome"];
- 10/** Loads a price table: provider, model, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, checked_on. */
- 11export function loadPrices(path) {
- 12 const rows = readCsv(path, "price table", ["model", "input_per_mtok", "output_per_mtok"]);
- 13 const prices = rows
- 14 .filter((r) => r.model && !r.model.startsWith("#"))
- 15 .map((r) => ({
- 16 provider: r.provider || "",
- 17 model: r.model.toLowerCase(),
- 18 input: num0(r.input_per_mtok),
- 19 output: num0(r.output_per_mtok),
- 20 cacheRead: Number.isFinite(num(r.cache_read_per_mtok)) ? num(r.cache_read_per_mtok) : num0(r.input_per_mtok),
- 21 cacheWrite: Number.isFinite(num(r.cache_write_per_mtok)) ? num(r.cache_write_per_mtok) : num0(r.input_per_mtok),
- 22 cacheWrite1h: Number.isFinite(num(r.cache_write_1h_per_mtok)) ? num(r.cache_write_1h_per_mtok) : NaN,
- 23 checkedOn: parseDate(r.checked_on),
- 24 }));
- 25 // Longest model name first, so "gpt-5-mini" wins over "gpt-5" for "gpt-5-mini-2025-08-07".
- 26 prices.sort((a, b) => b.model.length - a.model.length);
- 27 return prices;
- 28}
- 30export function findPrice(prices, model) {
- 31 const m = String(model || "").toLowerCase();
- 32 return prices.find((p) => m === p.model) || prices.find((p) => m.startsWith(p.model)) || null;
- 33}
- 35/** Cost in USD for one usage row, from the price table. Returns null when the model has no price. */
- 36export function priceRow(prices, r) {
- 37 const p = findPrice(prices, r.model);
- 38 if (!p) return null;
- 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;
- 40}
- 42/** Warns on stderr when prices were never checked or were checked more than maxAgeDays ago. */
- 43export function warnStalePrices(prices, path, maxAgeDays = 60) {
- 44 const now = Date.now();
- 45 const unchecked = prices.filter((p) => !p.checkedOn).map((p) => p.model);
- 46 const stale = prices.filter((p) => p.checkedOn && now - p.checkedOn.getTime() > maxAgeDays * DAY_MS).map((p) => p.model);
- 47 if (unchecked.length || stale.length)
- 48 process.stderr.write(
- 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`,
- 50 );
- 51}
- 53/**
- 54 * Reads a normalized usage CSV. When cost_usd is blank and a price table is given, cost is computed.
- 55 * Returns { rows, unpriced } where unpriced counts rows with no cost.
- 56 */
- 57export function readUsage(path, prices = null) {
- 58 const raw = readCsv(path, "usage CSV", ["timestamp", "customer", "model"]);
- 59 const rows = [];
- 60 let unpriced = 0;
- 61 let skipped = 0;
- 62 const unpricedModels = new Set();
- 63 raw.forEach((r) => {
- 64 const d = parseDate(r.timestamp);
- 65 if (!d) {
- 66 skipped++;
- 67 return;
- 68 }
- 69 const row = {
- 70 t: d.getTime(),
- 71 date: d,
- 72 customer: r.customer || "(unassigned)",
- 73 task: r.task || "(none)",
- 74 run_id: r.run_id || "",
- 75 provider: r.provider || "",
- 76 model: r.model,
- 77 input_tokens: num0(r.input_tokens),
- 78 output_tokens: num0(r.output_tokens),
- 79 cache_read_tokens: num0(r.cache_read_tokens),
- 80 cache_write_tokens: num0(r.cache_write_tokens),
- 81 cost: num(r.cost_usd),
- 82 outcome: (r.outcome || "").toLowerCase(),
- 83 };
- 84 if (!Number.isFinite(row.cost) && prices) {
- 85 const c = priceRow(prices, row);
- 86 if (c !== null) row.cost = c;
- 87 }
- 88 if (!Number.isFinite(row.cost)) {
- 89 unpriced++;
- 90 unpricedModels.add(row.model);
- 91 row.cost = 0;
- 92 }
- 93 rows.push(row);
- 94 });
- 95 if (!rows.length) fail(`no usable rows in ${path}.`);
- 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`);
- 97 if (skipped) process.stderr.write(`Skipped ${skipped} row(s) with a bad timestamp.\n`);
- 98 return { rows, unpriced, unpricedModels: [...unpricedModels], skipped };
- 99}
- 101/** Groups rows into runs (by run_id, or one run per row). A run succeeded if any row says success. */
- 102export function toRuns(rows) {
- 103 const runs = new Map();
- 104 rows.forEach((r, i) => {
- 105 const id = r.run_id || `row-${i}`;
- 106 const run = runs.get(id) || { id, customer: r.customer, task: r.task, t: r.t, cost: 0, calls: 0, outcome: "" };
- 107 run.cost += r.cost;
- 108 run.calls++;
- 109 run.t = Math.min(run.t, r.t);
- 110 if (r.outcome === "success") run.outcome = "success";
- 111 else if (r.outcome && run.outcome !== "success") run.outcome = r.outcome;
- 112 runs.set(id, run);
- 113 });
- 114 return [...runs.values()];
- 115}
