Fundamentals

Set a Monthly AI API Spending Limit

Put a hard ceiling on your AI spend: a provider-side cap plus a Python budget guard that tracks a month-keyed ledger, warns at 80% and blocks the rest.

By the end of this guide you will have two independent ceilings on your AI spending: one set at the provider, which stops your whole account when a billing threshold is passed, and one written into your Python code, which refuses to send a request that would push this month past a number you chose. The code is about sixty lines and takes half an hour to wire in, including the test run that deliberately trips the limit so you can watch it work.

This guide belongs to Managing AI API Costs and Tokens, the section that covers measuring and controlling what your scripts spend. It assumes you can already turn a request into an approximate dollar figure — if you cannot yet, read Estimate OpenAI API Costs with Python first, because a budget guard is only as honest as the cost function behind it.

Prerequisites

You need Python 3.10 or newer, an OpenAI API key, and a project folder you can write files into. If you have not made an isolated project environment yet, Create a Python Virtual Environment for AI walks through it.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "python-dotenv>=1.0"
pip freeze > requirements.txt

Everything that might change between machines goes in a .env file — a plain text file of NAME=value lines that your program reads at startup, so secrets and settings never sit inside the code:

OPENAI_API_KEY=sk-your-key-here
MONTHLY_BUDGET_USD=5.00
PRICE_IN_PER_MTOK=0.15
PRICE_OUT_PER_MTOK=0.60
SPEND_LEDGER=spend_ledger.json

The two price values are dollars per million tokens, copied from your provider's current pricing page. Never treat a price you read in an article as fact; look it up and paste today's number, then re-check it when you change models.

Now stop that file from ever reaching a public repository:

echo ".env" >> .gitignore
echo "spend_ledger.json" >> .gitignore

The ledger goes in .gitignore too. It is not a secret, but a file that every branch rewrites will produce a merge conflict on almost every pull.

Step 1 — Set the provider-side cap first

Every major provider lets you name a dollar figure in its billing settings, above which it stops accepting requests on your account. The exact wording differs between providers and the pages get redesigned often, so look for a billing, usage, or limits area in your account settings rather than following a click-by-click path from any tutorial. Two fields usually appear together: a soft threshold that emails you, and a hard threshold that starts rejecting calls. Set both, and set them to numbers that would annoy you rather than ruin you.

This cap has one enormous advantage over anything you write yourself: it cannot be bypassed. A typo in your code, a dependency that calls the API on import, a colleague running an old copy of the script — none of them escape it, because the enforcement happens on the provider's side of the connection. It also has one serious weakness. It measures billed spend, which lags real time, and it reports a single number for the whole account. If a runaway loop burns through the ceiling on a Tuesday, everything else you own stops working on that Tuesday too, and the first thing you learn about it is a failed request.

An in-code budget is the mirror image: easy to bypass, but it knows the cost of a call before the call happens and it can tell one job apart from another. Neither replaces the other, which is exactly why you set both.

Provider dashboard cap compared with an in-code budget guard A four-row comparison matrix contrasting the provider-side spending cap and a Python budget guard on what each one limits, when it fires, how granular it is, and what it cannot see. Compared on Dashboard cap Code budget What it caps scope of the limit Whole account every key and app One script run one ledger file When it fires when it triggers After the spend provider blocks calls Before the call raises BudgetExceeded Granularity how detailed One number no per-job split Per job or model you pick the split Blind spot what it misses No early warning you learn at 100% Trusts your code off if you skip it
The provider cap is unbypassable but blunt and late; the code budget is precise and early but only protects the paths that actually call it. Use both and each covers the other's weakness.

Step 2 — Keep a month-keyed spend ledger

A ledger is just a record of what you have spent, stored somewhere that survives your script exiting. The trick that makes monthly limits easy is the key: store each month under its own label, 2026-07, and never store a running total that you have to remember to clear.

Save this as budget.py:

"""budget.py — a tiny month-keyed spend ledger stored as JSON."""
from __future__ import annotations

import json
import os
from datetime import datetime, timezone
from pathlib import Path

LEDGER = Path(os.getenv("SPEND_LEDGER", "spend_ledger.json"))


def month_key(when: datetime | None = None) -> str:
    """Return the year-month label for a moment in time, e.g. '2026-07'."""
    when = when or datetime.now(timezone.utc)
    return when.strftime("%Y-%m")


def load_ledger() -> dict[str, float]:
    """Read the whole ledger; an absent or empty file counts as no spend."""
    if not LEDGER.exists() or LEDGER.stat().st_size == 0:
        return {}
    return json.loads(LEDGER.read_text(encoding="utf-8"))


def spent_this_month() -> float:
    return load_ledger().get(month_key(), 0.0)


def record_spend(amount_usd: float) -> float:
    """Add one call's cost to this month's total and save it. Returns the new total."""
    ledger = load_ledger()
    key = month_key()
    ledger[key] = round(ledger.get(key, 0.0) + amount_usd, 6)
    tmp = LEDGER.with_suffix(".tmp")
    tmp.write_text(json.dumps(ledger, indent=2, sort_keys=True), encoding="utf-8")
    tmp.replace(LEDGER)          # atomic swap: a crash cannot leave half a file
    return ledger[key]


if __name__ == "__main__":
    print(month_key(), "spent so far:", spent_this_month())

Two details are worth more than they look. Writing to a temporary file and then calling replace means the real ledger is swapped in one operation, so a crash halfway through can never leave you with truncated, unreadable JSON. And datetime.now(timezone.utc) pins the month boundary to a single timezone; if you use local time and later run the same script on a server in another region, you will get two different answers for what month it is on the first and last day.

Run python budget.py now. It prints the current month and 0.0, which is exactly right — the file does not exist yet, and load_ledger treats that as zero rather than crashing.

When to use SQLite instead

JSON is perfect for one script on one machine. The moment two processes write at once — a scheduled job and your terminal, or four workers in a queue — one of them will read the file, be paused by the operating system, and write back a total that ignores the other's update. SQLite solves that with a single statement:

import sqlite3
from budget import month_key

con = sqlite3.connect("spend.db", timeout=10)
con.execute("CREATE TABLE IF NOT EXISTS spend (month TEXT PRIMARY KEY, usd REAL NOT NULL)")
con.execute(
    "INSERT INTO spend (month, usd) VALUES (?, ?) "
    "ON CONFLICT(month) DO UPDATE SET usd = usd + excluded.usd",
    (month_key(), 0.0021),
)
con.commit()
row = con.execute("SELECT usd FROM spend WHERE month = ?", (month_key(),)).fetchone()
print(f"{month_key()}: ${row[0]:.4f}")
con.close()

The ON CONFLICT ... DO UPDATE clause makes the add-to-total a single indivisible operation, and timeout=10 tells a second process to wait rather than fail while the first one writes. Swap record_spend and spent_this_month for these two queries and nothing else in this guide changes.

Step 3 — Check the budget before every call

The check has to happen before the request leaves your machine, which means you need an estimate of the cost of a call you have not made yet. Estimate the input from the prompt text and the output from max_tokens, the ceiling you already set on how much the model may write. That estimate is deliberately pessimistic: the model usually writes less than its ceiling, so you stop slightly early rather than slightly late.

Save this as guard.py next to budget.py:

"""guard.py — refuse any call that would push this month past its budget."""
import os

from dotenv import load_dotenv
from openai import OpenAI

from budget import month_key, record_spend, spent_this_month

load_dotenv()                      # reads .env, which is listed in .gitignore

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

MONTHLY_BUDGET = float(os.getenv("MONTHLY_BUDGET_USD", "5"))
PRICE_IN = float(os.getenv("PRICE_IN_PER_MTOK", "0"))     # dollars per 1M input tokens
PRICE_OUT = float(os.getenv("PRICE_OUT_PER_MTOK", "0"))   # dollars per 1M output tokens


class BudgetExceeded(RuntimeError):
    """Raised when the next call would take this month over its limit."""


def cost_usd(input_tokens: int, output_tokens: int) -> float:
    return (input_tokens * PRICE_IN + output_tokens * PRICE_OUT) / 1_000_000


def check_budget(estimated_usd: float) -> None:
    spent = spent_this_month()
    if spent + estimated_usd > MONTHLY_BUDGET:
        raise BudgetExceeded(
            f"{month_key()}: ${spent:.4f} already spent; ${estimated_usd:.4f} more "
            f"would pass the ${MONTHLY_BUDGET:.2f} limit"
        )


def ask(prompt: str, max_tokens: int = 300, model: str = "gpt-4o-mini") -> str:
    rough_input = len(prompt) // 4          # about four characters per token
    check_budget(cost_usd(rough_input, max_tokens))
    response = client.chat.completions.create(
        model=model,
        max_tokens=max_tokens,
        messages=[{"role": "user", "content": prompt}],
    )
    usage = response.usage
    actual = cost_usd(usage.prompt_tokens, usage.completion_tokens)
    total = record_spend(actual)
    print(f"[budget] this call ${actual:.5f}, month to date ${total:.4f}")
    return response.choices[0].message.content


if __name__ == "__main__":
    try:
        print(ask("Give me three reasons to track API spend in a ledger."))
    except BudgetExceeded as exc:
        print("Stopped before sending:", exc)

The flow is worth saying out loud, because it is the whole design. Your script asks for an answer. The guard estimates, reads the ledger, and either raises BudgetExceeded without touching the network or lets the call through. When the reply arrives it carries a usage block with the real token counts, and those — not the estimate — are what get written back to the ledger. The estimate protects you; the actual figure keeps the ledger honest.

Path of one guarded API call through the budget check and the ledger A data-flow diagram showing a script calling the budget guard, which reads the month ledger and either raises BudgetExceeded or forwards the request to the API, whose reported usage is written back as the actual cost. Your script one prompt to send Budget guard check_budget() OpenAI API gpt-4o-mini ask() under over budget BudgetExceeded no call is sent Month ledger read, then updated Actual cost from usage in reply
The guard sits between your script and the network: it reads the ledger before deciding, and the ledger is only updated with the real token counts the API reports back.

To watch the block happen, set MONTHLY_BUDGET_USD=0.000001 in .env and run python guard.py again. You should see Stopped before sending: and a message with both figures in it, and no charge on your account.

Step 4 — Warn at 80% and let the month reset itself

A limit that only speaks when it blocks you is a bad limit. The first sign of trouble should be a line in your output several days before anything breaks, so that raising the ceiling or trimming the workload is a decision you make calmly rather than a fire you put out. Add a three-state check to guard.py:

WARN_AT = 0.80


def budget_state(estimated_usd: float = 0.0) -> tuple[str, float]:
    """Return ('ok' | 'warning' | 'blocked', fraction of budget projected)."""
    projected = spent_this_month() + estimated_usd
    fraction = projected / MONTHLY_BUDGET if MONTHLY_BUDGET > 0 else 1.0
    if fraction > 1.0:
        return "blocked", fraction
    if fraction >= WARN_AT:
        return "warning", fraction
    return "ok", fraction


def check_budget(estimated_usd: float) -> None:      # replaces the earlier version
    state, fraction = budget_state(estimated_usd)
    if state == "blocked":
        raise BudgetExceeded(
            f"{month_key()}: this call would reach {fraction:.0%} of "
            f"${MONTHLY_BUDGET:.2f} — stopping before it is sent"
        )
    if state == "warning":
        print(f"[budget] warning: {fraction:.0%} of ${MONTHLY_BUDGET:.2f} used this month")


def month_report() -> None:
    """Print every month the ledger has ever recorded."""
    from budget import load_ledger
    for key, amount in sorted(load_ledger().items()):
        print(f"{key}   ${amount:8.4f}")

Notice what is missing: any code that clears the total. Because record_spend writes under month_key() and spent_this_month reads under month_key(), the rollover is automatic. At midnight UTC on the first of the month the lookup misses, .get(key, 0.0) hands back zero, and the new month begins empty. The old months stay in the file, which is why month_report is worth having — three months of numbers side by side tell you far more about whether your limit is realistic than any single figure.

Three budget states and the automatic month rollover A decision tree that takes the projected month total, branches into a quiet zone below eighty percent, a warning zone between eighty and one hundred percent, and a blocked branch above it, with all three flowing into a fresh month key. Next call estimate added to month total under 80% 80 to 100% over 100% Quiet zone call runs, no notice Warning zone call runs, prints line Blocked BudgetExceeded date key changes New month starts fresh key, total 0
Three outcomes, one rollover: the warning band buys you days of notice, and because every total is stored under a year-month key, the first of the month starts at zero with no cleanup code.

If your script runs unattended, print is not enough — nobody reads the log of a job that succeeded. Send the warning where you will see it, which is what Log and Monitor AI API Calls in Production covers, and if the job is a scheduled one, Schedule Python AI Jobs with GitHub Actions shows where those settings live.

Step 5 — Give each project its own key and its own ledger

One key across five projects means one number for five projects, and a single bug spends everyone's money. Create a separate key per project in your provider dashboard, name each one after the project, and give each project its own .env and its own ledger file. Because budget.py reads the ledger path from the environment, that costs you one line:

OPENAI_API_KEY=sk-key-for-this-project-only
SPEND_LEDGER=ledgers/newsletter-bot.json
MONTHLY_BUDGET_USD=2.00
"""Show the month-to-date total for whichever project's .env is loaded."""
from dotenv import load_dotenv

load_dotenv()

from budget import LEDGER, month_key, spent_this_month   # import after load_dotenv

print(f"ledger : {LEDGER}")
print(f"month  : {month_key()}")
print(f"spent  : ${spent_this_month():.4f}")

The import order matters: budget.py reads SPEND_LEDGER when it is first imported, so load_dotenv() has to run before that import or you will silently get the default file. Separate keys pay off in three ways — the provider can report spend per key, a leaked key can be revoked without breaking your other projects, and a script stuck retrying a failing request burns only its own allowance. Key handling beyond a local .env is covered in Manage API Keys Safely in Production.

Quick reference

SettingWhere it livesWhat it does
Provider hard capBilling settings in your accountRejects all calls on the account once billed spend passes it
MONTHLY_BUDGET_USD.envCeiling your own code enforces before each request
WARN_ATguard.pyFraction, 0.80 by default, at which warnings begin
SPEND_LEDGER.envPath to this project's ledger, so projects stay separate

Troubleshooting

  • json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the ledger file exists but is empty or half-written, usually after a crash during an old-style direct write. The temp-file-and-replace pattern in record_spend prevents it; to recover, delete the file and let it rebuild, or restore the last good copy. More on this error in Fix JSONDecodeError with AI API Responses in Python.
  • AttributeError: 'NoneType' object has no attribute 'prompt_tokens'response.usage is None, which happens when you stream the reply without asking for usage. Pass stream_options={"include_usage": True} on streaming calls, or fall back to your estimate when usage is missing.
  • sqlite3.OperationalError: database is locked — two processes wrote to spend.db at the same moment and one gave up waiting. Raise the timeout value on sqlite3.connect, and keep each connection open for as short a time as possible.
  • BudgetExceeded on the first call of a brand-new month — almost always a timezone mismatch, where the ledger was written under local time and is now read under UTC. Use datetime.now(timezone.utc) everywhere, and if you are reading an unfamiliar traceback, Read a Python Traceback in Five Minutes will get you to the failing line quickly.

When to use this vs. alternatives

  • A monthly budget guard fits scripts and small tools where the money is yours and the question is "how much did this cost me in July". It is the right first control for anything you run from your own machine or a scheduled job.
  • A per-request rate limit fits apps with users, where the risk is not the calendar month but one person hammering an endpoint. Counting requests per minute per user is a different mechanism with a different failure mode; Rate-Limit AI API Calls in a SaaS with Python covers it, and the two happily run side by side.
  • Spending less per call beats blocking calls whenever you can arrange it. Trimming prompts after Count Tokens in Python Before You Send, or reusing answers with Cache AI Responses in Python to Cut Costs, pushes the month total down so the guard rarely has to fire at all.

Set the provider cap today, because it takes two minutes and protects everything. Add budget.py and guard.py to your next script, run it once with a deliberately tiny budget so you have seen the block with your own eyes, then set the real number and forget about it. A month later, run month_report() and you will have something most people never get: an exact record of what your AI work actually costs, month by month, with a ceiling you chose rather than one you discovered. Back to Managing AI API Costs and Tokens.

Frequently asked questions

Does a provider dashboard limit stop my script from spending money?

It stops the account, not the script. The provider counts what you have already been billed and refuses new requests once you pass the ceiling you set. Your program only finds out when a call fails. That is a real safety net, but it arrives after the money is gone, which is why you also want a check inside your own code.

Where should I store the running monthly total?

A small JSON file keyed by year-month is enough for one script on one machine. Use SQLite instead when several scripts, several scheduled jobs, or several processes write at once, because SQLite handles concurrent writes safely while two Python processes editing the same JSON file can overwrite each other.

How do I reset the total when a new month starts?

You do not reset anything. Store each month under its own key, such as 2026-07, and read the key for today's date every time you check. When the calendar rolls over, the lookup simply misses and returns zero, so the new month starts clean and the old figures stay available for review.

Why warn at 80% instead of just blocking at 100%?

Because a hard stop with no warning looks identical to a broken script. An 80% warning printed on every call gives you days of notice to raise the ceiling, switch to a cheaper model, or cache repeated answers before anything actually fails, and it turns a surprise outage into a planned decision.

Should every project have its own API key?

Yes, when you can. A separate key per project lets the provider report spend per project and lets you revoke one key without breaking the others. It also means a script stuck in a retry loop burns its own budget, not the budget your paying customers depend on.