@usagetap/sdk
v1.7.0
Published
UsageTap SDK core client plus optional React helpers.
Readme
@usagetap/sdk
Server-only JavaScript/TypeScript client for UsageTap. The SDK helps you instrument call_begin → vendor call → call_end flows with built-in retries, idempotency helpers, and vendor adapters.
For API requests and MCP tool calls, use the dedicated call meter without model or token fields:
await client.incrementCustomMeter(
{ customerId, meterSlot: "AGENTIC_API", amount: 1, feature: "mcp.tools.call" },
{ idempotencyKey: `mcp:${requestId}` },
);Configure its included allocation and single-rate or graduated overage pricing in More > Agentic & API on the usage plan. It uses the same reporting, credit funding, and asynchronous PAYG settlement as custom meters. This does not reserve credit or impose a rolling rate limit. See Agentic/API integration and limits.
Module formats
@usagetap/sdk ships real dual ESM (.mjs) and CommonJS (.cjs) entrypoints. In ESM projects use import { UsageTapClient } from "@usagetap/sdk";. For CommonJS runtimes (including VS Code extensions) rely on const { UsageTapClient } = require("@usagetap/sdk");.
Optional adapters live behind subpath exports so their peer dependencies stay out of the core bundle:
@usagetap/sdk/openai– OpenAI/OpenRouter helpers (wrapOpenAI,streamOpenAIRoute, etc.)@usagetap/sdk/anthropic– Anthropic helpers (withMetering,wrapAnthropic)@usagetap/sdk/openrouter– discoverable OpenRouter aliases for the OpenAI-compatible wrappers@usagetap/sdk/express– Express middleware@usagetap/sdk/react– React chat hook
Install only the peer dependencies for the adapters you actually use.
Quick start
Install the peer dependency for your vendor (e.g. openai or @anthropic-ai/sdk) and the UsageTap SDK in your server runtime.
npm install @usagetap/sdk openaiWrap the provider client you already use. USAGETAP_API_KEY and the production
UsageTap URL are read automatically:
import OpenAI from "openai";
import { withMetering } from "@usagetap/sdk/openai";
const openai = withMetering(new OpenAI(), "cust_123");
const completion = await openai.responses.create({
model: "gpt-5.6-luna",
input: "Draft a welcome email for our Pro plan",
});
console.log(completion.output_text);Only the customer ID is required for metering. Pass an object instead of the
string when you want optional feature, tag, entitlement, or prompt-compression
settings. Existing wrapOpenAI, wrapAnthropic, and manual withUsage flows
remain supported for advanced control.
For standalone compression, wrap the client without changing any downstream calls:
import OpenAI from "openai";
import { withCompression } from "@usagetap/sdk/openai";
const openai = withCompression(new OpenAI());
const completion = await openai.responses.create({
model: "gpt-5.6-luna",
input: longPrompt,
});The same withMetering and withCompression APIs are exported from
@usagetap/sdk/anthropic and @usagetap/sdk/openrouter. Remove the wrapper or
call .unwrap() to recover the original provider client.
withCompression compresses user messages only, uses a fast token estimate
over the combined request context, and skips the compression step below 1,000
estimated tokens by default. System instructions, tool content, and assistant
messages remain unchanged. Override roles or the minContextTokens cutoff only
when you need more control; set the cutoff to 0 to always attempt compression.
The separate minTokens option remains a per-text-segment cutoff.
When workload evidence calls for tuning, the wrapper also accepts the hosted Messages API controls directly:
const openai = withCompression(new OpenAI(), {
mode: "model_auto", // or "model_force" / "deterministic"
roles: {
user: { aggressiveness: 0.2 },
system: { aggressiveness: 0.1 },
},
latencyBudgetMs: 1_000,
compactEmptyUserMessages: false,
compactDuplicateUserTextParts: false,
failOpen: true,
});Omitting the options object remains the recommended starting point.
Wrappers compose. Put metering outside compression so the metered operation includes compression and the provider call:
const openai = withMetering(
withCompression(new OpenAI()),
"cust_123",
);Each .unwrap() removes one layer. Do not also set promptCompression: true on
withMetering when using a separate withCompression layer.
UsageTap Gateway
The core client can call the OpenAI-compatible UsageTap Gateway without a
second SDK. Use a utk- key with gateway:invoke. Add
compression:invoke if the same workflow also requests hosted Compression.
Existing gk- and compatible cmp- keys continue to work:
import { UsageTap } from "@usagetap/sdk";
const usageTap = new UsageTap();
const completion = await usageTap.gateway.chat.completions.create({
model: "usagetap/standard",
customerId: "cust_123",
feature: "chat.reply",
messages: [{ role: "user", content: "Summarize this account." }],
});
console.log(completion.choices[0].message?.content);The typed Responses resource supports portable built-in web search, structured
output, verbosity, prompt caching, and Gateway compression. It is buffered in
this release, so omit stream or set it to false:
const response = await usageTap.gateway.responses.create({
model: "usagetap/standard",
customerId: "cust_123",
feature: "answer.with.sources",
instructions: stablePolicy,
input: "Find the current answer and return it as JSON.",
tools: [{ type: "web_search" }],
text: {
verbosity: "low",
format: {
type: "json_schema",
name: "sourced_answer",
strict: true,
schema: {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
additionalProperties: false,
},
},
},
prompt_cache_key: "answer-policy-v1",
prompt_cache_options: { mode: "implicit" },
compress: true,
});
console.log(response.output_text);
console.log(response.usage?.input_tokens_details?.cached_tokens);The same resource exposes models.list() and the complete native batch
lifecycle. Batch creation generates the required idempotency key unless one is
provided:
const submitted = await usageTap.gateway.batches.create({
requests: reports.map((report) => ({
custom_id: report.id,
body: {
model: "usagetap/standard",
customerId: report.customerId,
messages: [{ role: "user", content: report.prompt }],
},
})),
});
const batch = await usageTap.gateway.batches.wait(submitted);
if (batch.status !== "completed") {
throw new Error(`Batch ended with status: ${batch.status}`);
}
// Parses the Gateway's NDJSON result stream into typed objects.
const results = await usageTap.gateway.batches.results(batch.id);Use gateway.batches.retrieve(), cancel(), and results() when you want to
manage polling yourself. Set gatewayBaseUrl or USAGETAP_GATEWAY_URL for a
non-default deployment.
Context summarization
Published context-summarization profiles are available through
usageTap.summarization. A managed single summary can wait for completion in
the initial request:
const summary = await usageTap.summarization.summaries.create({
profile: "weekly-account-summary-abcd5678",
wait: true,
context: {
id: "account-123",
type: "account_history",
content: accountHistory,
},
});
console.log(summary.result);Batch submissions and polling use the same resource pattern as Gateway batches:
const submitted = await usageTap.summarization.batches.create({
profile: "weekly-account-summary-abcd5678",
items: accounts.map((account) => ({
id: account.id,
type: "account_history",
content: account.history,
})),
});
const batch = await usageTap.summarization.batches.wait(submitted);Self-managed workflows can use summarization.profiles.retrieve() to load the
published prompt and model settings, then
summarization.measurements.create() to report source and summary token
counts.
Runaway circuit breaker
Set a local per-run call cap and pass the same runId on every model call in a
workflow. Once the cap is reached, the SDK throws USAGETAP_CIRCUIT_OPEN before
call_begin or the paid provider request can start:
import { UsageTapClient } from "@usagetap/sdk";
const usageTap = new UsageTapClient({
circuitBreaker: { maxCallsPerRun: 20 },
});
const run = { customerId: "cust_123", runId: crypto.randomUUID() };
try {
for (;;) {
const result = await usageTap.meter(run, async () => callModel());
if (result.done) break;
}
} finally {
usageTap.resetRun(run);
}canRunContinue(run) returns the current decision for graceful partial-result
handling. Idempotent retries do not consume another slot. The guard is
process-local by design, so use a stable runId in each SDK process and keep
account-level UsageTap limits enabled for distributed enforcement.
For advanced entitlement control, wrapOpenAI exposes the full UsageTap context
and applies entitlement-aware defaults when you omit model.
import { wrapOpenAI } from "@usagetap/sdk/openai";
const ai = wrapOpenAI(openai, usageTap, {
defaultContext: {
customerId: "cust_123",
feature: "chat.send",
requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
},
promptCompression: {
provider: "heuristic",
roles: { user: true, tool: true },
minTokens: 500,
},
});Optional end-user attribution
customerId identifies the customer account. When that account has multiple
users, add customerUserId to attribute the call to the responsible end user
in Live activity. Prefer a stable, non-PII application user ID.
customerUserName and customerUserEmail are optional display metadata. The
SDK does not infer them, and existing integrations remain valid when these
fields are omitted.
const context = {
customerId: currentCustomer.id,
customerUserId: currentUser.id, // Optional, recommended when available
customerUserName: currentUser.name, // Optional display metadata
customerUserEmail: currentUser.email, // Optional display metadata
feature: "chat.send",
};Prompt compression
Prompt compression is an explicit step after call_begin. beginCall only starts the call and returns the callId; promptCompress compresses locally, records savings metadata against that call, and returns the compressed prompt for your vendor request. Raw prompt content is not sent to UsageTap.
import { protectPromptText } from "@usagetap/sdk";
const begin = await usageTap.beginCall({
customerId: "cust_123",
customerUserId: currentUser.id,
customerUserName: currentUser.name,
customerUserEmail: currentUser.email,
feature: "chat.send",
});
const compressed = await usageTap.promptCompress({
callId: begin.data.callId,
input: `Please summarize this long prompt but keep ${protectPromptText("PLAN_ID_PRO_2026")} exact.`,
});
const response = await openai.responses.create({
model: "gpt5-mini",
input: compressed.compressedInput as string,
});The default heuristic is conservative: it normalizes whitespace, preserves fenced code indentation, minifies valid embedded JSON, and converts eligible JSON data blocks to TOON when that is smaller. Pass provider: "toon" to force local TOON-style encoding for structured data. Savings include both character counts and approximate token counts using lightweight regex tokenization ([\p{L}\p{N}]+|[^\s]), not a model-specific BPE tokenizer. If compression or savings reporting fails, the SDK returns the original input with zero savings so the vendor call can continue.
wrapOpenAI() and wrapAnthropic() can also compress prompts automatically after call_begin and before the vendor request. This is opt-in via promptCompression; assistant messages are skipped by default so historical assistant turns are not rewritten. Compression telemetry is aggregated once per UsageTap call, and stats are available on ai.promptCompression.totalTokensSaved.
import Anthropic from "@anthropic-ai/sdk";
import { wrapAnthropic } from "@usagetap/sdk/anthropic";
const anthropic = wrapAnthropic(
new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
usageTap,
{
defaultContext: { customerId: "cust_123", feature: "chat.send" },
promptCompression: { roles: { system: true, user: true, tool: true } },
},
);
await anthropic.messages.create({
model: "claude-3-5-haiku-latest",
max_tokens: 512,
system: "Long system prompt",
messages: [{ role: "user", content: "Long user prompt" }],
});Use provider: "usagetap" to compress with UsageTap's hosted endpoints. Manual promptCompress() and compressPromptInput() use the single-text The Token Company-compatible endpoint at https://compress.usagetap.com/v1/compress, where aggressiveness is a single number from 0.0 to 1.0. wrapOpenAI() and wrapAnthropic() use the message/request endpoint at https://compress.usagetap.com/v1/messages/compress, where aggressiveness may be a per-role object:
const result = await usageTap.promptCompress({
callId: begin.data.callId,
text: "Your text here",
provider: "usagetap",
model: "bear-2",
aggressiveness: 0.5,
});const ai = wrapOpenAI(openai, usageTap, {
defaultContext: { customerId: "cust_123", feature: "chat.send" },
promptCompression: {
provider: "usagetap",
aggressiveness: { user: 0.5, system: 0.5, tool: 0.5 },
},
});UsageTapClient sends your UsageTap API key by default. Override usageTapCompressionEndpoint for single-text compression or usageTapCompressionMessagesEndpoint for wrapper message compression.
When using The Token Company, configure tokenCompanyApiKey on UsageTapClient and set provider: "thetokencompany". Optional tokenCompanyModel, aggressiveness, and tokenCompanyAppId are supported at the client, manual promptCompress, and wrapper levels. Use protectPromptText() for text that must be passed through unchanged by compression-compatible providers.
For advanced custom flows, compressPromptInput(input, options?) returns compression results without recording telemetry, and recordPromptCompression({ callId, promptCompression }) records precomputed savings metadata against a call.
Heads up:
UsageTapClientalways negotiates the canonical UsageTap media type by sendingAccept: application/vnd.usagetap.v1+json. Every response uses the{ result, data, correlationId }envelope and the begin payload includesdata.idempotency.key(matchingcallId), per-meter snapshots, and subscription metadata. KeepautoIdempotencyenabled unless you provide a unique key yourself. The server fallback is deterministic, so identical inputs can replay an earlier call.
Streaming helpers
wrapOpenAI automatically instruments streaming responses. You can feed the wrapped stream directly into Next.js or an Express response using the exported helpers:
import { toNextResponse } from "@usagetap/sdk/openai";
export async function POST() {
const stream = await ai.chat.completions.create(
{
messages: [{ role: "user", content: "Stream it" }],
stream: true,
},
{
usageTap: {
requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
},
},
);
return toNextResponse(stream, { mode: "text" });
}wrapOpenAI preserves the model already supplied by the application. It does not invent a model mapping or fallback. Use the manual withUsage pattern when BLOCK or DOWNGRADE must control provider invocation, model selection, reasoning effort, or search tools.
Starting in @usagetap/sdk 1.6.0, the Anthropic adapter preserves both
streaming forms. Use
messages.create({ stream: true }) for the lower-level async iterable, or use
the synchronous messages.stream() helper when the application relies on
events and final-message accumulation:
const stream = anthropic.messages
.stream({
model: "claude-sonnet-4-5",
max_tokens: 512,
messages: [{ role: "user", content: "Stream it" }],
})
.on("text", (text) => process.stdout.write(text));
const message = await stream.finalMessage();messages.stream() still returns Anthropic's MessageStream immediately and
retains .on(), async iteration, .abort(), .done(), .finalText(), and
.finalMessage(). UsageTap begins metering before the provider request and
finalizes once when the underlying stream completes, fails, or is aborted.
Wrapped messages.create() results also retain Anthropic's .withResponse()
and .asResponse() helpers.
Overriding usage context per request
You can override the UsageTap begin payload on a per-call basis via the usageTap option:
await ai.chat.completions.create(
{ messages },
{
usageTap: {
customerId: currentCustomer.id,
customerUserId: currentUser.id, // Optional
feature: "chat.assist",
tags: ["beta"],
requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
},
},
);The begin response returns the granted entitlements. The application must use those fields to select only a model and capabilities it has explicitly configured.
For streaming calls created with { stream: true }, UsageTap automatically calculates usage from the final OpenAI response (or falls back to estimates when available). The wrapped stream retains OpenAI-specific helpers like finalChatCompletion().
responses.create support
The wrapper also instruments openai.responses.create, preserves text controls,
tools, prompt-cache controls and breakpoints through compression, and records
cache reads, cache writes, reasoning tokens, and completed web-search calls.
Install OpenAI SDK 7.5 or newer when you want its native TypeScript definitions
for the latest Responses fields; the UsageTap Gateway resource has its own types
and does not require the OpenAI package.
OpenRouter support
wrapOpenAI works seamlessly with OpenRouter since it uses an OpenAI-compatible API. Just point the base URL to OpenRouter:
import OpenAI from "openai";
import { UsageTapClient } from "@usagetap/sdk";
import { wrapOpenAI } from "@usagetap/sdk/openai";
const usageTap = new UsageTapClient({
apiKey: process.env.USAGETAP_API_KEY!,
baseUrl: process.env.USAGETAP_BASE_URL!,
});
const openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY!,
});
const ai = wrapOpenAI(openrouter, usageTap, {
defaultContext: {
customerId: "cust_123",
feature: "chat.send",
requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
},
});
const completion = await ai.chat.completions.create(
{
model: "your-existing-openrouter-model",
messages: [{ role: "user", content: "Hello from OpenRouter!" }],
},
{
usageTap: {
requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
},
},
);begin.data.models may surface organization-configured model shortlists. Treat them as guidance; keep the application's existing model or select an explicitly approved fallback after checking allowed.
Express middleware
For Express applications, use the withUsage middleware to attach UsageTap context to requests:
import express from "express";
import OpenAI from "openai";
import { UsageTapClient } from "@usagetap/sdk";
import { withUsage } from "@usagetap/sdk/express";
const app = express();
const usageTap = new UsageTapClient({
apiKey: process.env.USAGETAP_API_KEY!,
baseUrl: process.env.USAGETAP_BASE_URL!,
});
// Extract customer ID from your auth system
app.use(withUsage(usageTap, (req) => req.user.id));
app.post("/api/chat", async (req, res) => {
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const ai = req.usageTap!.openai(openai, {
feature: "chat.assistant",
requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
});
const stream = await ai.chat.completions.create(
{
messages: req.body.messages,
stream: true,
},
{
usageTap: {
requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
},
},
);
// Pipes stream to response and finalizes usage
req.usageTap!.pipeToResponse(stream, res);
});The middleware meters the supplied provider request. To enforce model tier, allowed.reasoningLevel, or allowed.search, read the begin payload inside route handlers (see the manual withUsage example above) and shape the provider request accordingly.
React hook for chat UIs
Build chat interfaces with automatic UsageTap tracking:
import { useChatWithUsage } from "@usagetap/sdk/react";
function ChatComponent({ customerId, currentUser }) {
const { messages, input, setInput, handleSubmit, isLoading } = useChatWithUsage({
api: "/api/chat",
customerId,
customerUserId: currentUser.id, // Optional hint; validate on the server
feature: "chat.assistant",
});
return (
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
Send
</button>
</form>
</div>
);
}The hook works with server routes that use UsageTap SDK (see streamOpenAIRoute above).
wrapFetch: minimal integration
For the smallest possible integration, use wrapFetch to wrap the fetch function passed to the OpenAI SDK. This requires zero changes to your OpenAI code:
import OpenAI from "openai";
import { UsageTapClient, wrapFetch } from "@usagetap/sdk";
const usageTap = new UsageTapClient({
apiKey: process.env.USAGETAP_API_KEY!,
baseUrl: process.env.USAGETAP_BASE_URL!,
});
const wrappedFetch = wrapFetch(usageTap, {
defaultContext: {
customerId: "cust_123",
feature: "chat",
requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
},
onMeteringError: ({ stage, callId, error }) => {
console.error("UsageTap metering failed", { stage, callId, error });
},
// Set true when an unmetered vendor response must fail the application call.
strictMetering: false,
});
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
fetch: wrappedFetch,
});
// Reuse the selectCapabilities helper shown above to map entitlements to models
// Pull the entitlements you cached after call_begin and pick the right tier
const { model } = selectCapabilities(session.entitlements.allowed);
const completion = await openai.chat.completions.create({
model,
messages: [{ role: "user", content: "Hello!" }],
});wrapFetch detects OpenAI API endpoints, handles streaming and non-streaming responses, requests final usage for chat streams, and automatically extracts usage data. It preserves the application's model. If limits must control provider selection, use an explicit begin decision before this layer. Use onMeteringError to export request parsing, begin, or end failures after SDK retries are exhausted. Set strictMetering when those failures should reject the application call. You can override metering context per request using special headers; the wrapper consumes these headers and removes them before calling the provider:
await openai.chat.completions.create(
{ messages: [{ role: "user", content: "Hello!" }] },
{
headers: {
"x-usagetap-customer-id": currentUser.id,
"x-usagetap-feature": "chat.premium",
},
},
);Unified /call endpoint (API-only)
Need a single round-trip without the SDK? The public REST API exposes POST /call, which wraps call_begin, an optional vendor invocation, and call_end into one atomic request. Supply your usual begin payload plus an optional vendor block containing the URL, headers, and body to execute. UsageTap merges usage metrics from the vendor response with any explicit overrides before finalizing the call.
async function getEntitlementsFor(customerId: string) {
// Call begin upfront or reuse a cached begin payload for this customer + feature
return sessionStore.read(customerId); // pseudo-code: use your own persistence layer
}
const entitlements = await getEntitlementsFor("cust_123"); // stash begin.data.allowed somewhere durable
const { model } = selectCapabilities(entitlements.allowed);
const response = await fetch(`${baseUrl}/call`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.USAGETAP_API_KEY}`,
Accept: "application/vnd.usagetap.v1+json",
"Content-Type": "application/json",
},
body: JSON.stringify({
customerId: "cust_123",
requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
feature: "chat.completions",
idempotency: crypto.randomUUID(),
vendor: {
url: "https://api.openai.com/v1/chat/completions",
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: {
model,
messages: [{ role: "user", content: "Hello" }],
},
responseType: "json",
},
usage: { modelUsed: model },
}),
});
const envelope = await response.json();
if (!response.ok || envelope.result.status !== "ACCEPTED") {
throw new Error(`UsageTap /call failed: ${envelope.result.code}`);
}
const { begin, end, vendor, endUsage } = envelope.data;- When the
vendorblock is omitted,/callsimply runs begin → end using the providedusageoverrides. - Non-2xx vendor responses still trigger
call_end; the envelope returnsCALL_VENDOR_WARNINGalongside vendor error metadata. - The canonical media type
application/vnd.usagetap.v1+jsonis required; the SDK already sends this header automatically when you rely onUsageTapClient.
Retrieve finalized call pricing (API-only)
An external server can retrieve the stored result later with GET
/calls/{callId}. Use a server API key containing usage:read; do not expose
the key in browser code.
const response = await fetch(
`${baseUrl}/calls/${encodeURIComponent(callId)}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.USAGETAP_API_KEY}`,
Accept: "application/vnd.usagetap.v1+json",
},
},
);
const envelope = await response.json();
if (!response.ok || envelope.result.status !== "ACCEPTED") {
throw new Error(`UsageTap call lookup failed: ${envelope.result.code}`);
}
if (envelope.data.pricingStatus !== "FINAL") {
throw new Error(`Call pricing is ${envelope.data.pricingStatus}`);
}
const providerCostUsd = envelope.data.costUSD;
const customerChargeUsd = envelope.data.payg.chargedUsd;
const customerId = envelope.data.customerId;
const customerUserId = envelope.data.customerUserId; // string or nullpricingStatus is PENDING while the call is open and UNAVAILABLE when a
completed call could not resolve model pricing. Only treat costUSD as final
when the status is FINAL.
The response includes only the stable customer identifiers recorded on the
call: customerId and nullable customerUserId. Use opaque, non-PII values
for both. Customer and end-user names or emails, billing-provider identifiers,
organizationId, and the internal orgIdCustomerId composite key are not
returned.
Exports
Key exports from @usagetap/sdk:
UsageTapClient– minimal HTTP client forcreateCustomer,changePlan,incrementCustomMeter,call_begin,call_end, andcheckUsage.createCustomer– idempotently ensure a customer subscription exists before starting a call.changePlan– switch a customer to a different usage plan with configurable strategy (immediate reset, prorated, or scheduled).incrementCustomMeter– track custom usage metrics beyond standard LLM counters (agent actions, documents, API calls, etc.).checkUsage– lightweight method to query current usage status without creating a call session.promptCompress/compressPromptToon– compress prompt input aftercall_begin, return the compressed payload, and record savings metadata for the call.protectPromptText/protect– mark exact text spans that compatible compressors should not rewrite.wrapFetch– wraps a fetch function to automatically instrument OpenAI API calls (minimal integration).createIdempotencyKey– helper for generating UsageTap-compatible idempotency keys.- Type definitions for canonical UsageTap request/response payloads.
Optional subpaths:
@usagetap/sdk/openai–wrapOpenAI,createOpenAIAdapter,streamOpenAIRoute,toNextResponse,pipeToResponse, and related types.@usagetap/sdk/anthropic–wrapAnthropicand related prompt compression types.@usagetap/sdk/express–withUsage,withUsageMiddleware, and corresponding Express request types.@usagetap/sdk/react–useChatWithUsageand supporting types for building chat interfaces.
All helpers are designed for server runtimes. Use UsageTapClient with allowBrowser: true only for sandbox/test scenarios.
Ensure a customer subscription exists
Run createCustomer before you invoke call_begin (or higher-level helpers) to guarantee the customer has an active subscription. The endpoint is fully idempotent—repeat calls return the existing snapshot and set newCustomer: false:
const snapshot = await usageTap.createCustomer({
customerId: "cust_123",
customerFriendlyName: "Acme AI",
customerEmail: "[email protected]",
stripeCustomerId: "cus_123",
});
console.log("New customer?", snapshot.data.newCustomer);
console.log("Plan:", snapshot.data.plan);
console.log("Allowed entitlements:", snapshot.data.allowed);customerFriendlyName (aka customerName) and customerEmail are HIGHLY IMPORTANT BUT OPTIONAL: they populate the customer profile on first creation and are safe to omit if you truly do not have them yet.
This returns the same rich subscription snapshot surfaces by call_begin and checkUsage, making it safe to cache the response for onboarding flows. Pass idempotencyKey in CreateCustomerOptions when you need deterministic keys across services; otherwise the client auto-generates one by default. Both idempotencyKey (preferred) and idempotency (deprecated) are supported.
Change a customer's plan
Use changePlan to switch a customer to a different usage plan. You can control how the change is applied with the strategy option:
const result = await usageTap.changePlan({
customerId: "cust_123",
planId: "plan_premium_v2",
strategy: "IMMEDIATE_RESET", // or "IMMEDIATE_PRORATED" or "AT_NEXT_REPLENISH"
});
console.log("Plan changed:", result.data.success);
console.log("New subscription:", result.data.subscription);Strategy options:
IMMEDIATE_RESET: Switch immediately, grant the target plan's full allowances, and start a new replenishment cycle (default when the SDK option is omitted)IMMEDIATE_PRORATED: Switch immediately, preserve usage already consumed, adjust remaining allowances to the target limits, and keep the current replenishment dateAT_NEXT_REPLENISH: Keep the current plan until its existing scheduled replenishment, then switch
The response includes the updated subscription details, including the new plan version, limits, and next replenishment timestamp. If strategy: "AT_NEXT_REPLENISH" is used, the subscription.pending field will indicate the scheduled plan change.
Check usage without creating a call
When you need to display current quota status, plan details, or remaining balances without tracking a vendor call, use checkUsage():
const usageStatus = await usageTap.checkUsage({ customerId: "cust_123" });
console.log("Meters:", usageStatus.data.meters);
console.log("Allowed:", usageStatus.data.allowed);
console.log("Plan:", usageStatus.data.plan);
console.log("Balances:", usageStatus.data.balances);This returns the same rich usage snapshot as call_begin (meters, entitlements, subscription details, plan info, balances) but without creating a call record. Use this for dashboard widgets, pre-flight checks, or displaying quota status to users.
Increment custom meters
Custom meters allow you to track usage beyond standard LLM metrics—ideal for agent actions, document processing, API calls, or any custom usage you need to meter.
const result = await usageTap.incrementCustomMeter({
customerId: "cust_123",
customerUserId: currentUser.id,
meterSlot: "CUSTOM1", // or "CUSTOM2" or "AGENTIC_API"
amount: 5,
feature: "agent_actions",
tags: ["workflow_automation"],
metadata: {
workflowId: "wf_abc123",
actionType: "email_send",
},
});
console.log("Event recorded:", result.data.eventId);
console.log("Remaining quota:", result.data.meter.remaining);
console.log("Blocked:", result.data.blocked);Parameters:
customerId(string, required): Customer identifiercustomerUserId(string, optional): Stable identifier for the end user responsible for the eventcustomerUserNameandcustomerUserEmail(string, optional): Display fields for live activitymeterSlot("CUSTOM1" | "CUSTOM2" | "AGENTIC_API", required): Which meter to incrementamount(number, required): Positive number to decrement from quotafeature(string, optional): Feature identifier for trackingtags(string[], optional): Tags for categorizationmetadata(object, optional): Additional metadata
The method returns the updated meter snapshot showing remaining quota, limits, and usage. If usage exceeds the allocation and eligible overage funding, it returns data.blocked: true. The SDK does not throw for this accepted usage report; your application must enforce the flag.
Use cases:
// Track agent tool invocations
await usageTap.incrementCustomMeter({
customerId: "cust_123",
meterSlot: "AGENTIC_API",
amount: 1,
feature: "agent.tool_call",
tags: ["web_search"],
metadata: { kind: "mcp", serverName: "search", toolName: "web_search" },
});
// Track document processing (10 pages)
await usageTap.incrementCustomMeter({
customerId: "cust_456",
meterSlot: "CUSTOM2",
amount: 10,
feature: "document.ocr",
metadata: { documentId: "doc_789", pages: 10 },
});
// Track external API calls
await usageTap.incrementCustomMeter({
customerId: "cust_789",
meterSlot: "AGENTIC_API",
amount: 1,
feature: "external_api.maps",
tags: ["geocoding"],
metadata: { kind: "api", method: "GET", endpoint: "/v1/geocode" },
});For Agentic & API Calls, use metadata.toolName (and optionally
metadata.serverName) for MCP operations, or metadata.endpoint and
metadata.method for HTTP APIs. Live View displays these values and removes URL
query strings from endpoint labels.
Important notes:
- Custom meters must be enabled in the customer's usage plan
- Report a positive safe integer quantity. Usage consumes allocation, then eligible purchased meter credits and/or PAYG funding. The last included unit is allowed.
- Unfunded usage is recorded with
blocked: true; the application must enforce that flag. LLMBLOCK/DOWNGRADEpolicies do not apply to meter reports. - PAYG overages require enabled PAYG and a configured single or graduated price. Settlement is asynchronous and does not reserve dollar credit or impose a rolling rate limit.
- Unlimited meters still record events for analytics but do not consume allocation or charge overages.
Response envelope (canonical only)
UsageTap responds exclusively with the canonical { result, data, correlationId } envelope for every endpoint. The SDK automatically sends Accept: application/vnd.usagetap.v1+json, parses the envelope, and returns strongly typed data structures. Transitional raw payloads and the normalize* helpers have been removed—response.data already contains the canonical shape you should persist or render.
Example call_begin success
{
"result": {
"status": "ACCEPTED",
"code": "CALL_BEGIN_SUCCESS",
"timestamp": "2025-10-04T18:21:37.482Z"
},
"data": {
"callId": "call_123",
"startTime": "2025-10-04T18:21:37.482Z",
"policy": "DOWNGRADE",
"newCustomer": false,
"canceled": false,
"allowed": {
"standard": true,
"premium": true,
"audio": false,
"image": false,
"search": true,
"reasoningLevel": "MEDIUM"
},
"entitlementHints": {
"suggestedModelTier": "standard",
"reasoningLevel": "MEDIUM",
"policy": "DOWNGRADE",
"downgrade": {
"reason": "PREMIUM_QUOTA_EXHAUSTED",
"fallbackTier": "standard"
}
},
"meters": {
"standardCalls": {
"remaining": 12,
"limit": 20,
"used": 8,
"unlimited": false,
"ratio": 0.6
},
"premiumCalls": {
"remaining": 0,
"limit": null,
"used": 0,
"unlimited": true,
"ratio": null
},
"standardTokens": {
"remaining": 800,
"limit": 1000,
"used": 200,
"unlimited": false,
"ratio": 0.8
}
},
"remainingRatios": {
"standardCalls": 0.6,
"standardTokens": 0.8
},
"subscription": {
"id": "sub_123",
"usagePlanVersionId": "plan_2025_01",
"planName": "Pro",
"planVersion": "2025-01",
"limitType": "DOWNGRADE",
"reasoningLevel": "MEDIUM",
"lastReplenishedAt": "2025-10-04T00:00:00.000Z",
"nextReplenishAt": "2025-11-04T00:00:00.000Z",
"subscriptionVersion": 14
},
"models": {
"standard": ["gpt5-mini"],
"premium": ["gpt5"]
},
"idempotency": {
"key": "call_123",
"source": "derived"
}
},
"correlationId": "corr_abc123"
}UsageTapClient exposes the normalized structure via UsageTapSuccessResponse<BeginCallResponseBody>. In addition to the flattened allowed map, the begin response now ships richer metadata:
entitlementHintssummarises the recommended model tier and downgrade rationale based on the active policy.metersis a per-counter snapshot including remaining quotas, total limits, usage to date, and convenience ratios.remainingRatiosmirrors the same information in a compact map for quick lookups.subscriptioncontains the active plan identity, versioning, and upcoming replenishment timestamps so you can render customer-facing UI without querying Dynamo yourself.modelssurfaces per-organization vendor hints (e.g. standard vs. premium model shortlists).idempotencyreveals the actual key that was persisted (callIdmirrors this value). The SDK generates a unique key by default. With SDK auto-generation disabled, the backend derives a deterministic hash from organization, customer, feature, requested entitlements, call type, and pricing mode; identical inputs can replay an earlier call.planandbalancesremain available alongside the core begin payload for backwards compatibility with earlier SDK versions.
Example call_end success
{
"result": {
"status": "ACCEPTED",
"code": "CALL_END_SUCCESS",
"timestamp": "2025-10-04T18:21:52.103Z"
},
"data": {
"callId": "call_123",
"costUSD": 0,
"usage": {
"inputTokens": 600,
"cachedInputTokens": 120,
"billableInputTokens": 480,
"responseTokens": 288,
"reasoningTokens": 0
},
"metered": {
"tokens": 288,
"calls": 1,
"searches": 1
},
"spendVelocity": {
"currency": "USD",
"source": "usage_aggregate",
"generatedAt": "2025-10-04T18:21:52.103Z",
"customerId": "cust_123",
"currentCallCostUsd": 0,
"windows": {
"hour": {
"bucket": "2025-10-04T18",
"windowMinutes": 60,
"startedAt": "2025-10-04T18:00:00.000Z",
"endedAt": "2025-10-04T18:21:52.103Z",
"completedCostUsd": 8.75,
"completedCalls": 24
},
"day": {
"bucket": "2025-10-04",
"windowMinutes": 1440,
"startedAt": "2025-10-04T00:00:00.000Z",
"endedAt": "2025-10-04T18:21:52.103Z",
"completedCostUsd": 42.1,
"completedCalls": 140
}
}
}
},
"correlationId": "corr_abc123"
}Send cachedInputTokens and cacheWriteInputTokens when available so UsageTap
can apply provider prompt-cache pricing correctly. inputTokens is always the
total input count and includes both subsets. OpenAI's prompt-token total already
includes cache reads. Anthropic reports ordinary input, cache reads, and cache
writes separately, so the Anthropic wrapper adds the three counters for
inputTokens while retaining both cache subsets.
metered.tokens is the provider-reported output-token count. Input tokens,
including cache reads and cache writes, remain recorded and part of provider
cost but do not consume the plan's Standard Output Tokens or Premium Output
Tokens allowance and are not sent to the corresponding Stripe token meters.
Reasoning tokens are a breakdown of provider output and are not added again.
The persisted meter keys remain standardTokens and premiumTokens for API
compatibility; they represent standard and premium output tokens in both plan
limits and Stripe usage metering.
spendVelocity is aggregate-backed current UTC hour/day telemetry. UsageTap does not enforce limits from this section; currentCallCostUsd is included separately because aggregate updates are asynchronous.
Premium detection and override
UsageTap automatically determines whether a call is premium based on the model's output token pricing:
- If the output token price exceeds $4.00 per million tokens, the call is classified as premium
- Otherwise, it's classified as standard
You can explicitly override this detection by passing isPremium in your call_end request:
await usageTap.endCall({
callId: begin.data.callId,
modelUsed: "custom-model-v2",
inputTokens: 100,
responseTokens: 200,
isPremium: true, // Explicitly mark this as a premium call
});This is useful when:
- You're using custom models that aren't in UsageTap's pricing database
- You want to enforce specific billing tiers regardless of pricing
- You're implementing your own tier classification logic
Batch pricing
Batch mode applies a 50% discount to standard pricing rates. UsageTap accepts
the execution mode reported by your application; it does not attempt to infer
or verify the vendor workflow. Prefer pricingMode: "batch"; batch: true is
the compatibility form.
When set on call_begin, the pricing mode carries through to call_end automatically. Setting it on call_end overrides the call_begin value.
// Option 1: Set on call_begin (carries through)
const begin = await usageTap.beginCall({
customerId: "cust_123",
batch: true,
pricingMode: "batch",
});
// Option 2: Set on call_end (overrides call_begin)
await usageTap.endCall({
callId: begin.data.callId,
modelUsed: "gpt-5.6-sol",
inputTokens: 100,
responseTokens: 200,
batch: true,
pricingMode: "batch",
});Both batch and pricingMode are echoed in the responses from call_begin and call_end.
When both request fields are supplied, pricingMode is authoritative.
OpenAI and Anthropic completion/message usage records provide token counts, but
they do not provide a dependable per-response signal that proves the request
received vendor batch pricing. The wrappers therefore never infer batch mode
from usage. Set it explicitly in wrapper context when your surrounding
workflow knows the request is a vendor batch:
const metered = withMetering(openai, {
customerId: "cust_123",
pricingMode: "batch",
usageTapClient: usageTap,
});The ordinary wrapOpenAI and wrapAnthropic create-method wrappers do not
submit native vendor batch jobs. For OpenAI Batch, Anthropic Message Batches, or
another asynchronous provider, open one UsageTap call per batch item, retain
its callId, then call endCall with the usage returned for that item. The
LLMAsAService POST /v1/batches integration performs this lifecycle
automatically.
Raw fetch integrations
Prefer UsageTapClient whenever possible—it handles retries, headers, and idempotency for you. If you still need to work with fetch directly, remember to request the canonical media type and consume the envelope shape directly:
import type { BeginCallResponseBody, EndCallResponseBody } from "@usagetap/sdk";
const beginResponse = await fetch(`${baseUrl}/call_begin`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/vnd.usagetap.v1+json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then((r) => r.json());
if (beginResponse.result.status !== "ACCEPTED") {
throw new Error(`call_begin failed: ${beginResponse.result.code}`);
}
const begin = beginResponse.data as BeginCallResponseBody;
// ...later, when closing the call
const endResponse = await fetch(`${baseUrl}/call_end`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/vnd.usagetap.v1+json",
"Content-Type": "application/json",
},
body: JSON.stringify({ callId: begin.callId }),
}).then((r) => r.json());
if (endResponse.result.status !== "ACCEPTED") {
throw new Error(`call_end failed: ${endResponse.result.code}`);
}
const end = endResponse.data as EndCallResponseBody;The canonical payloads (BeginCallResponseBody, EndCallResponseBody, etc.) now match the envelope exactly, keeping SDK and raw integrations aligned without extra helper utilities.
