Meter Vercel AI SDK, OpenAI and Anthropic token usage
Record input and output tokens for every model call, per customer, without slowing down or breaking the response. The provider's response id becomes the idempotency key, so a retry never counts twice.
MockLanguageModelV4 from ai/test, ai 7.0.143) and response fixtures shaped like OpenAI's and Anthropic's. No real model was called. Field names follow the providers' current docs; if one changes, its adapter is a one-line fix.Already covered elsewhere: new to Usage Lab? The AI app quickstart records one total in llm_tokens with curl or Node. Prefer a wrapper? usage-lab.js wraps any call. Deciding what to bill? Read metering LLM tokens per customer. This page adds what those don't cover: separate input and output counts, the AI SDK callbacks, the OpenAI Responses API, and edge runtimes.
1. Create two meters (once)
for m in llm_input_tokens llm_output_tokens; do
curl -X POST https://usage-lab.pages.dev/api/v1/meters \
-H "Authorization: Bearer $USAGE_LAB_KEY" -H "Content-Type: application/json" \
-d "{\"key\":\"$m\",\"aggregation\":\"sum\"}"
done
# 201 each time (409 meter_exists if you already made it)
Why two meters: rollups give one total per meter per customer, and providers price input and output tokens differently. Need a single number as well? Add the two totals together, or keep an llm_tokens meter alongside. The free plan includes 3 meters.
2. Add the helper
One file, no dependencies, MIT: usage-lab-tokens.mjs. Runs in Node 18+, Vercel (Node and Edge), Cloudflare Workers, Bun and Deno. Server-side only: keep your API key out of the browser.
/* Usage Lab: record LLM input and output tokens per customer. MIT license. No dependencies.
* Works in Node 18+, Vercel/Next.js (Node and Edge), Cloudflare Workers, Bun and Deno.
* meterTokens() never throws and never blocks: it returns a promise that resolves to the
* API response body, or null on any error or timeout. Don't await it in your request path;
* hand it to waitUntil() (or after()) on serverless/edge so the send can finish.
* Docs: https://usage-lab.pages.dev/docs/vercel-ai-sdk/
*/
export const USAGE_LAB_API = "https://usage-lab.pages.dev/api/v1";
export function meterTokens({ apiKey, customer, responseId, inputTokens = 0, outputTokens = 0,
timeoutMs = 2000, baseUrl = USAGE_LAB_API, fetch: doFetch = globalThis.fetch }) {
try {
if (!apiKey || !customer || !responseId) return Promise.resolve(null);
const events = [];
// One event per meter. The key is derived from the provider's response id, so a retry
// of the same response is deduplicated instead of counted twice.
if (inputTokens > 0) events.push({ meter: "llm_input_tokens", subscription: customer, value: inputTokens, idempotency_key: `${responseId}:input` });
if (outputTokens > 0) events.push({ meter: "llm_output_tokens", subscription: customer, value: outputTokens, idempotency_key: `${responseId}:output` });
if (events.length === 0) return Promise.resolve(null);
return doFetch(`${baseUrl}/usage`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ events }),
signal: AbortSignal.timeout(timeoutMs),
})
.then((res) => (res.ok ? res.json() : (console.warn(`usage-lab: HTTP ${res.status}`), null)))
.catch((err) => (console.warn(`usage-lab: send failed (${err && err.name})`), null));
} catch (err) {
return Promise.resolve(null);
}
}
// Adapters: map each provider's response to { responseId, inputTokens, outputTokens }.
// Vercel AI SDK (v5+): onEnd / onFinish event, or the generateText result. totalUsage covers every step.
export const fromAiSdk = ({ totalUsage, usage, response }) => {
const u = totalUsage || usage || {};
return { responseId: response && response.id, inputTokens: u.inputTokens || 0, outputTokens: u.outputTokens || 0 };
};
// OpenAI Chat Completions: usage.prompt_tokens / usage.completion_tokens, id "chatcmpl-...".
export const fromOpenAIChat = (c) => ({ responseId: c.id, inputTokens: (c.usage && c.usage.prompt_tokens) || 0, outputTokens: (c.usage && c.usage.completion_tokens) || 0 });
// OpenAI Responses API: usage.input_tokens / usage.output_tokens, id "resp_...".
export const fromOpenAIResponses = (r) => ({ responseId: r.id, inputTokens: (r.usage && r.usage.input_tokens) || 0, outputTokens: (r.usage && r.usage.output_tokens) || 0 });
// Anthropic Messages: usage.input_tokens / usage.output_tokens, id "msg_...".
// input_tokens excludes prompt-cache tokens (cache_creation_input_tokens, cache_read_input_tokens); add them if you bill for them.
export const fromAnthropic = (m) => ({ responseId: m.id, inputTokens: (m.usage && m.usage.input_tokens) || 0, outputTokens: (m.usage && m.usage.output_tokens) || 0 });
- One HTTP request per model call carries both events.
- The keys are
<response id>:inputand<response id>:output, so they're deterministic: send the same response twice and the second send comes back"duplicate". - It never throws. Any error or the 2-second timeout resolves to
nulland logs one warning line (never your key).
Field names at a glance
| Source | Input tokens | Output tokens | Idempotency id |
|---|---|---|---|
| Vercel AI SDK v5, v6, v7 | usage.inputTokens | usage.outputTokens | response.id |
| Vercel AI SDK v4 and older | usage.promptTokens | usage.completionTokens | response.id |
| OpenAI Chat Completions | usage.prompt_tokens | usage.completion_tokens | id (chatcmpl-…) |
| OpenAI Responses API | usage.input_tokens | usage.output_tokens | id (resp_…) |
| Anthropic Messages | usage.input_tokens | usage.output_tokens | id (msg_…) |
The AI SDK also reports totalTokens, inputTokenDetails.cacheReadTokens and outputTokenDetails.reasoningTokens; reasoning tokens are already counted in outputTokens. Anthropic's input_tokens doesn't include prompt-cache tokens (cache_creation_input_tokens, cache_read_input_tokens), so add them if you bill for them.
Vercel AI SDK
Meter from the callback that runs when generation ends. In AI SDK v7 it's onEnd; in v5 and v6 it's onFinish, which v7 still accepts as a deprecated alias. Use totalUsage: it covers every step of a multi-step (tool-calling) run. usage in the callback covers only the last step. If you're still on v4, the fields were promptTokens/completionTokens; map them yourself or upgrade.
// app/api/chat/route.ts (Next.js on Vercel, Node or Edge runtime)
import { streamText } from "ai";
import { waitUntil } from "@vercel/functions";
import { meterTokens, fromAiSdk } from "@/lib/usage-lab-tokens.mjs";
export async function POST(req: Request) {
const { prompt } = await req.json();
const customerId = await getCustomerId(req); // from your session, never from the request body
const result = streamText({
model: yourModel, // any AI SDK model or gateway model id
prompt,
// AI SDK v7 calls this onEnd. On v5 and v6 the same callback is called onFinish
// (v7 still accepts onFinish as a deprecated alias).
onEnd(event) {
// event.totalUsage = every step (tool calls included); event.usage = last step only
waitUntil(meterTokens({ apiKey: process.env.USAGE_LAB_KEY, customer: customerId, ...fromAiSdk(event) }));
},
});
return result.toTextStreamResponse();
}
generateText works the same way without a callback:
import { generateText } from "ai";
import { meterTokens, fromAiSdk } from "./usage-lab-tokens.mjs";
const result = await generateText({ model: yourModel, prompt });
// fromAiSdk reads result.totalUsage (v5/v6) or result.usage (v7: already the sum of all steps)
// plus result.response.id. Not awaited: the user gets their answer either way.
meterTokens({ apiKey: process.env.USAGE_LAB_KEY, customer: "cus_123", ...fromAiSdk(result) });
OpenAI (Chat Completions and Responses API)
import OpenAI from "openai";
import { meterTokens, fromOpenAIChat, fromOpenAIResponses } from "./usage-lab-tokens.mjs";
const openai = new OpenAI();
const key = process.env.USAGE_LAB_KEY;
// Chat Completions: usage.prompt_tokens / usage.completion_tokens, id "chatcmpl-..."
const completion = await openai.chat.completions.create({ model, messages });
meterTokens({ apiKey: key, customer: "cus_123", ...fromOpenAIChat(completion) });
// Responses API: usage.input_tokens / usage.output_tokens, id "resp_..."
const response = await openai.responses.create({ model, input: "Summarize this ticket" });
meterTokens({ apiKey: key, customer: "cus_123", ...fromOpenAIResponses(response) });
Streaming Chat Completions: set stream_options: { include_usage: true }. The last chunk carries usage and every chunk carries the same id, so call fromOpenAIChat({ id: chunk.id, usage: chunk.usage }) on that chunk. Streaming Responses: meter from the response.completed event's response.
Anthropic
import Anthropic from "@anthropic-ai/sdk";
import { meterTokens, fromAnthropic } from "./usage-lab-tokens.mjs";
const anthropic = new Anthropic();
// Messages API: usage.input_tokens / usage.output_tokens, id "msg_..."
const message = await anthropic.messages.create({ model, max_tokens: 1024, messages });
meterTokens({ apiKey: process.env.USAGE_LAB_KEY, customer: "cus_123", ...fromAnthropic(message) });
// Streaming: meter the final message once the stream ends
// const stream = anthropic.messages.stream({ ... });
// stream.finalMessage().then((m) => meterTokens({ apiKey, customer, ...fromAnthropic(m) }));
Never block or break the response
- Don't
awaitthe send in your request path. The helper resolves tonullon failure and never throws, so a metering outage can't break a chat. - The fetch has a short timeout (
AbortSignal.timeout, 2 s by default; change it withtimeoutMs). - Serverless and edge: the runtime can freeze as soon as your response is sent, which would cut the send off. Hand the promise to
waitUntil:waitUntilfrom@vercel/functions(or Next.jsafter()),ctx.waitUntilin Cloudflare Workers,context.waitUntilin Pages Functions. On a long-running Node server, plain fire-and-forget is enough. - Nothing retries a failed send automatically. If every token must be billed, also write
(response id, tokens)to your own table or queue and replay it later; the same keys make replays safe. See backfilling late events.
// Cloudflare Worker: ctx.waitUntil keeps the send alive after the response is returned
export default {
async fetch(request, env, ctx) {
const message = await callYourModel(request, env);
ctx.waitUntil(meterTokens({ apiKey: env.USAGE_LAB_KEY, customer: "cus_123", ...fromAnthropic(message) }));
return Response.json({ text: message.content[0].text });
},
};
What gets sent
# One request per model call: both events, keys derived from the provider response id
curl -X POST https://usage-lab.pages.dev/api/v1/usage \
-H "Authorization: Bearer $USAGE_LAB_KEY" -H "Content-Type: application/json" \
-d '{"events":[
{"meter":"llm_input_tokens","subscription":"cus_123","value":1200,"idempotency_key":"chatcmpl-abc123:input"},
{"meter":"llm_output_tokens","subscription":"cus_123","value":350,"idempotency_key":"chatcmpl-abc123:output"}]}'
# 200 with a result per event: "status": "created" the first time, "duplicate" on a retry
- Keys are unique per Usage Lab account, permanently. Same key with the same payload is a
duplicate; same key with a different value is409 idempotency_conflict. More in idempotency keys. - Don't add a timestamp that changes between attempts. Leave it out (we use the time we receive the event), or set it once from the response.
- If there's no response id, the helper skips the send rather than invent a key. If your provider doesn't return ids, generate one per request before you call the model and pass it as
responseIdon every attempt.
Read the totals
curl "https://usage-lab.pages.dev/api/v1/meters/llm_input_tokens/rollups?month=2026-10&subscription=cus_123" -H "Authorization: Bearer $USAGE_LAB_KEY"
curl "https://usage-lab.pages.dev/api/v1/meters/llm_output_tokens/rollups?month=2026-10&subscription=cus_123" -H "Authorization: Bearer $USAGE_LAB_KEY"
# { "meter": { "key": "llm_input_tokens", ... }, "month": "2026-10", "total_quantity": 3200,
# "rollups": [{ "subscription": "cus_123", "quantity": 3200, "event_count": 5, ... }] }
What we tested
The test script ran against the live API on 2026-10-17 with a temporary account, which it deleted afterwards. It passed all 9 checks:
generateTextandstreamTextwithMockLanguageModelV4returning realistic usage (1,200 in / 350 out with 200 cached; 800 in / 150 out), metered fromonEnd.- OpenAI Chat Completions, OpenAI Responses and Anthropic fixtures: 10 events created. The rollups showed exactly
llm_input_tokens = 3200andllm_output_tokens = 785. - Re-sending the same response ids returned
"duplicate"and the totals didn't change. - A hanging metering endpoint timed out to
null. The mock AI response returned in about 1 ms anyway. A wrong API key (401), a fetch that throws, and a missing response id all resolved tonullwithout throwing.
Next: API docs · get a free key · push monthly totals to Stripe
- 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
- Code examples
- Usage metering compared: Usage Lab vs Stripe meters, Orb, Lago, Metronome
- 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
- Stripe Billing Meters vs a usage ledger: when you need both