Symptom
One or more of these:
- Your code counts 1,200 tokens with
tiktoken, but the response saysprompt_tokens: 1,950. - Cost dashboards built from your own counts come in well under the invoice.
- Azure OpenAI returns 429 even though your measured tokens per minute are under quota.
- Output token cost is far above what the visible answers could explain.
The tokens are real. Your counting just leaves some of them out. Use the API's usage as the source of truth, and find the gap with the checks below.
Quick checks
Log the full usage object for one request, not just total_tokens:
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
print(resp.usage.model_dump_json(indent=2))
Fields to look at (Chat Completions):
| Field | What it tells you |
|---|---|
usage.prompt_tokens | All input tokens, including message formatting and tool definitions |
usage.prompt_tokens_details.cached_tokens | Part of the input served from the prompt cache (billed at a lower rate) |
usage.completion_tokens | All output tokens, including hidden reasoning |
usage.completion_tokens_details.reasoning_tokens | Reasoning tokens you were billed for but never saw |
The Responses API uses input_tokens, output_tokens, input_tokens_details.cached_tokens and output_tokens_details.reasoning_tokens. Bedrock Converse returns usage.inputTokens, usage.outputTokens and, when prompt caching is on, cacheReadInputTokens and cacheWriteInputTokens.
Symptom → cause → fix
| Symptom | Cause | Fix |
|---|---|---|
| Input is 5–20 tokens higher than your count | Per-message formatting tokens | Add per-message overhead, or measure it |
| Input is hundreds or thousands higher, only when tools are passed | Tool/function definitions are injected into the prompt | Measure with and without tools; send only the tools the step needs |
| Count is off by 10–30% on every request | Wrong tokenizer for the model | Use the model's own encoding or tokenizer |
| Output far larger than the visible text | Reasoning tokens on o-series and other reasoning models | Read reasoning_tokens; lower reasoning effort; cap max_completion_tokens |
| No usage at all on streamed calls | Usage is not sent on streams by default | Set stream_options={"include_usage": True} |
| 429 while measured TPM is under quota | Azure OpenAI rate limiting counts an estimate that includes max_tokens | Set max_tokens close to the real need |
| Invoice lower than your token total | Cached input tokens are discounted | Price cached and uncached input separately |
| Images cost more than expected | Image input is charged per tile, based on size and detail level | Resize images; use low detail where it's enough |
Fix 1: use the right tokenizer
tiktoken only matches OpenAI-family models, and only when you pick the matching encoding. GPT-4o, GPT-4.1 and the o-series use o200k_base. GPT-4, GPT-3.5 Turbo and the text-embedding-3 models use cl100k_base.
import tiktoken
def encoding_for(model: str):
try:
return tiktoken.encoding_for_model(model)
except KeyError:
# Newer model names may not be in your tiktoken version yet
return tiktoken.get_encoding("o200k_base")
Upgrade tiktoken when you adopt a new model family; older releases don't know new model names.
For non-OpenAI models, tiktoken is only a rough guide:
Anthropic: call the token counting endpoint. It accepts the same
messages,systemandtoolsas a real call.import anthropic client = anthropic.Anthropic() r = client.messages.count_tokens(model="<model-id>", messages=messages, tools=tools) print(r.input_tokens)Amazon Bedrock: some models support a CountTokens API. Check your model in the Bedrock documentation; otherwise rely on
usagein the Converse response.Self-hosted models: use the model's own tokenizer and chat template, because the template adds role markers and special tokens.
from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("<org>/<model>") ids = tok.apply_chat_template(messages, tokenize=True, add_generation_prompt=True) print(len(ids))vLLM's OpenAI-compatible server also has a
/tokenizeendpoint that applies the served model's tokenizer:curl -s http://localhost:8000/tokenize \ -H "Content-Type: application/json" \ -d '{"model": "<served-model-name>", "prompt": "How do I restart the agent?"}'
Fix 2: account for message and tool overhead
Each chat message carries a few formatting tokens on top of its content. OpenAI's cookbook uses about 3 tokens per message plus 3 to prime the reply for current chat models. Treat this as an approximation that can change between models:
def estimate_chat_tokens(messages, model="gpt-4o-mini"):
enc = encoding_for(model)
n = 3 # reply priming
for m in messages:
n += 3 + len(enc.encode(m.get("content") or ""))
if "name" in m:
n += 1
return n
Tool definitions are the bigger gap. The exact format used to inject them isn't published, so measure the cost of your tool set directly:
base = client.chat.completions.create(model=m, messages=msgs, max_tokens=1)
with_tools = client.chat.completions.create(model=m, messages=msgs, tools=tools, max_tokens=1)
print("tool overhead:", with_tools.usage.prompt_tokens - base.usage.prompt_tokens)
Store that number per tool set and add it to your estimates. If it's large, send only the tools each step needs and shorten long descriptions and enum lists.
Fix 3: capture usage on streamed responses
Streamed Chat Completions don't include usage unless you ask. With the option on, the final chunk has an empty choices list and the usage object:
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
stream=True,
stream_options={"include_usage": True},
)
usage = None
for chunk in stream:
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="")
if chunk.usage:
usage = chunk.usage
Warning
If the client disconnects or your code stops reading the stream early, you never receive the final usage chunk, but the provider may still bill for tokens it generated. Don't treat "no usage logged" as "zero cost". Reconcile against provider metrics.
Fix 4: stop Azure OpenAI 429s caused by max_tokens
Azure OpenAI checks TPM limits against an estimate made when the request arrives. That estimate includes the prompt size and the max_tokens you request, not the tokens actually generated. A service that sends max_tokens: 4000 on every call but gets 200-token answers uses up the rate limit far faster than its real usage suggests.
Set max_tokens / max_completion_tokens per use case, from the 95th percentile of actual output plus a margin. For reasoning models, leave room for reasoning tokens, or the visible answer comes back empty.
Reconcile with provider metrics
Compare your logged totals with what the platform recorded for the same window.
Azure OpenAI (metric names vary by API version and resource type; list them first):
RID=$(az cognitiveservices account show -n <aoai-name> -g <rg> --query id -o tsv)
az monitor metrics list-definitions --resource $RID --query "[].name.value" -o tsv
az monitor metrics list --resource $RID \
--metric ProcessedPromptTokens GeneratedTokens \
--interval PT1H --aggregation Total \
--start-time 2026-10-30T00:00:00Z --end-time 2026-10-31T00:00:00Z -o table
Amazon Bedrock:
aws cloudwatch get-metric-statistics --namespace AWS/Bedrock \
--metric-name InputTokenCount \
--dimensions Name=ModelId,Value=<model-id> \
--start-time 2026-10-30T00:00:00Z --end-time 2026-10-31T00:00:00Z \
--period 3600 --statistics Sum
Run the same for OutputTokenCount. If the provider total is well above your logged total, look for calls that bypass your logging: retries inside the SDK, evaluation jobs, abandoned streams or another app using the same key.
Tip
SDK retries are billed calls. A request retried twice after timeouts can be billed three times while your app logs it once. Log usage per attempt, not per logical request.
Prevent it next time
- Log the full
usageobject, model name and caller on every attempt, including streams. - Use estimates only for pre-call budgeting and trimming. Use
usagefor cost reporting and chargeback. - Keep a measured overhead number for each tool set and system prompt, and re-measure after model upgrades.
- Price input, cached input, output and reasoning tokens as separate lines. Check current prices with your provider.
- Set
max_tokensper use case instead of one large default. - Reconcile your logs with provider metrics weekly and alert when they drift apart by more than a few percent.