@usagetap/sdk 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1058 -863
  2. package/dist/adapters/anthropic.cjs +990 -24
  3. package/dist/adapters/anthropic.cjs.map +1 -1
  4. package/dist/adapters/anthropic.d.cts +45 -3
  5. package/dist/adapters/anthropic.d.ts +45 -3
  6. package/dist/adapters/anthropic.mjs +990 -25
  7. package/dist/adapters/anthropic.mjs.map +1 -1
  8. package/dist/adapters/openai.cjs +1164 -44
  9. package/dist/adapters/openai.cjs.map +1 -1
  10. package/dist/adapters/openai.d.cts +46 -3
  11. package/dist/adapters/openai.d.ts +46 -3
  12. package/dist/adapters/openai.mjs +1164 -45
  13. package/dist/adapters/openai.mjs.map +1 -1
  14. package/dist/adapters/openrouter.cjs +3899 -22
  15. package/dist/adapters/openrouter.cjs.map +1 -1
  16. package/dist/adapters/openrouter.d.cts +6 -3
  17. package/dist/adapters/openrouter.d.ts +6 -3
  18. package/dist/adapters/openrouter.mjs +3897 -23
  19. package/dist/adapters/openrouter.mjs.map +1 -1
  20. package/dist/anthropic/index.cjs +990 -24
  21. package/dist/anthropic/index.cjs.map +1 -1
  22. package/dist/anthropic/index.d.cts +2 -2
  23. package/dist/anthropic/index.d.ts +2 -2
  24. package/dist/anthropic/index.mjs +990 -25
  25. package/dist/anthropic/index.mjs.map +1 -1
  26. package/dist/client-CExQ8e1T.d.cts +1225 -0
  27. package/dist/client-CExQ8e1T.d.ts +1225 -0
  28. package/dist/express/index.cjs +421 -29
  29. package/dist/express/index.cjs.map +1 -1
  30. package/dist/express/index.d.cts +2 -2
  31. package/dist/express/index.d.ts +2 -2
  32. package/dist/express/index.mjs +421 -29
  33. package/dist/express/index.mjs.map +1 -1
  34. package/dist/index.cjs +680 -15
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +5 -3
  37. package/dist/index.d.ts +5 -3
  38. package/dist/index.mjs +680 -15
  39. package/dist/index.mjs.map +1 -1
  40. package/dist/openai/index.cjs +1165 -45
  41. package/dist/openai/index.cjs.map +1 -1
  42. package/dist/openai/index.d.cts +2 -2
  43. package/dist/openai/index.d.ts +2 -2
  44. package/dist/openai/index.mjs +1165 -46
  45. package/dist/openai/index.mjs.map +1 -1
  46. package/dist/openrouter/index.cjs +1182 -47
  47. package/dist/openrouter/index.cjs.map +1 -1
  48. package/dist/openrouter/index.d.cts +3 -3
  49. package/dist/openrouter/index.d.ts +3 -3
  50. package/dist/openrouter/index.mjs +1180 -46
  51. package/dist/openrouter/index.mjs.map +1 -1
  52. package/dist/react/index.cjs +19 -1
  53. package/dist/react/index.cjs.map +1 -1
  54. package/dist/react/index.d.cts +17 -4
  55. package/dist/react/index.d.ts +17 -4
  56. package/dist/react/index.mjs +19 -1
  57. package/dist/react/index.mjs.map +1 -1
  58. package/package.json +84 -84
  59. package/dist/client-BD8O2J8Z.d.cts +0 -668
  60. package/dist/client-BD8O2J8Z.d.ts +0 -668
package/README.md CHANGED
@@ -1,868 +1,1063 @@
1
- # @usagetap/sdk
2
-
3
- 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.
4
-
5
- ## Module formats
6
-
7
- `@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");`.
8
-
9
- Optional adapters live behind subpath exports so their peer dependencies stay out of the core bundle:
10
-
11
- - `@usagetap/sdk/openai` – OpenAI/OpenRouter helpers (`wrapOpenAI`, `streamOpenAIRoute`, etc.)
12
- - `@usagetap/sdk/anthropic` – Anthropic helper (`wrapAnthropic`)
13
- - `@usagetap/sdk/openrouter` – discoverable OpenRouter aliases for the OpenAI-compatible wrappers
14
- - `@usagetap/sdk/express` – Express middleware
15
- - `@usagetap/sdk/react` – React chat hook
16
-
17
- Install only the peer dependencies for the adapters you actually use.
18
-
19
- ## Quick start
20
-
21
- Install the peer dependency for your vendor (e.g. `openai` or `@anthropic-ai/sdk`) and the UsageTap SDK in your server runtime.
22
-
23
- ```bash
24
- npm install @usagetap/sdk openai
25
- ```
26
-
27
- Wrap the provider client you already use. `USAGETAP_API_KEY` and the production
28
- UsageTap URL are read automatically:
29
-
30
- ```ts
31
- import OpenAI from "openai";
32
- import { withMetering } from "@usagetap/sdk/openai";
33
-
34
- const openai = withMetering(new OpenAI(), "cust_123");
35
- const completion = await openai.responses.create({
36
- model: "gpt-5.5-mini",
37
- input: "Draft a welcome email for our Pro plan",
38
- });
39
-
40
- console.log(completion.output_text);
41
- ```
42
-
43
- Only the customer ID is required for metering. Pass an object instead of the
44
- string when you want optional feature, tag, entitlement, or prompt-compression
45
- settings. Existing `wrapOpenAI`, `wrapAnthropic`, and manual `withUsage` flows
46
- remain supported for advanced control.
47
-
48
- For standalone compression, wrap the client without changing any downstream
49
- calls:
50
-
51
- ```ts
52
- import OpenAI from "openai";
53
- import { withCompression } from "@usagetap/sdk/openai";
54
-
55
- const openai = withCompression(new OpenAI(), {
56
- // Defaults to 1,000. Use 0 to always attempt compression.
57
- minContextTokens: 2_000,
58
- });
59
- const completion = await openai.responses.create({
60
- model: "gpt-5.5-mini",
61
- input: longPrompt,
62
- });
63
- ```
64
-
1
+ # @usagetap/sdk
2
+
3
+ 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.
4
+
5
+ ## Module formats
6
+
7
+ `@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");`.
8
+
9
+ Optional adapters live behind subpath exports so their peer dependencies stay out of the core bundle:
10
+
11
+ - `@usagetap/sdk/openai` – OpenAI/OpenRouter helpers (`wrapOpenAI`, `streamOpenAIRoute`, etc.)
12
+ - `@usagetap/sdk/anthropic` – Anthropic helper (`wrapAnthropic`)
13
+ - `@usagetap/sdk/openrouter` – discoverable OpenRouter aliases for the OpenAI-compatible wrappers
14
+ - `@usagetap/sdk/express` – Express middleware
15
+ - `@usagetap/sdk/react` – React chat hook
16
+
17
+ Install only the peer dependencies for the adapters you actually use.
18
+
19
+ ## Quick start
20
+
21
+ Install the peer dependency for your vendor (e.g. `openai` or `@anthropic-ai/sdk`) and the UsageTap SDK in your server runtime.
22
+
23
+ ```bash
24
+ npm install @usagetap/sdk openai
25
+ ```
26
+
27
+ Wrap the provider client you already use. `USAGETAP_API_KEY` and the production
28
+ UsageTap URL are read automatically:
29
+
30
+ ```ts
31
+ import OpenAI from "openai";
32
+ import { withMetering } from "@usagetap/sdk/openai";
33
+
34
+ const openai = withMetering(new OpenAI(), "cust_123");
35
+ const completion = await openai.responses.create({
36
+ model: "gpt-5.6-luna",
37
+ input: "Draft a welcome email for our Pro plan",
38
+ });
39
+
40
+ console.log(completion.output_text);
41
+ ```
42
+
43
+ Only the customer ID is required for metering. Pass an object instead of the
44
+ string when you want optional feature, tag, entitlement, or prompt-compression
45
+ settings. Existing `wrapOpenAI`, `wrapAnthropic`, and manual `withUsage` flows
46
+ remain supported for advanced control.
47
+
48
+ For standalone compression, wrap the client without changing any downstream
49
+ calls:
50
+
51
+ ```ts
52
+ import OpenAI from "openai";
53
+ import { withCompression } from "@usagetap/sdk/openai";
54
+
55
+ const openai = withCompression(new OpenAI());
56
+ const completion = await openai.responses.create({
57
+ model: "gpt-5.6-luna",
58
+ input: longPrompt,
59
+ });
60
+ ```
61
+
65
62
  The same `withMetering` and `withCompression` APIs are exported from
66
63
  `@usagetap/sdk/anthropic` and `@usagetap/sdk/openrouter`. Remove the wrapper or
67
64
  call `.unwrap()` to recover the original provider client.
68
65
 
69
- `withCompression` uses a fast token estimate over the combined request context
70
- and skips the compression step below 1,000 estimated tokens by default. This
71
- avoids an extra network round trip when likely savings are small. Override the
72
- cutoff with `minContextTokens`; set it to `0` to always attempt compression.
66
+ `withCompression` compresses user messages only, uses a fast token estimate
67
+ over the combined request context, and skips the compression step below 1,000
68
+ estimated tokens by default. System instructions, tool content, and assistant
69
+ messages remain unchanged. Override roles or the `minContextTokens` cutoff only
70
+ when you need more control; set the cutoff to `0` to always attempt compression.
73
71
  The separate `minTokens` option remains a per-text-segment cutoff.
74
-
75
- Wrappers compose. Put metering outside compression so the metered operation
76
- includes compression and the provider call:
77
-
78
- ```ts
79
- const openai = withMetering(
80
- withCompression(new OpenAI()),
81
- "cust_123",
82
- );
83
- ```
84
-
85
- Each `.unwrap()` removes one layer. Do not also set `promptCompression: true` on
86
- `withMetering` when using a separate `withCompression` layer.
87
-
88
- For advanced entitlement control, `wrapOpenAI` exposes the full UsageTap context
89
- and applies entitlement-aware defaults when you omit `model`.
90
-
91
- ```ts
92
- import { wrapOpenAI } from "@usagetap/sdk/openai";
93
-
94
- const ai = wrapOpenAI(openai, usageTap, {
95
- defaultContext: {
96
- customerId: "cust_123",
97
- feature: "chat.send",
98
- requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
99
- },
100
- promptCompression: {
101
- provider: "heuristic",
102
- roles: { user: true, tool: true },
103
- minTokens: 500,
104
- },
105
- });
106
- ```
107
-
108
- ### Prompt compression
109
-
110
- 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.
111
-
112
- ```ts
113
- import { protectPromptText } from "@usagetap/sdk";
114
-
115
- const begin = await usageTap.beginCall({
116
- customerId: "cust_123",
117
- feature: "chat.send",
118
- });
119
-
120
- const compressed = await usageTap.promptCompress({
121
- callId: begin.data.callId,
122
- input: `Please summarize this long prompt but keep ${protectPromptText("PLAN_ID_PRO_2026")} exact.`,
123
- });
124
-
125
- const response = await openai.responses.create({
126
- model: "gpt5-mini",
127
- input: compressed.compressedInput as string,
128
- });
129
- ```
130
-
131
- 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.
132
-
133
- `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`.
134
-
135
- ```ts
136
- import Anthropic from "@anthropic-ai/sdk";
137
- import { wrapAnthropic } from "@usagetap/sdk/anthropic";
138
-
139
- const anthropic = wrapAnthropic(
140
- new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
141
- usageTap,
142
- {
143
- defaultContext: { customerId: "cust_123", feature: "chat.send" },
144
- promptCompression: { roles: { system: true, user: true, tool: true } },
145
- },
146
- );
147
-
148
- await anthropic.messages.create({
149
- model: "claude-3-5-haiku-latest",
150
- max_tokens: 512,
151
- system: "Long system prompt",
152
- messages: [{ role: "user", content: "Long user prompt" }],
153
- });
154
- ```
155
-
156
- 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:
157
-
158
- ```ts
159
- const result = await usageTap.promptCompress({
160
- callId: begin.data.callId,
161
- text: "Your text here",
162
- provider: "usagetap",
163
- model: "bear-2",
164
- aggressiveness: 0.5,
165
- });
166
- ```
167
-
168
- ```ts
169
- const ai = wrapOpenAI(openai, usageTap, {
170
- defaultContext: { customerId: "cust_123", feature: "chat.send" },
171
- promptCompression: {
172
- provider: "usagetap",
173
- aggressiveness: { user: 0.5, system: 0.5, tool: 0.5 },
174
- },
175
- });
176
- ```
177
-
178
- `UsageTapClient` sends your UsageTap API key by default. Override `usageTapCompressionEndpoint` for single-text compression or `usageTapCompressionMessagesEndpoint` for wrapper message compression.
179
-
180
- 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.
181
-
182
- For advanced custom flows, `compressPromptInput(input, options?)` returns compression results without recording telemetry, and `recordPromptCompression({ callId, promptCompression })` records precomputed savings metadata against a call.
183
-
184
- > **Heads up:** `UsageTapClient` always negotiates the canonical UsageTap media type by sending `Accept: application/vnd.usagetap.v1+json`. Every response now uses the `{ result, data, correlationId }` envelope exclusively and the begin payload includes `data.idempotency.key` (always matching `callId`), per-meter snapshots, and subscription metadata. Set `autoIdempotency: false` (or pass your own `idempotency`) to skip the SDK's auto-generated key and rely on the server's deterministic fallback when retriable semantics are acceptable.
185
-
186
- ### Streaming helpers
187
-
188
- `wrapOpenAI` automatically instruments streaming responses. You can feed the wrapped stream directly into Next.js or an Express response using the exported helpers:
189
-
190
- ```ts
191
- import { toNextResponse } from "@usagetap/sdk/openai";
192
-
193
- export async function POST() {
194
- const stream = await ai.chat.completions.create(
195
- {
196
- messages: [{ role: "user", content: "Stream it" }],
197
- stream: true,
198
- },
199
- {
200
- usageTap: {
201
- requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
202
- },
203
- },
204
- );
205
-
206
- return toNextResponse(stream, { mode: "text" });
207
- }
208
- ```
209
-
210
- `wrapOpenAI` inspects `begin.data.vendorHints.preferredModel`: premium entitlements resolve to `gpt5`, otherwise the wrapper falls back to `gpt5-mini`. Use the manual pattern shown earlier when you need to toggle reasoning effort or attach search tools based on the returned allowances.
211
-
212
- ### Overriding usage context per request
213
-
214
- You can override the UsageTap begin payload on a per-call basis via the `usageTap` option:
215
-
216
- ```ts
217
- await ai.chat.completions.create(
218
- { messages },
219
- {
220
- usageTap: {
221
- customerId: currentUser.id,
222
- feature: "chat.assist",
223
- tags: ["beta"],
224
- requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
225
- },
226
- },
227
- );
228
- ```
229
-
230
- The begin response for that call will promote premium plans to `gpt5`, fall back to `gpt5-mini` otherwise, and cap reasoning to the granted tier.
231
-
232
- 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()`.
233
-
234
- ### responses.create support
235
-
236
- The wrapper also instruments `openai.responses.create`, applying vendor hints (preferred models, token limits) and collecting usage data the same way as chat completions.
237
-
238
- ### OpenRouter support
239
-
240
- `wrapOpenAI` works seamlessly with OpenRouter since it uses an OpenAI-compatible API. Just point the base URL to OpenRouter:
241
-
242
- ```ts
243
- import OpenAI from "openai";
244
- import { UsageTapClient } from "@usagetap/sdk";
245
- import { wrapOpenAI } from "@usagetap/sdk/openai";
246
-
247
- const usageTap = new UsageTapClient({
248
- apiKey: process.env.USAGETAP_API_KEY!,
249
- baseUrl: process.env.USAGETAP_BASE_URL!,
250
- });
251
-
252
- const openrouter = new OpenAI({
253
- baseURL: "https://openrouter.ai/api/v1",
254
- apiKey: process.env.OPENROUTER_API_KEY!,
255
- });
256
-
257
- const ai = wrapOpenAI(openrouter, usageTap, {
258
- defaultContext: {
259
- customerId: "cust_123",
260
- feature: "chat.send",
261
- requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
262
- },
263
- });
264
-
265
- const completion = await ai.chat.completions.create(
266
- {
267
- messages: [{ role: "user", content: "Hello from OpenRouter!" }],
268
- },
269
- {
270
- usageTap: {
271
- requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
272
- },
273
- },
274
- );
275
- ```
276
-
277
- `begin.data.models` will surface the OpenRouter-specific identifiers the customer can use (for example, `standard` ⇒ `gpt5-mini`, `premium` ⇒ `gpt5`). Since `wrapOpenAI` honors those hints, you can omit `model` and let UsageTap keep the request aligned with the active entitlement.
278
-
279
- ### Express middleware
280
-
281
- For Express applications, use the `withUsage` middleware to attach UsageTap context to requests:
282
-
283
- ```ts
284
- import express from "express";
285
- import OpenAI from "openai";
286
- import { UsageTapClient } from "@usagetap/sdk";
287
- import { withUsage } from "@usagetap/sdk/express";
288
-
289
- const app = express();
290
- const usageTap = new UsageTapClient({
291
- apiKey: process.env.USAGETAP_API_KEY!,
292
- baseUrl: process.env.USAGETAP_BASE_URL!,
293
- });
294
-
295
- // Extract customer ID from your auth system
296
- app.use(withUsage(usageTap, (req) => req.user.id));
297
-
298
- app.post("/api/chat", async (req, res) => {
299
- const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
300
- const ai = req.usageTap!.openai(openai, {
301
- feature: "chat.assistant",
302
- requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
303
- });
304
-
305
- const stream = await ai.chat.completions.create(
306
- {
307
- messages: req.body.messages,
308
- stream: true,
309
- },
310
- {
311
- usageTap: {
312
- requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
313
- },
314
- },
315
- );
316
-
317
- // Pipes stream to response and finalizes usage
318
- req.usageTap!.pipeToResponse(stream, res);
319
- });
320
- ```
321
-
322
- With that context in place, premium calls receive `gpt5` and everyone else falls back to `gpt5-mini`. To respect `allowed.reasoningLevel` or `allowed.search`, read the begin payload inside route handlers (see the manual `withUsage` example above) and shape the OpenAI request accordingly.
323
-
324
- ### React hook for chat UIs
325
-
326
- Build chat interfaces with automatic UsageTap tracking:
327
-
328
- ```tsx
329
- import { useChatWithUsage } from "@usagetap/sdk/react";
330
-
331
- function ChatComponent({ userId }) {
332
- const { messages, input, setInput, handleSubmit, isLoading } = useChatWithUsage({
333
- api: "/api/chat",
334
- customerId: userId,
335
- feature: "chat.assistant",
336
- });
337
-
338
- return (
339
- <div>
340
- {messages.map((m) => (
341
- <div key={m.id}>
342
- <strong>{m.role}:</strong> {m.content}
343
- </div>
344
- ))}
345
- <form onSubmit={handleSubmit}>
346
- <input
347
- value={input}
348
- onChange={(e) => setInput(e.target.value)}
349
- disabled={isLoading}
350
- />
351
- <button type="submit" disabled={isLoading}>
352
- Send
353
- </button>
354
- </form>
355
- </div>
356
- );
357
- }
358
- ```
359
-
360
- The hook works with server routes that use UsageTap SDK (see `streamOpenAIRoute` above).
361
-
362
- ### wrapFetch: minimal integration
363
-
364
- 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:
365
-
366
- ```ts
367
- import OpenAI from "openai";
368
- import { UsageTapClient, wrapFetch } from "@usagetap/sdk";
369
-
370
- const usageTap = new UsageTapClient({
371
- apiKey: process.env.USAGETAP_API_KEY!,
372
- baseUrl: process.env.USAGETAP_BASE_URL!,
373
- });
374
-
375
- const wrappedFetch = wrapFetch(usageTap, {
376
- defaultContext: {
377
- customerId: "cust_123",
378
- feature: "chat",
379
- requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
380
- },
381
- });
382
-
383
- const openai = new OpenAI({
384
- apiKey: process.env.OPENAI_API_KEY!,
385
- fetch: wrappedFetch,
386
- });
387
-
388
- // Reuse the selectCapabilities helper shown above to map entitlements to models
389
- // Pull the entitlements you cached after call_begin and pick the right tier
390
- const { model } = selectCapabilities(session.entitlements.allowed);
391
-
392
- const completion = await openai.chat.completions.create({
393
- model,
394
- messages: [{ role: "user", content: "Hello!" }],
395
- });
396
- ```
397
-
398
- `wrapFetch` detects OpenAI API endpoints, handles streaming and non-streaming responses, and automatically extracts usage data. Persist the `begin.data.allowed` blob wherever you store session context so every downstream `openai` call can resolve to `gpt5` (premium) or `gpt5-mini` (standard). You can override context per-request using special headers:
399
-
400
- ```ts
401
- await openai.chat.completions.create(
402
- { messages: [{ role: "user", content: "Hello!" }] },
403
- {
404
- headers: {
405
- "x-usagetap-customer-id": currentUser.id,
406
- "x-usagetap-feature": "chat.premium",
407
- },
408
- },
409
- );
410
- ```
411
-
412
- ### Unified `/call` endpoint (API-only)
413
-
414
- 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.
415
-
416
- ```ts
417
- async function getEntitlementsFor(customerId: string) {
418
- // Call begin upfront or reuse a cached begin payload for this customer + feature
419
- return sessionStore.read(customerId); // pseudo-code: use your own persistence layer
420
- }
421
-
422
- const entitlements = await getEntitlementsFor("cust_123"); // stash begin.data.allowed somewhere durable
423
- const { model } = selectCapabilities(entitlements.allowed);
424
-
425
- const response = await fetch(`${baseUrl}/call`, {
426
- method: "POST",
427
- headers: {
428
- Authorization: `Bearer ${process.env.USAGETAP_API_KEY}`,
429
- Accept: "application/vnd.usagetap.v1+json",
430
- "Content-Type": "application/json",
431
- },
432
- body: JSON.stringify({
433
- customerId: "cust_123",
434
- requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
435
- feature: "chat.completions",
436
- idempotency: crypto.randomUUID(),
437
- vendor: {
438
- url: "https://api.openai.com/v1/chat/completions",
439
- method: "POST",
440
- headers: {
441
- Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
442
- "Content-Type": "application/json",
443
- },
444
- body: {
445
- model,
446
- messages: [{ role: "user", content: "Hello" }],
447
- },
448
- responseType: "json",
449
- },
450
- usage: { modelUsed: model },
451
- }),
452
- });
453
-
454
- const envelope = await response.json();
455
- if (!response.ok || envelope.result.status !== "ACCEPTED") {
456
- throw new Error(`UsageTap /call failed: ${envelope.result.code}`);
457
- }
458
-
459
- const { begin, end, vendor, endUsage } = envelope.data;
460
- ```
461
-
462
- - When the `vendor` block is omitted, `/call` simply runs begin → end using the provided `usage` overrides.
463
- - Non-2xx vendor responses still trigger `call_end`; the envelope returns `CALL_VENDOR_WARNING` alongside vendor error metadata.
464
- - The canonical media type `application/vnd.usagetap.v1+json` is required; the SDK already sends this header automatically when you rely on `UsageTapClient`.
465
-
466
- ## Exports
467
-
468
- Key exports from `@usagetap/sdk`:
469
-
470
- - `UsageTapClient` – minimal HTTP client for `createCustomer`, `changePlan`, `incrementCustomMeter`, `call_begin`, `call_end`, and `checkUsage`.
471
- - `createCustomer` – idempotently ensure a customer subscription exists before starting a call.
472
- - `changePlan` – switch a customer to a different usage plan with configurable strategy (immediate reset, prorated, or scheduled).
473
- - `incrementCustomMeter` – track custom usage metrics beyond standard LLM counters (agent actions, documents, API calls, etc.).
474
- - `checkUsage` – lightweight method to query current usage status without creating a call session.
475
- - `promptCompress` / `compressPromptToon` compress prompt input after `call_begin`, return the compressed payload, and record savings metadata for the call.
476
- - `protectPromptText` / `protect` – mark exact text spans that compatible compressors should not rewrite.
477
- - `wrapFetch` – wraps a fetch function to automatically instrument OpenAI API calls (minimal integration).
478
- - `createIdempotencyKey` – helper for generating UsageTap-compatible idempotency keys.
479
- - Type definitions for canonical UsageTap request/response payloads.
480
-
481
- Optional subpaths:
482
-
483
- - `@usagetap/sdk/openai` – `wrapOpenAI`, `createOpenAIAdapter`, `streamOpenAIRoute`, `toNextResponse`, `pipeToResponse`, and related types.
484
- - `@usagetap/sdk/anthropic` – `wrapAnthropic` and related prompt compression types.
485
- - `@usagetap/sdk/express` `withUsage`, `withUsageMiddleware`, and corresponding Express request types.
486
- - `@usagetap/sdk/react` – `useChatWithUsage` and supporting types for building chat interfaces.
487
-
488
- All helpers are designed for server runtimes. Use `UsageTapClient` with `allowBrowser: true` only for sandbox/test scenarios.
489
-
490
- ### Ensure a customer subscription exists
491
-
492
- 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`:
493
-
494
- ```ts
495
- const snapshot = await usageTap.createCustomer({
496
- customerId: "cust_123",
497
- customerFriendlyName: "Acme AI",
498
- customerEmail: "billing@acme.ai",
499
- stripeCustomerId: "cus_123",
500
- });
501
-
502
- console.log("New customer?", snapshot.data.newCustomer);
503
- console.log("Plan:", snapshot.data.plan);
504
- console.log("Allowed entitlements:", snapshot.data.allowed);
505
- ```
506
-
507
- `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.
508
-
509
- 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.
510
-
511
- ### Change a customer's plan
512
-
513
- Use `changePlan` to switch a customer to a different usage plan. You can control how the change is applied with the `strategy` option:
514
-
515
- ```ts
516
- const result = await usageTap.changePlan({
517
- customerId: "cust_123",
518
- planId: "plan_premium_v2",
519
- strategy: "IMMEDIATE_RESET", // or "IMMEDIATE_PRORATED" or "AT_NEXT_REPLENISH"
520
- });
521
-
522
- console.log("Plan changed:", result.data.success);
523
- console.log("New subscription:", result.data.subscription);
524
- ```
525
-
526
- **Strategy options:**
527
- - `IMMEDIATE_RESET`: Switch plan immediately and reset all usage counters to zero
528
- - `IMMEDIATE_PRORATED`: Switch plan immediately and prorate existing usage against new limits
529
- - `AT_NEXT_REPLENISH`: Schedule the plan change for the next replenishment cycle (default)
530
-
531
- 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.
532
-
533
- ### Check usage without creating a call
534
-
535
- When you need to display current quota status, plan details, or remaining balances without tracking a vendor call, use `checkUsage()`:
536
-
537
- ```ts
538
- const usageStatus = await usageTap.checkUsage({ customerId: "cust_123" });
539
-
540
- console.log("Meters:", usageStatus.data.meters);
541
- console.log("Allowed:", usageStatus.data.allowed);
542
- console.log("Plan:", usageStatus.data.plan);
543
- console.log("Balances:", usageStatus.data.balances);
544
- ```
545
-
546
- 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.
547
-
548
- ### Increment custom meters
549
-
550
- 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.
551
-
552
- ```ts
553
- const result = await usageTap.incrementCustomMeter({
554
- customerId: "cust_123",
555
- meterSlot: "CUSTOM1", // or "CUSTOM2"
556
- amount: 5,
557
- feature: "agent_actions",
558
- tags: ["workflow_automation"],
559
- metadata: {
560
- workflowId: "wf_abc123",
561
- actionType: "email_send",
562
- },
563
- });
564
-
565
- console.log("Event recorded:", result.data.eventId);
566
- console.log("Remaining quota:", result.data.meter.remaining);
567
- console.log("Blocked:", result.data.blocked);
568
- ```
569
-
570
- **Parameters:**
571
-
572
- - `customerId` (string, required): Customer identifier
573
- - `meterSlot` ("CUSTOM1" | "CUSTOM2", required): Which custom meter to increment
574
- - `amount` (number, required): Positive number to decrement from quota
575
- - `feature` (string, optional): Feature identifier for tracking
576
- - `tags` (string[], optional): Tags for categorization
577
- - `metadata` (object, optional): Additional metadata
578
-
579
- The method returns the updated meter snapshot showing remaining quota, limits, and usage. If the customer's plan has `limitType: "BLOCK"` and quota is exceeded, a `UsageTapError` is thrown with code `USAGETAP_AUTH_ERROR`.
580
-
581
- **Use cases:**
582
-
583
- ```ts
584
- // Track agent tool invocations
585
- await usageTap.incrementCustomMeter({
586
- customerId: "cust_123",
587
- meterSlot: "CUSTOM1",
588
- amount: 1,
589
- feature: "agent.tool_call",
590
- tags: ["web_search"],
591
- });
592
-
593
- // Track document processing (10 pages)
594
- await usageTap.incrementCustomMeter({
595
- customerId: "cust_456",
596
- meterSlot: "CUSTOM2",
597
- amount: 10,
598
- feature: "document.ocr",
599
- metadata: { documentId: "doc_789", pages: 10 },
600
- });
601
-
602
- // Track external API calls
603
- await usageTap.incrementCustomMeter({
604
- customerId: "cust_789",
605
- meterSlot: "CUSTOM1",
606
- amount: 1,
607
- feature: "external_api.maps",
608
- tags: ["geocoding"],
609
- });
610
- ```
611
-
612
- **Important notes:**
613
-
614
- 1. Custom meters must be enabled in the customer's usage plan
615
- 2. The `amount` decrements the remaining quota (like token usage)
616
- 3. With `BLOCK` policy, exceeding quota throws an error
617
- 4. With `DOWNGRADE` policy, usage continues but quota can go negative
618
- 5. Unlimited meters don't track usage but still record events for analytics
619
-
620
- ## Response envelope (canonical only)
621
-
622
- 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.
623
-
624
- ### Example `call_begin` success
625
-
626
- ```json
627
- {
628
- "result": {
629
- "status": "ACCEPTED",
630
- "code": "CALL_BEGIN_SUCCESS",
631
- "timestamp": "2025-10-04T18:21:37.482Z"
632
- },
633
- "data": {
634
- "callId": "call_123",
635
- "startTime": "2025-10-04T18:21:37.482Z",
636
- "policy": "DOWNGRADE",
637
- "newCustomer": false,
638
- "canceled": false,
639
- "allowed": {
640
- "standard": true,
641
- "premium": true,
642
- "audio": false,
643
- "image": false,
644
- "search": true,
645
- "reasoningLevel": "MEDIUM"
646
- },
647
- "entitlementHints": {
648
- "suggestedModelTier": "standard",
649
- "reasoningLevel": "MEDIUM",
650
- "policy": "DOWNGRADE",
651
- "downgrade": {
652
- "reason": "PREMIUM_QUOTA_EXHAUSTED",
653
- "fallbackTier": "standard"
654
- }
655
- },
656
- "meters": {
657
- "standardCalls": {
658
- "remaining": 12,
659
- "limit": 20,
660
- "used": 8,
661
- "unlimited": false,
662
- "ratio": 0.6
663
- },
664
- "premiumCalls": {
665
- "remaining": 0,
666
- "limit": null,
667
- "used": 0,
668
- "unlimited": true,
669
- "ratio": null
670
- },
671
- "standardTokens": {
672
- "remaining": 800,
673
- "limit": 1000,
674
- "used": 200,
675
- "unlimited": false,
676
- "ratio": 0.8
677
- }
678
- },
679
- "remainingRatios": {
680
- "standardCalls": 0.6,
681
- "standardTokens": 0.8
682
- },
683
- "subscription": {
684
- "id": "sub_123",
685
- "usagePlanVersionId": "plan_2025_01",
686
- "planName": "Pro",
687
- "planVersion": "2025-01",
688
- "limitType": "DOWNGRADE",
689
- "reasoningLevel": "MEDIUM",
690
- "lastReplenishedAt": "2025-10-04T00:00:00.000Z",
691
- "nextReplenishAt": "2025-11-04T00:00:00.000Z",
692
- "subscriptionVersion": 14
693
- },
694
- "models": {
695
- "standard": ["gpt5-mini"],
696
- "premium": ["gpt5"]
697
- },
698
- "idempotency": {
699
- "key": "call_123",
700
- "source": "derived"
701
- }
702
- },
703
- "correlationId": "corr_abc123"
704
- }
705
- ```
706
-
707
- `UsageTapClient` exposes the normalized structure via `UsageTapSuccessResponse<BeginCallResponseBody>`. In addition to the flattened `allowed` map, the begin response now ships richer metadata:
708
-
709
- - `entitlementHints` summarises the recommended model tier and downgrade rationale based on the active policy.
710
- - `meters` is a per-counter snapshot including remaining quotas, total limits, usage to date, and convenience ratios. `remainingRatios` mirrors the same information in a compact map for quick lookups.
711
- - `subscription` contains the active plan identity, versioning, and upcoming replenishment timestamps so you can render customer-facing UI without querying Dynamo yourself.
712
- - `models` surfaces per-organization vendor hints (e.g. standard vs. premium model shortlists).
713
- - `idempotency` reveals the actual key that was persisted (`callId` mirrors this value). When you omit `idempotency` in the request, the backend derives a deterministic hash from organization, customer, feature, and requested entitlements.
714
- - `plan` and `balances` remain available alongside the core begin payload for backwards compatibility with earlier SDK versions.
715
-
716
- ### Example `call_end` success
717
-
718
- ```json
719
- {
720
- "result": {
721
- "status": "ACCEPTED",
722
- "code": "CALL_END_SUCCESS",
723
- "timestamp": "2025-10-04T18:21:52.103Z"
724
- },
725
- "data": {
726
- "callId": "call_123",
727
- "costUSD": 0,
728
- "usage": {
729
- "inputTokens": 600,
730
- "cachedInputTokens": 120,
731
- "billableInputTokens": 480,
732
- "responseTokens": 288,
733
- "reasoningTokens": 0
734
- },
735
- "metered": {
736
- "tokens": 768,
737
- "calls": 1,
738
- "searches": 1
739
- },
740
- "spendVelocity": {
741
- "currency": "USD",
742
- "source": "usage_aggregate",
743
- "generatedAt": "2025-10-04T18:21:52.103Z",
744
- "customerId": "cust_123",
745
- "currentCallCostUsd": 0,
746
- "windows": {
747
- "hour": {
748
- "bucket": "2025-10-04T18",
749
- "windowMinutes": 60,
750
- "startedAt": "2025-10-04T18:00:00.000Z",
751
- "endedAt": "2025-10-04T18:21:52.103Z",
752
- "completedCostUsd": 8.75,
753
- "completedCalls": 24
754
- },
755
- "day": {
756
- "bucket": "2025-10-04",
757
- "windowMinutes": 1440,
758
- "startedAt": "2025-10-04T00:00:00.000Z",
759
- "endedAt": "2025-10-04T18:21:52.103Z",
760
- "completedCostUsd": 42.1,
761
- "completedCalls": 140
762
- }
763
- }
764
- }
765
- },
766
- "correlationId": "corr_abc123"
767
- }
768
- ```
769
-
770
- Send `cachedInputTokens` when available so UsageTap can apply provider cache-read pricing correctly.
771
-
772
- `metered` is derived from the raw Dynamo deltas. Additional meters (audio seconds, reasoning tokens, balances) will populate in later phases without breaking the contract.
773
-
774
- `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.
775
-
776
- ### Premium detection and override
777
-
778
- UsageTap automatically determines whether a call is premium based on the model's output token pricing:
779
- - If the output token price exceeds **$4.00 per million tokens**, the call is classified as premium
780
- - Otherwise, it's classified as standard
781
-
782
- You can explicitly override this detection by passing `isPremium` in your `call_end` request:
783
-
784
- ```ts
785
- await usageTap.endCall({
786
- callId: begin.data.callId,
787
- modelUsed: "custom-model-v2",
788
- inputTokens: 100,
789
- responseTokens: 200,
790
- isPremium: true, // Explicitly mark this as a premium call
791
- });
792
- ```
793
-
794
- This is useful when:
795
- - You're using custom models that aren't in UsageTap's pricing database
796
- - You want to enforce specific billing tiers regardless of pricing
797
- - You're implementing your own tier classification logic
798
-
799
- ### Batch pricing
800
-
801
- Batch mode applies a **50% discount** to standard pricing rates. Set `batch: true` or `pricingMode: "batch"` on either `call_begin` or `call_end`.
802
-
803
- When set on `call_begin`, the pricing mode carries through to `call_end` automatically. Setting it on `call_end` overrides the `call_begin` value.
804
-
805
- ```ts
806
- // Option 1: Set on call_begin (carries through)
807
- const begin = await usageTap.beginCall({
808
- customerId: "cust_123",
809
- batch: true,
810
- pricingMode: "batch",
811
- });
812
-
813
- // Option 2: Set on call_end (overrides call_begin)
814
- await usageTap.endCall({
815
- callId: begin.data.callId,
816
- modelUsed: "gpt-4o",
817
- inputTokens: 100,
818
- responseTokens: 200,
819
- batch: true,
820
- pricingMode: "batch",
821
- });
822
- ```
823
-
824
- Both `batch` and `pricingMode` are echoed in the responses from `call_begin` and `call_end`.
825
-
826
- ### Raw fetch integrations
827
-
828
- 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:
829
-
830
- ```ts
831
- import type { BeginCallResponseBody, EndCallResponseBody } from "@usagetap/sdk";
832
-
833
- const beginResponse = await fetch(`${baseUrl}/call_begin`, {
834
- method: "POST",
835
- headers: {
836
- Authorization: `Bearer ${apiKey}`,
837
- Accept: "application/vnd.usagetap.v1+json",
838
- "Content-Type": "application/json",
839
- },
840
- body: JSON.stringify(payload),
841
- }).then((r) => r.json());
842
-
843
- if (beginResponse.result.status !== "ACCEPTED") {
844
- throw new Error(`call_begin failed: ${beginResponse.result.code}`);
845
- }
846
-
847
- const begin = beginResponse.data as BeginCallResponseBody;
848
-
849
- // ...later, when closing the call
850
-
851
- const endResponse = await fetch(`${baseUrl}/call_end`, {
852
- method: "POST",
853
- headers: {
854
- Authorization: `Bearer ${apiKey}`,
855
- Accept: "application/vnd.usagetap.v1+json",
856
- "Content-Type": "application/json",
857
- },
858
- body: JSON.stringify({ callId: begin.callId }),
859
- }).then((r) => r.json());
860
-
861
- if (endResponse.result.status !== "ACCEPTED") {
862
- throw new Error(`call_end failed: ${endResponse.result.code}`);
863
- }
864
-
865
- const end = endResponse.data as EndCallResponseBody;
866
- ```
867
-
868
- The canonical payloads (`BeginCallResponseBody`, `EndCallResponseBody`, etc.) now match the envelope exactly, keeping SDK and raw integrations aligned without extra helper utilities.
72
+
73
+ When workload evidence calls for tuning, the wrapper also accepts the hosted
74
+ Messages API controls directly:
75
+
76
+ ```ts
77
+ const openai = withCompression(new OpenAI(), {
78
+ mode: "model_auto", // or "model_force" / "deterministic"
79
+ roles: {
80
+ user: { aggressiveness: 0.2 },
81
+ system: { aggressiveness: 0.1 },
82
+ },
83
+ latencyBudgetMs: 1_000,
84
+ compactEmptyUserMessages: false,
85
+ compactDuplicateUserTextParts: false,
86
+ failOpen: true,
87
+ });
88
+ ```
89
+
90
+ Omitting the options object remains the recommended starting point.
91
+
92
+ Wrappers compose. Put metering outside compression so the metered operation
93
+ includes compression and the provider call:
94
+
95
+ ```ts
96
+ const openai = withMetering(
97
+ withCompression(new OpenAI()),
98
+ "cust_123",
99
+ );
100
+ ```
101
+
102
+ Each `.unwrap()` removes one layer. Do not also set `promptCompression: true` on
103
+ `withMetering` when using a separate `withCompression` layer.
104
+
105
+ ### UsageTap Gateway
106
+
107
+ The core client can call the OpenAI-compatible UsageTap Gateway without a
108
+ second SDK. Use a `utk-` key with `gateway:invoke`. Add
109
+ `compression:invoke` if the same workflow also requests hosted Compression.
110
+ Existing `gk-` and compatible `cmp-` keys continue to work:
111
+
112
+ ```ts
113
+ import { UsageTap } from "@usagetap/sdk";
114
+
115
+ const usageTap = new UsageTap();
116
+ const completion = await usageTap.gateway.chat.completions.create({
117
+ model: "usagetap/standard",
118
+ customerId: "cust_123",
119
+ feature: "chat.reply",
120
+ messages: [{ role: "user", content: "Summarize this account." }],
121
+ });
122
+
123
+ console.log(completion.choices[0].message?.content);
124
+ ```
125
+
126
+ The same resource exposes `models.list()` and the complete native batch
127
+ lifecycle. Batch creation generates the required idempotency key unless one is
128
+ provided:
129
+
130
+ ```ts
131
+ const submitted = await usageTap.gateway.batches.create({
132
+ requests: reports.map((report) => ({
133
+ custom_id: report.id,
134
+ body: {
135
+ model: "usagetap/standard",
136
+ customerId: report.customerId,
137
+ messages: [{ role: "user", content: report.prompt }],
138
+ },
139
+ })),
140
+ });
141
+
142
+ const batch = await usageTap.gateway.batches.wait(submitted);
143
+ if (batch.status !== "completed") {
144
+ throw new Error(`Batch ended with status: ${batch.status}`);
145
+ }
146
+
147
+ // Parses the Gateway's NDJSON result stream into typed objects.
148
+ const results = await usageTap.gateway.batches.results(batch.id);
149
+ ```
150
+
151
+ Use `gateway.batches.retrieve()`, `cancel()`, and `results()` when you want to
152
+ manage polling yourself. Set `gatewayBaseUrl` or `USAGETAP_GATEWAY_URL` for a
153
+ non-default deployment.
154
+
155
+ ### Context summarization
156
+
157
+ Published context-summarization profiles are available through
158
+ `usageTap.summarization`. A managed single summary can wait for completion in
159
+ the initial request:
160
+
161
+ ```ts
162
+ const summary = await usageTap.summarization.summaries.create({
163
+ profile: "weekly-account-summary-abcd5678",
164
+ wait: true,
165
+ context: {
166
+ id: "account-123",
167
+ type: "account_history",
168
+ content: accountHistory,
169
+ },
170
+ });
171
+
172
+ console.log(summary.result);
173
+ ```
174
+
175
+ Batch submissions and polling use the same resource pattern as Gateway
176
+ batches:
177
+
178
+ ```ts
179
+ const submitted = await usageTap.summarization.batches.create({
180
+ profile: "weekly-account-summary-abcd5678",
181
+ items: accounts.map((account) => ({
182
+ id: account.id,
183
+ type: "account_history",
184
+ content: account.history,
185
+ })),
186
+ });
187
+
188
+ const batch = await usageTap.summarization.batches.wait(submitted);
189
+ ```
190
+
191
+ Self-managed workflows can use `summarization.profiles.retrieve()` to load the
192
+ published prompt and model settings, then
193
+ `summarization.measurements.create()` to report source and summary token
194
+ counts.
195
+
196
+ ### Runaway circuit breaker
197
+
198
+ Set a local per-run call cap and pass the same `runId` on every model call in a
199
+ workflow. Once the cap is reached, the SDK throws `USAGETAP_CIRCUIT_OPEN` before
200
+ `call_begin` or the paid provider request can start:
201
+
202
+ ```ts
203
+ import { UsageTapClient } from "@usagetap/sdk";
204
+
205
+ const usageTap = new UsageTapClient({
206
+ circuitBreaker: { maxCallsPerRun: 20 },
207
+ });
208
+ const run = { customerId: "cust_123", runId: crypto.randomUUID() };
209
+
210
+ try {
211
+ for (;;) {
212
+ const result = await usageTap.meter(run, async () => callModel());
213
+ if (result.done) break;
214
+ }
215
+ } finally {
216
+ usageTap.resetRun(run);
217
+ }
218
+ ```
219
+
220
+ `canRunContinue(run)` returns the current decision for graceful partial-result
221
+ handling. Idempotent retries do not consume another slot. The guard is
222
+ process-local by design, so use a stable `runId` in each SDK process and keep
223
+ account-level UsageTap limits enabled for distributed enforcement.
224
+
225
+ For advanced entitlement control, `wrapOpenAI` exposes the full UsageTap context
226
+ and applies entitlement-aware defaults when you omit `model`.
227
+
228
+ ```ts
229
+ import { wrapOpenAI } from "@usagetap/sdk/openai";
230
+
231
+ const ai = wrapOpenAI(openai, usageTap, {
232
+ defaultContext: {
233
+ customerId: "cust_123",
234
+ feature: "chat.send",
235
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
236
+ },
237
+ promptCompression: {
238
+ provider: "heuristic",
239
+ roles: { user: true, tool: true },
240
+ minTokens: 500,
241
+ },
242
+ });
243
+ ```
244
+
245
+ ### Optional end-user attribution
246
+
247
+ `customerId` identifies the customer account. When that account has multiple
248
+ users, add `customerUserId` to attribute the call to the responsible end user
249
+ in Live activity. Prefer a stable, non-PII application user ID.
250
+ `customerUserName` and `customerUserEmail` are optional display metadata. The
251
+ SDK does not infer them, and existing integrations remain valid when these
252
+ fields are omitted.
253
+
254
+ ```ts
255
+ const context = {
256
+ customerId: currentCustomer.id,
257
+ customerUserId: currentUser.id, // Optional, recommended when available
258
+ customerUserName: currentUser.name, // Optional display metadata
259
+ customerUserEmail: currentUser.email, // Optional display metadata
260
+ feature: "chat.send",
261
+ };
262
+ ```
263
+
264
+ ### Prompt compression
265
+
266
+ 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.
267
+
268
+ ```ts
269
+ import { protectPromptText } from "@usagetap/sdk";
270
+
271
+ const begin = await usageTap.beginCall({
272
+ customerId: "cust_123",
273
+ customerUserId: currentUser.id,
274
+ customerUserName: currentUser.name,
275
+ customerUserEmail: currentUser.email,
276
+ feature: "chat.send",
277
+ });
278
+
279
+ const compressed = await usageTap.promptCompress({
280
+ callId: begin.data.callId,
281
+ input: `Please summarize this long prompt but keep ${protectPromptText("PLAN_ID_PRO_2026")} exact.`,
282
+ });
283
+
284
+ const response = await openai.responses.create({
285
+ model: "gpt5-mini",
286
+ input: compressed.compressedInput as string,
287
+ });
288
+ ```
289
+
290
+ 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.
291
+
292
+ `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`.
293
+
294
+ ```ts
295
+ import Anthropic from "@anthropic-ai/sdk";
296
+ import { wrapAnthropic } from "@usagetap/sdk/anthropic";
297
+
298
+ const anthropic = wrapAnthropic(
299
+ new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
300
+ usageTap,
301
+ {
302
+ defaultContext: { customerId: "cust_123", feature: "chat.send" },
303
+ promptCompression: { roles: { system: true, user: true, tool: true } },
304
+ },
305
+ );
306
+
307
+ await anthropic.messages.create({
308
+ model: "claude-3-5-haiku-latest",
309
+ max_tokens: 512,
310
+ system: "Long system prompt",
311
+ messages: [{ role: "user", content: "Long user prompt" }],
312
+ });
313
+ ```
314
+
315
+ 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:
316
+
317
+ ```ts
318
+ const result = await usageTap.promptCompress({
319
+ callId: begin.data.callId,
320
+ text: "Your text here",
321
+ provider: "usagetap",
322
+ model: "bear-2",
323
+ aggressiveness: 0.5,
324
+ });
325
+ ```
326
+
327
+ ```ts
328
+ const ai = wrapOpenAI(openai, usageTap, {
329
+ defaultContext: { customerId: "cust_123", feature: "chat.send" },
330
+ promptCompression: {
331
+ provider: "usagetap",
332
+ aggressiveness: { user: 0.5, system: 0.5, tool: 0.5 },
333
+ },
334
+ });
335
+ ```
336
+
337
+ `UsageTapClient` sends your UsageTap API key by default. Override `usageTapCompressionEndpoint` for single-text compression or `usageTapCompressionMessagesEndpoint` for wrapper message compression.
338
+
339
+ 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.
340
+
341
+ For advanced custom flows, `compressPromptInput(input, options?)` returns compression results without recording telemetry, and `recordPromptCompression({ callId, promptCompression })` records precomputed savings metadata against a call.
342
+
343
+ > **Heads up:** `UsageTapClient` always negotiates the canonical UsageTap media type by sending `Accept: application/vnd.usagetap.v1+json`. Every response uses the `{ result, data, correlationId }` envelope and the begin payload includes `data.idempotency.key` (matching `callId`), per-meter snapshots, and subscription metadata. Keep `autoIdempotency` enabled unless you provide a unique key yourself. The server fallback is deterministic, so identical inputs can replay an earlier call.
344
+
345
+ ### Streaming helpers
346
+
347
+ `wrapOpenAI` automatically instruments streaming responses. You can feed the wrapped stream directly into Next.js or an Express response using the exported helpers:
348
+
349
+ ```ts
350
+ import { toNextResponse } from "@usagetap/sdk/openai";
351
+
352
+ export async function POST() {
353
+ const stream = await ai.chat.completions.create(
354
+ {
355
+ messages: [{ role: "user", content: "Stream it" }],
356
+ stream: true,
357
+ },
358
+ {
359
+ usageTap: {
360
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
361
+ },
362
+ },
363
+ );
364
+
365
+ return toNextResponse(stream, { mode: "text" });
366
+ }
367
+ ```
368
+
369
+ `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.
370
+
371
+ ### Overriding usage context per request
372
+
373
+ You can override the UsageTap begin payload on a per-call basis via the `usageTap` option:
374
+
375
+ ```ts
376
+ await ai.chat.completions.create(
377
+ { messages },
378
+ {
379
+ usageTap: {
380
+ customerId: currentCustomer.id,
381
+ customerUserId: currentUser.id, // Optional
382
+ feature: "chat.assist",
383
+ tags: ["beta"],
384
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
385
+ },
386
+ },
387
+ );
388
+ ```
389
+
390
+ The begin response returns the granted entitlements. The application must use those fields to select only a model and capabilities it has explicitly configured.
391
+
392
+ 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()`.
393
+
394
+ ### responses.create support
395
+
396
+ The wrapper also instruments `openai.responses.create`, preserves the supplied model and request, and collects usage data the same way as chat completions.
397
+
398
+ ### OpenRouter support
399
+
400
+ `wrapOpenAI` works seamlessly with OpenRouter since it uses an OpenAI-compatible API. Just point the base URL to OpenRouter:
401
+
402
+ ```ts
403
+ import OpenAI from "openai";
404
+ import { UsageTapClient } from "@usagetap/sdk";
405
+ import { wrapOpenAI } from "@usagetap/sdk/openai";
406
+
407
+ const usageTap = new UsageTapClient({
408
+ apiKey: process.env.USAGETAP_API_KEY!,
409
+ baseUrl: process.env.USAGETAP_BASE_URL!,
410
+ });
411
+
412
+ const openrouter = new OpenAI({
413
+ baseURL: "https://openrouter.ai/api/v1",
414
+ apiKey: process.env.OPENROUTER_API_KEY!,
415
+ });
416
+
417
+ const ai = wrapOpenAI(openrouter, usageTap, {
418
+ defaultContext: {
419
+ customerId: "cust_123",
420
+ feature: "chat.send",
421
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
422
+ },
423
+ });
424
+
425
+ const completion = await ai.chat.completions.create(
426
+ {
427
+ model: "your-existing-openrouter-model",
428
+ messages: [{ role: "user", content: "Hello from OpenRouter!" }],
429
+ },
430
+ {
431
+ usageTap: {
432
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
433
+ },
434
+ },
435
+ );
436
+ ```
437
+
438
+ `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`.
439
+
440
+ ### Express middleware
441
+
442
+ For Express applications, use the `withUsage` middleware to attach UsageTap context to requests:
443
+
444
+ ```ts
445
+ import express from "express";
446
+ import OpenAI from "openai";
447
+ import { UsageTapClient } from "@usagetap/sdk";
448
+ import { withUsage } from "@usagetap/sdk/express";
449
+
450
+ const app = express();
451
+ const usageTap = new UsageTapClient({
452
+ apiKey: process.env.USAGETAP_API_KEY!,
453
+ baseUrl: process.env.USAGETAP_BASE_URL!,
454
+ });
455
+
456
+ // Extract customer ID from your auth system
457
+ app.use(withUsage(usageTap, (req) => req.user.id));
458
+
459
+ app.post("/api/chat", async (req, res) => {
460
+ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
461
+ const ai = req.usageTap!.openai(openai, {
462
+ feature: "chat.assistant",
463
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
464
+ });
465
+
466
+ const stream = await ai.chat.completions.create(
467
+ {
468
+ messages: req.body.messages,
469
+ stream: true,
470
+ },
471
+ {
472
+ usageTap: {
473
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
474
+ },
475
+ },
476
+ );
477
+
478
+ // Pipes stream to response and finalizes usage
479
+ req.usageTap!.pipeToResponse(stream, res);
480
+ });
481
+ ```
482
+
483
+ 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.
484
+
485
+ ### React hook for chat UIs
486
+
487
+ Build chat interfaces with automatic UsageTap tracking:
488
+
489
+ ```tsx
490
+ import { useChatWithUsage } from "@usagetap/sdk/react";
491
+
492
+ function ChatComponent({ customerId, currentUser }) {
493
+ const { messages, input, setInput, handleSubmit, isLoading } = useChatWithUsage({
494
+ api: "/api/chat",
495
+ customerId,
496
+ customerUserId: currentUser.id, // Optional hint; validate on the server
497
+ feature: "chat.assistant",
498
+ });
499
+
500
+ return (
501
+ <div>
502
+ {messages.map((m) => (
503
+ <div key={m.id}>
504
+ <strong>{m.role}:</strong> {m.content}
505
+ </div>
506
+ ))}
507
+ <form onSubmit={handleSubmit}>
508
+ <input
509
+ value={input}
510
+ onChange={(e) => setInput(e.target.value)}
511
+ disabled={isLoading}
512
+ />
513
+ <button type="submit" disabled={isLoading}>
514
+ Send
515
+ </button>
516
+ </form>
517
+ </div>
518
+ );
519
+ }
520
+ ```
521
+
522
+ The hook works with server routes that use UsageTap SDK (see `streamOpenAIRoute` above).
523
+
524
+ ### wrapFetch: minimal integration
525
+
526
+ 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:
527
+
528
+ ```ts
529
+ import OpenAI from "openai";
530
+ import { UsageTapClient, wrapFetch } from "@usagetap/sdk";
531
+
532
+ const usageTap = new UsageTapClient({
533
+ apiKey: process.env.USAGETAP_API_KEY!,
534
+ baseUrl: process.env.USAGETAP_BASE_URL!,
535
+ });
536
+
537
+ const wrappedFetch = wrapFetch(usageTap, {
538
+ defaultContext: {
539
+ customerId: "cust_123",
540
+ feature: "chat",
541
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
542
+ },
543
+ });
544
+
545
+ const openai = new OpenAI({
546
+ apiKey: process.env.OPENAI_API_KEY!,
547
+ fetch: wrappedFetch,
548
+ });
549
+
550
+ // Reuse the selectCapabilities helper shown above to map entitlements to models
551
+ // Pull the entitlements you cached after call_begin and pick the right tier
552
+ const { model } = selectCapabilities(session.entitlements.allowed);
553
+
554
+ const completion = await openai.chat.completions.create({
555
+ model,
556
+ messages: [{ role: "user", content: "Hello!" }],
557
+ });
558
+ ```
559
+
560
+ `wrapFetch` detects OpenAI API endpoints, handles streaming and non-streaming responses, 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. You can override metering context per request using special headers:
561
+
562
+ ```ts
563
+ await openai.chat.completions.create(
564
+ { messages: [{ role: "user", content: "Hello!" }] },
565
+ {
566
+ headers: {
567
+ "x-usagetap-customer-id": currentUser.id,
568
+ "x-usagetap-feature": "chat.premium",
569
+ },
570
+ },
571
+ );
572
+ ```
573
+
574
+ ### Unified `/call` endpoint (API-only)
575
+
576
+ 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.
577
+
578
+ ```ts
579
+ async function getEntitlementsFor(customerId: string) {
580
+ // Call begin upfront or reuse a cached begin payload for this customer + feature
581
+ return sessionStore.read(customerId); // pseudo-code: use your own persistence layer
582
+ }
583
+
584
+ const entitlements = await getEntitlementsFor("cust_123"); // stash begin.data.allowed somewhere durable
585
+ const { model } = selectCapabilities(entitlements.allowed);
586
+
587
+ const response = await fetch(`${baseUrl}/call`, {
588
+ method: "POST",
589
+ headers: {
590
+ Authorization: `Bearer ${process.env.USAGETAP_API_KEY}`,
591
+ Accept: "application/vnd.usagetap.v1+json",
592
+ "Content-Type": "application/json",
593
+ },
594
+ body: JSON.stringify({
595
+ customerId: "cust_123",
596
+ requested: { standard: true, premium: true, search: true, reasoningLevel: "MEDIUM" },
597
+ feature: "chat.completions",
598
+ idempotency: crypto.randomUUID(),
599
+ vendor: {
600
+ url: "https://api.openai.com/v1/chat/completions",
601
+ method: "POST",
602
+ headers: {
603
+ Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
604
+ "Content-Type": "application/json",
605
+ },
606
+ body: {
607
+ model,
608
+ messages: [{ role: "user", content: "Hello" }],
609
+ },
610
+ responseType: "json",
611
+ },
612
+ usage: { modelUsed: model },
613
+ }),
614
+ });
615
+
616
+ const envelope = await response.json();
617
+ if (!response.ok || envelope.result.status !== "ACCEPTED") {
618
+ throw new Error(`UsageTap /call failed: ${envelope.result.code}`);
619
+ }
620
+
621
+ const { begin, end, vendor, endUsage } = envelope.data;
622
+ ```
623
+
624
+ - When the `vendor` block is omitted, `/call` simply runs begin → end using the provided `usage` overrides.
625
+ - Non-2xx vendor responses still trigger `call_end`; the envelope returns `CALL_VENDOR_WARNING` alongside vendor error metadata.
626
+ - The canonical media type `application/vnd.usagetap.v1+json` is required; the SDK already sends this header automatically when you rely on `UsageTapClient`.
627
+
628
+ ## Exports
629
+
630
+ Key exports from `@usagetap/sdk`:
631
+
632
+ - `UsageTapClient` – minimal HTTP client for `createCustomer`, `changePlan`, `incrementCustomMeter`, `call_begin`, `call_end`, and `checkUsage`.
633
+ - `createCustomer` – idempotently ensure a customer subscription exists before starting a call.
634
+ - `changePlan` – switch a customer to a different usage plan with configurable strategy (immediate reset, prorated, or scheduled).
635
+ - `incrementCustomMeter` – track custom usage metrics beyond standard LLM counters (agent actions, documents, API calls, etc.).
636
+ - `checkUsage` – lightweight method to query current usage status without creating a call session.
637
+ - `promptCompress` / `compressPromptToon` – compress prompt input after `call_begin`, return the compressed payload, and record savings metadata for the call.
638
+ - `protectPromptText` / `protect` – mark exact text spans that compatible compressors should not rewrite.
639
+ - `wrapFetch` – wraps a fetch function to automatically instrument OpenAI API calls (minimal integration).
640
+ - `createIdempotencyKey` – helper for generating UsageTap-compatible idempotency keys.
641
+ - Type definitions for canonical UsageTap request/response payloads.
642
+
643
+ Optional subpaths:
644
+
645
+ - `@usagetap/sdk/openai` – `wrapOpenAI`, `createOpenAIAdapter`, `streamOpenAIRoute`, `toNextResponse`, `pipeToResponse`, and related types.
646
+ - `@usagetap/sdk/anthropic` – `wrapAnthropic` and related prompt compression types.
647
+ - `@usagetap/sdk/express` – `withUsage`, `withUsageMiddleware`, and corresponding Express request types.
648
+ - `@usagetap/sdk/react` – `useChatWithUsage` and supporting types for building chat interfaces.
649
+
650
+ All helpers are designed for server runtimes. Use `UsageTapClient` with `allowBrowser: true` only for sandbox/test scenarios.
651
+
652
+ ### Ensure a customer subscription exists
653
+
654
+ 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`:
655
+
656
+ ```ts
657
+ const snapshot = await usageTap.createCustomer({
658
+ customerId: "cust_123",
659
+ customerFriendlyName: "Acme AI",
660
+ customerEmail: "billing@acme.ai",
661
+ stripeCustomerId: "cus_123",
662
+ });
663
+
664
+ console.log("New customer?", snapshot.data.newCustomer);
665
+ console.log("Plan:", snapshot.data.plan);
666
+ console.log("Allowed entitlements:", snapshot.data.allowed);
667
+ ```
668
+
669
+ `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.
670
+
671
+ 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.
672
+
673
+ ### Change a customer's plan
674
+
675
+ Use `changePlan` to switch a customer to a different usage plan. You can control how the change is applied with the `strategy` option:
676
+
677
+ ```ts
678
+ const result = await usageTap.changePlan({
679
+ customerId: "cust_123",
680
+ planId: "plan_premium_v2",
681
+ strategy: "IMMEDIATE_RESET", // or "IMMEDIATE_PRORATED" or "AT_NEXT_REPLENISH"
682
+ });
683
+
684
+ console.log("Plan changed:", result.data.success);
685
+ console.log("New subscription:", result.data.subscription);
686
+ ```
687
+
688
+ **Strategy options:**
689
+ - `IMMEDIATE_RESET`: Switch plan immediately and reset all usage counters to zero
690
+ - `IMMEDIATE_PRORATED`: Switch plan immediately and prorate existing usage against new limits
691
+ - `AT_NEXT_REPLENISH`: Schedule the plan change for the next replenishment cycle (default)
692
+
693
+ 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.
694
+
695
+ ### Check usage without creating a call
696
+
697
+ When you need to display current quota status, plan details, or remaining balances without tracking a vendor call, use `checkUsage()`:
698
+
699
+ ```ts
700
+ const usageStatus = await usageTap.checkUsage({ customerId: "cust_123" });
701
+
702
+ console.log("Meters:", usageStatus.data.meters);
703
+ console.log("Allowed:", usageStatus.data.allowed);
704
+ console.log("Plan:", usageStatus.data.plan);
705
+ console.log("Balances:", usageStatus.data.balances);
706
+ ```
707
+
708
+ 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.
709
+
710
+ ### Increment custom meters
711
+
712
+ 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.
713
+
714
+ ```ts
715
+ const result = await usageTap.incrementCustomMeter({
716
+ customerId: "cust_123",
717
+ customerUserId: currentUser.id,
718
+ meterSlot: "CUSTOM1", // or "CUSTOM2"
719
+ amount: 5,
720
+ feature: "agent_actions",
721
+ tags: ["workflow_automation"],
722
+ metadata: {
723
+ workflowId: "wf_abc123",
724
+ actionType: "email_send",
725
+ },
726
+ });
727
+
728
+ console.log("Event recorded:", result.data.eventId);
729
+ console.log("Remaining quota:", result.data.meter.remaining);
730
+ console.log("Blocked:", result.data.blocked);
731
+ ```
732
+
733
+ **Parameters:**
734
+
735
+ - `customerId` (string, required): Customer identifier
736
+ - `customerUserId` (string, optional): Stable identifier for the end user responsible for the event
737
+ - `customerUserName` and `customerUserEmail` (string, optional): Display fields for live activity
738
+ - `meterSlot` ("CUSTOM1" | "CUSTOM2", required): Which custom meter to increment
739
+ - `amount` (number, required): Positive number to decrement from quota
740
+ - `feature` (string, optional): Feature identifier for tracking
741
+ - `tags` (string[], optional): Tags for categorization
742
+ - `metadata` (object, optional): Additional metadata
743
+
744
+ The method returns the updated meter snapshot showing remaining quota, limits, and usage. If the customer's plan has `limitType: "BLOCK"` and quota is exceeded, a `UsageTapError` is thrown with code `USAGETAP_AUTH_ERROR`.
745
+
746
+ **Use cases:**
747
+
748
+ ```ts
749
+ // Track agent tool invocations
750
+ await usageTap.incrementCustomMeter({
751
+ customerId: "cust_123",
752
+ meterSlot: "CUSTOM1",
753
+ amount: 1,
754
+ feature: "agent.tool_call",
755
+ tags: ["web_search"],
756
+ });
757
+
758
+ // Track document processing (10 pages)
759
+ await usageTap.incrementCustomMeter({
760
+ customerId: "cust_456",
761
+ meterSlot: "CUSTOM2",
762
+ amount: 10,
763
+ feature: "document.ocr",
764
+ metadata: { documentId: "doc_789", pages: 10 },
765
+ });
766
+
767
+ // Track external API calls
768
+ await usageTap.incrementCustomMeter({
769
+ customerId: "cust_789",
770
+ meterSlot: "CUSTOM1",
771
+ amount: 1,
772
+ feature: "external_api.maps",
773
+ tags: ["geocoding"],
774
+ });
775
+ ```
776
+
777
+ **Important notes:**
778
+
779
+ 1. Custom meters must be enabled in the customer's usage plan
780
+ 2. The `amount` decrements the remaining quota (like token usage)
781
+ 3. With `BLOCK` policy, exceeding quota throws an error
782
+ 4. With `DOWNGRADE` policy, usage continues but quota can go negative
783
+ 5. Unlimited meters don't track usage but still record events for analytics
784
+
785
+ ## Response envelope (canonical only)
786
+
787
+ 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.
788
+
789
+ ### Example `call_begin` success
790
+
791
+ ```json
792
+ {
793
+ "result": {
794
+ "status": "ACCEPTED",
795
+ "code": "CALL_BEGIN_SUCCESS",
796
+ "timestamp": "2025-10-04T18:21:37.482Z"
797
+ },
798
+ "data": {
799
+ "callId": "call_123",
800
+ "startTime": "2025-10-04T18:21:37.482Z",
801
+ "policy": "DOWNGRADE",
802
+ "newCustomer": false,
803
+ "canceled": false,
804
+ "allowed": {
805
+ "standard": true,
806
+ "premium": true,
807
+ "audio": false,
808
+ "image": false,
809
+ "search": true,
810
+ "reasoningLevel": "MEDIUM"
811
+ },
812
+ "entitlementHints": {
813
+ "suggestedModelTier": "standard",
814
+ "reasoningLevel": "MEDIUM",
815
+ "policy": "DOWNGRADE",
816
+ "downgrade": {
817
+ "reason": "PREMIUM_QUOTA_EXHAUSTED",
818
+ "fallbackTier": "standard"
819
+ }
820
+ },
821
+ "meters": {
822
+ "standardCalls": {
823
+ "remaining": 12,
824
+ "limit": 20,
825
+ "used": 8,
826
+ "unlimited": false,
827
+ "ratio": 0.6
828
+ },
829
+ "premiumCalls": {
830
+ "remaining": 0,
831
+ "limit": null,
832
+ "used": 0,
833
+ "unlimited": true,
834
+ "ratio": null
835
+ },
836
+ "standardTokens": {
837
+ "remaining": 800,
838
+ "limit": 1000,
839
+ "used": 200,
840
+ "unlimited": false,
841
+ "ratio": 0.8
842
+ }
843
+ },
844
+ "remainingRatios": {
845
+ "standardCalls": 0.6,
846
+ "standardTokens": 0.8
847
+ },
848
+ "subscription": {
849
+ "id": "sub_123",
850
+ "usagePlanVersionId": "plan_2025_01",
851
+ "planName": "Pro",
852
+ "planVersion": "2025-01",
853
+ "limitType": "DOWNGRADE",
854
+ "reasoningLevel": "MEDIUM",
855
+ "lastReplenishedAt": "2025-10-04T00:00:00.000Z",
856
+ "nextReplenishAt": "2025-11-04T00:00:00.000Z",
857
+ "subscriptionVersion": 14
858
+ },
859
+ "models": {
860
+ "standard": ["gpt5-mini"],
861
+ "premium": ["gpt5"]
862
+ },
863
+ "idempotency": {
864
+ "key": "call_123",
865
+ "source": "derived"
866
+ }
867
+ },
868
+ "correlationId": "corr_abc123"
869
+ }
870
+ ```
871
+
872
+ `UsageTapClient` exposes the normalized structure via `UsageTapSuccessResponse<BeginCallResponseBody>`. In addition to the flattened `allowed` map, the begin response now ships richer metadata:
873
+
874
+ - `entitlementHints` summarises the recommended model tier and downgrade rationale based on the active policy.
875
+ - `meters` is a per-counter snapshot including remaining quotas, total limits, usage to date, and convenience ratios. `remainingRatios` mirrors the same information in a compact map for quick lookups.
876
+ - `subscription` contains the active plan identity, versioning, and upcoming replenishment timestamps so you can render customer-facing UI without querying Dynamo yourself.
877
+ - `models` surfaces per-organization vendor hints (e.g. standard vs. premium model shortlists).
878
+ - `idempotency` reveals the actual key that was persisted (`callId` mirrors 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, PAYG hold, and pricing mode; identical inputs can replay an earlier call.
879
+ - `plan` and `balances` remain available alongside the core begin payload for backwards compatibility with earlier SDK versions.
880
+
881
+ ### Example `call_end` success
882
+
883
+ ```json
884
+ {
885
+ "result": {
886
+ "status": "ACCEPTED",
887
+ "code": "CALL_END_SUCCESS",
888
+ "timestamp": "2025-10-04T18:21:52.103Z"
889
+ },
890
+ "data": {
891
+ "callId": "call_123",
892
+ "costUSD": 0,
893
+ "usage": {
894
+ "inputTokens": 600,
895
+ "cachedInputTokens": 120,
896
+ "billableInputTokens": 480,
897
+ "responseTokens": 288,
898
+ "reasoningTokens": 0
899
+ },
900
+ "metered": {
901
+ "tokens": 768,
902
+ "calls": 1,
903
+ "searches": 1
904
+ },
905
+ "spendVelocity": {
906
+ "currency": "USD",
907
+ "source": "usage_aggregate",
908
+ "generatedAt": "2025-10-04T18:21:52.103Z",
909
+ "customerId": "cust_123",
910
+ "currentCallCostUsd": 0,
911
+ "windows": {
912
+ "hour": {
913
+ "bucket": "2025-10-04T18",
914
+ "windowMinutes": 60,
915
+ "startedAt": "2025-10-04T18:00:00.000Z",
916
+ "endedAt": "2025-10-04T18:21:52.103Z",
917
+ "completedCostUsd": 8.75,
918
+ "completedCalls": 24
919
+ },
920
+ "day": {
921
+ "bucket": "2025-10-04",
922
+ "windowMinutes": 1440,
923
+ "startedAt": "2025-10-04T00:00:00.000Z",
924
+ "endedAt": "2025-10-04T18:21:52.103Z",
925
+ "completedCostUsd": 42.1,
926
+ "completedCalls": 140
927
+ }
928
+ }
929
+ }
930
+ },
931
+ "correlationId": "corr_abc123"
932
+ }
933
+ ```
934
+
935
+ Send `cachedInputTokens` and `cacheWriteInputTokens` when available so UsageTap
936
+ can apply provider prompt-cache pricing correctly. `inputTokens` is always the
937
+ total input count and includes both subsets. OpenAI's prompt-token total already
938
+ includes cache reads. Anthropic reports ordinary input, cache reads, and cache
939
+ writes separately, so the Anthropic wrapper adds the three counters for
940
+ `inputTokens` while retaining both cache subsets.
941
+
942
+ `metered` is derived from the raw Dynamo deltas. Additional meters (audio seconds, reasoning tokens, balances) will populate in later phases without breaking the contract.
943
+
944
+ `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.
945
+
946
+ ### Premium detection and override
947
+
948
+ UsageTap automatically determines whether a call is premium based on the model's output token pricing:
949
+ - If the output token price exceeds **$4.00 per million tokens**, the call is classified as premium
950
+ - Otherwise, it's classified as standard
951
+
952
+ You can explicitly override this detection by passing `isPremium` in your `call_end` request:
953
+
954
+ ```ts
955
+ await usageTap.endCall({
956
+ callId: begin.data.callId,
957
+ modelUsed: "custom-model-v2",
958
+ inputTokens: 100,
959
+ responseTokens: 200,
960
+ isPremium: true, // Explicitly mark this as a premium call
961
+ });
962
+ ```
963
+
964
+ This is useful when:
965
+ - You're using custom models that aren't in UsageTap's pricing database
966
+ - You want to enforce specific billing tiers regardless of pricing
967
+ - You're implementing your own tier classification logic
968
+
969
+ ### Batch pricing
970
+
971
+ Batch mode applies a **50% discount** to standard pricing rates. UsageTap accepts
972
+ the execution mode reported by your application; it does not attempt to infer
973
+ or verify the vendor workflow. Prefer `pricingMode: "batch"`; `batch: true` is
974
+ the compatibility form.
975
+
976
+ When set on `call_begin`, the pricing mode carries through to `call_end` automatically. Setting it on `call_end` overrides the `call_begin` value.
977
+
978
+ ```ts
979
+ // Option 1: Set on call_begin (carries through)
980
+ const begin = await usageTap.beginCall({
981
+ customerId: "cust_123",
982
+ batch: true,
983
+ pricingMode: "batch",
984
+ });
985
+
986
+ // Option 2: Set on call_end (overrides call_begin)
987
+ await usageTap.endCall({
988
+ callId: begin.data.callId,
989
+ modelUsed: "gpt-5.6-sol",
990
+ inputTokens: 100,
991
+ responseTokens: 200,
992
+ batch: true,
993
+ pricingMode: "batch",
994
+ });
995
+ ```
996
+
997
+ Both `batch` and `pricingMode` are echoed in the responses from `call_begin` and `call_end`.
998
+ When both request fields are supplied, `pricingMode` is authoritative.
999
+
1000
+ OpenAI and Anthropic completion/message usage records provide token counts, but
1001
+ they do not provide a dependable per-response signal that proves the request
1002
+ received vendor batch pricing. The wrappers therefore never infer batch mode
1003
+ from `usage`. Set it explicitly in wrapper context when your surrounding
1004
+ workflow knows the request is a vendor batch:
1005
+
1006
+ ```ts
1007
+ const metered = withMetering(openai, {
1008
+ customerId: "cust_123",
1009
+ pricingMode: "batch",
1010
+ usageTapClient: usageTap,
1011
+ });
1012
+ ```
1013
+
1014
+ The ordinary `wrapOpenAI` and `wrapAnthropic` create-method wrappers do not
1015
+ submit native vendor batch jobs. For OpenAI Batch, Anthropic Message Batches, or
1016
+ another asynchronous provider, open one UsageTap call per batch item, retain
1017
+ its `callId`, then call `endCall` with the usage returned for that item. The
1018
+ LLMAsAService `POST /v1/batches` integration performs this lifecycle
1019
+ automatically.
1020
+
1021
+ ### Raw fetch integrations
1022
+
1023
+ 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:
1024
+
1025
+ ```ts
1026
+ import type { BeginCallResponseBody, EndCallResponseBody } from "@usagetap/sdk";
1027
+
1028
+ const beginResponse = await fetch(`${baseUrl}/call_begin`, {
1029
+ method: "POST",
1030
+ headers: {
1031
+ Authorization: `Bearer ${apiKey}`,
1032
+ Accept: "application/vnd.usagetap.v1+json",
1033
+ "Content-Type": "application/json",
1034
+ },
1035
+ body: JSON.stringify(payload),
1036
+ }).then((r) => r.json());
1037
+
1038
+ if (beginResponse.result.status !== "ACCEPTED") {
1039
+ throw new Error(`call_begin failed: ${beginResponse.result.code}`);
1040
+ }
1041
+
1042
+ const begin = beginResponse.data as BeginCallResponseBody;
1043
+
1044
+ // ...later, when closing the call
1045
+
1046
+ const endResponse = await fetch(`${baseUrl}/call_end`, {
1047
+ method: "POST",
1048
+ headers: {
1049
+ Authorization: `Bearer ${apiKey}`,
1050
+ Accept: "application/vnd.usagetap.v1+json",
1051
+ "Content-Type": "application/json",
1052
+ },
1053
+ body: JSON.stringify({ callId: begin.callId }),
1054
+ }).then((r) => r.json());
1055
+
1056
+ if (endResponse.result.status !== "ACCEPTED") {
1057
+ throw new Error(`call_end failed: ${endResponse.result.code}`);
1058
+ }
1059
+
1060
+ const end = endResponse.data as EndCallResponseBody;
1061
+ ```
1062
+
1063
+ The canonical payloads (`BeginCallResponseBody`, `EndCallResponseBody`, etc.) now match the envelope exactly, keeping SDK and raw integrations aligned without extra helper utilities.