Meter OpenAI Agents SDK tool calls and tokens per customer
Hook into an OpenAI Agents SDK run, record every model call's input and output tokens and every tool call against the customer it ran for, and send them to Usage Lab with keys that make retries count once. A sibling of the Vercel AI SDK, OpenAI and Anthropic guide.
The hooks (checked Oct 25, 2026)
RunHooks: subclass it and pass an instance toRunner.run(agent, input, context=..., hooks=...). Per-agentAgentHooksonagent.hookshave the same tool and LLM callbacks.on_llm_end(context, agent, response): called right after each model call.responseis aModelResponsewithusage(input_tokens,output_tokens) andresponse_id(not supported by every provider).on_tool_end(context, agent, tool, result): called right after a local tool runs. For function tools,contextis typically aToolContextwithtool_call_id,tool_nameandtool_arguments; other tool families may pass a plainRunContextWrapper.context.usagealso holds the running totals for the whole run (result.context_wrapper.usageafter it ends).
Sources: Agents SDK reference: lifecycle (RunHooks, AgentHooks), reference: items (ModelResponse), Usage guide. The wiring below was also checked against the openai-agents 0.24.0 package from PyPI.
Deterministic idempotency keys
Every Usage Lab event needs an idempotency key, and Usage Lab never makes one up for you. Build keys from things a retry repeats:
- Tokens:
run_id : response_id : inandrun_id : response_id : out, one event each tollm_input_tokensandllm_output_tokens(sum meters), so you can price input and output differently. - Tool calls:
run_id : tool : tool_call_id, one event toagent_tool_calls(a count meter).
Hash with SHA-256 (keys can be up to 255 characters). Save run_id with your job so a retried job resends the same keys: they come back as duplicate and count once. The same key with a different body returns 409 and keeps the original. Each event is tagged traffic_class: ai_agent. Agent calls are real, billable usage, and we never filter them.
1. The metering helper (runs standalone)
No OpenAI key needed. It creates the three meters (the Free plan's 3), replays one agent run for cust_acme (two model calls, two tool calls) exactly as the hooks would report it, sends it twice to show the retry counts once, and prints this month's totals. It needs only Python's standard library and USAGE_LAB_KEY (a free key from /signup/). Save it as usage_lab_agents_meter.py and run python3 usage_lab_agents_meter.py.
# usage_lab_agents_meter.py: Python 3.8+, standard library only. Needs USAGE_LAB_KEY in the environment.
# Metering helper for OpenAI Agents SDK runs: tool calls + input/output tokens per customer.
# Runs standalone (no OpenAI key needed): the bottom half replays what the RunHooks would report.
import hashlib, json, os, sys, urllib.error, urllib.request
from datetime import datetime, timezone
API = "https://usage-lab.pages.dev/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["USAGE_LAB_KEY"], "Content-Type": "application/json",
"User-Agent": "usage-lab-quickstart/1.0"} # any descriptive User-Agent; see the note below the sample
METERS = {"agent_tool_calls": "count", "llm_input_tokens": "sum", "llm_output_tokens": "sum"}
def call(method, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(API + path, data=data, headers=HEADERS, method=method)
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.load(e)
def key(*parts):
# Deterministic: the same run + model call / tool call always gives the same key, so retries count once.
return "oa_" + hashlib.sha256(":".join(str(p) for p in parts).encode()).hexdigest()[:40]
class UsageLabMeter:
"""Collects events from hook callbacks for one run, then flushes them in batches of up to 100."""
def __init__(self, customer, run_id):
self.customer, self.run_id, self.events, self.llm_calls = customer, run_id, [], 0
def llm_end(self, response_id, input_tokens, output_tokens, agent_name):
self.llm_calls += 1
rid = response_id or f"llm{self.llm_calls}" # response_id is None on some providers
props = {"agent": agent_name, "traffic_class": "ai_agent"}
for meter, n, kind in (("llm_input_tokens", input_tokens, "in"), ("llm_output_tokens", output_tokens, "out")):
self.events.append({"meter": meter, "subscription": self.customer, "value": n,
"idempotency_key": key(self.run_id, rid, kind), "properties": props})
def tool_end(self, tool_call_id, tool_name, agent_name):
self.events.append({"meter": "agent_tool_calls", "subscription": self.customer,
"idempotency_key": key(self.run_id, "tool", tool_call_id),
"properties": {"tool": tool_name, "agent": agent_name, "traffic_class": "ai_agent"}})
def flush(self):
sent, self.events = self.events, []
for i in range(0, len(sent), 100):
status, body = call("POST", "/usage", {"events": sent[i:i + 100]})
if status >= 400:
raise RuntimeError(f"usage: HTTP {status} {body}")
yield body["summary"]
def ensure_meters():
for k, agg in METERS.items():
status, body = call("POST", "/meters", {"key": k, "aggregation": agg, "name": k})
if status not in (201, 409): # 409 meter_exists on a re-run is fine
sys.exit(f"create meter {k}: HTTP {status} {body}")
if __name__ == "__main__":
ensure_meters()
month = datetime.now(timezone.utc).strftime("%Y-%m")
run_id = f"run_{month}_c41d"
# Replay of one agent run for customer cust_acme: 2 model calls and 2 function tool calls,
# exactly what on_llm_end / on_tool_end receive (response.usage, context.tool_call_id).
for attempt in ("first send", "retry"):
m = UsageLabMeter("cust_acme", run_id)
m.llm_end("resp_demo_001", 820, 64, "Support agent")
m.tool_end("call_demo_a1", "lookup_order", "Support agent")
m.tool_end("call_demo_a2", "refund_order", "Support agent")
m.llm_end("resp_demo_002", 1130, 210, "Support agent")
for summary in m.flush():
print(attempt, summary)
for k in METERS:
status, body = call("GET", f"/meters/{k}/rollups?month={month}")
if status != 200:
sys.exit(f"rollups {k}: HTTP {status} {body}")
for r in body["rollups"]:
print(k, r["subscription"], r["quantity"])
Example output, not a command.
first send {'created': 6, 'duplicate': 0, 'conflict': 0}
retry {'created': 0, 'duplicate': 6, 'conflict': 0}
agent_tool_calls cust_acme 2
llm_input_tokens cust_acme 1950
llm_output_tokens cust_acme 274
User-Agent note: the Cloudflare edge in front of *.pages.dev rejects Python's default Python-urllib User-Agent (error 1010) before the request reaches us, so the helper sets usage-lab-quickstart/1.0. Any descriptive User-Agent works, including AI agents, bots and scripts, which are welcome.
2. Wire it into the Agents SDK
This part needs pip install openai-agents and an OPENAI_API_KEY, so our docs test doesn't run it against OpenAI. We did run it against openai-agents 0.24.0 with a stand-in model: the hooks reported both model calls (response ids and token counts) and the tool call's tool_call_id. The helper sends events in flush() after the run, so metering never slows the agent down, and the finally meters what ran even if the run fails part-way.
SDK wiring: needs openai-agents and OPENAI_API_KEY, so it is not run by our docs test. Save as agent_app.py next to the helper.
# agent_app.py: SDK wiring. Needs `pip install openai-agents` and OPENAI_API_KEY, plus the helper above
# saved as usage_lab_agents_meter.py next to this file. Checked against openai-agents 0.24.0.
import asyncio, uuid
from dataclasses import dataclass
from agents import Agent, RunContextWrapper, RunHooks, Runner, function_tool
from usage_lab_agents_meter import UsageLabMeter, ensure_meters
@dataclass
class Ctx:
customer: str # your customer id -> Usage Lab subscription
class MeterHooks(RunHooks[Ctx]):
def __init__(self, meter):
self.meter, self.tools = meter, 0
async def on_llm_end(self, context, agent, response):
u = response.usage # ModelResponse.usage: input_tokens, output_tokens
self.meter.llm_end(response.response_id, u.input_tokens, u.output_tokens, agent.name)
async def on_tool_end(self, context, agent, tool, result):
# For function tools, context is a ToolContext with tool_call_id and tool_name
self.tools += 1 # fallback step number for tool families without a tool_call_id
call_id = getattr(context, "tool_call_id", None) or f"step{self.tools}:{tool.name}"
self.meter.tool_end(call_id, getattr(context, "tool_name", tool.name), agent.name)
@function_tool
def lookup_order(order_id: str) -> str:
return f"Order {order_id}: shipped"
async def main():
ensure_meters()
run_id = "run_" + uuid.uuid4().hex # store it with the job so a retried job reuses it
meter = UsageLabMeter("cust_acme", run_id)
agent = Agent(name="Support agent", instructions="Help with orders.", tools=[lookup_order])
try:
result = await Runner.run(agent, "Where is order 42?", context=Ctx("cust_acme"), hooks=MeterHooks(meter))
print(result.final_output)
finally:
for summary in meter.flush(): # meter what ran, even if the run failed part-way
print(summary)
asyncio.run(main())
3. Per-customer totals
GET /api/v1/meters/agent_tool_calls/rollups?month=YYYY-MM (and the same for each token meter) returns each customer's monthly quantity and event count, recomputed from the raw events. Push them to Stripe with Stripe push (beta, Pro and Ultra), or read them into your own billing. More: metering AI agent and MCP tool calls, idempotent usage events.
FAQ
Which OpenAI Agents SDK hooks should I meter from?
Subclass RunHooks and pass it to Runner.run(..., hooks=...). on_llm_end(context, agent, response) runs after every model call and gives you response.usage (input_tokens, output_tokens) and response.response_id. on_tool_end(context, agent, tool, result) runs after each local tool call; for function tools the context is a ToolContext with tool_call_id and tool_name. Checked against the official SDK docs and openai-agents 0.24.0 on Oct 25, 2026.
How are the idempotency keys built?
Deterministically from your run id plus the model call's response_id (one key for input tokens, one for output) or the tool call's tool_call_id, hashed with SHA-256. Store the run id with the job, so a retried job resends the same keys and Usage Lab counts each call once.
Do I need an OpenAI key to try this?
No. The metering helper runs on its own with only Python's standard library and a free Usage Lab key: it replays what the hooks report for one run. The SDK wiring needs openai-agents and an OpenAI API key.
Do agent calls count as real usage?
Yes. Every model call and tool call an agent makes for a customer is real, billable usage, tagged traffic_class ai_agent. Usage Lab never blocks or filters agent or bot traffic.
Why not just read result.context_wrapper.usage at the end?
That total is fine for one number per run, but it's lost if the run fails part-way and it can't tell tool calls apart. Per-call hooks with per-call keys meter exactly what ran and stay retry-safe.
What does Usage Lab cost?
Free: CA$0 for 100,000 events and 3 meters a month. Pro: CA$12 a month for 2M events and 10 meters. Ultra: CA$36 a month for 20M events and 100 meters. Flat prices, never a percentage of your billings.
- Playground: validate a usage event in your browser
- Usage event validator: check event JSON and idempotency keys
- Drop-in LLM metering helper + margin calculator
- Usage-based pricing calculator (LLM tokens or events)
- Live end-to-end demo
- API docs
- Quickstart: meter an AI app
- Meter Vercel AI SDK, OpenAI and Anthropic tokens (input + output)
- Code examples
- Usage metering compared: Usage Lab vs Stripe meters, Orb, Lago, Metronome, OpenMeter
- OpenMeter alternative: Usage Lab vs OpenMeter (Kong)
- Lago alternative: Usage Lab vs Lago
- Amberflo alternative: Usage Lab vs Amberflo
- Metering usage for Stripe usage-based billing
- Deduping usage events with idempotency keys
- Backfilling late usage events: replays, late windows and adjustments
- Usage Lab vs UsageBox: an honest comparison
- Metronome and Orb alternatives for small SaaS teams
- How to meter LLM token usage per customer for billing
- How to meter AI agent and MCP tool calls per customer
- Stripe Billing Meters vs a usage ledger: when you need both