@bymax-one/nest-ai-tokens 1.0.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.
package/README.md ADDED
@@ -0,0 +1,795 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/%40bymax--one-nest--ai--tokens-000000?style=for-the-badge&logo=nestjs&logoColor=E0234E" alt="@bymax-one/nest-ai-tokens" />
3
+ </p>
4
+
5
+ <h1 align="center">@bymax-one/nest-ai-tokens</h1>
6
+
7
+ <p align="center">
8
+ <strong>AI token metering and usage-based billing for NestJS</strong><br />
9
+ <sub>9 Provider Normalizers · Exact bigint nano-USD · Append-only Ledger · Hash-chain Integrity · Prepaid Wallets · Budgets · Streaming · Zero Runtime Dependencies</sub>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@bymax-one/nest-ai-tokens"><img src="https://img.shields.io/npm/v/@bymax-one/nest-ai-tokens?style=flat-square&colorA=000000&colorB=000000" alt="npm version" /></a>
14
+ <a href="https://www.npmjs.com/package/@bymax-one/nest-ai-tokens"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-ai-tokens?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
+ <a href="https://github.com/bymaxone/nest-ai-tokens/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-ai-tokens/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
+ <a href="https://github.com/bymaxone/nest-ai-tokens/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
17
+ <a href="https://github.com/bymaxone/nest-ai-tokens/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-100%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
18
+ <a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-ai-tokens"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-ai-tokens/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
+ <a href="https://github.com/bymaxone/nest-ai-tokens/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-ai-tokens?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
20
+ <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
21
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-24%2B-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" /></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="https://github.com/bymaxone/nest-ai-tokens">GitHub</a> ·
26
+ <a href="https://github.com/bymaxone/nest-ai-tokens/issues">Issues</a> ·
27
+ <a href="#-quick-start">Quick Start</a> ·
28
+ <a href="#-api-reference">API Reference</a> ·
29
+ <a href="https://github.com/bymaxone/nest-ai-tokens-example">Example App</a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## ✨ Overview
35
+
36
+ `@bymax-one/nest-ai-tokens` meters AI usage and bills it, in-process. Instead of assembling a
37
+ proxy for enforcement, a ledger for accounting, a price table for rating and a wallet for
38
+ prepaid credit — four systems that must agree on what a call cost — you install one library
39
+ and get all of it behind a NestJS guard and interceptor.
40
+
41
+ The library has **zero direct dependencies**. NestJS, Prisma, `ioredis`, the event emitter and
42
+ OpenTelemetry all arrive as peer dependencies, and the optional ones are never imported unless
43
+ the feature that needs them is enabled.
44
+
45
+ ### Why nest-ai-tokens?
46
+
47
+ - **Enforcement happens in the request.** A guard refuses the call before the handler runs
48
+ and an interceptor settles the actual usage after it — no proxy to deploy, no sidecar to
49
+ keep alive, no external hop that can fail open.
50
+ - **Money is exact by construction.** Every persisted amount is `bigint` nano-USD. There is no
51
+ float arithmetic on a money path, because a rounding difference there is a number a customer
52
+ is charged.
53
+ - **The ledger is evidence, not state.** Entries are appended and corrections are compensating
54
+ records; with the optional hash chain on, a row edited in the database stops matching the
55
+ chain that follows it.
56
+ - **Charging twice is prevented by the database.** The idempotency key carries a unique
57
+ constraint, and the replay path is that violation being recognized as a conflict — not
58
+ application care that someone can forget.
59
+
60
+ ---
61
+
62
+ ## 🔥 Features
63
+
64
+ ### 📊 Metering
65
+
66
+ - ✅ **Nine provider normalizers** — OpenAI Chat, OpenAI Responses, OpenAI-compatible
67
+ (DeepSeek / xAI / Groq / Azure), Anthropic, Gemini, Bedrock Converse, Mistral, OpenRouter
68
+ and the Vercel AI SDK, all folded into one usage shape
69
+ - ✅ **Provider-agnostic by construction** — normalizers consume plain objects, so no provider
70
+ SDK is a dependency and this library never makes the model call itself
71
+ - ✅ **Every billing dimension** — cached tokens, reasoning tokens, audio and image units, and
72
+ the write/read split providers report separately
73
+ - ✅ **Streaming capture** — `StreamUsageCollector` accumulates chunks and prefers the
74
+ provider's final usage, falling back to its own count when a stream is cut short
75
+
76
+ ### 💰 Money & Ledger
77
+
78
+ - ✅ **Exact `bigint` nano-USD** — on every persisted amount; no float arithmetic on a money
79
+ path
80
+ - ✅ **Point-in-time rating** — an entry is priced at the rate in effect at the call
81
+ timestamp, and never re-rated by a later price change
82
+ - ✅ **Append-only ledger** — corrections are compensating records that point at what they
83
+ reverse; nothing is updated in place
84
+ - ✅ **Hash-chain integrity** — optional per-entry chain over the previous posted entry, so a
85
+ row edited directly in the database is detectable
86
+ - ✅ **Exactly-once accounting** — a unique constraint on the idempotency key; the violation
87
+ is what the replay path recognizes as a conflict
88
+ - ✅ **Markup as configuration** — a fixed multiplier or a per-call `IMarkupPolicy`, applied
89
+ in both rating modes, so the resale spread is not application code
90
+
91
+ ### 🛡️ Enforcement
92
+
93
+ - ✅ **Hold → capture lifecycle** — the guard places a spend hold before the handler runs when
94
+ an estimate is declared; the interceptor settles it to actuals afterwards
95
+ - ✅ **Multi-dimension budgets** — cap spend, token count and operation count per scope per
96
+ window (daily / weekly / monthly), hard or soft
97
+ - ✅ **Atomic budget counter** — the optional `RedisBudgetCounterStore` runs check and
98
+ increment as one Lua script, so two concurrent requests cannot both fit under a cap that
99
+ holds one
100
+ - ✅ **Prepaid wallets** — append-only grant / debit / refund / adjust entries, with a
101
+ configurable burn order (expiry, priority, FIFO)
102
+
103
+ ### 🧩 Developer Experience
104
+
105
+ - ✅ **Zero runtime dependencies** — Prisma, `ioredis`, the event emitter and OpenTelemetry
106
+ all arrive as peers, and the optional ones are imported only when enabled
107
+ - ✅ **Five subpaths** — `.` server · `./shared` zero-dependency contracts · `./prices` seed
108
+ data · `./prisma` the PostgreSQL store · `./redis` the budget counter
109
+ - ✅ **Usage reports** — summarize by scope, feature, model or date, with currency conversion
110
+ and CSV or JSON export
111
+ - ✅ **Typed events** — ten emitted event types over `@nestjs/event-emitter`, optional
112
+ (`wallet.low_balance` is declared in the catalog and reserved; nothing emits it yet)
113
+ - ✅ **OpenTelemetry** — an optional sink that traces every metering call, carrying model and
114
+ operation and never prompt or completion text
115
+ - ✅ **Typed end to end** — TypeScript `strict` with `exactOptionalPropertyTypes` and
116
+ `noUncheckedIndexedAccess`; zero `any`
117
+
118
+ ---
119
+
120
+ ## 📦 Subpath Exports
121
+
122
+ | Subpath | What it contains | Runtime deps |
123
+ | ---------------------------------- | ---------------------------------------------------------------------------------- | ----------------------- |
124
+ | `@bymax-one/nest-ai-tokens` | Dynamic module, services, guard, interceptor, decorators, collector, ports, errors | NestJS (peer) |
125
+ | `@bymax-one/nest-ai-tokens/shared` | Normalizers, pure cost math, types, catalogs, error codes | Zero |
126
+ | `@bymax-one/nest-ai-tokens/prices` | `MODEL_PRICES_SEED` — pinned price snapshot (data-only, large; imported lazily) | Zero |
127
+ | `@bymax-one/nest-ai-tokens/prisma` | `PrismaAiTokensStore` — the official PostgreSQL adapter | `@prisma/client` (peer) |
128
+ | `@bymax-one/nest-ai-tokens/redis` | `RedisBudgetCounterStore` — optional fast budget counter | `ioredis` (peer) |
129
+
130
+ > The `./shared` subpath is framework-free and edge-safe — use it in frontends, workers, and edge functions that must not pull NestJS.
131
+ > The `./prices` subpath exists so `./shared` stays within the family's tiny-bundle budget.
132
+
133
+ ### Artifact sizes (brotli)
134
+
135
+ | Artifact | Brotli size | Budget |
136
+ | ------------------------------------------- | ----------- | ------- |
137
+ | server (`@bymax-one/nest-ai-tokens`) | 37 KB | < 40 KB |
138
+ | shared (`@bymax-one/nest-ai-tokens/shared`) | 5 KB | < 10 KB |
139
+ | prisma (`@bymax-one/nest-ai-tokens/prisma`) | 10 KB | < 15 KB |
140
+ | redis (`@bymax-one/nest-ai-tokens/redis`) | 1.1 KB | < 5 KB |
141
+ | prices (data-only, exempt from budget) | 1.3 KB | exempt |
142
+
143
+ ---
144
+
145
+ ## 🚀 Quick Start
146
+
147
+ ### 1 — Install
148
+
149
+ ```bash
150
+ npm i @bymax-one/nest-ai-tokens
151
+ ```
152
+
153
+ Required peers (if not already present):
154
+
155
+ ```bash
156
+ npm i @nestjs/common @nestjs/core reflect-metadata rxjs
157
+ # Persistence:
158
+ npm i @prisma/client
159
+ # Prisma 7 only — the client is opened through a driver adapter:
160
+ npm i @prisma/adapter-pg
161
+ ```
162
+
163
+ > **Prisma 6 and 7 are both supported.** The adapter talks to PostgreSQL through
164
+ > parameterized raw SQL and never touches a generated model delegate, so the same
165
+ > code runs on either. What differs is how _your application_ builds the client it
166
+ > hands over:
167
+ >
168
+ > ```typescript
169
+ > // Prisma 6
170
+ > const prisma = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL })
171
+ >
172
+ > // Prisma 7 — `datasourceUrl` was removed; open through a driver adapter
173
+ > import { PrismaPg } from '@prisma/adapter-pg'
174
+ > const prisma = new PrismaClient({
175
+ > adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
176
+ > })
177
+ > ```
178
+ >
179
+ > Prisma 7 also removed `url` from `datasource` blocks in `schema.prisma`; it
180
+ > moves to a `prisma.config.ts` at your project root. Neither change reaches this
181
+ > library's API — `PrismaAiTokensStore` receives whatever client you built.
182
+
183
+ ### 2 — Register the module
184
+
185
+ ```typescript
186
+ import { Module } from '@nestjs/common'
187
+ import { BymaxAiTokensModule } from '@bymax-one/nest-ai-tokens'
188
+ import { PrismaAiTokensStore } from '@bymax-one/nest-ai-tokens/prisma'
189
+
190
+ @Module({
191
+ imports: [
192
+ BymaxAiTokensModule.forRootAsync({
193
+ imports: [PrismaModule],
194
+ inject: [PrismaService],
195
+ useFactory: (prisma: PrismaService) => ({
196
+ store: new PrismaAiTokensStore(prisma),
197
+ // The resale lever — end-users pay 4× provider cost:
198
+ markup: 4.0,
199
+ wallets: { creditRateNanoUsd: 5_000_000_000n }, // 1 credit = $5
200
+ budgets: { defaultPolicy: 'block', alertThresholds: [0.8, 1.0] },
201
+ pricing: { seedFromSnapshot: true }, // seed price table on first boot
202
+ }),
203
+ }),
204
+ ],
205
+ })
206
+ export class AppModule {}
207
+ ```
208
+
209
+ ### 3 — Record usage (post-hoc, observe-only)
210
+
211
+ ```typescript
212
+ import { Injectable } from '@nestjs/common'
213
+ import { MeteringService, providerPresets } from '@bymax-one/nest-ai-tokens'
214
+ import { deriveIdempotencyKey } from '@bymax-one/nest-ai-tokens/shared'
215
+
216
+ @Injectable()
217
+ export class WorkoutAiService {
218
+ constructor(private readonly metering: MeteringService) {}
219
+
220
+ async generate(tenantId: string, userId: string, jobId: string) {
221
+ const res = await this.openai.chat.completions.create({ model: 'gpt-4o', messages: [] })
222
+ await this.metering.record({
223
+ usage: res.usage,
224
+ preset: providerPresets.openaiChat,
225
+ context: {
226
+ tenantId,
227
+ scope: { type: 'user', id: userId },
228
+ feature: 'workout.generate',
229
+ idempotencyKey: deriveIdempotencyKey({ jobId }), // retry-safe
230
+ },
231
+ })
232
+ return res.choices[0]?.message.content
233
+ }
234
+ }
235
+ ```
236
+
237
+ ### 4 — Enforced metering via guard + interceptor
238
+
239
+ ```typescript
240
+ import { Controller, Post, Get, Body, UseGuards, UseInterceptors, Req } from '@nestjs/common'
241
+ import {
242
+ BudgetGuard,
243
+ MeteringInterceptor,
244
+ Meter,
245
+ RequireBudget,
246
+ MeteringService,
247
+ toJsonSafe,
248
+ providerPresets,
249
+ } from '@bymax-one/nest-ai-tokens'
250
+
251
+ @Controller('ai')
252
+ @UseGuards(BudgetGuard)
253
+ @UseInterceptors(MeteringInterceptor)
254
+ export class AiController {
255
+ constructor(private readonly metering: MeteringService) {}
256
+
257
+ @Post('summarize')
258
+ @RequireBudget({ scope: 'tenant', estimate: { tokens: 3_000 } })
259
+ @Meter({
260
+ feature: 'doc.summarize',
261
+ scope: 'tenant',
262
+ preset: providerPresets.anthropic,
263
+ exposeHeaders: true,
264
+ })
265
+ async summarize(@Body() dto: { content: string }) {
266
+ // Guard checks budget and places a hold; interceptor settles it with the handler's actual usage.
267
+ return this.ai.summarize(dto.content) // returns { summary, usage }
268
+ }
269
+
270
+ @Get('me/usage')
271
+ async myUsage(@Req() req: { user: { tenantId: string; id: string } }) {
272
+ const status = await this.metering.getStatus(req.user.tenantId, {
273
+ type: 'user',
274
+ id: req.user.id,
275
+ })
276
+ return toJsonSafe(status) // bigint → decimal strings for JSON serialization
277
+ }
278
+ }
279
+ ```
280
+
281
+ ---
282
+
283
+ ## ⚙️ Configuration
284
+
285
+ `BymaxAiTokensModule.forRoot(options)` / `.forRootAsync({ useFactory })` accept `BymaxAiTokensModuleOptions`:
286
+
287
+ | Option | Type | Default | Purpose |
288
+ | --------------------------- | ------------------------------------- | -------------- | ----------------------------------------------------------------------------- |
289
+ | `store` | `IAiTokensStore` | — (required) | Persistence adapter (use `PrismaAiTokensStore`) |
290
+ | `scopeResolver` | `(ctx) => MeteringContext` | — | Resolves the caller's scope from the request (required for guard/interceptor) |
291
+ | `ratingMode` | `'rate-table' \| 'provider-reported'` | `'rate-table'` | Default cost rating mode |
292
+ | `markup` | `number \| IMarkupPolicy` | `1.0` | The resale multiplier (e.g. `4.0` = 4× provider cost) |
293
+ | `pricing.seedFromSnapshot` | `boolean` | `false` | Seed `MODEL_PRICES_SEED` into the registry on first boot |
294
+ | `wallets.creditRateNanoUsd` | `bigint` | — | Exchange rate: 1 credit = this many nano-USD |
295
+ | `wallets.burnOrder` | `'expiry' \| 'priority' \| 'fifo'` | `'expiry'` | Order in which grants are consumed |
296
+ | `budgets.defaultPolicy` | `'block' \| 'alert'` | `'alert'` | Default enforcement when a budget is exhausted |
297
+ | `budgets.alertThresholds` | `number[]` | `[0.8, 1.0]` | Soft-threshold events at these fractions |
298
+ | `holds.ttlSeconds` | `number` | `300` | Spend-hold TTL; the reaper voids expired holds |
299
+ | `telemetry` | `ITelemetrySink` | no-op | OpenTelemetry sink (pass `OtelTelemetrySink`) |
300
+
301
+ ---
302
+
303
+ ## 🧩 Providers Matrix
304
+
305
+ | Provider | API | Normalizer |
306
+ | ----------------- | --------------------------------- | ---------------------------------------------------------------------------------- |
307
+ | OpenAI | Chat Completions | `normalizeOpenAiChatUsage` / `providerPresets.openaiChat` |
308
+ | OpenAI | Responses API | `normalizeOpenAiResponsesUsage` / `providerPresets.openaiResponses` |
309
+ | OpenAI-compatible | DeepSeek, xAI, Groq, Azure OpenAI | `normalizeOpenAiCompatibleUsage` / `providerPresets.openaiCompatible` |
310
+ | Anthropic | Messages API | `normalizeAnthropicUsage` / `providerPresets.anthropic` |
311
+ | Google | Gemini / Vertex AI | `normalizeGeminiUsage` / `providerPresets.gemini` |
312
+ | AWS | Bedrock Converse | `normalizeBedrockConverseUsage` / `providerPresets.bedrockConverse` |
313
+ | Mistral | Chat API | `normalizeMistralUsage` / `providerPresets.mistral` |
314
+ | OpenRouter | Chat | `normalizeOpenRouterUsage` / `providerPresets.openrouter` (provider-reported cost) |
315
+ | Vercel | AI SDK v5/v6 | `normalizeVercelAiSdkUsage` / `providerPresets.vercelAiSdk` |
316
+
317
+ All normalizers are pure functions over plain objects — no provider SDK peer dependency. Pass the raw response object and get back a `NormalizedUsage`.
318
+
319
+ ---
320
+
321
+ ## 💰 Pricing & Markup
322
+
323
+ The fundamental difference between "what the provider charged you" and "what you charge your users" is the **markup multiplier**. This library makes that multiplier a first-class configuration option.
324
+
325
+ ```typescript
326
+ // Configure at module level:
327
+ BymaxAiTokensModule.forRootAsync({
328
+ useFactory: (prisma) => ({
329
+ store: new PrismaAiTokensStore(prisma),
330
+ markup: 4.0, // 4× resale margin
331
+ wallets: { creditRateNanoUsd: 5_000_000_000n }, // sell credits at $5 each
332
+ }),
333
+ })
334
+
335
+ // Example ledger entry:
336
+ // Provider cost: $0.005 (5_000_000n nano-USD)
337
+ // Billed to user: $0.020 (20_000_000n nano-USD, after 4× markup)
338
+ // Margin: $0.015 per call
339
+ ```
340
+
341
+ For per-tenant or per-feature pricing, implement `IMarkupPolicy`:
342
+
343
+ ```typescript
344
+ class MyMarkupPolicy implements IMarkupPolicy {
345
+ resolve(ctx: MeteringContext): number | Promise<number> {
346
+ return ctx.feature === 'premium.summarize' ? 5.0 : 3.0
347
+ }
348
+ }
349
+ ```
350
+
351
+ Every billed amount is stored in both `rawCostNanoUsd` (provider cost) and `billedCostNanoUsd` (after markup), so reports can show provider cost, margin, and customer-facing cost independently.
352
+
353
+ ---
354
+
355
+ ## 👛 Wallets & Budgets
356
+
357
+ ### Prepaid Wallets
358
+
359
+ ```typescript
360
+ // Grant credits (e.g. on subscription renewal):
361
+ await wallets.grant(
362
+ { tenantId, ownerType: 'user', ownerId: userId },
363
+ {
364
+ amountNanoUsd: 25_000_000_000n, // $25 in credits
365
+ expiresAt: nextRenewal,
366
+ idempotencyKey: `allowance:${userId}:${cycle}`,
367
+ reason: 'monthly plan allowance',
368
+ },
369
+ )
370
+
371
+ // Credits are debited automatically when the ledger posts a billed cost.
372
+ // Check balance:
373
+ const wallet = await wallets.getOrCreate({ tenantId, ownerType: 'user', ownerId: userId })
374
+ console.log(wallet.balanceNanoUsd) // current balance
375
+ ```
376
+
377
+ ### Multi-Dimension Budgets
378
+
379
+ ```typescript
380
+ // Set a monthly budget (anchored to subscription renewal, not calendar):
381
+ await budgets.upsertBudget({
382
+ tenantId,
383
+ scope: { type: 'user', id: userId },
384
+ features: ['workout.generate'],
385
+ limitCount: plan.maxAIGenerationsPerMonth, // count quota
386
+ limitTokens: plan.aiTokensMonthly, // token quota
387
+ window: 'month',
388
+ anchorAt: subscription.renewalDate, // renewal-anchored window
389
+ softThresholds: [0.8, 1.0],
390
+ policy: 'block',
391
+ })
392
+
393
+ // Check status:
394
+ const status = await metering.getStatus(tenantId, { type: 'user', id: userId })
395
+ // status[].remaining, status[].exhausted, status[].policy
396
+ ```
397
+
398
+ **Unlimited semantics:** no budget row = unlimited. `limit = 0` = hard block. Never use `null` or `0` to mean unlimited (see §22 migration notes).
399
+
400
+ ---
401
+
402
+ ## 🌊 Streaming
403
+
404
+ ```typescript
405
+ const collector = new StreamUsageCollector({
406
+ provider: 'openai',
407
+ model: 'gpt-4o',
408
+ preset: providerPresets.openaiChat,
409
+ })
410
+
411
+ const hold = await metering.hold(ctx, {
412
+ provider: 'openai',
413
+ model: 'gpt-4o',
414
+ operation: 'chat',
415
+ inputTokens: estimatedInput,
416
+ maxOutputTokens: 1024,
417
+ })
418
+
419
+ try {
420
+ const stream = await openai.chat.completions.create({
421
+ model: 'gpt-4o',
422
+ messages,
423
+ stream: true,
424
+ stream_options: { include_usage: true },
425
+ })
426
+ for await (const chunk of stream) {
427
+ collector.push(chunk) // accumulates chunks, watches for the final usage object
428
+ res.write(chunk.choices[0]?.delta?.content ?? '')
429
+ }
430
+ } catch (err) {
431
+ // Aborted or provider error: bill partial usage. capture() is idempotent.
432
+ await metering.capture(hold, collector)
433
+ throw err
434
+ }
435
+ await metering.capture(hold, collector) // settle with provider-final actuals
436
+ ```
437
+
438
+ On abort, the collector falls back to a tokenizer estimate of the output tokens so the aborted call is still billed for what it produced.
439
+
440
+ ---
441
+
442
+ ## 📊 Reporting & Status
443
+
444
+ ```typescript
445
+ // Usage summary — by feature, for the last 30 days:
446
+ const summary = await metering.summarize({
447
+ tenantId,
448
+ scope: { type: 'user', id: userId },
449
+ from: thirtyDaysAgo,
450
+ to: now,
451
+ groupBy: ['feature', 'model'],
452
+ })
453
+
454
+ // Export as CSV (streaming):
455
+ const csv = await reports.export({
456
+ tenantId,
457
+ from,
458
+ to,
459
+ format: 'csv',
460
+ })
461
+ csv.pipe(res)
462
+
463
+ // Access status / remaining budget:
464
+ const status = await metering.getStatus(tenantId, { type: 'user', id: userId })
465
+ return toJsonSafe(status) // bigint → decimal strings
466
+ ```
467
+
468
+ ---
469
+
470
+ ## 📣 Events
471
+
472
+ When `@nestjs/event-emitter` is installed and `EventEmitterModule.forRoot()` is registered, the library emits typed events on the NestJS event bus:
473
+
474
+ | Event | When |
475
+ | ------------------------------------- | --------------------------------------------------------- |
476
+ | `ai_tokens.usage.recorded` | A usage record is posted to the ledger |
477
+ | `ai_tokens.usage.reversed` | A record is reversed by a compensating entry |
478
+ | `ai_tokens.hold.released` | A hold is released without charging |
479
+ | `ai_tokens.budget.threshold_crossed` | A soft threshold is crossed |
480
+ | `ai_tokens.budget.exceeded` | A hard budget is exhausted |
481
+ | `ai_tokens.budget.projected_exceeded` | An estimate would exceed a budget |
482
+ | `ai_tokens.wallet.granted` | Credit is granted to a wallet |
483
+ | `ai_tokens.wallet.low_balance` | _Reserved._ Declared in the catalog; nothing emits it yet |
484
+ | `ai_tokens.wallet.depleted` | A wallet balance reaches zero |
485
+ | `ai_tokens.price.missing` | No rate is in effect for a model at the call timestamp |
486
+ | `ai_tokens.audit` | An auditable operation completed |
487
+
488
+ All event payloads use `bigint` nano-USD internally and `string` decimal at JSON boundaries.
489
+
490
+ ---
491
+
492
+ ## 🚨 Error Codes
493
+
494
+ All errors are `AiTokensException extends HttpException`. Use the exported `AI_TOKENS_ERROR_CODES` catalog in switch/catch:
495
+
496
+ | Code | HTTP | When |
497
+ | -------------------------------- | ---- | ------------------------------------------------------------------------------------- |
498
+ | `AI_TOKENS_NOT_CONFIGURED` | 503 | Service invoked before async init completed |
499
+ | `AI_TOKENS_INVALID_CONFIG` | 500 | Bad markup, negative limits, missing `scopeResolver`, missing fx |
500
+ | `AI_TOKENS_UNKNOWN_PROVIDER` | 400 | No preset/normalizer and usage is not already `NormalizedUsage` |
501
+ | `AI_TOKENS_USAGE_MALFORMED` | 422 | Normalizer could not read required token fields |
502
+ | `AI_TOKENS_PRICE_NOT_FOUND` | 422 | No effective-dated rate after 6-step resolution chain (strict mode) |
503
+ | `AI_TOKENS_FX_REQUIRED` | 500 | `currency !== 'USD'` with no `fx` resolver |
504
+ | `AI_TOKENS_BUDGET_EXCEEDED` | 402 | A hard spend budget blocks the call |
505
+ | `AI_TOKENS_QUOTA_EXCEEDED` | 429 | A hard token/count quota blocks the call |
506
+ | `AI_TOKENS_INSUFFICIENT_CREDITS` | 402 | Wallet debit failed (balance + overdraft below amount) |
507
+ | `AI_TOKENS_HOLD_NOT_FOUND` | 404 | `capture()`/`release()` on an unknown or cross-tenant hold |
508
+ | `AI_TOKENS_HOLD_EXPIRED` | 410 | `capture()` after the reaper swept the hold (retry via `record()`) |
509
+ | `AI_TOKENS_HOLD_ALREADY_SETTLED` | 409 | `capture()` after `release()` |
510
+ | `AI_TOKENS_IDEMPOTENCY_CONFLICT` | 409 | Same idempotency key, different payload hash; or reversing an already-reversed record |
511
+ | `AI_TOKENS_STREAM_USAGE_MISSING` | 422 | Stream ended without provider usage and no tokenizer fallback available |
512
+ | `AI_TOKENS_STORE_ERROR` | 502 | Persistence adapter raised an unmapped error |
513
+
514
+ ---
515
+
516
+ ## 🔢 BigInt & JSON
517
+
518
+ `bigint` does not survive `JSON.stringify`. Use `toJsonSafe()` at controller boundaries:
519
+
520
+ ```typescript
521
+ import { toJsonSafe } from '@bymax-one/nest-ai-tokens'
522
+
523
+ // In your controller:
524
+ return toJsonSafe(await metering.getStatus(tenantId, scope))
525
+ // bigint fields (balanceNanoUsd, billedCostNanoUsd, etc.) become decimal strings.
526
+
527
+ // For display/formatting:
528
+ import { formatNanoUsd } from '@bymax-one/nest-ai-tokens/shared'
529
+ formatNanoUsd(5_000_000n) // '$0.005000'
530
+ formatNanoUsd(5_000_000n, { currency: 'BRL', fxRateNano: 5_000_000_000n }) // '0.025000 BRL'
531
+ ```
532
+
533
+ ---
534
+
535
+ ## 🚚 Migration
536
+
537
+ If you are migrating the `_commons_/ai/*` layer from `bymax-fitness`, see [docs/technical_specification.md §22](./docs/technical_specification.md) for the full field-by-field mapping table, the zero/null → unlimited translation rules, and backfill notes.
538
+
539
+ Key caution: `bymax-fitness` treats `0` as unlimited on most paths and as blocked on one path. Do not import plan rows verbatim. Translate `0`/`null` limits to **no budget row** (the normative unlimited representation in this library).
540
+
541
+ ---
542
+
543
+ ## 📖 API Reference
544
+
545
+ The sections above document each of these with a runnable example. This is the index.
546
+
547
+ ### Services
548
+
549
+ | Service | Surface |
550
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
551
+ | `MeteringService` | `meter<T>` · `record` · `estimateCost` · `hold` · `capture` · `release` · `restoreReleasedHold` · `reverse` · `getStatus` |
552
+ | `LedgerService` | `append` · `query` · `findById` · `findByIdempotencyKey` · `findExpiredHolds` · `sumCost` · `transition` · `reverse` · `verifyChain` |
553
+ | `WalletService` | `getBalance` · `grant` · `debit` · `refund` · `adjust` · `settleAdjustment` · `getEntries` · `reconcile` |
554
+ | `BudgetService` | `upsertBudget` · `removeBudget` · `list` · `status` · `consume` · `release` · `adjust` · `rotateWindow` · `reconcileWindow` |
555
+ | `PricingService` | `resolveRate` · `upsertPrice` · `getPriceHistory` · `seedFromSnapshot` |
556
+ | `UsageReportService` | `summarize` · `export` |
557
+ | `StreamUsageCollector` | Accumulates streamed chunks; prefers the provider's final usage over its own count |
558
+
559
+ ### Request-scoped enforcement
560
+
561
+ | Class | Role |
562
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
563
+ | `BudgetGuard` | Check-only gate: refuses the request before the handler runs when a hard budget is exhausted, and — when `@RequireBudget.estimate` is present — places the hold and attaches it to the request |
564
+ | `MeteringInterceptor` | The capture half: extracts usage from the handler's result, then settles the guard's hold or records post-hoc when there is none. On a handler error it releases the hold and rethrows the original error |
565
+
566
+ ### Subpaths
567
+
568
+ | Subpath | Exports |
569
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
570
+ | `.` | Everything above, the DI tokens, the option types and the error catalog |
571
+ | `./shared` | The zero-dependency contract layer — types, `AI_TOKENS_ERROR_CODES`, event shapes; safe to import from a browser bundle |
572
+ | `./prices` | `MODEL_PRICES_SEED` — a dated price snapshot to seed `PricingService` from |
573
+ | `./prisma` | `PrismaAiTokensStore` — the reference store over PostgreSQL |
574
+ | `./redis` | `RedisBudgetCounterStore` — the atomic budget counter |
575
+
576
+ ---
577
+
578
+ ## 🏗️ Architecture
579
+
580
+ ```
581
+ HTTP request
582
+ │
583
+ ▼
584
+ ┌─────────────────────┐
585
+ │ BudgetGuard │ ← scopeResolver
586
+ │ check-only; holds │ (your VERIFIED auth
587
+ │ when an estimate │ context, never the
588
+ │ is declared │ client's body)
589
+ └──────────┬──────────┘
590
+ │ refuses here if a hard budget is spent
591
+ ▼
592
+ your handler
593
+ (calls the provider)
594
+ │
595
+ ▼
596
+ ┌─────────────────────┐
597
+ │ MeteringInterceptor │
598
+ │ extracts usage, then│
599
+ │ settles the hold or │
600
+ │ records post-hoc │
601
+ └──────────┬──────────┘
602
+ │
603
+ ▼
604
+ normalizers/
605
+ 9 providers → one usage shape
606
+ │
607
+ ▼
608
+ pricing/
609
+ rate in effect AT the call timestamp
610
+ (never re-rated)
611
+ │
612
+ ▼
613
+ markup/
614
+ multiplier or IMarkupPolicy
615
+ │
616
+ ▼
617
+ ledger/
618
+ append-only; a correction is a
619
+ compensating record. Optional hash
620
+ chain over the previous posted entry
621
+ │
622
+ ┌───────────────┴───────────────┐
623
+ │ │
624
+ wallets/budgets RedisBudgetCounterStore
625
+ append-only entries one Lua incrIfBelow —
626
+ spend · tokens · count check and increment
627
+ per scope per window cannot interleave
628
+ ```
629
+
630
+ **Storage is an adapter, not a dependency.** `./prisma` is the reference
631
+ implementation over PostgreSQL and `./redis` accelerates budget counters; both are
632
+ peers you already control. The library defines the contracts.
633
+
634
+ ### Design Principles
635
+
636
+ | Principle | Description |
637
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
638
+ | 💵 **Exact money, always** | Every persisted amount is `bigint` nano-USD. A float on a billing path is a rounding difference someone is charged for |
639
+ | 📜 **Append, never update** | The ledger is evidence. A correction is a compensating record that points at what it reverses, so the history of a charge survives the correction |
640
+ | 🔒 **The database prevents the double charge** | A unique constraint on the idempotency key, with the violation recognized as a conflict — not application discipline that can be forgotten |
641
+ | ⏱️ **Priced when it happened** | An entry is rated at the price in effect at the call timestamp and never re-rated, so a price change does not rewrite the past |
642
+ | 🚪 **In-request enforcement** | Guard and interceptor, in-process: no proxy to deploy, no sidecar to keep alive, no external hop that can fail open |
643
+ | 🧊 **Zero runtime dependencies** | `dependencies` is `{}`, and an optional peer is imported only when the feature that needs it is enabled |
644
+
645
+ ---
646
+
647
+ ## 🔐 Security Model
648
+
649
+ This library decides what a customer is charged and holds the record that proves it. Its
650
+ security contract is about arithmetic that cannot drift, a record that cannot be quietly
651
+ rewritten, and text that does not travel.
652
+
653
+ ### Money is `bigint` nano-USD everywhere it is persisted
654
+
655
+ Not because floats are imprecise in the abstract, but because a rounding difference on a
656
+ billing path is a number a customer is charged. There is no float arithmetic on a money
657
+ path.
658
+
659
+ ### The ledger cannot be rewritten
660
+
661
+ Entries are appended; a correction is a compensating record that points at what it
662
+ reverses. With the optional hash chain on, each posted entry carries a hash over its
663
+ predecessor, so a row edited directly in the database stops matching the chain that follows
664
+ it. It is tamper-_evident_, not tamper-proof: pair it with database permissions that forbid
665
+ `UPDATE` on the ledger table.
666
+
667
+ ### Charging twice is prevented by the database
668
+
669
+ The idempotency key carries a unique constraint, and the replay path is that constraint
670
+ violation being recognized as a conflict rather than surfacing as a store error. Application
671
+ care is not what stands between a retry and a second charge.
672
+
673
+ ### Prompt and completion text never reach the ledger, the events or the telemetry
674
+
675
+ The stream collector holds response text only to count it; the OpenTelemetry emitter carries
676
+ model, operation, provider and service tier, and never content. The ledger stores no text at
677
+ all.
678
+
679
+ There is one place text can be persisted, and it is opt-in: `IContentStore`, the content
680
+ sidecar. A host that enables it stores **masked** text under a short TTL, separate from the
681
+ ledger, with a `purge()` that deletes by tenant, record or subject so an erasure request can
682
+ be honoured without touching the accounting. It is off unless you provide a store — and if
683
+ you do, text is being written, and where it is written is your implementation.
684
+
685
+ ### Budget checks are atomic
686
+
687
+ The Redis counter runs check-and-increment as one Lua script, so two concurrent requests
688
+ cannot both observe room under a cap that only fits one.
689
+
690
+ ### The scope resolver is trusted input
691
+
692
+ `scopeResolver` decides whose budget and whose wallet a request draws on. It is documented
693
+ as taking the host's **verified** auth context — never the client's body or query. A resolver
694
+ that reads a tenant id from request input lets a caller bill someone else.
695
+
696
+ ---
697
+
698
+ ## 🛡️ Security Table
699
+
700
+ | Layer | Implementation |
701
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
702
+ | Money | `bigint` nano-USD on every persisted amount; no float arithmetic on a money path |
703
+ | Ledger | Append-only; corrections are compensating records, never updates |
704
+ | Tamper evidence | Optional per-entry hash chain over the previous posted entry |
705
+ | Double charging | Unique constraint on the idempotency key; the violation is the exactly-once signal |
706
+ | Rating | Point-in-time — an entry is priced at the call timestamp and never re-rated |
707
+ | Telemetry | Model, operation, provider, service tier; prompt and completion text never emitted |
708
+ | Content | Never in the ledger, events or telemetry. The opt-in `IContentStore` sidecar stores masked text under a short TTL, with `purge()` by tenant, record or subject |
709
+ | Budget races | One atomic Lua `incrIfBelow` — the check and the increment cannot interleave |
710
+ | Provider surface | Normalizers consume plain objects; no provider SDK dependency, no outbound call made here |
711
+ | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL, TruffleHog and OpenSSF Scorecard |
712
+
713
+ > [!IMPORTANT]
714
+ > **The hash chain is tamper-_evident_, not tamper-_proof_.** It makes an edited row
715
+ > detectable by anyone who verifies the chain; it does not stop someone with write
716
+ > access from rewriting the chain wholesale. Pair it with database permissions that
717
+ > forbid `UPDATE` on the ledger table.
718
+
719
+ ---
720
+
721
+ ## 🧱 Tech Stack
722
+
723
+ - **Runtime:** Node.js 24+
724
+ - **Framework:** NestJS 11 (guard + interceptor, `ConfigurableModuleBuilder`, `Symbol()` tokens)
725
+ - **Persistence:** `@prisma/client ^6 || ^7` (peer) over PostgreSQL — both majors supported
726
+ - **Budget acceleration:** `ioredis ^5` (peer), optional
727
+ - **Events:** `@nestjs/event-emitter >=2` (peer), optional
728
+ - **Telemetry:** `@opentelemetry/api ^1.9` (peer), optional
729
+ - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
730
+ - **Tests:** Jest + Testcontainers (PostgreSQL, end-to-end) + Stryker (mutation)
731
+ - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
732
+
733
+ ---
734
+
735
+ ## 🧪 Testing & Quality
736
+
737
+ This library decides what a customer is charged, so the suite is held to a bar beyond "the
738
+ tests pass".
739
+
740
+ - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
741
+ - ✅ **100% mutation score against a `break: 95` gate** — the gate is the floor a run must
742
+ clear; the score the suite actually reaches is 100%, verified with
743
+ [Stryker](https://stryker-mutator.io/) ([report](./docs/mutation_testing_results.md))
744
+ - ✅ **Real PostgreSQL in e2e** — Testcontainers, so the unique constraint that prevents a
745
+ double charge is exercised against an actual database rather than a mock of one
746
+ - ✅ **Both Prisma majors** — the raw-query violation path is unit-tested for the query engine
747
+ _and_ the driver adapter, because the peer range admits 6 and 7 and each reports the
748
+ SQLSTATE in a different place
749
+ - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
750
+ system does, `check:runtime` loads every subpath from the packed tarball in ESM and
751
+ CommonJS, and `check:published` compiles this README's snippets against `dist/`
752
+ - ✅ **Every suppression carries its reason** — no coverage directives anywhere; each
753
+ `// Stryker disable` in the production source states, after `--`, why the mutant it
754
+ silences is unobservable from a unit test (an internal error context, a provider id
755
+ reached only through integration)
756
+
757
+ ```bash
758
+ pnpm test # unit tests
759
+ pnpm test:cov # unit tests with the 100% coverage gate
760
+ pnpm test:e2e # end-to-end against PostgreSQL (requires Docker)
761
+ pnpm mutation # Stryker mutation testing (break: 95)
762
+ pnpm typecheck # tsc strict check
763
+ pnpm lint # ESLint
764
+ pnpm build && pnpm size # bundle-size budgets
765
+ ```
766
+
767
+ ---
768
+
769
+ ## 🤝 Contributing
770
+
771
+ Pull requests are welcome. Please open an issue first for significant changes.
772
+
773
+ - Read [`docs/technical_specification.md`](./docs/technical_specification.md) for architecture decisions.
774
+ - Run `pnpm test:cov` and `pnpm lint` before opening a PR.
775
+ - Please use Conventional Commits for the message; nothing enforces it here, so it is a convention rather than a gate.
776
+
777
+ ---
778
+
779
+ ## 🔒 Security Policy
780
+
781
+ If you discover a security vulnerability, please **do not** open a public issue. Instead, email us
782
+ at **support@bymax.one** with details. We take security seriously and will respond promptly. See
783
+ [`SECURITY.md`](./SECURITY.md) for the full policy.
784
+
785
+ ---
786
+
787
+ ## 📄 License
788
+
789
+ [MIT](./LICENSE) © [Bymax One](https://github.com/bymaxone)
790
+
791
+ ---
792
+
793
+ <p align="center">
794
+ <sub>Built with ❤️ by <a href="https://github.com/bymaxone">Bymax One</a></sub>
795
+ </p>