UsageTap SDK Reference
Complete reference for the @usagetap/sdk TypeScript/JavaScript SDK (v1.4.0).
Table of Contents
- Installation
- Quick Start
- Client Configuration
- Core Methods
- OpenAI Integration
- Anthropic Integration
- Express Middleware
- React Hook
- wrapFetch (Minimal Integration)
- OpenTelemetry Exporter
- Embeddable Widgets
- Error Handling
- Type Definitions
Installation
# Core SDK + OpenAI adapter
npm install @usagetap/sdk openai
# Optional: Anthropic adapter
npm install @usagetap/sdk @anthropic-ai/sdk
# Optional: OpenTelemetry exporter
npm install @usagetap/otel-exporter
Peer dependencies are optional — install only what you use:
| Subpath | Peer Dependency |
|---|---|
@usagetap/sdk |
None |
@usagetap/sdk/openai |
openai >= 4.0.0 |
@usagetap/sdk/anthropic |
@anthropic-ai/sdk >= 0.20.0 |
@usagetap/sdk/openrouter |
openai >= 4.0.0 |
@usagetap/sdk/express |
express >= 4.0.0 |
@usagetap/sdk/react |
react >= 18.0.0 |
Module formats: Ships dual ESM (.mjs) and CommonJS (.cjs).
// ESM
import { UsageTapClient } from "@usagetap/sdk";
// CommonJS
const { UsageTapClient } = require("@usagetap/sdk");
Requirements: Node.js >= 18.17
Quick Start
import OpenAI from "openai";
import { withMetering } from "@usagetap/sdk/openai";
const openai = withMetering(new OpenAI(), "cust_123");
// Existing calls stay the same and are metered automatically.
const result = await openai.chat.completions.create({
model: "gpt-5.6-luna",
messages: [{ role: "user", content: "Hello!" }],
});
withMetering preserves the provider client and finalizes usage, but it cannot
invent an application-approved model fallback. Use the explicit
UsageTapClient.withUsage() lifecycle when BLOCK or DOWNGRADE must control
whether the provider is invoked or which model is selected.
Environment variables:
USAGETAP_API_KEY=utk-... # Add the scopes used by this service
USAGETAP_BASE_URL=https://api.usagetap.com/ # Optional override
OPENAI_API_KEY=sk-... # If using OpenAI
OPENROUTER_API_KEY=sk-or-... # If using OpenRouter
Client Configuration
const client = new UsageTapClient({
// Optional when using the standard environment variables/default endpoint
apiKey?: string; // Defaults to USAGETAP_API_KEY
baseUrl?: string; // Defaults to USAGETAP_BASE_URL, then production
// Optional defaults
defaultFeature?: string; // Applied to all calls
defaultTags?: string[]; // Applied to all calls
// Network
fetchImpl?: typeof fetch; // Custom fetch (default: globalThis.fetch)
headers?: Record<string, string>; // Additional headers on every request
retries?: {
maxAttempts?: number; // Default: 3
baseDelayMs?: number; // Default: 250
maxDelayMs?: number; // Default: 5000
jitterRatio?: number; // Default: 0.2
};
// Idempotency
idempotencyGenerator?: () => string; // Custom UUID generator
autoIdempotency?: boolean; // Default: true — auto-generate when missing
// Observability
onLog?: (entry: UsageTapLogEntry) => void; // Logging callback
onUsageMetric?: (event: UsageMetricEvent) => void; // OTEL / metrics callback
// Auth style
useApiKeyHeader?: boolean; // Use x-api-key instead of Authorization Bearer
// Safety
allowBrowser?: boolean; // Allow browser usage (testing only, default: false)
circuitBreaker?: {
maxCallsPerRun: number; // Local hard stop for calls sharing customerId + runId
runInactivityMs?: number; // Forget inactive runs (default: 60 minutes)
};
// Optional remote prompt compression provider
tokenCompanyApiKey?: string;
tokenCompanyEndpoint?: string;
tokenCompanyModel?: string; // Default: "bear-2"
aggressiveness?: number; // 0.0 to 1.0, default: 0.2
tokenCompanyAppId?: string; // Optional provider app identifier
usageTapCompressionEndpoint?: string; // Single-text endpoint override
usageTapCompressionMessagesEndpoint?: string; // Message/request endpoint override
});
The SDK automatically sends Accept: application/vnd.usagetap.v1+json and negotiates the canonical response envelope on every request.
Core Methods
Runaway circuit breaker
The minimal circuit breaker is an SDK-local hard cap on model-call attempts in
one application workflow. Configure the cap once and include a stable runId
on each call:
const client = new UsageTapClient({
circuitBreaker: { maxCallsPerRun: 20 },
});
const run = { customerId: "cust_123", runId: crypto.randomUUID() };
try {
for (;;) {
const result = await client.meter(run, async () => callModel());
if (result.done) break;
}
} catch (error) {
if (error instanceof UsageTapError &&
error.code === "USAGETAP_CIRCUIT_OPEN") {
return buildPartialResult(error.details);
}
throw error;
} finally {
client.resetRun(run);
}
The SDK reserves a slot before call_begin, so an open circuit prevents the
next paid provider request. Requests with the same idempotency key count once.
canRunContinue(run) inspects the decision without consuming a slot, and
resetRun(run) releases the local state when a workflow finishes.
This guard is process-local and deliberately adds no synchronous database dependency. Keep account-level UsageTap limits enabled for enforcement across multiple SDK processes.
runId is SDK-only metadata and is not sent to the UsageTap API.
Sampling
Sampling retains a policy-controlled fraction of complete model calls for compression analysis. When no local sampling policy is supplied, the SDK loads the policy saved on the Sampling page and caches it for up to five minutes. Configuration failures are fail-closed: model calls continue, but raw input is not sampled.
For the complete path from call-list opportunity through Production Sample collection to measured cost per passing task, see Benchmark Models on Production Tasks.
For supported providers, use withSampling():
import OpenAI from "openai";
import { withSampling } from "@usagetap/sdk/openai";
const openai = withSampling(new OpenAI());
await openai.responses.create(
{ model: "gpt-5.6", input: longPrompt },
{ usageTap: { customerId: "cust_123", feature: "assistant.answer" } },
);
withSampling() is also exported by @usagetap/sdk/anthropic and @usagetap/sdk/openrouter. To compose it with hosted compression, pass sampling: true to withCompression().
For custom providers, let the SDK load and evaluate the saved policy locally:
const selected = await client.shouldSampleAsync({
customerId: "cust_123",
feature: "assistant.answer",
input: messages,
});
if (selected) {
await client.captureSample({
customerId: "cust_123",
feature: "assistant.answer",
provider: "custom",
model: result.model,
input: messages,
output: result.output,
usage: result.usage,
});
}
The lower-level methods are:
getSamplingSettings({ forceRefresh? }): fetch and cache the canonical policy.shouldSample(request, policy): synchronously evaluate an explicit local policy.shouldSampleAsync(request, policy?): evaluate an override, configured local policy, or cached remote policy in that order.decideSample({ samplingKey?, customerId?, feature?, inputTokens? | inputCharacters? }): obtain a deterministic metadata-only server decision.captureSample(request): store a selected call and return its expiration time.
Set sampling: false on UsageTapClientOptions to disable automatic sampling, or provide a SamplingOptions object to override the saved policy locally. samplingSettingsCacheMs caps the local cache lifetime.
beginCall
Start a usage tracking session and retrieve customer entitlements.
const response = await client.beginCall({
customerId: "cust_123", // Required
customerUserId: currentUser.id, // Optional stable end-user ID
customerUserName: currentUser.name, // Optional display metadata
customerUserEmail: currentUser.email, // Optional display metadata
runId: "workflow_abc", // Optional local circuit-breaker scope
feature: "chat.send", // Feature being accessed
requested: { // Requested capabilities
standard: true,
premium: true,
audio: false,
image: false,
search: true,
reasoningLevel: "HIGH", // "NONE" | "LOW" | "MEDIUM" | "HIGH"
},
idempotencyKey: crypto.randomUUID(), // Safe retries
tags: ["production", "web-app"], // Analytics tags
customerName: "Acme Corp", // Display name (new customers)
customerEmail: "billing@acme.com", // Email (new customers)
stripeCustomerId: "cus_...", // Link to Stripe
batch: false, // Batch pricing (50% discount)
pricingMode: "standard", // "batch" | "standard"
});
// Access the response
const { callId, allowed, entitlementHints, meters, subscription } = response.data;
Use customerId for the customer account and customerUserId for the person
or application user responsible for the call. Prefer a stable, non-PII user ID.
All end-user fields are optional; name and email are display metadata and may
be omitted.
Response fields:
| Field | Type | Description |
|---|---|---|
callId |
string |
Use in endCall |
newCustomer |
boolean |
true if customer was just created |
canceled |
boolean |
true if subscription is canceled |
policy |
"NONE" | "BLOCK" | "DOWNGRADE" |
Quota enforcement policy |
allowed |
AllowedEntitlements |
What the customer can use |
entitlementHints |
EntitlementHints |
Model selection guidance |
rateLimits |
RateLimitsSnapshot |
Optional rolling-call limit state |
meters |
Record<string, MeterSummary> |
Per-meter usage snapshots |
subscription |
SubscriptionSnapshot |
Plan and billing details |
models |
Record<string, string[]> |
Model hints by tier |
idempotency |
{ key, source } |
Resolved idempotency key |
promptCompress
Opt-in prompt compression for manual flows. Call it after beginCall and before the vendor request. beginCall remains unchanged and never compresses prompts automatically.
const begin = await client.beginCall({
customerId: "cust_123",
feature: "chat.send",
requested: { standard: true },
idempotencyKey: crypto.randomUUID(),
});
const compressed = await client.promptCompress({
callId: begin.data.callId,
input: {
prompt: "Summarize this report for finance",
data: [
{ account: "A-100", spend: 1200, status: "active" },
{ account: "B-200", spend: 850, status: "paused" },
],
},
});
const response = await openai.responses.create({
model: "gpt-5.6-luna",
input: compressed.compressedInput,
});
await client.endCall({
callId: begin.data.callId,
modelUsed: "gpt-5.6-luna",
inputTokens: response.usage?.input_tokens ?? 0,
responseTokens: response.usage?.output_tokens ?? 0,
});
For exact spans that compatible compressors should leave unchanged, wrap the span with protectPromptText():
import { protectPromptText } from "@usagetap/sdk";
const compressed = await client.promptCompress({
callId: begin.data.callId,
input: `Keep ${protectPromptText("PLAN_ID_PRO_2026")} exact.`,
});
Request fields:
| Field | Type | Description |
|---|---|---|
callId |
string |
Required. The call returned by beginCall |
input |
unknown |
Prompt string, message payload, or structured data to compress locally |
text |
string |
Optional The Token Company-style alias for single-text compression |
provider |
"heuristic" | "toon" | "thetokencompany" | "usagetap" |
Optional. Defaults to conservative local heuristic |
model |
string |
Optional compression model override, e.g. "bear-2" |
tokenCompanyModel |
string |
Optional The Token Company model override |
aggressiveness |
number |
Optional compression aggressiveness from 0.0 to 1.0 |
tokenCompanyAppId |
string |
Optional The Token Company app identifier |
usageTapCompressionModel |
string |
Optional UsageTap compression model override |
Response fields:
| Field | Type | Description |
|---|---|---|
compressedInput |
unknown |
Send this to your LLM vendor |
savedTokens |
number |
Approximate input tokens avoided |
tokenSavingsRatio |
number |
Approximate token reduction from 0 to 1 |
savedCharacters |
number |
Character savings |
techniques |
string[] |
Compression strategies applied |
The default compressor is conservative: it normalizes whitespace, preserves fenced code indentation, minifies valid embedded JSON, and converts eligible JSON blocks to TOON when smaller. Counts use lightweight regex token estimation, not a model-specific BPE tokenizer. If compression or savings reporting fails, the SDK returns the original input with zero savings so your vendor call can continue.
For provider: "usagetap", manual promptCompress() and compressPromptInput() use the single-text The Token Company-compatible endpoint at https://compress.usagetap.com/v1/compress; aggressiveness is a single number from 0.0 to 1.0 for this path.
wrapOpenAI() and wrapAnthropic() also support opt-in automatic prompt compression:
const ai = wrapOpenAI(openai, client, {
defaultContext: { customerId: "cust_123", feature: "chat.send" },
promptCompression: {
provider: "heuristic",
roles: { user: true, tool: true },
minTokens: 500,
},
});
console.log(ai.promptCompression.totalTokensSaved);
With provider: "usagetap", wrappers use https://compress.usagetap.com/v1/messages/compress and can pass The Token Company-style per-role aggressiveness:
const ai = wrapOpenAI(openai, client, {
defaultContext: { customerId: "cust_123", feature: "chat.send" },
promptCompression: {
provider: "usagetap",
aggressiveness: { user: 0.5, system: 0.5, tool: 0.5 },
},
});
User messages are compressed by default. System instructions, tool content, and assistant messages are skipped. If you pass a roles object, only the listed roles are compressed. The wrappers aggregate compression telemetry once per UsageTap call and keep raw prompt content out of UsageTap telemetry.
Lower-level helpers are available for custom pipelines:
const result = await client.compressPromptInput(input, {
provider: "heuristic",
});
await client.recordPromptCompression({
callId: begin.data.callId,
promptCompression: {
provider: result.provider,
originalCharacters: result.originalCharacters,
compressedCharacters: result.compressedCharacters,
savedCharacters: result.savedCharacters,
originalTokens: result.originalTokens,
compressedTokens: result.compressedTokens,
savedTokens: result.savedTokens,
tokenSavingsRatio: result.tokenSavingsRatio,
savingsRatio: result.savingsRatio,
techniques: result.techniques,
},
});
endCall
Report actual usage for a tracked call. Always call this, even on errors.
const response = await client.endCall({
callId: "call_xyz789", // Required: from beginCall
providerUsed: "openai", // Vendor that executed the call
modelUsed: "gpt-5.6-sol", // Model identifier
reasoningEffort: "high", // Actual/best-known execution effort
reasoningEffortSource: "provider_response", // How effort was established
reasoningMode: "enabled", // Optional provider-specific mode
reasoningBudgetTokens: 2048, // Optional explicit thinking budget
inputTokens: 512, // Prompt tokens
responseTokens: 256, // Completion tokens
cachedInputTokens: 100, // Cached prompt tokens (included in inputTokens)
cacheWriteInputTokens: 25, // Cache-write tokens (included in inputTokens)
reasoningTokens: 0, // Reasoning tokens (o1/o3 models)
searches: 1, // Web searches used
audioSeconds: 0, // Audio processing seconds
batch: false, // Batch pricing flag
pricingMode: "standard", // "batch" | "standard"
error: undefined, // Set if call failed: { code, message }
stripeCustomerId: "cus_...", // Override Stripe customer
});
console.log("Cost:", response.data.costUSD);
console.log("Metered:", response.data.metered);
The OpenAI, Anthropic, OpenRouter, and OpenAI-compatible fetch wrappers populate execution metadata automatically when it is present in the provider request or response. Reasoning token counts never imply an effort level.
Response fields:
| Field | Type | Description |
|---|---|---|
callId |
string |
Echo of call ID |
status |
"COMPLETED" | "FAILED" |
Provider outcome recorded by UsageTap |
error |
{ code, message } |
Normalized provider error when status is FAILED |
providerUsed / modelUsed |
string |
Execution vendor and resolved model |
reasoningEffort |
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" |
Execution effort, distinct from the entitlement ceiling returned by beginCall |
reasoningEffortSource |
string |
provider_response, provider_request, gateway_config, or model_default |
costUSD |
number |
Final calculated cost after the selected pricing mode |
costUsdNano |
string |
Optional high-precision cost representation |
usage |
object |
Normalized input, cache-read, cache-write, output, and reasoning tokens |
standardCostUSD |
number |
Cost before a vendor-batch discount |
pricingMultiplier |
number |
0.5 for batch or 1 for standard |
metered |
object |
Usage that was counted against quotas |
spendVelocity |
SpendVelocitySnapshot |
Aggregate-backed customer spend for current UTC hour/day |
balances |
object |
Remaining quotas after this call |
For gateway calls, reasoningEffort is the effective normalized value that was
sent after provider/model capability mapping, not an unsupported requested
value. Native mode and fixed-budget details are reported separately through
reasoningMode and reasoningBudgetTokens when known.
withUsage
High-level helper that wraps the entire begin → vendor call → end lifecycle. Automatically calls endCall even on errors or aborts.
const result = await client.withUsage(
{
customerId: "cust_123",
feature: "chat.send",
idempotencyKey: crypto.randomUUID(),
requested: { standard: true, premium: true },
},
async ({ begin, setUsage, setError }) => {
// 1. Select model based on entitlements
const model = begin.data.allowed.premium
? "gpt-5.6-sol"
: begin.data.allowed.standard
? "gpt-5.6-luna"
: null;
if (!model) {
throw new Error("Usage is not allowed for this customer");
}
try {
// 2. Make your vendor call
const response = await openai.chat.completions.create({
model,
messages: [{ role: "user", content: "Hello" }],
});
// 3. Report usage
setUsage({
modelUsed: model,
inputTokens: response.usage?.prompt_tokens ?? 0,
responseTokens: response.usage?.completion_tokens ?? 0,
cachedInputTokens: response.usage?.prompt_tokens_details?.cached_tokens ?? 0,
reasoningTokens: 0,
});
return response.choices[0].message.content;
} catch (error) {
// 4. Report errors
setError({
code: "VENDOR_ERROR",
message: "Provider request failed",
});
throw error;
}
}
);
createCustomer
Create or retrieve a customer record. Fully idempotent — repeat calls return the existing snapshot.
const response = await client.createCustomer(
{
customerId: "cust_123",
customerFriendlyName: "Acme Corp", // Display name
customerEmail: "billing@acme.com", // Email for notifications
stripeCustomerId: "cus_stripe123", // Link to Stripe
},
{
idempotencyKey: crypto.randomUUID(), // Optional request option
},
);
console.log("New?", response.data.newCustomer); // true on first call
console.log("Plan:", response.data.subscription.planName);
console.log("Entitlements:", response.data.allowed);
Tip:
customerFriendlyNameandcustomerEmailare highly recommended — they populate the customer profile and enable usage notification emails.
checkUsage
Query current usage status without creating a call session. Returns the same data as beginCall minus callId.
const status = await client.checkUsage({
customerId: "cust_123",
});
console.log("Meters:", status.data.meters);
console.log("Allowed:", status.data.allowed);
console.log("Plan:", status.data.plan);
console.log("Balances:", status.data.balances);
Use this for dashboard widgets, pre-flight checks, or displaying quota status.
changePlan
Switch a customer to a different usage plan. Requires the customers:write
API key scope.
const result = await client.changePlan({
customerId: "cust_123",
planId: "plan_premium_v2",
strategy: "IMMEDIATE_RESET",
});
console.log("Success:", result.data.success);
console.log("New plan:", result.data.subscription.planName);
Strategy options:
| Strategy | Behavior |
|---|---|
IMMEDIATE_RESET |
Switch now, reset all usage counters to zero |
IMMEDIATE_PRORATED |
Switch now, prorate existing usage to new limits |
AT_NEXT_REPLENISH |
Schedule change for next replenishment cycle |
If AT_NEXT_REPLENISH is used, the subscription.pending field will indicate the scheduled change.
incrementCustomMeter
Track custom usage beyond standard LLM metrics — agent actions, document processing, API calls, etc.
const result = await client.incrementCustomMeter(
{
customerId: "cust_123",
customerUserId: currentUser.id, // Optional stable end-user attribution
meterSlot: "CUSTOM1", // "CUSTOM1" or "CUSTOM2"
amount: 5, // Positive number to decrement from quota
feature: "agent_actions", // Feature tracking
tags: ["workflow_automation"], // Categorization
metadata: { // Arbitrary metadata
workflowId: "wf_abc123",
actionType: "email_send",
},
},
{
idempotencyKey: "stable-background-job-key",
},
);
console.log("Event ID:", result.data.eventId);
console.log("Remaining:", result.data.meter.remaining);
console.log("Blocked:", result.data.blocked);
Returns: Updated meter snapshot with remaining, limit, used, unlimited, and label fields.
Notes:
- Custom meters must be enabled in the customer's usage plan
- The SDK generates an idempotency key by default; pass a stable option when a background job can retry across processes
BLOCKpolicy throws an error when quota is exceededDOWNGRADEpolicy allows usage to continue past quota- Unlimited meters still record events for analytics
UsageTap Gateway
UsageTapClient.gateway exposes the OpenAI-compatible Gateway and native batch
jobs without requiring the OpenAI SDK:
import { UsageTap } from "@usagetap/sdk";
const usageTap = new UsageTap();
const completion = await usageTap.gateway.chat.completions.create({
model: "usagetap/standard",
customerId: "cust_123",
messages: [{ role: "user", content: "Hello" }],
});
The batch resource uses a consistent create, wait, and results lifecycle:
const submitted = await usageTap.gateway.batches.create({
requests: jobs.map((job) => ({
custom_id: job.id,
body: {
model: "usagetap/standard",
messages: job.messages,
},
})),
});
const batch = await usageTap.gateway.batches.wait(submitted);
const results = await usageTap.gateway.batches.results(batch.id);
Available methods are gateway.models.list(),
gateway.chat.completions.create(), and
gateway.batches.create(), retrieve(), wait(), cancel(), and
results(). The SDK generates the required batch idempotency key and parses
NDJSON results into objects.
Context Summarization
UsageTapClient.summarization runs published profiles through the managed
summarization API:
const summary = await usageTap.summarization.summaries.create({
profile: "weekly-account-summary-abcd5678",
wait: true,
context: {
id: "account-123",
type: "account_history",
content: accountHistory,
},
});
Use summarization.batches.create() and .wait() for 1 to 20 source
contexts. summarization.profiles.retrieve() loads the immutable prompt and
settings for self-managed execution, and
summarization.measurements.create() reports the resulting token savings.
OpenAI Integration
Reversible wrappers
Use withMetering for metering or withCompression for standalone prompt
compression. Both preserve the provider client's methods and arguments, so the
rest of the application stays unchanged.
import OpenAI from "openai";
import { withCompression, withMetering } from "@usagetap/sdk/openai";
const metered = withMetering(new OpenAI(), "cust_123");
const compressed = withCompression(new OpenAI(), {
// Defaults to 1,000. Use 0 to always attempt compression.
minContextTokens: 2_000,
});
// Optional settings are only needed when you want more control.
const both = withMetering(new OpenAI(), {
customerId: "cust_123",
feature: "chat.send",
promptCompression: true,
});
Remove the wrapper or call .unwrap() to recover the original client. The same
two functions are available from @usagetap/sdk/anthropic and
@usagetap/sdk/openrouter.
withCompression first makes a fast estimate of the combined prompt context.
It skips the compression call below 1,000 estimated tokens by default, avoiding
an extra network round trip when likely savings are small. Override the cutoff
with minContextTokens, or set it to 0 to always attempt compression.
minTokens is separate and applies to individual text segments.
For advanced workloads, the wrapper passes the hosted Messages API settings without requiring a Runtime Compression Policy:
const openai = withCompression(new OpenAI(), {
mode: "model_auto", // "model_force" or "deterministic"
roles: {
user: { aggressiveness: 0.2 },
system: { aggressiveness: 0.1 },
},
latencyBudgetMs: 1_000,
compactEmptyUserMessages: false,
compactDuplicateUserTextParts: false,
failOpen: true,
});
The zero-config withCompression(new OpenAI()) form remains the recommended
starting point.
Wrappers are composable and remove one layer per .unwrap() call:
const openai = withMetering(
withCompression(new OpenAI()),
"cust_123",
);
Keep metering outside compression when the metered operation should include
compression latency. Avoid enabling promptCompression on withMetering when
a separate withCompression layer is already present.
wrapOpenAI
Wrap an OpenAI client for automatic usage tracking with zero boilerplate.
import OpenAI from "openai";
import { wrapOpenAI } from "@usagetap/sdk/openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const ai = wrapOpenAI(openai, client, {
defaultContext: {
customerId: "cust_123",
feature: "chat.send",
requested: {
standard: true,
premium: true,
search: true,
reasoningLevel: "HIGH",
},
},
});
const completion = await ai.chat.completions.create(
{
model: existingModel,
messages: [{ role: "user", content: "Hello!" }],
},
{
usageTap: {
idempotencyKey: crypto.randomUUID(),
},
}
);
Override per request:
await ai.chat.completions.create(
{ messages },
{
usageTap: {
customerId: currentCustomer.id,
customerUserId: currentUser.id, // Optional
feature: "chat.assist",
tags: ["beta"],
requested: { standard: true, premium: true, reasoningLevel: "MEDIUM" },
},
}
);
Model selection: wrapOpenAI preserves the model supplied by the application. It does not invent a model mapping. Use withUsage() when BLOCK or DOWNGRADE must control whether the provider is called or which approved fallback is selected.
Supported methods:
ai.chat.completions.create()— chat completions (streaming & non-streaming)ai.responses.create()— responses API
Streaming
Streaming responses are automatically instrumented. Use the exported helpers to pipe streams to clients:
Next.js App Router:
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: {
customerId: "cust_123",
idempotencyKey: crypto.randomUUID(),
},
}
);
return toNextResponse(stream, { mode: "text" });
}
Express:
import { pipeToResponse } from "@usagetap/sdk/openai";
app.post("/api/chat", async (req, res) => {
const stream = await ai.chat.completions.create(
{ messages: req.body.messages, stream: true },
{ usageTap: { customerId: req.user.id } }
);
pipeToResponse(stream, res);
});
OpenRouter
OpenRouter uses the same reversible OpenAI-compatible wrapper:
import OpenAI from "openai";
import { withMetering } from "@usagetap/sdk/openrouter";
const openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY!,
});
const ai = withMetering(openrouter, "cust_123");
const completion = await ai.chat.completions.create({
messages: [{ role: "user", content: "Hello from OpenRouter!" }],
// Model selected via entitlements, using OpenRouter identifiers
});
begin.data.models surfaces OpenRouter-specific model identifiers for each tier.
Anthropic Integration
For the short path, import withMetering or withCompression from
@usagetap/sdk/anthropic. They preserve anthropic.messages.create() and can be
removed without changing downstream calls.
wrapAnthropic
Wrap an Anthropic client for automatic usage tracking and optional role-aware prompt compression.
import Anthropic from "@anthropic-ai/sdk";
import { wrapAnthropic } from "@usagetap/sdk/anthropic";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const claude = wrapAnthropic(anthropic, client, {
defaultContext: {
customerId: "cust_123",
feature: "chat.anthropic",
requested: { standard: true, premium: true },
},
promptCompression: {
provider: "heuristic",
roles: { system: true, user: true, tool: true },
},
});
const message = await claude.messages.create({
model: "claude-3-5-haiku-latest",
max_tokens: 512,
system: "Long system prompt",
messages: [{ role: "user", content: "Long user prompt" }],
});
wrapAnthropic() instruments messages.create(), preserves the supplied model, extracts usage.input_tokens, usage.output_tokens, and cache-read tokens when present, and exposes compression totals on claude.promptCompression. Compression understands Anthropic system, message text blocks, and tool_result blocks; assistant messages are skipped by default.
Express Middleware
Attach UsageTap context to all Express 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!,
});
// Attach UsageTap to every request
app.use(withUsage(usageTap, (req) => req.user?.id || "anonymous"));
app.post("/api/chat", async (req, res) => {
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
// Get a UsageTap-wrapped OpenAI client from the request
const ai = req.usageTap!.openai(openai, {
feature: "chat.assistant",
requested: { standard: true, premium: true, reasoningLevel: "HIGH" },
});
const stream = await ai.chat.completions.create(
{ messages: req.body.messages, stream: true },
{ usageTap: { idempotencyKey: crypto.randomUUID() } }
);
// Pipes stream and finalizes usage automatically
req.usageTap!.pipeToResponse(stream, res);
});
app.listen(3000);
React Hook
Build chat UIs with automatic usage tracking:
import { useChatWithUsage } from "@usagetap/sdk/react";
function Chat({ customerId, currentUser }: ChatProps) {
const { messages, input, setInput, handleSubmit, isLoading, error } =
useChatWithUsage({
api: "/api/chat", // Your server endpoint
customerId,
customerUserId: currentUser.id, // Optional hint; validate on the server
feature: "chat.assistant",
});
return (
<div>
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.content}
</div>
))}
</div>
{error && <div className="error">{error.message}</div>}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send"}
</button>
</form>
</div>
);
}
The hook works with any server route that uses the UsageTap SDK.
wrapFetch (Minimal Integration)
The smallest possible integration — wraps fetch and instruments OpenAI calls automatically:
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 },
},
});
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
fetch: wrappedFetch,
});
// Zero changes to your OpenAI code
const completion = await openai.chat.completions.create({
model: "gpt-5.6-sol",
messages: [{ role: "user", content: "Hello!" }],
});
Override context per request via headers:
await openai.chat.completions.create(
{ messages: [{ role: "user", content: "Hello!" }] },
{
headers: {
"x-usagetap-customer-id": currentUser.id,
"x-usagetap-feature": "chat.premium",
},
}
);
OpenTelemetry Exporter
Export UsageTap metrics to any OTLP-compatible backend (Datadog, Grafana Cloud, New Relic, etc.).
Installation
npm install @usagetap/otel-exporter
Setup
import { createOtelExporter } from "@usagetap/otel-exporter";
import { UsageTapClient } from "@usagetap/sdk";
const otel = createOtelExporter({
endpoint: "https://otel-collector.example.com:4318/v1/metrics",
headers: { "Authorization": "Bearer your-otel-key" },
serviceName: "my-app",
serviceVersion: "1.0.0",
resourceAttributes: { "deployment.environment": "production" },
exportIntervalMs: 60000, // Default: 60s
});
const usageTap = new UsageTapClient({
apiKey: process.env.USAGETAP_API_KEY!,
baseUrl: process.env.USAGETAP_BASE_URL!,
onUsageMetric: otel.send, // Wire up metrics export
});
// On application shutdown:
await otel.shutdown();
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string |
Required | OTLP HTTP endpoint |
headers |
Record<string, string> |
{} |
Auth and custom headers |
serviceName |
string |
"usagetap" |
OTEL service name |
serviceVersion |
string |
"0.1.0" |
Service version |
resourceAttributes |
Record<string, string> |
{} |
OTEL resource attributes |
exportIntervalMs |
number |
60000 |
Export interval in ms |
Exported Metrics
| Metric | Type | Description |
|---|---|---|
usagetap.calls |
Counter | Total calls tracked |
usagetap.input_tokens |
Counter | Input tokens consumed |
usagetap.output_tokens |
Counter | Output tokens generated |
usagetap.cached_tokens |
Counter | Cached tokens (prompt cache hits) |
usagetap.reasoning_tokens |
Counter | Reasoning tokens used |
usagetap.cost_usd |
Counter | Cost in USD |
usagetap.searches |
Counter | Web searches performed |
usagetap.audio_seconds |
Counter | Audio processing seconds |
usagetap.custom_meter |
Counter | Custom meter increments |
All metrics include attributes: customer_id, feature, model, tags.
Platform Examples
Datadog:
const otel = createOtelExporter({
endpoint: "https://http-intake.logs.datadoghq.com/api/v2/otlp/v1/metrics",
headers: { "DD-API-KEY": process.env.DD_API_KEY! },
serviceName: "my-ai-app",
});
Grafana Cloud:
const otel = createOtelExporter({
endpoint: "https://otlp-gateway-prod-us-central-0.grafana.net/otlp/v1/metrics",
headers: {
Authorization: `Basic ${Buffer.from(
`${process.env.GRAFANA_INSTANCE_ID}:${process.env.GRAFANA_API_KEY}`
).toString("base64")}`,
},
serviceName: "my-ai-app",
});
New Relic:
const otel = createOtelExporter({
endpoint: "https://otlp.nr-data.net:4318/v1/metrics",
headers: { "api-key": process.env.NEW_RELIC_LICENSE_KEY! },
serviceName: "my-ai-app",
});
Embeddable Widgets
Display customer usage data on any webpage with embeddable widgets.
HTML Data Attributes
<div class="usagetap-widget"
data-api-key="ek-..."
data-organization-id="org123"
data-customer-id="cust456"
data-type="usage"
data-format="compact"
data-refresh="60"
data-show-refresh-button="true"
data-page-size="20"
data-width="600px"
data-height="auto">
</div>
<script src="https://usagetap.com/widget.js"></script>
JavaScript API
UsageTap.createWidget('#container', {
apiKey: 'ek-...',
organizationId: 'org123',
customerId: 'cust456',
type: 'usage', // "usage" | "calls" | "custom"
format: 'compact', // "compact" | "detailed"
refreshInterval: 60, // Auto-refresh in seconds
showRefreshButton: true, // Manual refresh button
pageSize: 20, // Items per page (detailed mode)
width: '600px',
height: 'auto',
});
Widget Types
| Type | Description |
|---|---|
usage |
Overall usage summary with meter cards |
calls |
Paginated call history list |
custom |
Custom meter visualization |
Widget Data API
Fetch widget data programmatically:
GET /embed/widget-data?organization_id=org123&customer_id=cust456&type=usage&format=compact
Authorization: Bearer ek-...
Query Parameters:
| Parameter | Required | Default | Description |
|---|---|---|---|
organization_id |
Yes | — | Organization ID |
customer_id |
Yes | — | Customer ID |
type |
No | "usage" |
"usage", "plan", or "calls" |
format |
No | "compact" |
"compact" or "detailed" |
metrics |
No | — | Comma-separated metrics to filter |
debug |
No | — | "1" to enable debug mode |
Error Handling
The SDK throws UsageTapError for all API errors:
import { UsageTapError } from "@usagetap/sdk";
try {
await client.beginCall({ customerId: "cust_123" });
} catch (error) {
if (error instanceof UsageTapError) {
console.error("Code:", error.code);
console.error("Message:", error.message);
console.error("Status:", error.status);
console.error("Retryable:", error.retryable);
}
}
Error Codes
| Code | Description | Retryable |
|---|---|---|
USAGETAP_AUTH_ERROR |
Invalid API key or unauthorized | No |
USAGETAP_BAD_REQUEST |
Invalid request parameters | No |
USAGETAP_RATE_LIMITED |
Too many requests | Yes |
USAGETAP_CIRCUIT_OPEN |
Per-run model-call cap reached | No |
USAGETAP_SERVER_ERROR |
Server-side error | Yes |
USAGETAP_NETWORK_ERROR |
Network failure | Yes |
USAGETAP_INVALID_RESPONSE |
Unexpected response format | No |
USAGETAP_END_CALL_ERROR |
Failed to finalize call | No |
USAGETAP_BROWSER_RUNTIME |
SDK used in browser without allowBrowser |
No |
Retry Behavior
The SDK automatically retries on 429, 500, 502, 503, 504 with exponential backoff + jitter. Configure via retries option:
const client = new UsageTapClient({
apiKey: "...",
baseUrl: "...",
retries: {
maxAttempts: 5, // Default: 3
baseDelayMs: 500, // Default: 250
maxDelayMs: 10000, // Default: 5000
jitterRatio: 0.3, // Default: 0.2
},
});
Type Definitions
ReasoningLevel
type ReasoningLevel = "NONE" | "LOW" | "MEDIUM" | "HIGH";
LimitType
type LimitType = "NONE" | "BLOCK" | "DOWNGRADE";
RequestedEntitlements
interface RequestedEntitlements {
standard?: boolean;
premium?: boolean;
audio?: boolean;
image?: boolean;
search?: boolean;
reasoningLevel?: ReasoningLevel;
}
AllowedEntitlements
type AllowedEntitlements = Required<RequestedEntitlements>;
MeterSummary
interface MeterSummary {
remaining: number; // Always numeric (check unlimited flag instead)
limit: number | null;
used: number;
unlimited: boolean; // true = unbounded; remaining is informational
ratio: number | null; // remaining/limit (0-1), null when unlimited
label?: string;
}
Note:
remainingis always a number. Theunlimitedboolean flag indicates unbounded meters (notremaining === null).
SubscriptionSnapshot
interface SubscriptionSnapshot {
id: string;
usagePlanVersionId: string;
planName: string;
planVersion: string;
limitType: LimitType;
reasoningLevel: ReasoningLevel;
lastReplenishedAt: string; // ISO 8601
nextReplenishAt: string; // ISO 8601
subscriptionVersion: number;
customerFriendlyName?: string;
customerEmail?: string;
stripeCustomerId?: string;
pending?: {
usagePlanVersionId: string;
strategy: string;
effectiveAt: string;
};
}
EntitlementHints
interface EntitlementHints {
suggestedModelTier: "premium" | "standard" | "none";
reasoningLevel: ReasoningLevel;
policy: LimitType;
downgrade?: {
reason: string;
fallbackTier?: "premium" | "standard" | "none";
};
rateLimit?: {
tier: string;
windowHours: number;
limit: number;
used: number;
remaining: number;
blocked: boolean;
};
}
SpendVelocitySnapshot
Returned from call_end as aggregate-backed customer spend telemetry. UsageTap does not enforce limits from this field yet. Aggregates are updated asynchronously, so currentCallCostUsd is included separately from the aggregate totals.
interface SpendVelocitySnapshot {
currency: "USD";
source: "usage_aggregate";
generatedAt: string; // ISO 8601
customerId: string;
currentCallCostUsd: number;
windows: Record<"hour" | "day", {
bucket: string; // UTC bucket key
windowMinutes: number;
startedAt: string; // ISO 8601
endedAt: string; // ISO 8601
completedCostUsd: number;
completedCalls: number;
}>;
}
UsageMetricEvent
interface UsageMetricEvent {
type: "call_end" | "custom_meter";
timestamp: string; // ISO 8601
customerId: string;
callId?: string;
feature?: string;
tags?: string[];
metrics: {
inputTokens?: number;
cachedInputTokens?: number;
responseTokens?: number;
costUsd?: number;
customMeterSlot?: "CUSTOM1" | "CUSTOM2";
customMeterAmount?: number;
};
correlationId?: string;
}
PromptCompression
type PromptCompressionProvider = "heuristic" | "toon" | "thetokencompany" | "usagetap";
interface PromptCompressionRequest {
callId: string;
input?: unknown;
text?: string;
provider?: PromptCompressionProvider;
model?: string;
tokenCompanyModel?: string;
aggressiveness?: number;
/** @deprecated Use aggressiveness instead. */
tokenCompanyAggressiveness?: number;
tokenCompanyAppId?: string;
usageTapCompressionModel?: string;
/** @deprecated Use aggressiveness instead. */
usageTapCompressionAggressiveness?: number;
}
interface PromptCompressionStandaloneOptions {
provider?: PromptCompressionProvider;
failOpen?: boolean;
model?: string;
tokenCompanyModel?: string;
aggressiveness?: number;
/** @deprecated Use aggressiveness instead. */
tokenCompanyAggressiveness?: number;
tokenCompanyAppId?: string;
usageTapCompressionModel?: string;
/** @deprecated Use aggressiveness instead. */
usageTapCompressionAggressiveness?: number;
signal?: AbortSignal;
}
type PromptCompressionMessageRole = "system" | "user" | "tool" | "assistant";
type PromptCompressionRoleAggressiveness =
Partial<Record<PromptCompressionMessageRole, number>>;
type PromptCompressionMessagesAggressiveness =
number | PromptCompressionRoleAggressiveness;
interface PromptCompressionMessagesOptions {
provider?: "usagetap";
failOpen?: boolean;
aggressiveness?: PromptCompressionMessagesAggressiveness;
/** @deprecated Use aggressiveness instead. */
usageTapCompressionAggressiveness?: PromptCompressionMessagesAggressiveness;
signal?: AbortSignal;
}
interface PromptCompressionTelemetry {
provider: PromptCompressionProvider;
originalCharacters: number;
compressedCharacters: number;
savedCharacters: number;
originalTokens?: number;
compressedTokens?: number;
savedTokens?: number;
tokenSavingsRatio?: number;
savingsRatio: number;
techniques: string[];
}
interface RecordPromptCompressionRequest {
callId: string;
promptCompression: PromptCompressionTelemetry;
}
interface PromptCompressionResult {
input: unknown;
compressedInput: unknown;
provider: PromptCompressionProvider;
originalCharacters: number;
compressedCharacters: number;
savedCharacters: number;
originalTokens: number;
compressedTokens: number;
savedTokens: number;
tokenSavingsRatio: number;
savingsRatio: number;
techniques: string[];
}
type AnthropicPromptCompressionRole = "system" | "user" | "tool" | "assistant";
interface AnthropicPromptCompressionOptions {
enabled?: boolean;
provider?: PromptCompressionProvider;
roles?: Partial<Record<AnthropicPromptCompressionRole, boolean | {
enabled?: boolean;
provider?: PromptCompressionProvider;
minTokens?: number;
aggressiveness?: number;
/** @deprecated Use aggressiveness instead. */
tokenCompanyAggressiveness?: number;
/** @deprecated Use aggressiveness instead. */
usageTapCompressionAggressiveness?: number;
}>>;
/** Aggregate request threshold. withCompression defaults to 1,000. */
minContextTokens?: number;
/** Per-text-segment threshold. */
minTokens?: number;
failOpen?: boolean;
tokenCompanyModel?: string;
aggressiveness?: number | Partial<Record<AnthropicPromptCompressionRole, number>>;
/** @deprecated Use aggressiveness instead. */
tokenCompanyAggressiveness?: number | Partial<Record<AnthropicPromptCompressionRole, number>>;
usageTapCompressionModel?: string;
/** @deprecated Use aggressiveness instead. */
usageTapCompressionAggressiveness?: number | Partial<Record<AnthropicPromptCompressionRole, number>>;
tokenCompanyAppId?: string;
}
interface AnthropicPromptCompressionStats {
history: PromptCompressionTelemetry[];
failures: Array<{ callId: string; stage: "telemetry"; message: string; timestamp: number }>;
calls: number;
telemetryFailures: number;
failOpenEvents: number;
totalTokensSaved: number;
totalCharactersSaved: number;
tokenSavingsRatio: number;
savingsRatio: number;
}
OpenAIPromptCompressionStats exposes the same aggregate counters plus telemetryFailures and failOpenEvents, with OpenAI-specific operation names in history.
function protectPromptText(text: string): string;
Last Updated: July 29, 2026 SDK Version: 1.4.0 OTEL Exporter Version: 0.1.1