A surprise five-figure invoice from an LLM provider is a rite of passage for teams that ship without guardrails. LLM spend alerts turn that mystery into a predictable metric you can watch daily. This guide walks through building a minimal but production-realistic alerting pipeline using token usage metering and a scheduled job, no enterprise tooling required.
Step 1: Capture token usage on every request
Every OpenAI-compatible chat completion response returns a usage object. If you are not logging it, you are flying blind. Instrument your client wrapper to extract and emit that object on each call.
import os
import requests
BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1")
API_KEY = os.environ["LLM_API_KEY"]
def chat(model: str, messages: list, **kwargs) -> dict:
resp = requests.post(
f"{BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": model, "messages": messages, **kwargs},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
# usage is the only billing truth
usage = data["usage"]
log_usage(model, usage)
return data
The log_usage function can write to stdout, a file, or directly to a database. Do not rely on estimates from character counts—providers bill on tokenizers you do not control.
Step 2: Persist usage to a local ledger
A flat log rotates and gets lost. Use SQLite to keep a queryable ledger. It is enough for most single-service deployments and avoids standing up Postgres just to count tokens.
import sqlite3
from datetime import datetime
conn = sqlite3.connect("llm_usage.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY,
ts TEXT,
model TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER
)
""")
def log_usage(model: str, usage: dict):
conn.execute(
"INSERT INTO usage (ts, model, prompt_tokens, completion_tokens, total_tokens) VALUES (?,?,?,?,?)",
(datetime.utcnow().isoformat(), model,
usage["prompt_tokens"], usage["completion_tokens"], usage["total_tokens"]),
)
conn.commit()
If you route through a gateway like n4n.ai, its per-token usage metering exposes aggregated totals via an API, skipping the manual ledger. For everyone else, the table above is the source of truth.
Step 3: Map usage to cost without guessing
Token counts are not dollars. You need a pricing table that reflects your actual negotiated rates, not the public list price you saw in a blog post. Treat the numbers below as placeholders.
# Replace with your contracted rates per 1K tokens
PRICING = {
"gpt-4o": {"prompt": 0.005, "completion": 0.015},
"gpt-3.5-turbo": {"prompt": 0.0005, "completion": 0.0015},
}
def cost_for(model: str, prompt_tokens: int, completion_tokens: int) -> float:
rate = PRICING.get(model)
if not rate:
return 0.0 # unknown model: exclude or alert separately
return (prompt_tokens / 1000) * rate["prompt"] + (completion_tokens / 1000) * rate["completion"]
Run this over your ledger rows to get a daily spend figure. Keep the mapping in a config file, not hardcoded, so finance can update it without a deploy.
Step 4: Define threshold policy and alert routing
LLM spend alerts should fire before you hit the hard limit, not after. Set a soft warning at 70% of budget and a hard alert at 100%. Route to a Slack channel or PagerDuty severity that someone actually watches.
import requests
SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK"]
def send_alert(message: str, severity: str = "warning"):
# severity can drive channel or emoji prefix
prefix = ":rotating_light:" if severity == "critical" else ":warning:"
requests.post(SLACK_WEBHOOK, json={"text": f"{prefix} {message}"}, timeout=10)
A minimal policy config:
{
"daily_limit_usd": 50.0,
"monthly_limit_usd": 1000.0,
"warn_ratio": 0.7
}
Step 5: Run the alert checker on a schedule
Write a standalone script that sums the ledger for the current day and month, computes cost, and compares against limits. Run it every 15 minutes via cron.
import sqlite3
from datetime import datetime, timezone
def check_spend():
conn = sqlite3.connect("llm_usage.db")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
rows = conn.execute(
"SELECT model, prompt_tokens, completion_tokens FROM usage WHERE ts >= ?",
(today,),
).fetchall()
daily_cost = sum(cost_for(m, p, c) for m, p, c in rows)
limit = 50.0 # from config
if daily_cost >= limit * 0.7:
send_alert(f"Daily LLM spend at ${daily_cost:.2f} (70% of ${limit:.2f})", "warning")
if daily_cost >= limit:
send_alert(f"Daily LLM spend exceeded ${limit:.2f}: ${daily_cost:.2f}", "critical")
if __name__ == "__main__":
check_spend()
Cron entry:
*/15 * * * * /usr/bin/python3 /opt/llm_alerts/check_spend.py >> /var/log/llm_alerts.log 2>&1
For monthly aggregation, extend the query to filter by month prefix and load monthly_limit_usd from the same config. The pattern is identical.
Step 6: Verify the pipeline end-to-end
An alerting system you have not tested is a liability. Inject a synthetic row that pushes spend past the warning threshold, then run the checker manually.
# test_inject.py
conn = sqlite3.connect("llm_usage.db")
conn.execute(
"INSERT INTO usage (ts, model, prompt_tokens, completion_tokens, total_tokens) VALUES (?,?,?,?,?)",
(datetime.utcnow().isoformat(), "gpt-4o", 100000, 100000, 200000),
)
conn.commit()
Run python3 check_spend.py. You should receive a Slack message within seconds. Remove the test row afterward so it does not pollute real metrics.
sqlite3 llm_usage.db "DELETE FROM usage WHERE model='gpt-4o' AND total_tokens=200000;"
Once real traffic flows, confirm the ledger grows and the daily cost calculation tracks your provider dashboard within a small rounding delta. LLM spend alerts are only useful if they reflect reality.
Edge cases that will bite you
Model aliases. A gateway may rewrite model to a versioned string like gpt-4o-2024-05-13. Key your pricing table on a normalized prefix, or you will silently exclude spend from unknown models.
Cached tokens. Some providers return prompt_tokens_details.cached_tokens. If your gateway forwards provider cache-control hints, those tokens may be billed at a discount. Extend cost_for to apply the cached rate if you have one.
Concurrent writers. SQLite handles low concurrency fine, but if you run multiple workers, use a connection per process and consider BEGIN IMMEDIATE to avoid lock errors.
Time zones. Store UTC always. Compare against UTC day boundaries. A 2 a.m. local-time cron with UTC ledger will mismatch your provider’s billing cycle.
What good looks like
After a week, you should have a daily spend curve, a Slack alert that fired at least once in test, and a config file finance has edited without your help. The LLM spend alerts are now a background process, not a monthly panic. If you later move to a gateway with native metering, you can replace Step 2’s ledger with a single API pull and keep the same threshold logic.