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.

How this was tested: every example on this page ran end to end on 2026-10-17 against the live Usage Lab API, using the AI SDK's official mock model (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 });

Field names at a glance

SourceInput tokensOutput tokensIdempotency id
Vercel AI SDK v5, v6, v7usage.inputTokensusage.outputTokensresponse.id
Vercel AI SDK v4 and olderusage.promptTokensusage.completionTokensresponse.id
OpenAI Chat Completionsusage.prompt_tokensusage.completion_tokensid (chatcmpl-…)
OpenAI Responses APIusage.input_tokensusage.output_tokensid (resp_…)
Anthropic Messagesusage.input_tokensusage.output_tokensid (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

// 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

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:

Next: API docs · get a free key · push monthly totals to Stripe