@usagetap/sdk 1.3.2 → 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 +219 -24
  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 +1 -1
  59. package/dist/client-BD8O2J8Z.d.cts +0 -668
  60. package/dist/client-BD8O2J8Z.d.ts +0 -668
package/README.md CHANGED
@@ -33,7 +33,7 @@ import { withMetering } from "@usagetap/sdk/openai";
33
33
 
34
34
  const openai = withMetering(new OpenAI(), "cust_123");
35
35
  const completion = await openai.responses.create({
36
- model: "gpt-5.5-mini",
36
+ model: "gpt-5.6-luna",
37
37
  input: "Draft a welcome email for our Pro plan",
38
38
  });
39
39
 
@@ -52,12 +52,9 @@ calls:
52
52
  import OpenAI from "openai";
53
53
  import { withCompression } from "@usagetap/sdk/openai";
54
54
 
55
- const openai = withCompression(new OpenAI(), {
56
- // Defaults to 1,000. Use 0 to always attempt compression.
57
- minContextTokens: 2_000,
58
- });
55
+ const openai = withCompression(new OpenAI());
59
56
  const completion = await openai.responses.create({
60
- model: "gpt-5.5-mini",
57
+ model: "gpt-5.6-luna",
61
58
  input: longPrompt,
62
59
  });
63
60
  ```
@@ -66,12 +63,32 @@ 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
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
+
75
92
  Wrappers compose. Put metering outside compression so the metered operation
76
93
  includes compression and the provider call:
77
94
 
@@ -85,6 +102,126 @@ const openai = withMetering(
85
102
  Each `.unwrap()` removes one layer. Do not also set `promptCompression: true` on
86
103
  `withMetering` when using a separate `withCompression` layer.
87
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
+
88
225
  For advanced entitlement control, `wrapOpenAI` exposes the full UsageTap context
89
226
  and applies entitlement-aware defaults when you omit `model`.
90
227
 
@@ -105,6 +242,25 @@ const ai = wrapOpenAI(openai, usageTap, {
105
242
  });
106
243
  ```
107
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
+
108
264
  ### Prompt compression
109
265
 
110
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.
@@ -114,6 +270,9 @@ import { protectPromptText } from "@usagetap/sdk";
114
270
 
115
271
  const begin = await usageTap.beginCall({
116
272
  customerId: "cust_123",
273
+ customerUserId: currentUser.id,
274
+ customerUserName: currentUser.name,
275
+ customerUserEmail: currentUser.email,
117
276
  feature: "chat.send",
118
277
  });
119
278
 
@@ -181,7 +340,7 @@ When using The Token Company, configure `tokenCompanyApiKey` on `UsageTapClient`
181
340
 
182
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.
183
342
 
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.
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.
185
344
 
186
345
  ### Streaming helpers
187
346
 
@@ -207,7 +366,7 @@ export async function POST() {
207
366
  }
208
367
  ```
209
368
 
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.
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.
211
370
 
212
371
  ### Overriding usage context per request
213
372
 
@@ -218,7 +377,8 @@ await ai.chat.completions.create(
218
377
  { messages },
219
378
  {
220
379
  usageTap: {
221
- customerId: currentUser.id,
380
+ customerId: currentCustomer.id,
381
+ customerUserId: currentUser.id, // Optional
222
382
  feature: "chat.assist",
223
383
  tags: ["beta"],
224
384
  requested: { standard: true, premium: true, search: true, reasoningLevel: "HIGH" },
@@ -227,13 +387,13 @@ await ai.chat.completions.create(
227
387
  );
228
388
  ```
229
389
 
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.
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.
231
391
 
232
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()`.
233
393
 
234
394
  ### responses.create support
235
395
 
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.
396
+ The wrapper also instruments `openai.responses.create`, preserves the supplied model and request, and collects usage data the same way as chat completions.
237
397
 
238
398
  ### OpenRouter support
239
399
 
@@ -264,6 +424,7 @@ const ai = wrapOpenAI(openrouter, usageTap, {
264
424
 
265
425
  const completion = await ai.chat.completions.create(
266
426
  {
427
+ model: "your-existing-openrouter-model",
267
428
  messages: [{ role: "user", content: "Hello from OpenRouter!" }],
268
429
  },
269
430
  {
@@ -274,7 +435,7 @@ const completion = await ai.chat.completions.create(
274
435
  );
275
436
  ```
276
437
 
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.
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`.
278
439
 
279
440
  ### Express middleware
280
441
 
@@ -319,7 +480,7 @@ app.post("/api/chat", async (req, res) => {
319
480
  });
320
481
  ```
321
482
 
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.
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.
323
484
 
324
485
  ### React hook for chat UIs
325
486
 
@@ -328,10 +489,11 @@ Build chat interfaces with automatic UsageTap tracking:
328
489
  ```tsx
329
490
  import { useChatWithUsage } from "@usagetap/sdk/react";
330
491
 
331
- function ChatComponent({ userId }) {
492
+ function ChatComponent({ customerId, currentUser }) {
332
493
  const { messages, input, setInput, handleSubmit, isLoading } = useChatWithUsage({
333
494
  api: "/api/chat",
334
- customerId: userId,
495
+ customerId,
496
+ customerUserId: currentUser.id, // Optional hint; validate on the server
335
497
  feature: "chat.assistant",
336
498
  });
337
499
 
@@ -395,7 +557,7 @@ const completion = await openai.chat.completions.create({
395
557
  });
396
558
  ```
397
559
 
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:
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:
399
561
 
400
562
  ```ts
401
563
  await openai.chat.completions.create(
@@ -552,6 +714,7 @@ Custom meters allow you to track usage beyond standard LLM metrics—ideal for a
552
714
  ```ts
553
715
  const result = await usageTap.incrementCustomMeter({
554
716
  customerId: "cust_123",
717
+ customerUserId: currentUser.id,
555
718
  meterSlot: "CUSTOM1", // or "CUSTOM2"
556
719
  amount: 5,
557
720
  feature: "agent_actions",
@@ -570,6 +733,8 @@ console.log("Blocked:", result.data.blocked);
570
733
  **Parameters:**
571
734
 
572
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
573
738
  - `meterSlot` ("CUSTOM1" | "CUSTOM2", required): Which custom meter to increment
574
739
  - `amount` (number, required): Positive number to decrement from quota
575
740
  - `feature` (string, optional): Feature identifier for tracking
@@ -710,7 +875,7 @@ UsageTap responds exclusively with the canonical `{ result, data, correlationId
710
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.
711
876
  - `subscription` contains the active plan identity, versioning, and upcoming replenishment timestamps so you can render customer-facing UI without querying Dynamo yourself.
712
877
  - `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.
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.
714
879
  - `plan` and `balances` remain available alongside the core begin payload for backwards compatibility with earlier SDK versions.
715
880
 
716
881
  ### Example `call_end` success
@@ -767,7 +932,12 @@ UsageTap responds exclusively with the canonical `{ result, data, correlationId
767
932
  }
768
933
  ```
769
934
 
770
- Send `cachedInputTokens` when available so UsageTap can apply provider cache-read pricing correctly.
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.
771
941
 
772
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.
773
943
 
@@ -798,7 +968,10 @@ This is useful when:
798
968
 
799
969
  ### Batch pricing
800
970
 
801
- Batch mode applies a **50% discount** to standard pricing rates. Set `batch: true` or `pricingMode: "batch"` on either `call_begin` or `call_end`.
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.
802
975
 
803
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.
804
977
 
@@ -813,7 +986,7 @@ const begin = await usageTap.beginCall({
813
986
  // Option 2: Set on call_end (overrides call_begin)
814
987
  await usageTap.endCall({
815
988
  callId: begin.data.callId,
816
- modelUsed: "gpt-4o",
989
+ modelUsed: "gpt-5.6-sol",
817
990
  inputTokens: 100,
818
991
  responseTokens: 200,
819
992
  batch: true,
@@ -822,6 +995,28 @@ await usageTap.endCall({
822
995
  ```
823
996
 
824
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.
825
1020
 
826
1021
  ### Raw fetch integrations
827
1022