Danielle Scantling9 pieces · 14 min
How to Track Token Usage in Claude and OpenAI (Python Tutorial, 2026)
Every AI bill is the same math: tokens in, tokens out, times a price. The problem is nobody looks at the tokens until the invoice shows up, and by then you can't tell which feature, prompt or runaway loop ate the money.
The good news: both Claude and OpenAI hand you the exact token counts on every single response. You just have to read them. This post walks through doing that in Python for both providers, counting tokens before you send, logging everything to one file, checking your numbers against the official org-wide reports, and (for teams) seeing who's using what.
One warning up front: Claude and OpenAI count cached tokens differently. Both call the field input_tokens, but it means different things on each, so tracking code that's right for one is wrong for the other. I'll show you exactly where.
01 / 09 · 1 min
What You're Actually Paying For
A token is a chunk of text, roughly three-quarters of a word in English. Code, JSON and non-English text usually take more tokens per word.
Every API call has a few token buckets, and they're priced differently:
| Bucket | What it is | Why it matters |
|---|---|---|
| Input | Your system prompt, chat history, tool definitions, files | Resent on every turn, so it grows fast in long chats |
| Output | What the model writes back | Several times the input price (5x on current Claude models) |
| Cached input | Input the provider already saw recently | Much cheaper, so reusing the same prompt prefix saves real money |
| Reasoning / thinking | Tokens the model "thinks" before answering | Billed as output, even when you never see them |
That last row surprises people. Reasoning models think before they answer, and you pay output rates for that thinking even when the response only shows you a short final answer. Tracking just the visible text undercounts.
02 / 09 · 1 min
Tracking Claude Tokens
Every Claude Messages API response has a usage object. Install the SDK with pip install anthropic and read it straight off the response:
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=2000,
messages=[{"role": "user", "content": "Explain tokens in two sentences."}],
)
u = response.usage
print("uncached input:", u.input_tokens)
print("cache reads: ", u.cache_read_input_tokens)
print("cache writes: ", u.cache_creation_input_tokens)
print("output: ", u.output_tokens)What each field means:
| Field | Meaning |
|---|---|
input_tokens |
Input tokens billed at full price (the uncached part only) |
cache_read_input_tokens |
Input served from the prompt cache (about 0.1x the input price) |
cache_creation_input_tokens |
Input written to the cache this call (about 1.25x for the default 5-minute cache) |
output_tokens |
Everything the model generated, including thinking |
Streaming
If you stream (and you should for long responses), don't try to add up chunks yourself. Let the SDK hand you the final message, which carries the same usage object:
with client.messages.stream(
model="claude-sonnet-5-5",
max_tokens=16000,
messages=[{"role": "user", "content": "Write a haiku about invoices."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()
print("\n", final.usage)Under the hood, the output count arrives in the message_delta event near the end of the stream. get_final_message() stitches it together for you.
03 / 09 · 2 min
Tracking OpenAI Tokens
OpenAI's Responses API also returns a usage object, with slightly different names. Install with pip install openai:
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
MODEL = "gpt-5.5" # use whichever model your account runs
response = client.responses.create(
model=MODEL,
input="Explain tokens in two sentences.",
)
u = response.usage
print("input (incl. cached):", u.input_tokens)
print(" of which cached: ", u.input_tokens_details.cached_tokens)
print("output (incl. reasoning):", u.output_tokens)
print(" of which reasoning: ", u.output_tokens_details.reasoning_tokens)
print("total:", u.total_tokens)Notice the "of which" lines. On OpenAI, cached_tokens is a subset of input_tokens, and reasoning_tokens is a subset of output_tokens. Don't add them on top or you'll double count.
Streaming
With the Responses API, usage arrives on the final response.completed event:
stream = client.responses.create(model=MODEL, input="Write a haiku about invoices.", stream=True)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "response.completed":
print("\n", event.response.usage)Still on the older Chat Completions API? Streaming responses don't include usage unless you ask for it. Miss this and your OpenAI tracking will show zero usage for every streamed call:
stream = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "Hi"}],
stream=True,
stream_options={"include_usage": True}, # without this, no usage at all
)
for chunk in stream:
if chunk.usage: # only the last chunk has it, and its choices list is empty
print(chunk.usage.prompt_tokens, chunk.usage.completion_tokens)Chat Completions also uses different names: prompt_tokens and completion_tokens instead of input_tokens and output_tokens.
04 / 09 · 1 min
The Caching Gotcha
Here's the trap I mentioned. Same idea, opposite math:
| Claude | OpenAI | |
|---|---|---|
input_tokens means |
Uncached input only | All input, cached included |
| Total input | input_tokens + cache_read_input_tokens + cache_creation_input_tokens |
input_tokens |
| Full-price input | input_tokens |
input_tokens - cached_tokens |
| Caching is | Opt-in with cache_control |
Automatic on long prompts |
So if you write total_input = usage.input_tokens for Claude, you'll undercount every cached request. If you write total_input = input_tokens + cached_tokens for OpenAI, you'll overcount. Normalize them once, in one place (next sections), and never think about it again.
A quick health check for Claude caching: if cache_read_input_tokens stays at zero across repeated requests with the same long prefix, something in your prefix is changing every call. The usual culprit is a timestamp or random ID baked into the system prompt.
05 / 09 · 1 min
Count Before You Send
Sometimes you want the number before you spend the money: checking whether a big document fits, or estimating a batch job. Both providers have a free counting endpoint that takes the same payload as a real request.
Claude:
count = client.messages.count_tokens(
model="claude-sonnet-5-5",
messages=[{"role": "user", "content": open("big_doc.md").read()}],
)
print(count.input_tokens)OpenAI:
count = client.responses.input_tokens.count(
model=MODEL,
input=open("big_doc.md").read(),
)
print(count.input_tokens)Both count input only. Neither can predict how long the answer will be.
One thing not to do: don't use tiktoken to count Claude tokens. tiktoken is OpenAI's tokenizer. Claude uses a different tokenizer, so a tiktoken count won't match what Claude bills you. Each provider's own counting endpoint is the only number you can trust for that provider, and token counts can change between model generations too, so pass the model you'll actually use.
06 / 09 · 2 min
One Logger for Both
Here's the part I actually use. One function that takes a response from either provider, normalizes it into the same shape, prices it, and appends a line to a JSONL file:
import json
import time
# $ per 1M tokens. Claude rates as of October 2026; fill in OpenAI's from
# their pricing page and re-check both whenever you change models.
PRICES = {
"claude-opus-5-5": {"input": 4.00, "output": 20.00},
"claude-sonnet-5-5": {"input": 2.00, "output": 10.00},
"claude-haiku-5-5": {"input": 0.10, "output": 0.50},
# "gpt-5.5": {"input": ..., "output": ..., "cached": ...},
}
def normalize(provider: str, model: str, usage) -> dict:
if provider == "anthropic":
return {
"uncached_input": usage.input_tokens,
"cache_read": usage.cache_read_input_tokens or 0,
"cache_write": usage.cache_creation_input_tokens or 0,
"output": usage.output_tokens,
"reasoning": None, # included in output, not split out
}
if provider == "openai":
cached = usage.input_tokens_details.cached_tokens or 0
return {
"uncached_input": usage.input_tokens - cached,
"cache_read": cached,
"cache_write": 0, # OpenAI doesn't charge extra to write the cache
"output": usage.output_tokens,
"reasoning": usage.output_tokens_details.reasoning_tokens,
}
raise ValueError(f"unknown provider: {provider}")
def estimate_cost(provider: str, model: str, t: dict) -> float | None:
p = PRICES.get(model)
if not p:
return None
if provider == "anthropic":
cached_rate = p["input"] * 0.1
write_rate = p["input"] * 1.25 # 5-minute cache
else:
cached_rate = p.get("cached", p["input"])
write_rate = 0
return (
t["uncached_input"] * p["input"]
+ t["cache_read"] * cached_rate
+ t["cache_write"] * write_rate
+ t["output"] * p["output"]
) / 1_000_000
def log_usage(provider: str, model: str, usage, feature: str, user: str | None = None,
path="token_log.jsonl"):
t = normalize(provider, model, usage)
record = {
"ts": time.time(),
"provider": provider,
"model": model,
"feature": feature, # tag every call with what it was for
"user": user, # and who it was for (see the team section)
**t,
"est_cost_usd": estimate_cost(provider, model, t),
}
with open(path, "a") as f:
f.write(json.dumps(record) + "\n")
return recordUse it right after every call:
log_usage("anthropic", "claude-sonnet-5-5", response.usage, feature="summarize-ticket", user="dana")
log_usage("openai", MODEL, oai_response.usage, feature="classify-email")The feature tag is the most important field in the whole file. "We spent $400 on Claude" is useless. "The ticket summarizer spent $380 because it sends the full thread every time" is something you can fix.
To see where the money went:
from collections import defaultdict
totals = defaultdict(lambda: {"calls": 0, "tokens": 0, "cost": 0.0})
for line in open("token_log.jsonl"):
r = json.loads(line)
key = (r["feature"], r["model"])
totals[key]["calls"] += 1
totals[key]["tokens"] += r["uncached_input"] + r["cache_read"] + r["cache_write"] + r["output"]
totals[key]["cost"] += r["est_cost_usd"] or 0
for (feature, model), t in sorted(totals.items(), key=lambda kv: -kv[1]["cost"]):
print(f"{feature:20} {model:20} {t['calls']:5} calls {t['tokens']:>10,} tok ${t['cost']:.2f}")Treat est_cost_usd as an estimate. Batch discounts, long-context pricing tiers and price changes all shift the real number, which is why the next section exists.
07 / 09 · 1 min
Org-Wide Usage Reports
Your own log tells you why you spent tokens. The provider's reports tell you what you were actually billed. Check one against the other every so often; if they disagree a lot, some code path isn't logging.
Both providers have dashboards in their consoles, and both have APIs behind them. Both APIs need an admin key, not your normal API key, so keep that key out of your app and only use it in a reporting script.
Claude (Usage and Cost API):
curl "https://api.anthropic.com/v1/organizations/usage_report/messages?starting_at=2026-10-01T00:00:00Z&bucket_width=1d&group_by[]=model" \
-H "x-api-key: $ANTHROPIC_ADMIN_KEY" \
-H "anthropic-version: 2023-06-01"Results are split into uncached_input_tokens, cache_read_input_tokens, cache creation and output_tokens, so you can see your real cache hit rate. For dollars, call /v1/organizations/cost_report the same way. Data shows up within a few minutes of a request.
OpenAI (Usage API):
curl "https://api.openai.com/v1/organization/usage/completions?start_time=1759276800&bucket_width=1d&group_by=model" \
-H "Authorization: Bearer $OPENAI_ADMIN_KEY"start_time is a Unix timestamp. For dollars, use /v1/organization/costs.
Two tips that make these reports far more useful:
- Use a separate API key (or Claude workspace / OpenAI project) per app or feature. The reports can group by key, so you get per-feature numbers for free.
- Set spend limits in both consoles. Tracking tells you what happened. A limit stops the 3am runaway loop from happening in the first place.
08 / 09 · 4 min
Tracking a Whole Team
Everything above tells you what got spent. On a team account, the next question is always who. Here's the catch: API usage is attributed to the key that made the call. Claude's usage report groups by API key and workspace, not by person. OpenAI's can group by user, but a key shared by five people still lumps them together. So "who used what" is really a question of how you hand out keys.
Step 1: Give every person (or team) their own key
This is the whole trick. If five engineers share one API key, no report on earth can split their usage afterward.
| Claude | OpenAI | |
|---|---|---|
| Group people into | Workspaces (one per team or project) | Projects (one per team or project) |
| Per-person tracking | One API key per person, named after them | One API key per person; the usage report can also group by user_id |
| Spend limits | Per workspace | Per project |
Name keys so they're readable in a report: dana-laptop, ci-pipeline, support-bot-prod. Future you will thank present you.
Step 2: Group the org report by key
Claude: group by api_key_id (and workspace_id for team-level totals):
curl "https://api.anthropic.com/v1/organizations/usage_report/messages?starting_at=2026-10-01T00:00:00Z&bucket_width=1d&group_by[]=api_key_id&group_by[]=model" \
-H "x-api-key: $ANTHROPIC_ADMIN_KEY" \
-H "anthropic-version: 2023-06-01"You get key IDs back, not names. Map them to people with the Admin API's list-API-keys endpoint, or just keep a small lookup table. Two quirks: usage from the Console playground has no key (api_key_id is null), and the cost report only splits dollars by workspace, so for per-person dollars, multiply each key's tokens by the prices from earlier.
OpenAI: the usage endpoint can group by user directly, plus project and key:
curl "https://api.openai.com/v1/organization/usage/completions?start_time=1759276800&bucket_width=1d&group_by=user_id&group_by=project_id" \
-H "Authorization: Bearer $OPENAI_ADMIN_KEY"Step 3: Claude Code has its own per-person report
If your team uses Claude Code on the company's Console org, you don't need a key per person for that. The Claude Code Analytics API reports per user (by email) per day: sessions, lines of code, commits, PRs, tool accept rates, and estimated cost and tokens by model.
curl "https://api.anthropic.com/v1/organizations/usage_report/claude_code?starting_at=2026-10-21&limit=100" \
-H "x-api-key: $ANTHROPIC_ADMIN_KEY" \
-H "anthropic-version: 2023-06-01"Each request covers one day, so loop over dates for a weekly or monthly view. Each record has an actor that's either a person's email or an API key name.
Want it live instead of daily? Claude Code can export OpenTelemetry metrics, including claude_code.token.usage and claude_code.cost.usage, tagged with user.email. An admin can turn it on for everyone through the managed settings file so nobody has to configure their own machine:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://your-collector:4317",
"OTEL_RESOURCE_ATTRIBUTES": "team.id=platform,cost_center=eng-123"
}
}Point it at whatever you already use (Grafana, Datadog, Honeycomb) and you get a per-person, per-team dashboard. You do need a collector to send it to, so this is the "we already have observability" option, not the first step.
Step 4: If it's your app spending the tokens, log the user yourself
When the spender is your product (a support bot, an internal tool) rather than a person at a keyboard, keys can't tell you which customer or employee triggered a call. That's what the user field in the logger above is for. Pass whoever the request is on behalf of, then group your JSONL by user the same way the summary script groups by feature.
Seat plans are a different world
Subscription seats (Claude Team or Enterprise on claude.ai, ChatGPT Business or Enterprise) are billed per seat, separately from the API, so this post's API reports aren't where you'll find that usage. Check the workspace admin console for those. Claude Enterprise orgs that want this programmatically use a separate Enterprise Analytics API with its own key type; an Admin API key won't work there.
Which report answers which question
| Question | Where to look |
|---|---|
| What did the whole org spend? | Cost report (Claude) / Costs API (OpenAI) |
| Which team spent it? | Usage report grouped by workspace (Claude) or project (OpenAI) |
| Which person spent it? | Usage report grouped by API key (Claude) or user (OpenAI) |
| Who's burning tokens in Claude Code? | Claude Code Analytics API, or OpenTelemetry for live data |
| Which customer or feature in our app? | Your own log, tagged with user and feature |
One human note: tell your team you're doing this, and why. "We want to know which workflows are expensive so we can fix them" lands very differently than a surprise leaderboard. The goal is finding the runaway loop, not shaming the person who uses AI the most.
09 / 09 · 1 min
What to Watch For
Once you're logging, these are the patterns that actually cost money:
| Pattern | What it looks like in the log | Fix |
|---|---|---|
| Growing history | Input tokens climb every turn of a chat | Trim or summarize old turns |
| Cache never hits | cache_read stuck at 0 on repeated prompts |
Move timestamps and IDs out of the prefix |
| Reasoning blowup | Output huge, visible answer short | Lower the reasoning effort setting for simple tasks |
| Wrong model for the job | Classification calls running on the top model | Route simple calls to a smaller model |
| Retry storms | Same feature, many identical calls seconds apart | Fix the loop; add backoff and a max-retry cap |
Growing history is the big one for agents. Every turn resends everything before it, which is a big part of how OpenClaw users ended up with surprise bills.
References
- 01Token counting (Claude API docs)
- 02Prompt caching (Claude API docs)
- 03Usage and Cost API (Claude API docs)
- 04Claude Code Analytics API (Claude API docs)
- 05Monitoring usage (Claude Code docs)
- 06Counting tokens (OpenAI docs)
- 07Completions Usage API example (OpenAI Cookbook)
- 08Usage API: completions (OpenAI API reference)