@usagetap/sdk 1.3.1 → 1.3.2
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.
- package/README.md +855 -855
- package/dist/adapters/anthropic.cjs +1 -1
- package/dist/adapters/anthropic.cjs.map +1 -1
- package/dist/adapters/anthropic.mjs +1 -1
- package/dist/adapters/anthropic.mjs.map +1 -1
- package/dist/adapters/openai.cjs +1 -1
- package/dist/adapters/openai.cjs.map +1 -1
- package/dist/adapters/openai.mjs +1 -1
- package/dist/adapters/openai.mjs.map +1 -1
- package/dist/adapters/openrouter.cjs.map +1 -1
- package/dist/adapters/openrouter.mjs.map +1 -1
- package/dist/anthropic/index.cjs +1 -1
- package/dist/anthropic/index.cjs.map +1 -1
- package/dist/anthropic/index.mjs +1 -1
- package/dist/anthropic/index.mjs.map +1 -1
- package/dist/express/index.cjs.map +1 -1
- package/dist/express/index.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/openai/index.cjs +1 -1
- package/dist/openai/index.cjs.map +1 -1
- package/dist/openai/index.mjs +1 -1
- package/dist/openai/index.mjs.map +1 -1
- package/dist/openrouter/index.cjs +1 -1
- package/dist/openrouter/index.cjs.map +1 -1
- package/dist/openrouter/index.mjs +1 -1
- package/dist/openrouter/index.mjs.map +1 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.mjs.map +1 -1
- package/package.json +84 -84
package/README.md
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
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
|
-
|
|
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
55
|
const openai = withCompression(new OpenAI(), {
|
|
56
56
|
// Defaults to 1,000. Use 0 to always attempt compression.
|
|
57
57
|
minContextTokens: 2_000,
|
|
58
58
|
});
|
|
59
|
-
const completion = await openai.responses.create({
|
|
60
|
-
model: "gpt-5.5-mini",
|
|
61
|
-
input: longPrompt,
|
|
62
|
-
});
|
|
63
|
-
```
|
|
64
|
-
|
|
59
|
+
const completion = await openai.responses.create({
|
|
60
|
+
model: "gpt-5.5-mini",
|
|
61
|
+
input: longPrompt,
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
65
|
The same `withMetering` and `withCompression` APIs are exported from
|
|
66
66
|
`@usagetap/sdk/anthropic` and `@usagetap/sdk/openrouter`. Remove the wrapper or
|
|
67
67
|
call `.unwrap()` to recover the original provider client.
|
|
@@ -71,798 +71,798 @@ and skips the compression step below 1,000 estimated tokens by default. This
|
|
|
71
71
|
avoids an extra network round trip when likely savings are small. Override the
|
|
72
72
|
cutoff with `minContextTokens`; set it to `0` to always attempt compression.
|
|
73
73
|
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.
|
|
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.
|