Diagnostic LLM APIs
spend jumped week over week and no release explains it
The invoice is three times last month's and nothing shipped. There is no error to look up, no status code, no failed request — the API worked perfectly all month, which is the problem. Somewhere in the last six weeks a cron went from hourly to every five minutes, or a prompt template grew a retrieved document, or a customer onboarded, and the feedback loop between that change and the bill is measured in weeks. By the time the number arrives, nobody remembers the week it started in.
Read eight weeks of daily cost with an admin key, fold it into whole weeks, and compare the most recent complete week against the mean of the ones before it. On OpenAI: GET /v1/organization/costs?start_time={now-56d}&bucket_width=1d&limit=56. On Anthropic: GET /v1/organizations/cost_report?starting_at={now-56d}&limit=31, paging on next_page because 31 buckets is that endpoint's maximum.
Then classify the shape, because "spend went up" is three different findings. A spike is one week high and the rest normal: a job that ran once, a backfill, an incident. A step is a new level that has held for two weeks or more: something shipped and is still shipping. A ramp is a line that has climbed every week, which a week-over-week comparison never catches, because each week is only a little above the mean of the weeks it raised.
Drop today. The current day's bucket is always partial, and a comparison that includes it reports a fall in spend every time you run it before lunch.
The problem in plain words
Nothing about this arrives as an alert, because neither provider pushes one. Cost is a pull surface: an endpoint that answers accurately whenever you ask, and answers nothing at all when you do not. The teams that find a spike in week one have a scheduled job reading the endpoint; everybody else finds it on the invoice, which is 2 to 6 weeks after the change landed.
The delay is what makes it expensive. A retry loop that triples request volume is a five minute fix in the week it ships and a forensic exercise a month later, because by then there have been forty deploys, two new customers and a model migration, and any of them is a plausible cause. The endpoint can tell you the week it started in, which narrows forty deploys to three. That is the whole value of running this weekly rather than reading it annually.
Why it happens
Cost has three inputs and any one can move alone. Requests, tokens per request, and price per token. A prompt template that grew moves the second without touching the first; a cron schedule moves the first without touching the second; a model migration moves the third. They are indistinguishable in a total and obvious the moment you group, which is why this note ends by handing you to the attribution one.
The current bucket is always partial. Both cost reports bucket by day and both will happily return today, half full. Include it and the newest week is short by however much of today has not happened yet, so the check reports a fall while spend is climbing. Dropping the incomplete day is not a refinement, it is the difference between a report that works and one that lies every morning.
A ramp is invisible to week-over-week. Comparing the latest week against the mean of the previous four hides steady growth, because the growth is already in the baseline. Ten percent a week compounds to sixty percent over five weeks and never trips a forty percent threshold once. That is why monotonicity is checked separately from the ratio.
The two providers disagree about what money is. OpenAI returns amount.value as a float in dollars with a lowercase currency. Anthropic returns amount as a decimal string in cents, which is a deliberate invitation to parse it as a decimal rather than a float. Doing that arithmetic in floats across 56 buckets is how a report ends up a cent adrift and an afternoon gets spent on it.
Late data revises the recent past. Both reports can be updated as late events land, so the last day or two of a window is soft. Re-read the same window before escalating a finding to a team, and prefer a whole-week comparison over a day-to-day one for exactly this reason.
The fix, as a flow
One number over eight weeks, and the answer is its shape rather than its size. Today is dropped before anything is compared, because the current bucket is always partial and a report that includes it announces a fall in spend every morning.
How to fix it
Get an admin key for whichever organization you are reading
OpenAI's /v1/organization/costs rejects project keys; Anthropic's /v1/organizations/cost_report rejects workspace keys. Both accept an admin key provisioned read-only, which is all this script wants.
Ask for the whole window explicitly
Both endpoints default to a handful of buckets. OpenAI's limit runs 1–180 with a default of 7; Anthropic's tops out at 31, so eight weeks needs paging on next_page until has_more is false. A naive call returns one week and hides the comparison you came for.
Fold days into whole weeks and throw today away
Seven-day windows anchored on the last complete day, not on today. A partial bucket at either end skews the week it lands in, and the newest week is the one every conclusion rests on.
Classify the shape rather than the size
A spike sends someone to look for a job that ran once. A step sends someone to the deploys in that week. A ramp is usually growth — or a leak that has been open all along — and wants a projection rather than an investigation. Printing one number for all three loses the only information that tells you who to call.
Print the ceiling, then go and attribute the delta
The repair is a hard spend limit and an alert below it, printed as an exact call for you to run. Then group the same window by line item and project to find what moved: this script deliberately does not, because a script that both detects and attributes ends up doing neither clearly.
How to check it worked
Re-run after a week. A resolved spike drops back into the baseline and the state turns flat.
python3 llm_spend_week_over_week.py --provider openai --weeks 8
# flat 2026-08-16..2026-08-22 $4,102.11 against a $4,020.44 baseline (+2.0%)
# 8 whole week(s) read, no change worth reporting
The full code
Two parsers and two pure steps. The parsers exist because the two providers disagree about what money is — a float in dollars on one side, a decimal string in cents on the other — and the cents string is parsed as an integer number of millicents rather than as a float, which is the difference between a report that reconciles and one that is mysteriously a cent out. The folding step drops the incomplete day, which is the single most important line in the script. The classifier separates a spike from a step from a ramp, because those want three different people looking at them.
"""Report a change in organization spend and say what shape the change is.
Read only. One paginated GET against whichever provider you point it at, and
nothing else. Both cost reports need an organization admin key: OpenAI's
rejects project keys, Anthropic's rejects workspace keys. Read-only admin keys
work and are what this should hold.
The repair is a spend limit and an alert, printed as an exact call for you to
run. This script never sets one: a script holding an admin key that can also
change your billing configuration is a worse tool than one that cannot.
"""
import argparse
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("llm_spend_week_over_week")
OPENAI_API = "https://api.openai.com/v1"
ANTHROPIC_API = "https://api.anthropic.com/v1"
ANTHROPIC_VERSION = "2023-06-01"
EPOCH = date(1970, 1, 1)
FINDINGS = ("spike", "step", "ramp", "drop", "new-spend")
def _day_number(text):
"""An ISO day string to a day count since the epoch, or None."""
try:
return (date.fromisoformat(str(text)[:10]) - EPOCH).days
except (TypeError, ValueError):
return None
def _day_iso(number):
return (EPOCH + timedelta(days=int(number))).isoformat()
def parse_cents(text):
"""Anthropic's decimal string of cents to integer millicents. Pure.
Returns None on anything unparseable, which the caller skips rather than
reading as zero. Integer millicents rather than a float because summing 56
buckets of float cents is how a total ends up a cent adrift and an afternoon
gets spent working out where.
"""
raw = str(text if text is not None else "").strip()
if not raw:
return None
negative = raw.startswith("-")
if raw[:1] in ("+", "-"):
raw = raw[1:]
whole, _, frac = raw.partition(".")
whole = whole or "0"
frac = (frac + "000")[:3]
if not whole.isdigit() or not frac.isdigit():
return None
value = int(whole) * 1000 + int(frac)
return -value if negative else value
def daily_from_openai(buckets):
"""Fold GET /v1/organization/costs into {day: dollars}. Pure.
amount.value is a float in dollars and start_time is a Unix timestamp, so
the day key is the UTC date the bucket opened on.
"""
days = {}
for bucket in buckets or []:
try:
opened = int(bucket.get("start_time"))
except (TypeError, ValueError):
continue
key = datetime.fromtimestamp(opened, timezone.utc).date().isoformat()
for result in bucket.get("results") or []:
try:
value = float((result.get("amount") or {}).get("value") or 0.0)
except (TypeError, ValueError):
continue
days[key] = round(days.get(key, 0.0) + value, 6)
return days
def daily_from_anthropic(buckets):
"""Fold GET /v1/organizations/cost_report into {day: dollars}. Pure.
amount is a decimal string in cents, so it is parsed as an exact number of
millicents and only converted to dollars once, at the end.
"""
days = {}
for bucket in buckets or []:
key = str(bucket.get("starting_at") or "")[:10]
if _day_number(key) is None:
continue
for result in bucket.get("results") or []:
millicents = parse_cents(result.get("amount"))
if millicents is None:
continue
days[key] = days.get(key, 0) + millicents
return {day: round(total / 100000.0, 4) for day, total in days.items()}
def weeks(daily, today, count=8):
"""Fold {day: dollars} into whole weeks, newest first. Pure.
Returns [(first_day, last_day, dollars), ...]. Today is excluded, always:
the current day's bucket is partial, and a comparison that includes it
reports a fall in spend every time it runs before lunch. The anchor is the
most recent complete day that carries data rather than yesterday, because
both cost reports lag by a day or two and an empty tail would otherwise
drag every week boundary with it.
"""
end = _day_number(today)
if end is None:
return []
totals = {}
for key, value in (daily or {}).items():
number = _day_number(key)
if number is None or number >= end:
continue
try:
totals[number] = totals.get(number, 0.0) + float(value or 0.0)
except (TypeError, ValueError):
continue
if not totals:
return []
first = min(totals)
stop = min(end, max(totals) + 1)
out = []
while len(out) < int(count):
start = stop - 7
if start < first:
break
total = sum(totals.get(day, 0.0) for day in range(start, stop))
out.append((_day_iso(start), _day_iso(stop - 1), round(total, 2)))
stop = start
return out
def classify(totals, threshold=0.40, min_weeks=3):
"""Classify a list of weekly totals, newest first. Pure. (state, detail).
Three ways for spend to be higher than it was, and they want three
different people: a spike is one week and a job that ran once, a step is a
new level that something shipped into, a ramp is growth that no
week-over-week ratio will ever catch because it is already in the baseline.
"""
series = []
for value in totals or []:
try:
series.append(float(value))
except (TypeError, ValueError):
return ("unreadable", "a weekly total that is not a number")
if len(series) < int(min_weeks):
return ("too-short",
"%d whole week(s) of history, which is not enough to call "
"anything a change" % len(series))
latest, prior = series[0], series[1:]
baseline = sum(prior) / len(prior)
if baseline <= 0:
if latest > 0:
return ("new-spend",
"$%.2f in the latest week against nothing at all before it. "
"This organization started spending inside the window."
% latest)
return ("no-spend", "no spend in any of the %d week(s) read" % len(series))
oldest_first = list(reversed(series))
climbing = all(b > a for a, b in zip(oldest_first, oldest_first[1:]))
if (len(series) >= 4 and climbing and oldest_first[0] > 0
and (latest - oldest_first[0]) / oldest_first[0] > threshold):
return ("ramp",
"every one of %d week(s) is higher than the one before it, "
"$%.2f to $%.2f (+%.0f%%). A week-over-week check never sees "
"this, because the growth is already in the baseline."
% (len(series), oldest_first[0], latest,
100 * (latest - oldest_first[0]) / oldest_first[0]))
change = (latest - baseline) / baseline
if change > threshold:
older = series[2:]
older_baseline = sum(older) / len(older) if older else 0.0
if older_baseline > 0 and (series[1] - older_baseline) / older_baseline > threshold:
return ("step",
"$%.2f in the latest week and $%.2f in the one before it, "
"against a $%.2f baseline before that. The new level has "
"held for two weeks, so something shipped rather than ran "
"once." % (latest, series[1], older_baseline))
return ("spike",
"$%.2f in the latest week against a $%.2f baseline (+%.0f%%), "
"and the week before it was normal. One week high is a job that "
"ran, not a level that changed."
% (latest, baseline, change * 100))
if change < -threshold:
return ("drop",
"$%.2f in the latest week against a $%.2f baseline (%.0f%%). "
"Spend falling this fast is usually traffic that stopped rather "
"than money that was saved." % (latest, baseline, change * 100))
return ("flat",
"$%.2f against a $%.2f baseline (%+.1f%%)"
% (latest, baseline, change * 100))
def get(session, url, params, headers=None):
r = session.get(url, params=params, headers=headers or {}, timeout=90)
if r.status_code in (401, 403):
raise SystemExit("%d from the cost report: this endpoint needs an "
"organization admin key, not a project or workspace key"
% r.status_code)
r.raise_for_status()
return r.json()
def openai_buckets(session, days, max_pages=40):
params = {"start_time": int(time.time()) - days * 86400,
"bucket_width": "1d", "limit": min(180, max(1, days))}
for _ in range(max_pages):
page = get(session, OPENAI_API + "/organization/costs", params)
for bucket in page.get("data") or []:
yield bucket
if not page.get("has_more") or not page.get("next_page"):
return
params = dict(params)
params["page"] = page["next_page"]
def anthropic_buckets(session, days, max_pages=40):
started = datetime.now(timezone.utc) - timedelta(days=days)
params = {"starting_at": started.strftime("%Y-%m-%dT00:00:00Z"), "limit": 31}
headers = {"anthropic-version": ANTHROPIC_VERSION}
for _ in range(max_pages):
page = get(session, ANTHROPIC_API + "/organizations/cost_report",
params, headers)
for bucket in page.get("data") or []:
yield bucket
if not page.get("has_more") or not page.get("next_page"):
return
params = dict(params)
params["page"] = page["next_page"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--provider", choices=("openai", "anthropic"),
default="openai", help="which cost report to read")
ap.add_argument("--weeks", type=int, default=8,
help="whole weeks to read (default 8)")
ap.add_argument("--threshold", type=float, default=0.40,
help="fractional change worth reporting (default 0.40)")
args = ap.parse_args()
session = requests.Session()
if args.provider == "openai":
key = os.environ.get("OPENAI_ADMIN_KEY")
if not key:
log.error("set OPENAI_ADMIN_KEY (an organization admin key, "
"read-only scopes are enough)")
return 2
session.headers.update({"Authorization": "Bearer " + key})
buckets = list(openai_buckets(session, args.weeks * 7 + 1))
daily = daily_from_openai(buckets)
else:
key = os.environ.get("ANTHROPIC_ADMIN_KEY")
if not key:
log.error("set ANTHROPIC_ADMIN_KEY (an Admin API key, sk-ant-admin)")
return 2
session.headers.update({"x-api-key": key})
buckets = list(anthropic_buckets(session, args.weeks * 7 + 1))
daily = daily_from_anthropic(buckets)
today = datetime.now(timezone.utc).date().isoformat()
series = weeks(daily, today, args.weeks)
if not series:
log.info("no whole weeks of cost data in the window")
return 0
state, detail = classify([total for _, _, total in series], args.threshold)
first, last, _ = series[0]
log.info("%d whole week(s) read, most recent %s..%s", len(series), first, last)
for week_first, week_last, total in series:
log.info(" %s..%s $%.2f", week_first, week_last, total)
if state in FINDINGS:
log.warning("%-11s %s..%s %s", state, first, last, detail)
log.warning(" repair: attribute the delta before you act on it. Group "
"the same window by line item and by project and read the "
"rows that moved, rather than the rows you remember being "
"expensive.")
if args.provider == "openai":
log.warning(" repair: print, do not run. Set a ceiling with "
"POST /v1/organization/spend_limit "
"{'threshold_amount': <cents>, 'currency': 'USD', "
"'interval': 'month'} and an early warning with "
"POST /v1/organization/spend_alerts at about 60% of it.")
else:
log.warning(" repair: Anthropic has no spend-limit endpoint. Set "
"the organization and per-workspace limits in the "
"console, and re-read this window first because late "
"events revise the recent past.")
return 1
log.info("%-11s %s..%s %s", state, first, last, detail)
log.info("%d whole week(s) read, no change worth reporting", len(series))
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report a change in organization spend and say what shape the change is.
*
* Read only. One paginated GET against whichever provider you point it at.
* Both cost reports need an organization admin key: OpenAI's rejects project
* keys, Anthropic's rejects workspace keys. Read-only admin keys work.
*/
const OPENAI_API = 'https://api.openai.com/v1';
const ANTHROPIC_API = 'https://api.anthropic.com/v1';
const ANTHROPIC_VERSION = '2023-06-01';
const DAY = 86400000;
const FINDINGS = ['spike', 'step', 'ramp', 'drop', 'new-spend'];
/** An ISO day string to a day count since the epoch, or null. */
function dayNumber(text) {
const parsed = Date.parse(`${String(text ?? '').slice(0, 10)}T00:00:00Z`);
return Number.isNaN(parsed) ? null : Math.round(parsed / DAY);
}
function dayIso(number) {
return new Date(number * DAY).toISOString().slice(0, 10);
}
/**
* Anthropic's decimal string of cents to integer millicents. Pure. Returns
* null on anything unparseable, which the caller skips rather than reading as
* zero. Integers rather than floats because summing 56 buckets of float cents
* is how a total ends up a cent adrift.
*/
export function parseCents(text) {
let raw = String(text ?? '').trim();
if (!raw) return null;
const negative = raw.startsWith('-');
if (raw[0] === '+' || raw[0] === '-') raw = raw.slice(1);
const dot = raw.indexOf('.');
const whole = (dot < 0 ? raw : raw.slice(0, dot)) || '0';
const frac = `${dot < 0 ? '' : raw.slice(dot + 1)}000`.slice(0, 3);
if (!/^\d+$/.test(whole) || !/^\d+$/.test(frac)) return null;
const value = Number(whole) * 1000 + Number(frac);
return negative ? -value : value;
}
/**
* Fold GET /v1/organization/costs into {day: dollars}. Pure. amount.value is a
* float in dollars and start_time is a Unix timestamp.
*/
export function dailyFromOpenai(buckets) {
const days = new Map();
for (const bucket of buckets ?? []) {
const opened = Number(bucket.start_time);
if (!Number.isFinite(opened)) continue;
const key = new Date(opened * 1000).toISOString().slice(0, 10);
for (const result of bucket.results ?? []) {
const value = Number(result.amount?.value ?? 0);
if (!Number.isFinite(value)) continue;
days.set(key, Math.round(((days.get(key) ?? 0) + value) * 1e6) / 1e6);
}
}
return days;
}
/**
* Fold GET /v1/organizations/cost_report into {day: dollars}. Pure. amount is
* a decimal string in cents, parsed exactly and converted once at the end.
*/
export function dailyFromAnthropic(buckets) {
const days = new Map();
for (const bucket of buckets ?? []) {
const key = String(bucket.starting_at ?? '').slice(0, 10);
if (dayNumber(key) === null) continue;
for (const result of bucket.results ?? []) {
const millicents = parseCents(result.amount);
if (millicents === null) continue;
days.set(key, (days.get(key) ?? 0) + millicents);
}
}
const out = new Map();
for (const [day, total] of days) out.set(day, Math.round(total / 10) / 10000);
return out;
}
/**
* Fold a day-to-dollars map into whole weeks, newest first. Pure. Returns
* [[firstDay, lastDay, dollars], ...]. Today is excluded, always: the current
* bucket is partial and a comparison that includes it reports a fall in spend
* every time it runs before lunch.
*/
export function weeks(daily, today, count = 8) {
const end = dayNumber(today);
if (end === null) return [];
const entries = daily instanceof Map ? [...daily] : Object.entries(daily ?? {});
const totals = new Map();
for (const [key, value] of entries) {
const number = dayNumber(key);
const amount = Number(value);
if (number === null || number >= end || !Number.isFinite(amount)) continue;
totals.set(number, (totals.get(number) ?? 0) + amount);
}
if (totals.size === 0) return [];
const numbers = [...totals.keys()];
const first = Math.min(...numbers);
let stop = Math.min(end, Math.max(...numbers) + 1);
const out = [];
while (out.length < Number(count)) {
const start = stop - 7;
if (start < first) break;
let total = 0;
for (let day = start; day < stop; day += 1) total += totals.get(day) ?? 0;
out.push([dayIso(start), dayIso(stop - 1), Math.round(total * 100) / 100]);
stop = start;
}
return out;
}
/**
* Classify a list of weekly totals, newest first. Pure. Returns [state,
* detail]. Three ways for spend to be higher and three different people to
* call: a spike is a job that ran once, a step is a level something shipped
* into, a ramp is growth no week-over-week ratio will ever catch.
*/
export function classify(totals, threshold = 0.40, minWeeks = 3) {
const series = [];
for (const value of totals ?? []) {
const number = Number(value);
if (!Number.isFinite(number)) {
return ['unreadable', 'a weekly total that is not a number'];
}
series.push(number);
}
if (series.length < Number(minWeeks)) {
return ['too-short',
`${series.length} whole week(s) of history, which is not enough to call ` +
'anything a change'];
}
const latest = series[0];
const prior = series.slice(1);
const baseline = prior.reduce((a, b) => a + b, 0) / prior.length;
if (baseline <= 0) {
if (latest > 0) {
return ['new-spend',
`$${latest.toFixed(2)} in the latest week against nothing at all ` +
'before it. This organization started spending inside the window.'];
}
return ['no-spend', `no spend in any of the ${series.length} week(s) read`];
}
const oldestFirst = [...series].reverse();
const climbing = oldestFirst.every((v, i) => i === 0 || v > oldestFirst[i - 1]);
if (series.length >= 4 && climbing && oldestFirst[0] > 0
&& (latest - oldestFirst[0]) / oldestFirst[0] > threshold) {
const growth = 100 * (latest - oldestFirst[0]) / oldestFirst[0];
return ['ramp',
`every one of ${series.length} week(s) is higher than the one before it, ` +
`$${oldestFirst[0].toFixed(2)} to $${latest.toFixed(2)} ` +
`(+${growth.toFixed(0)}%). A week-over-week check never sees this, ` +
'because the growth is already in the baseline.'];
}
const change = (latest - baseline) / baseline;
if (change > threshold) {
const older = series.slice(2);
const olderBaseline = older.length
? older.reduce((a, b) => a + b, 0) / older.length : 0;
if (olderBaseline > 0 && (series[1] - olderBaseline) / olderBaseline > threshold) {
return ['step',
`$${latest.toFixed(2)} in the latest week and $${series[1].toFixed(2)} ` +
`in the one before it, against a $${olderBaseline.toFixed(2)} baseline ` +
'before that. The new level has held for two weeks, so something ' +
'shipped rather than ran once.'];
}
return ['spike',
`$${latest.toFixed(2)} in the latest week against a ` +
`$${baseline.toFixed(2)} baseline (+${(change * 100).toFixed(0)}%), and ` +
'the week before it was normal. One week high is a job that ran, not a ' +
'level that changed.'];
}
if (change < -threshold) {
return ['drop',
`$${latest.toFixed(2)} in the latest week against a ` +
`$${baseline.toFixed(2)} baseline (${(change * 100).toFixed(0)}%). Spend ` +
'falling this fast is usually traffic that stopped rather than money ' +
'that was saved.'];
}
const signed = `${change >= 0 ? '+' : ''}${(change * 100).toFixed(1)}`;
return ['flat',
`$${latest.toFixed(2)} against a $${baseline.toFixed(2)} baseline (${signed}%)`];
}
async function get(url, params, headers) {
const target = new URL(url);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) target.searchParams.set(k, String(v));
}
const res = await fetch(target, { headers });
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from the cost report: this endpoint needs an ` +
'organization admin key, not a project or workspace key');
}
if (!res.ok) throw new Error(`${res.status} from ${target.pathname}`);
return res.json();
}
async function readBuckets(url, params, headers, maxPages = 40) {
const out = [];
let query = { ...params };
for (let i = 0; i < maxPages; i += 1) {
const page = await get(url, query, headers);
out.push(...(page.data ?? []));
if (!page.has_more || !page.next_page) break;
query = { ...params, page: page.next_page };
}
return out;
}
async function main() {
const provider = (process.argv.find((a) => a.startsWith('--provider='))
?? '--provider=openai').slice('--provider='.length);
const howMany = Number(process.env.WEEKS ?? 8);
const threshold = Number(process.env.THRESHOLD ?? 0.40);
const days = howMany * 7 + 1;
let daily;
if (provider === 'anthropic') {
const key = process.env.ANTHROPIC_ADMIN_KEY;
if (!key) {
console.error('set ANTHROPIC_ADMIN_KEY (an Admin API key, sk-ant-admin)');
process.exitCode = 2;
return;
}
const startedAt = new Date(Date.now() - days * DAY).toISOString().slice(0, 10);
const buckets = await readBuckets(`${ANTHROPIC_API}/organizations/cost_report`,
{ starting_at: `${startedAt}T00:00:00Z`, limit: 31 },
{ 'x-api-key': key, 'anthropic-version': ANTHROPIC_VERSION });
daily = dailyFromAnthropic(buckets);
} else {
const key = process.env.OPENAI_ADMIN_KEY;
if (!key) {
console.error('set OPENAI_ADMIN_KEY (an organization admin key, read-only ' +
'scopes are enough)');
process.exitCode = 2;
return;
}
const buckets = await readBuckets(`${OPENAI_API}/organization/costs`, {
start_time: Math.floor((Date.now() - days * DAY) / 1000),
bucket_width: '1d',
limit: Math.min(180, Math.max(1, days)),
}, { Authorization: `Bearer ${key}` });
daily = dailyFromOpenai(buckets);
}
const today = new Date().toISOString().slice(0, 10);
const series = weeks(daily, today, howMany);
if (series.length === 0) {
console.log('no whole weeks of cost data in the window');
return;
}
const [state, detail] = classify(series.map(([, , total]) => total), threshold);
const [first, last] = series[0];
console.log(`${series.length} whole week(s) read, most recent ${first}..${last}`);
for (const [weekFirst, weekLast, total] of series) {
console.log(` ${weekFirst}..${weekLast} $${total.toFixed(2)}`);
}
if (FINDINGS.includes(state)) {
console.warn(`${state.padEnd(11)} ${first}..${last} ${detail}`);
console.warn(' repair: attribute the delta before you act on it. Group the ' +
'same window by line item and by project and read the rows that moved, ' +
'rather than the rows you remember being expensive.');
console.warn(provider === 'anthropic'
? ' repair: Anthropic has no spend-limit endpoint. Set the organization ' +
'and per-workspace limits in the console, and re-read this window first ' +
'because late events revise the recent past.'
: ' repair: print, do not run. Set a ceiling with POST ' +
"/v1/organization/spend_limit {'threshold_amount': <cents>, 'currency': " +
"'USD', 'interval': 'month'} and an early warning with POST " +
'/v1/organization/spend_alerts at about 60% of it.');
process.exitCode = 1;
return;
}
console.log(`${state.padEnd(11)} ${first}..${last} ${detail}`);
console.log(`${series.length} whole week(s) read, no change worth reporting`);
}
// Only run when invoked directly, so importing this module from the test file
// does not fire main() and fail on the missing key.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Four tests carry this one. Today must be dropped, or the newest week is short by however much of today has not happened and the script reports a fall every morning. A ramp must be caught, or steady ten-percent-a-week growth passes a forty percent threshold forever. A spike and a step must not collapse into each other, because one sends somebody to look for a job that ran and the other to a week of deploys. And the cents string must parse exactly, because that is the difference between a total that reconciles and one that is mysteriously a cent out.
from llm_spend_week_over_week import (classify, daily_from_anthropic,
daily_from_openai, parse_cents, weeks)
def test_today_is_never_counted_in_the_newest_week():
# Fifteen days of a dollar a day, run on the fifteenth. The last day is
# partial by definition, so two whole weeks come back and today is not in
# either of them.
daily = {"2026-08-%02d" % day: 1.0 for day in range(1, 16)}
got = weeks(daily, "2026-08-15")
assert len(got) == 2
assert got[0] == ("2026-08-08", "2026-08-14", 7.0)
assert got[1] == ("2026-08-01", "2026-08-07", 7.0)
def test_a_partial_oldest_week_is_dropped_rather_than_reported_short():
daily = {"2026-08-%02d" % day: 10.0 for day in range(1, 12)}
got = weeks(daily, "2026-08-12")
assert [w[2] for w in got] == [70.0]
def test_one_high_week_is_a_spike_and_two_are_a_step():
spike, detail = classify([3000.0, 1000.0, 1000.0, 1000.0])
assert spike == "spike"
assert "a job that ran" in detail
step, detail = classify([3000.0, 3000.0, 1000.0, 1000.0])
assert step == "step"
assert "held for two weeks" in detail
def test_a_ramp_is_caught_even_though_week_over_week_never_trips():
# +15% a week. Against the mean of the previous three the newest week is
# only 31% up, so a ratio threshold of 40% would call this flat forever.
state, detail = classify([1520.88, 1322.5, 1150.0, 1000.0])
assert state == "ramp"
assert "already in the baseline" in detail
assert classify([1000.0, 1000.0, 1000.0, 1000.0])[0] == "flat"
def test_spend_falling_off_a_cliff_is_reported_rather_than_celebrated():
state, detail = classify([400.0, 1000.0, 1000.0, 1000.0])
assert state == "drop"
assert "traffic that stopped" in detail
def test_a_short_history_and_a_standing_start_are_their_own_answers():
assert classify([5000.0, 10.0])[0] == "too-short"
assert classify([500.0, 0.0, 0.0])[0] == "new-spend"
assert classify([0.0, 0.0, 0.0])[0] == "no-spend"
assert classify(["lots", 1.0, 2.0])[0] == "unreadable"
def test_anthropic_cents_are_parsed_exactly_and_not_as_floats():
assert parse_cents("1234.5") == 1234500
assert parse_cents("0.001") == 1
assert parse_cents("-250") == -250000
assert parse_cents("") is None
assert parse_cents(None) is None
assert parse_cents("1,234") is None
assert parse_cents("lots") is None
def test_both_providers_fold_into_the_same_day_keyed_dollars():
# 2026-08-01T00:00:00Z is 1785542400. Two results in one bucket sum.
openai = daily_from_openai([{
"start_time": 1785542400, "end_time": 1785628800,
"results": [{"amount": {"value": 12.5, "currency": "usd"}},
{"amount": {"value": 0.25, "currency": "usd"}}]}])
assert openai == {"2026-08-01": 12.75}
anthropic = daily_from_anthropic([{
"starting_at": "2026-08-01T00:00:00Z",
"results": [{"amount": "1250.0"}, {"amount": "25"}]}])
assert anthropic == {"2026-08-01": 12.75}
assert daily_from_anthropic([{"starting_at": "nonsense",
"results": [{"amount": "1"}]}]) == {}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, dailyFromAnthropic, dailyFromOpenai, parseCents, weeks }
from './llm-spend-week-over-week.mjs';
function dollarsPerDay(from, to, amount) {
const daily = {};
for (let day = from; day <= to; day += 1) {
daily[`2026-08-${String(day).padStart(2, '0')}`] = amount;
}
return daily;
}
test('today is never counted in the newest week', () => {
const got = weeks(dollarsPerDay(1, 15, 1.0), '2026-08-15');
assert.equal(got.length, 2);
assert.deepEqual(got[0], ['2026-08-08', '2026-08-14', 7.0]);
assert.deepEqual(got[1], ['2026-08-01', '2026-08-07', 7.0]);
});
test('a partial oldest week is dropped rather than reported short', () => {
const got = weeks(dollarsPerDay(1, 11, 10.0), '2026-08-12');
assert.deepEqual(got.map((w) => w[2]), [70.0]);
});
test('one high week is a spike and two are a step', () => {
const [spike, spikeDetail] = classify([3000, 1000, 1000, 1000]);
assert.equal(spike, 'spike');
assert.match(spikeDetail, /a job that ran/);
const [step, stepDetail] = classify([3000, 3000, 1000, 1000]);
assert.equal(step, 'step');
assert.match(stepDetail, /held for two weeks/);
});
test('a ramp is caught even though week over week never trips', () => {
const [state, detail] = classify([1520.88, 1322.5, 1150.0, 1000.0]);
assert.equal(state, 'ramp');
assert.match(detail, /already in the baseline/);
assert.equal(classify([1000, 1000, 1000, 1000])[0], 'flat');
});
test('spend falling off a cliff is reported rather than celebrated', () => {
const [state, detail] = classify([400, 1000, 1000, 1000]);
assert.equal(state, 'drop');
assert.match(detail, /traffic that stopped/);
});
test('a short history and a standing start are their own answers', () => {
assert.equal(classify([5000, 10])[0], 'too-short');
assert.equal(classify([500, 0, 0])[0], 'new-spend');
assert.equal(classify([0, 0, 0])[0], 'no-spend');
assert.equal(classify(['lots', 1, 2])[0], 'unreadable');
});
test('anthropic cents are parsed exactly and not as floats', () => {
assert.equal(parseCents('1234.5'), 1234500);
assert.equal(parseCents('0.001'), 1);
assert.equal(parseCents('-250'), -250000);
assert.equal(parseCents(''), null);
assert.equal(parseCents(null), null);
assert.equal(parseCents('1,234'), null);
assert.equal(parseCents('lots'), null);
});
test('both providers fold into the same day keyed dollars', () => {
const openai = dailyFromOpenai([{
start_time: 1785542400,
end_time: 1785628800,
results: [{ amount: { value: 12.5, currency: 'usd' } },
{ amount: { value: 0.25, currency: 'usd' } }],
}]);
assert.deepEqual([...openai], [['2026-08-01', 12.75]]);
const anthropic = dailyFromAnthropic([{
starting_at: '2026-08-01T00:00:00Z',
results: [{ amount: '1250.0' }, { amount: '25' }],
}]);
assert.deepEqual([...anthropic], [['2026-08-01', 12.75]]);
assert.deepEqual([...dailyFromAnthropic([{ starting_at: 'nonsense',
results: [{ amount: '1' }] }])], []);
});
FAQ
Why compare whole weeks instead of the last seven days against the seven before?
Because a rolling window moves the day boundaries every time you run it, and LLM traffic has a weekly rhythm: a window that starts on a Wednesday contains a different number of weekend days than one that starts on a Monday. Whole weeks anchored on the last complete day make two consecutive runs comparable, which is what turns this into a scheduled check rather than a one-off investigation.
What counts as a big enough change to care about?
Forty percent above the trailing baseline is the default here and it is a starting point, not a law. Tune it to your own variance: an organization whose weekly spend already swings thirty percent will drown in findings at forty, and one that is flat to within five percent should set it far lower. The shape classification matters more than the threshold.
The report says my spend dropped. Why is that a finding?
Because spend falling by half in a week is almost never a cost saving. It is traffic that stopped: a broken deploy, an expired key, a spend limit that started enforcing, or a customer who left. All four are worth knowing about within the week, and all four look identical to a cost report, which is why the script reports the drop and does not try to explain it.
Can the script set the spend limit it prints?
It could, and it will not. Everything in this section holds a credential that can spend money on inference, and the whole design is that these scripts read and print. A hard ceiling on your organization's billing is also exactly the kind of change you want a human to type deliberately, with the number in front of them.
Anthropic's amounts look like ordinary numbers. Why the parsing fuss?
Because they are strings, in cents, and the docs say to parse them as decimals rather than floats. Read as floats and summed across 56 buckets, the total drifts in the last decimal place, which is invisible until you reconcile it against an invoice and lose an afternoon. Parsing to integer millicents costs six lines and the question never comes up.
Related field notes
- The one line item most of the bill is made of
- Nothing stops a runaway once it starts
- Streamed tokens the dashboard never recorded
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Costs — OpenAI API reference
- Get cost report — Claude Docs
- Usage and cost API — Claude Docs
- Admin APIs — OpenAI developer docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.