Fundamentals

Estimate OpenAI API Costs with Python

Turn token counts into dollars: build a PRICES dict, write an estimate_cost helper, log the real cost of every OpenAI call to CSV, then project a month.

By the end of this guide you will have two small Python files: one that converts any pair of token counts into a dollar figure, and one that logs the real cost of every call your project makes to a CSV file you can open in a spreadsheet. From that log you will produce a monthly projection you can actually defend. Budget about forty minutes, most of it spent copying current rates from OpenAI's pricing page and running a handful of test calls.

Counting tokens tells you how big a request is. This guide is the next step in Managing AI API Costs and Tokens: turning that size into money, so you can answer "what will this cost me if a thousand people use it?" with arithmetic instead of a shrug.

Prerequisites

You need Python 3.10 or newer, a working OpenAI API key, and a project folder with a virtual environment. If the environment part is new, Create a Python Virtual Environment for AI walks through it in five minutes. This guide assumes you can already make a successful call — if yours fails on authentication, Fix the 401 Unauthorized Error in OpenAI Python sorts that out first.

Install the two packages this guide uses. Everything else comes from the standard library:

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

Put your key in a .env file in the project folder:

OPENAI_API_KEY=sk-your-key-here

Then keep it out of version control, because a leaked key is billed to you:

echo ".env" >> .gitignore

Step 1 — Record today's prices in one dict

The single most common mistake in cost code is scattering rate numbers through the script — one in a comment, one hard-coded in a multiplication, one in a spreadsheet nobody opens. Put every rate in one dictionary, in one file, with the date you copied it.

Open OpenAI's pricing page, find the models you actually use, and copy the input and output rates. They are quoted per million tokens — a token being a chunk of text of roughly three-quarters of a word. Do not take rates from this page, from a tutorial, or from memory. Prices move, and a stale number produces a confident, wrong budget.

Create costs.py:

"""costs.py — the only place prices live.

Rates are US dollars per 1,000,000 tokens. Copy them from OpenAI's official
pricing page and update PRICES_CHECKED on the day you do it.
"""

PRICES_CHECKED = "2026-07-24"

PRICES = {
    "gpt-4o-mini": {"input": 0.0, "output": 0.0},
    "gpt-4o": {"input": 0.0, "output": 0.0},
}

The zeros are deliberate placeholders. In the next step the helper refuses to run while they are still zero, so you cannot silently ship a budget built on nothing. Fill in the four numbers before you go further, and set PRICES_CHECKED to today.

Two details worth noticing while you are on the pricing page. Input and output have separate rates, and the output rate is the higher of the two on every current chat model. And rates differ by model, often by a large multiple between the small and large models in the same family — which is why model is an argument in everything below rather than a constant.

Step 2 — Turn tokens into dollars

Now the arithmetic. One function, three inputs, one dollar figure out. Add this to the bottom of costs.py, below the PRICES dictionary:

def estimate_cost(input_tokens: int, output_tokens: int, model: str = "gpt-4o-mini") -> float:
    """Return the US-dollar cost of one call, given its two token counts."""
    try:
        rates = PRICES[model]
    except KeyError:
        raise KeyError(
            f"No price recorded for {model!r}. Add it to PRICES in costs.py."
        ) from None

    if rates["input"] == 0.0 and rates["output"] == 0.0:
        raise ValueError(
            f"Prices for {model!r} are still placeholders. "
            f"Copy the current rates from the pricing page first."
        )

    input_cost = input_tokens / 1_000_000 * rates["input"]
    output_cost = output_tokens / 1_000_000 * rates["output"]
    return round(input_cost + output_cost, 6)


if __name__ == "__main__":
    print(f"1,200 in / 400 out: ${estimate_cost(1200, 400):.6f}")

Run it with python costs.py. Six decimal places is not fussiness — a single short call on a small model can land in the tens of millionths of a dollar, and rounding to cents would show you $0.00 for every request you ever make. Keep the precision until you multiply up to a month.

The two divisions are the part people get wrong. A rate of, say, three dollars per million tokens is 0.000003 dollars per token. Dividing the token count by 1_000_000 first keeps the units obvious and stops you from misplacing a factor of a thousand — the error that turns a fifty-dollar month into a five-cent one on your slide.

Here is the whole pipeline this guide builds, from raw counts to a monthly figure:

How token counts become a monthly cost figure A six-stage data flow: token counts feed a PRICES dictionary lookup, the two rates are multiplied and added, the result is a per-call cost, which is appended to a CSV log and finally multiplied out into a monthly figure. Token counts input and output PRICES dict USD per 1M tokens Multiply and add input plus output Cost of the call six decimal places Append to CSV one row per call Monthly figure mean times volume
Every stage after the PRICES lookup is plain arithmetic — which is why getting the rates and the token counts right is the whole job.

If you want an estimate before you spend anything, you need a token count for text you have not sent yet. That is a separate technique, covered in Count Tokens in Python Before You Send; feed its result straight into estimate_cost as the input_tokens argument.

Step 3 — Log what every call really cost

An estimate made before the call is a forecast. The response itself carries the truth: every OpenAI chat response includes a usage object with the exact counts you were billed for. Reading it costs nothing extra and takes one line.

Create log_calls.py next to costs.py:

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

from dotenv import load_dotenv
from openai import OpenAI

from costs import estimate_cost

load_dotenv()                      # reads .env, which is in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

LOG = Path("usage_log.csv")
FIELDS = ["timestamp", "model", "input_tokens", "output_tokens", "cost_usd", "label"]


def log_row(row: dict) -> None:
    """Append one call to the CSV, writing a header row the first time."""
    is_new = not LOG.exists()
    with LOG.open("a", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=FIELDS)
        if is_new:
            writer.writeheader()
        writer.writerow(row)


def ask(prompt: str, model: str = "gpt-4o-mini", label: str = "adhoc") -> str:
    """Send one prompt, log its real cost, return the reply text."""
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=400,
    )
    usage = response.usage
    cost = estimate_cost(usage.prompt_tokens, usage.completion_tokens, model)

    log_row({
        "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "model": model,
        "input_tokens": usage.prompt_tokens,
        "output_tokens": usage.completion_tokens,
        "cost_usd": f"{cost:.6f}",
        "label": label,
    })

    print(f"{label}: {usage.prompt_tokens} in, {usage.completion_tokens} out, ${cost:.6f}")
    return response.choices[0].message.content


if __name__ == "__main__":
    ask("Write a one-sentence tagline for a dog-walking app.", label="tagline")
    ask("List three reasons a customer might cancel a subscription.", label="churn")

Run python log_calls.py. You get two printed lines and a usage_log.csv you can open in any spreadsheet. The label column is the part people skip and later wish they had: it lets you answer "which feature is eating the budget?" rather than just "how much did I spend?". Give each distinct job in your project its own label from day one.

Notice what the numbers show. Change one call to ask for a long answer and the input count barely moves while the output count multiplies — and output is the more expensive side of the bill. Almost every surprise invoice traces back to that asymmetry, not to prompt length.

What you know before a call compared with after it A three-row comparison matrix contrasting the estimate available before a call with the actual figures returned afterwards, for input tokens, output tokens and total cost. Estimate Actual Input tokens what you send counted locally close to exact prompt_tokens read off the reply Output tokens what it writes max_tokens cap an upper bound completion_tokens exact, after the call Cost in dollars the number you use planning number good enough to plan the billed amount log it every call
The input side is knowable in advance; the output side is not, which is why the gap between an estimate and a bill is nearly always on the output row.

If you already cache repeated requests, note that a cache hit never reaches the API and so never appears in this log — you are seeing paid calls only, which is exactly what you want when projecting spend. Cache AI Responses in Python to Cut Costs covers that layer.

Step 4 — Project a month from real calls

A per-call figure of $0.000312 is unpersuasive on its own. What convinces you, or a finance team, is a monthly number derived from calls that actually happened. Create project.py:

import csv
from pathlib import Path

LOG = Path("usage_log.csv")


def project_month(calls_per_day: int, log_path: Path = LOG) -> None:
    """Print a monthly projection from the calls logged so far."""
    if not log_path.exists():
        raise SystemExit("No usage_log.csv yet — run log_calls.py a few times first.")

    rows = list(csv.DictReader(log_path.open(encoding="utf-8")))
    if not rows:
        raise SystemExit("usage_log.csv has no data rows yet.")

    costs = [float(row["cost_usd"]) for row in rows]
    inputs = sum(int(row["input_tokens"]) for row in rows)
    outputs = sum(int(row["output_tokens"]) for row in rows)
    mean = sum(costs) / len(costs)

    print(f"sample size          : {len(rows)} calls")
    print(f"mean cost per call   : ${mean:.6f}")
    print(f"most expensive call  : ${max(costs):.6f}")
    print(f"input tokens logged  : {inputs:,}")
    print(f"output tokens logged : {outputs:,}")
    print(f"typical month        : ${mean * calls_per_day * 30:.2f}")
    print(f"worst-case month     : ${max(costs) * calls_per_day * 30:.2f}")


if __name__ == "__main__":
    project_month(calls_per_day=200)

Two projections come out, and you should quote both. The typical month uses the mean; the worst-case month assumes every call is as expensive as your priciest logged one. Real usage sits between them, and the distance between the two lines tells you how variable your workload is. A narrow gap means a predictable bill. A wide gap means one long document or one chatty user can double your month, and you should decide in advance what happens when that occurs.

Before you trust the projection, check the sample honestly. If your log contains twenty short prompts but your product also summarises fifty-page PDFs, the mean is fiction. Run each kind of request a few times, keep the labels distinct, and project per label if the shapes differ a lot.

What to do once you have a monthly projection A decision tree: compare the monthly estimate against your budget; if it is under, keep logging, and if it is over, choose one of three levers — shrink the prompt, cache repeats, or cap the output length. Monthly estimate against your budget Under budget keep the log running Over budget pick a lever below Shrink the prompt fewer input tokens Cache repeats skip the paid call Cap max_tokens output costs more
A projection is only useful if it changes a decision — under budget means keep measuring, over budget means pull one of three specific levers.

When the projection comes in high, the third lever is usually the fastest win, because output is the expensive side. Lowering max_tokens from 400 to 150 on a summarisation job cuts the dominant half of the bill immediately, and the replies are often better for it. Once you have chosen a number you can live with, Set a Monthly AI API Spending Limit shows how to make your code stop before it exceeds it, rather than merely reporting the damage afterwards.

What each usage field bills for

Every chat response carries these fields on response.usage. Learn what each one means and you will never again guess at a bill.

FieldWhat it countsHow it is billed
usage.prompt_tokensEverything you sent: system message, history, user textAt the model's input rate
usage.completion_tokensOnly the text the model wrote backAt the higher output rate
usage.total_tokensThe two above added togetherNot billed as a unit — never price with it
usage.prompt_tokens_details.cached_tokensThe share of input served from the provider's cacheAt a reduced input rate; check the pricing page

The third row is the trap. total_tokens is a convenient single number, so people multiply it by one rate and call it a cost estimate. Because input and output are priced differently, that answer is wrong in whichever direction your traffic leans — and it is wrong by more, not less, as your usage grows.

Troubleshooting

  • KeyError: No price recorded for 'gpt-4o' — you called estimate_cost with a model missing from PRICES. Add an entry for that exact model string, copying both rates from the pricing page. The exact string matters: gpt-4o and gpt-4o-mini are different keys.
  • ValueError: Prices for 'gpt-4o-mini' are still placeholders — the guard from Step 2 is doing its job. Replace the 0.0 values in costs.py with today's real rates and update PRICES_CHECKED.
  • AttributeError: 'NoneType' object has no attribute 'prompt_tokens' — you are streaming the response, and streamed calls omit usage unless you ask for it. Pass stream_options={"include_usage": True} and read the usage from the final chunk.
  • FileNotFoundError: usage_log.csvproject.py ran before any call was logged. Run log_calls.py first; the guard in project_month turns this into a readable message rather than a traceback. If tracebacks still throw you, Read a Python Traceback in Five Minutes is worth the detour.
  • Costs look implausibly small — that is usually correct. Small models are cheap per call, and the number only becomes meaningful once multiplied by monthly volume. Check your arithmetic against a deliberate test: 1,000,000 input tokens should cost exactly the input rate you typed in.

When to use this vs. alternatives

  • This CSV approach vs. the provider dashboard. The dashboard shows spend after the fact, aggregated across your whole account, with a reporting delay. Your log shows cost per call, per feature, per model, in real time. Use the dashboard as the authoritative total and your log for every decision about which calls to change.
  • This approach vs. estimating before you send. Pre-call counting is the right tool for a gate — refuse or trim a request that is too big before spending anything. Post-call logging is the right tool for a budget, because it uses figures you were actually charged for. Most projects want both, and they share the same estimate_cost function.
  • A CSV file vs. a proper logging service. A CSV is perfect for one developer and a few thousand rows: no setup, opens in a spreadsheet, easy to inspect. Once several people or several machines are writing at once, move to a database or a hosted tool — Log and Monitor AI API Calls in Production covers that transition, and Rate-Limit AI API Calls in a SaaS with Python covers capping spend per customer.

The habit that matters more than any of this code is refreshing your rates. Put a recurring reminder in your calendar, update PRICES and PRICES_CHECKED together, and rerun project.py against your existing log — the projection moves, the code does not. A cost model you maintain for ten minutes a month is worth more than a sophisticated one you wrote once and never touched.

Back to Managing AI API Costs and Tokens.

Frequently asked questions

How do I calculate the cost of an OpenAI API call in Python?

Read the token counts from response.usage, then multiply each count by that model's rate. Rates are quoted per million tokens, so the formula is tokens divided by 1,000,000, multiplied by the rate, done separately for input and output and added together. Keep the rates in a dictionary you update by hand.

Where do I find the per-token price to put in my script?

Copy it from OpenAI's official pricing page on the day you write the script, and record that date in a comment. Prices change, and no blog post or tutorial is a reliable source. Treat any number inside your code as a snapshot you are responsible for refreshing.

Why is my actual bill higher than my estimate?

Almost always because output tokens cost more per token than input tokens, and you cannot know how many the model will write until it answers. An estimate made before the call is a guess about output length. Log the real usage figures after each call and your projection stops drifting.

Does response.usage cost anything extra to read?

No. The usage object arrives inside the response you already paid for, so reading prompt_tokens and completion_tokens is free and instant. It is the most accurate cost signal you have, which is why every call in this guide writes those two numbers to a CSV file.

How many calls do I need to log before a monthly projection is trustworthy?

Log enough calls to cover every kind of request your project makes, including the longest ones. A sample of a few dozen similar calls gives a usable average. Track the most expensive call as well as the mean, because one long document can outweigh many short prompts.