Tokens and cost¶
Every call is counted. You can check the usage of a single call, of a block of code, or of the whole process, and group it by model or by your own tags.
Per call¶
Every result carries its own usage and cost:
res = az.structured(prompt, Invoice)
res.usage.input_tokens # 812 prompt tokens, including the schema
res.usage.cached_tokens # 0 part of input_tokens served from Azure's prompt cache
res.usage.output_tokens # 41 completion tokens, including reasoning tokens
res.usage.reasoning_tokens # 0 part of output_tokens spent on hidden reasoning (o-series, gpt-5)
res.usage.total_tokens # 853 input + output
res.cost # 0.00244 US dollars, or None if the model has no price
If the call needed validation retries, these numbers are the sum of all attempts.
The whole process: az.tracker¶
az.tracker is always on. It sees every call made through the module in the current process, from any thread
or task, including failed calls.
tracker: global — 2 calls, 0 errors
model calls input cached output reasoning cost($)
gpt-4o-2024-11-20 1 812 0 41 0 0.002440
gpt-4o-mini-2024-07-18 1 500 256 30 0 0.000074
TOTAL 2 1,312 256 71 0 0.002514
For use in code:
az.tracker.calls # 2
az.tracker.errors # 0
az.tracker.usage # Usage(input_tokens=1312, output_tokens=71, cached_tokens=256, reasoning_tokens=0)
az.tracker.cost # 0.0025138
az.tracker.last # the CallRecord of the most recent call
az.tracker.totals() # Stats(calls=2, errors=0, usage=..., cost=..., unpriced_calls=0, latency_s=...)
az.tracker.by_model() # {"gpt-4o-2024-11-20": Stats(...), "gpt-4o-mini-2024-07-18": Stats(...)}
az.tracker.by_tag() # {"invoices": Stats(...), "triage": Stats(...)}
az.tracker.reset() # start counting from zero
Group by tag¶
Pass tag= on your calls to label them by feature, customer, pipeline step, or experiment. Then group the
report by tag:
az.extract(invoice_text, Invoice, tag="invoices")
az.structured(ticket, Triage, tag="triage")
print(az.tracker.summary(by="tag"))
tracker: global — 2 calls, 0 errors
tag calls input cached output reasoning cost($)
invoices 1 812 0 41 0 0.002440
triage 1 500 256 30 0 0.000074
TOTAL 2 1,312 256 71 0 0.002514
Calls without a tag are listed as (none).
Track a block of code¶
az.track() counts only the calls made inside a with block:
with az.track("import-job") as t:
for doc in documents:
az.extract(doc, Invoice)
print(t.summary())
print(t.calls, t.usage.total_tokens, t.cost)
for rec in t.records: # every CallRecord from the block
print(rec.status, rec.latency_s, rec.cost_usd)
- It covers
structured_manyworker threads and asyncio tasks started inside the block. - Scopes can be nested. A call is counted by every enclosing scope and by the global tracker:
with az.track("pipeline") as whole:
with az.track("step-1") as step1:
az.structured(a, A)
with az.track("step-2") as step2:
az.structured(b, B)
# whole.calls == 2, step1.calls == 1, step2.calls == 1
Report on a job with several steps¶
A job often calls the model from several functions, each with its own prompt and often its own deployment. To see what one run of the job cost, and which step and which model the money went to:
- pass a
tag=for each step, and - run the job inside
az.track().
The functions don't need a tracker passed to them. Every call made inside the with block is counted, however deep
in the call stack it happens.
from typing import Literal
from pydantic import BaseModel, field_validator
import azure_openai_utils as az
class Invoice(BaseModel):
vendor: str
total: float
@field_validator("total")
@classmethod
def not_negative(cls, v: float) -> float:
if v < 0:
raise ValueError("total must be >= 0")
return v
class Summary(BaseModel):
summary: str
def classify(doc: str) -> str:
kind = Literal["invoice", "contract", "other"]
return az.structured(doc, kind, system="Classify the document.", deployment="gpt-5.4-mini", tag="classify").parsed
def extract(doc: str) -> Invoice:
return az.extract(doc, Invoice, deployment="gpt-5.4-mini", tag="extract").parsed
def summarize(docs: list[str]) -> str:
return az.structured("\n\n".join(docs), Summary, deployment="gpt-5.4", tag="summarize").parsed.summary
def process(docs: list[str]) -> tuple[list[Invoice], str]:
invoices = [extract(doc) for doc in docs if classify(doc) == "invoice"]
return invoices, summarize(docs)
with az.track("process-batch") as t:
invoices, summary = process(docs)
print(t.summary()) # grouped by model
print(t.summary(by="tag")) # grouped by step
With two invoices and one contract in docs:
tracker: process-batch — 6 calls, 0 errors
model calls input cached output reasoning cost($)
gpt-5.4-2026-03-05 1 6,200 0 820 512 0.027800
gpt-5.4-mini-2026-03-17 5 13,310 3,072 272 0 0.009133
TOTAL 6 19,510 3,072 1,092 512 0.036933
tracker: process-batch — 6 calls, 0 errors
tag calls input cached output reasoning cost($)
classify 3 3,450 3,072 36 0 0.000676
extract 2 9,860 0 236 0 0.008457
summarize 1 6,200 0 820 512 0.027800
TOTAL 6 19,510 3,072 1,092 512 0.036933
The two tables are different views of the same six calls:
- By model:
gpt-5.4-minimade 5 of the 6 calls, but the singlegpt-5.4call cost three times as much as all of them together. - By step:
extractcost 12 times as much asclassifyon the same model. Its prompts are longer, and each extraction needed one validation retry, whose tokens are included.classifywas cheap because 1,024 of each prompt's 1,150 input tokens came from the prompt cache. - A retry counts toward that call's
attempts, not as an extra call, socallsis the number of calls your code made.
One track() block per job
az.tracker counts everything since the process started. In a process that runs many jobs, such as a web
server or a queue worker, wrap each job in its own track() block so every report starts from zero. If the
job's steps run on threads you start yourself, see Using your own threads.
Save the report as JSON¶
summary() is for reading. To keep the numbers in a file, object storage or a database, build a dict from the
same tracker:
import json
import uuid
from dataclasses import asdict
def stats_dict(s: az.Stats) -> dict:
return {
"calls": s.calls,
"errors": s.errors,
"cost_usd": round(s.cost, 8),
"unpriced_calls": s.unpriced_calls,
**asdict(s.usage),
"latency_s": round(s.latency_s, 3),
}
def job_report(t: az.UsageTracker) -> dict:
return {
"job": t.name,
"total": stats_dict(t.totals()),
"by_model": {model: stats_dict(s) for model, s in t.by_model().items()},
"by_tag": {tag or "(none)": stats_dict(s) for tag, s in t.by_tag().items()},
"calls": [rec.to_dict(content=False) for rec in t.records], # prompts and outputs left out
}
job_id = uuid.uuid4().hex
with az.track("process-batch") as t:
try:
invoices, summary = process(docs)
finally: # a job that fails part-way still gets a report
with open(f"report-{job_id}.json", "w", encoding="utf-8") as f:
json.dump(job_report(t), f, indent=2)
The report for the run above, shortened so each group fits on one line:
{
"job": "process-batch",
"total": {"calls": 6, "errors": 0, "cost_usd": 0.0369329, "unpriced_calls": 0, "input_tokens": 19510, "output_tokens": 1092, "cached_tokens": 3072, "reasoning_tokens": 512, "latency_s": 5.254},
"by_model": {
"gpt-5.4-mini-2026-03-17": {"calls": 5, "errors": 0, "cost_usd": 0.0091329, "unpriced_calls": 0, "input_tokens": 13310, "output_tokens": 272, "cached_tokens": 3072, "reasoning_tokens": 0, "latency_s": 3.153},
"gpt-5.4-2026-03-05": {"calls": 1, "errors": 0, "cost_usd": 0.0278, "unpriced_calls": 0, "input_tokens": 6200, "output_tokens": 820, "cached_tokens": 0, "reasoning_tokens": 512, "latency_s": 2.101}
},
"by_tag": {
"classify": {"calls": 3, "errors": 0, "cost_usd": 0.0006759, "unpriced_calls": 0, "input_tokens": 3450, "output_tokens": 36, "cached_tokens": 3072, "reasoning_tokens": 0, "latency_s": 1.351},
"extract": {"calls": 2, "errors": 0, "cost_usd": 0.008457, "unpriced_calls": 0, "input_tokens": 9860, "output_tokens": 236, "cached_tokens": 0, "reasoning_tokens": 0, "latency_s": 1.802},
"summarize": {"calls": 1, "errors": 0, "cost_usd": 0.0278, "unpriced_calls": 0, "input_tokens": 6200, "output_tokens": 820, "cached_tokens": 0, "reasoning_tokens": 512, "latency_s": 2.101}
},
"calls": [
// one entry per call (6 here); only the first `extract` call is shown
{
"id": "c4e736cc00ce4982a0f17d89f4c400e5",
"ts": "2026-09-26T14:25:25.167Z",
"fn": "extract",
"deployment": "gpt-5.4-mini",
"model": "gpt-5.4-mini-2026-03-17",
"schema": "Invoice",
"mode": "strict",
"tag": "extract",
"status": "ok",
"errors": ["total: Value error, total must be >= 0"],
"attempts": 2,
"usage": {"input_tokens": 4930, "output_tokens": 118, "cached_tokens": 0, "reasoning_tokens": 0},
"cost_usd": 0.0042285,
"latency_s": 0.901,
"metadata": {}
}
]
}
by_modelandby_taghold the same numbers as the two tables.callsholds everyCallRecordfrom the block, so it shows which model each step used and which calls were retried (attempts,errors).- Failed and cancelled calls are in the report too, with their
statusand the tokens they used, and they count towarderrors. Thefinallyblock writes the report even when the job is interrupted. cost_usdleaves out calls that have no price, andunpriced_callssays how many there were. See Set your own prices.- Drop
content=Falseto keep each call's prompt and output in the report.
Pricing¶
Cost is calculated from the model that Azure reports for each response, such as gpt-4o-2024-11-20.
The module strips the date and looks the model up in az.PRICING. The built-in table uses OpenAI's list prices
(USD per 1M tokens), which Azure Global Standard deployments follow:
| Model | Input | Cached input | Output |
|---|---|---|---|
gpt-5.4 |
2.50 | 0.25 | 15.00 |
gpt-5.4-mini |
0.75 | 0.075 | 4.50 |
gpt-5.4-nano |
0.20 | 0.02 | 1.25 |
gpt-5.2 |
1.75 | 0.175 | 14.00 |
gpt-5.1 |
1.25 | 0.125 | 10.00 |
gpt-5 |
1.25 | 0.125 | 10.00 |
gpt-5-mini |
0.25 | 0.025 | 2.00 |
gpt-5-nano |
0.05 | 0.005 | 0.40 |
gpt-4.1 |
2.00 | 0.50 | 8.00 |
gpt-4.1-mini |
0.40 | 0.10 | 1.60 |
gpt-4.1-nano |
0.10 | 0.025 | 0.40 |
gpt-4o |
2.50 | 1.25 | 10.00 |
gpt-4o-mini |
0.15 | 0.075 | 0.60 |
o3 |
2.00 | 0.50 | 8.00 |
o3-mini |
1.10 | 0.55 | 4.40 |
o4-mini |
1.10 | 0.275 | 4.40 |
Cached input tokens are billed at the cached rate. Reasoning tokens are already included in output tokens, so they aren't charged twice.
gpt-5.4 has a 1.05M-token context window, and a call with more than 272K input tokens is billed at the
long-context rate for the whole call: 5.00 input, 0.50 cached input and 22.50 output. The module applies this
automatically. gpt-5.4-mini and gpt-5.4-nano accept at most 272K input tokens, so they have a single rate.
gpt-5.4-pro isn't in the table because it only works with the Responses API, and this module calls Chat Completions.
Set your own prices¶
Data Zone and Regional deployments cost more than Global Standard, and new models may be missing from the
table. Use set_price for either case:
# Price a specific deployment. This takes precedence over the model's price.
az.set_price("my-eu-gpt-4o", input=2.75, output=11.00, cached_input=1.375)
# Add a model the table doesn't know
az.set_price("gpt-6-preview", input=3.00, output=15.00)
set_price sets a single rate. To keep a long-context rate on a deployment, assign a Price yourself:
az.PRICING["my-eu-gpt-5.4"] = az.Price(2.75, 16.50, 0.275, long_context=az.Price(5.50, 24.75, 0.55))
The lookup order is:
- an entry for the deployment name (so you can price each deployment separately),
- the exact model name Azure reported,
- that model name without its trailing
-YYYY-MM-DD.
Unknown models
If none of those match, the call's cost is None and a warning is logged once for that model. The
tracker still counts its tokens, and its summary marks the cost column with *:
text * 3 call(s) have no price and are left out of cost; see set_price()
Your own tracker¶
UsageTracker is a public class. For a running total that you can read and reset independently of
az.tracker, create one and feed it from a hook: