@bymax-one/nest-ai-tokens 1.1.2 → 1.1.4

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/CHANGELOG.md CHANGED
@@ -8,6 +8,149 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.4] - 2026-11-01
12
+
13
+ **Apply:** `verifyChain` has a new result, `{ valid: false, status: 'never-enabled' }`, which has no
14
+ `brokenAtRecordId`; handle it wherever you read that field. Enable `ledger.hashChain` before the
15
+ first write: records written earlier are reported as unchained and are not protected.
16
+
17
+ ### Fixed
18
+
19
+ - **Enabling `ledger.hashChain` on a ledger that already has records no longer makes `verifyChain`
20
+ report the oldest record as tampered.** `verifyChain` required every settled record to carry a
21
+ hash, so the first record written before the chain was enabled failed verification and ended the
22
+ walk, and the chained records after it were never checked. It now follows the chain by its links:
23
+ records without a hash are counted in the new `unchainedCount` and reported as not protected, the
24
+ hashed records must start at one genesis with no gap in the links, and a record whose hash was
25
+ removed while its `prevHash` stayed is reported as broken. A ledger with settled records and no
26
+ hashed one returns `{ valid: false, status: 'never-enabled' }`. The result type gains
27
+ `unchainedCount` and an optional `chainStartsAtRecordId`; the new `never-enabled` variant has no
28
+ `brokenAtRecordId`. A `from`/`to` call still checks each record's own hash only. A record that no other
29
+ record names as its predecessor (the newest one, and a hold that settled while a later-created record
30
+ was already hashed) can be deleted without detection, and the chain is unkeyed; both limits are in the README.
31
+
32
+ **Apply:** code that reads `brokenAtRecordId` after `valid === false` must also handle the
33
+ `never-enabled` variant, which has none. Enable the chain before the first write; a rise in
34
+ `unchainedCount` after enabling is worth investigating.
35
+
36
+ ### Changed
37
+
38
+ - The GitHub Release body is now the tag's own `CHANGELOG.md` section instead of the generated
39
+ pull-request list, and the release stops before publishing when the version has no section.
40
+ No change to the package.
41
+ - The release-notes step starts the section only at a line that begins `## [<version>]`, so a
42
+ version token elsewhere in a heading, such as `## [Unreleased] [1.1.1]`, no longer selects the
43
+ wrong body.
44
+ - The unused optional peer `@opentelemetry/api` is removed from `peerDependencies` and
45
+ `peerDependenciesMeta`. The package imports nothing from it; telemetry goes through the
46
+ `ITelemetrySink` you implement. Consumers lose only an install hint.
47
+
48
+ ### Documentation
49
+
50
+ - The README says that `MODEL_PRICES_SEED` has no rows for `azure-openai`, `vertex`, `bedrock` or
51
+ `openrouter`, so with `pricing.strict` on a call through the first three presets throws
52
+ `AI_TOKENS_PRICE_NOT_FOUND` until the host adds a price (the `openrouter` preset rates from the
53
+ provider-reported cost), and shows a minimal `upsertPrice` call.
54
+
55
+ ## [1.1.3] - 2026-10-31
56
+
57
+ **Billing:** if you set prices with `upsertPrice` for a model the snapshot also prices while
58
+ `pricing.seedFromSnapshot` was on, a restart of an earlier version could replace them with the
59
+ snapshot. Run the two queries under the seed entry below to look for affected rows.
60
+
61
+ ### Fixed
62
+
63
+ - **`forRootAsync({ useFactory })` accepts a factory with typed parameters.** The declared
64
+ signature `(...args: unknown[]) => ...` rejected the form Nest code normally writes,
65
+ `useFactory: (config: ConfigService) => ...`, so every consumer had to take `unknown` and cast.
66
+ The member is now declared with method syntax, which makes its parameters bivariant, and the
67
+ variadic form still compiles. No runtime change.
68
+
69
+ **Apply:** no action required. A cast such as `(prisma: unknown) => ... prisma as PrismaService`
70
+ can be replaced by a typed parameter.
71
+
72
+ - **A disabled wallet, budget or guard service no longer injects as `null` with `forRootAsync`.**
73
+ `forRootAsync` resolves its options after the providers are declared, so it registers
74
+ `WalletService`, `BudgetService` and `BudgetGuard` even when the options leave them out, and it
75
+ used to inject `null`: a consumer got `TypeError: Cannot read properties of null` at its first
76
+ call, with a type that said `WalletService`. It now injects a stand-in whose methods throw
77
+ `AI_TOKENS_INVALID_CONFIG`; the reason names the missing options block, `wallets` or
78
+ `budgets`. The stand-in is an instance of the service's class. `forRoot` is unchanged: it does not register a disabled service.
79
+
80
+ **Apply:** code that tested the injected service for `null` to detect a disabled feature must
81
+ check its own options instead.
82
+
83
+ - **`AI_TOKENS_NOT_CONFIGURED` is documented as reserved.** Nest finishes resolving the async
84
+ options before it builds any service, so nothing raises it.
85
+
86
+ - **A restart no longer reverts prices the host set, and the snapshot seed no longer adds rows.**
87
+ With `pricing.seedFromSnapshot` on (the default), every boot re-ran each seed row through
88
+ `upsertPrice`, which closes the open row of that model and inserts a new one. A second boot
89
+ against a seeded database added one closed zero-length row and one new open row per seed row
90
+ (34 rows after two boots, 17 closed), and a rate the host had set with `upsertPrice` was
91
+ closed before its own start date while the snapshot row became the open row again, so the
92
+ snapshot price silently replaced the host's price on every restart. The seed now inserts
93
+ only the model, operation and tier combinations that have no row at all, in one transaction
94
+ under a per-combination `pg_advisory_xact_lock`, and never touches existing history. The
95
+ session-scoped `pg_try_advisory_lock` the seed used is no longer taken: a session lock stays
96
+ held until its connection closes, so whether a boot seeded depended on which old connections
97
+ were still alive. `PrismaAiTokensStore.acquireSeedLock` stays for compatibility and is
98
+ deprecated. `IPricingStore` gains the optional method `seedPrice`; a custom store without it
99
+ is seeded from `getPriceHistory` and `upsertPrice`, which is not atomic across replicas.
100
+
101
+ **Apply:** a 1.1.x database may hold duplicated zero-length rows and reverted overrides. Find
102
+ them with the queries below; deleting or re-entering rows is your call, nothing here changes
103
+ data.
104
+
105
+ ```sql
106
+ -- Rows the old seed closed at the instant they began (one per extra boot).
107
+ SELECT "provider", "model", "operation", "serviceTier", COUNT(*) AS "rows"
108
+ FROM "ai_model_prices"
109
+ WHERE "effectiveTo" = "effectiveFrom"
110
+ GROUP BY 1, 2, 3, 4;
111
+
112
+ -- Rows closed before their own start: a price set with upsertPrice that a later boot reverted.
113
+ SELECT "provider", "model", "operation", "serviceTier", "inputNanoUsdPerMillion",
114
+ "effectiveFrom", "effectiveTo"
115
+ FROM "ai_model_prices"
116
+ WHERE "effectiveTo" < "effectiveFrom";
117
+ ```
118
+
119
+ A row from the second query is a rate you set; re-enter it with `upsertPrice` at its original
120
+ `effectiveFrom`.
121
+
122
+ ### Added
123
+
124
+ - `ChainVerification`, the result of `LedgerService.verifyChain`, is exported by name from the
125
+ package root (a type only).
126
+
127
+ ### Documentation
128
+
129
+ - **The README says how to create the tables and what the public API is.** The SQL migration and
130
+ the Prisma schema fragment ship in the package under `dist/prisma/` but were not mentioned; the
131
+ setup section now names both. The API reference lists the decorators (including `@AiFeature`),
132
+ the money and ledger helpers, the catalogs, the injection tokens and `CounterValueOutOfRangeError`.
133
+ The `budgets.defaultPolicy` Type column read `'block' | 'alert'`; the type is
134
+ `'block' | 'throttle' | 'allow'`. The content sidecar section states that the library ships no
135
+ `IContentStore` implementation or table, and the configuration section describes the
136
+ `forRootAsync` and `forRoot` behaviour for a disabled wallet or budget block. The 1.0.0 entries
137
+ that named an `OtelTelemetrySink` and a six-step resolution chain were corrected to the code.
138
+
139
+ - **The README now matches the code.** The configuration table gave the wrong default for
140
+ `pricing.seedFromSnapshot` (`true`, not `false`), `budgets.defaultPolicy` (`'block'`, not
141
+ `'alert'`) and `holds.ttlSeconds` (`3600`, not `300`), and gave no default for
142
+ `wallets.creditRateNanoUsd` (`1_000_000_000n`). It told readers to pass an
143
+ `OtelTelemetrySink` that the library does not ship; the option is `telemetry.sink`, which
144
+ takes your own `ITelemetrySink`, next to `telemetry.metrics`. The Bedrock preset is
145
+ `providerPresets.bedrock`, not `providerPresets.bedrockConverse`. The artifact-size table,
146
+ the stack list (it named a `ConfigurableModuleBuilder` the module does not use) and the
147
+ `AI_TOKENS_PRICE_NOT_FOUND` description (it counted resolution steps) were corrected too.
148
+ - **The README examples compile.** Some examples called methods or read fields that do not
149
+ exist: `wallets.getOrCreate` (the method is `getBalance`, and it returns `nanoUsd` and
150
+ `credits`), `metering.summarize` (it is `UsageReportService.summarize`), `reports.export`
151
+ with a `format` property (the format is the second argument), and a markup policy typed on
152
+ `MeteringContext` (its `resolve` takes the narrower call context).
153
+
11
154
  ## [1.1.2] - 2026-10-30
12
155
 
13
156
  ### Fixed
@@ -203,7 +346,7 @@ have regressed from. They are kept because the reasoning is worth having.
203
346
  ### Added
204
347
 
205
348
  - **Nine provider normalizers** — OpenAI Chat Completions, OpenAI Responses API, OpenAI-compatible (DeepSeek, xAI, Groq, Azure OpenAI), Anthropic Messages, Google Gemini / Vertex AI, AWS Bedrock Converse, Mistral, OpenRouter, Vercel AI SDK v5/v6. All are pure functions over plain objects; no provider SDK peer dependency.
206
- - **Versioned effective-dated pricing** — `PricingService` with a six-step model-resolution chain, in-memory TTL cache, `upsertPrice()`, and idempotent `MODEL_PRICES_SEED` snapshot seed. Rates are bigint nano-USD per million tokens; past records are never re-rated.
349
+ - **Versioned effective-dated pricing** — `PricingService` with a model-resolution chain (exact, `baseModel`, alias map, normalized id, longest prefix), in-memory TTL cache, `upsertPrice()`, and idempotent `MODEL_PRICES_SEED` snapshot seed. Rates are bigint nano-USD per million tokens; past records are never re-rated.
207
350
  - **Immutable append-only ledger** — `LedgerService` with content-derived idempotency (`deriveIdempotencyKey`), exactly-once semantics (payload-hash replay detection), lifecycle transitions (pending → posted → reversed/released), filtered queries, and optional per-tenant tamper-evident hash chain (`verifyChain()`).
208
351
  - **First-class markup / margin** — `MarkupResolver` with a fixed multiplier or a per-call `IMarkupPolicy`. Every record stores both `rawCostNanoUsd` (provider cost) and `billedCostNanoUsd` (after markup). The resale lever is a first-class configuration option, not application code.
209
352
  - **Prepaid wallets** — `WalletService` with grant/debit/refund/adjust entries, configurable burn order (expiry/priority/FIFO), overdraft support, and debit-allocation tracking.
@@ -214,7 +357,7 @@ have regressed from. They are kept because the reasoning is worth having.
214
357
  - **NestJS enforcement** — `BudgetGuard` (CanActivate), `MeteringInterceptor` (NestInterceptor), and the `@Meter`, `@RequireBudget`, `@AiFeature` decorators for declarative controller-level metering.
215
358
  - **Usage reporting** — `UsageReportService` with SQL-aggregated `summarize()`, CSV/JSON `export()`, paginated history, per-model analytics, and currency conversion.
216
359
  - **Events** — Typed domain events (`ai_tokens.*`) delivered to `@nestjs/event-emitter` (optional peer) and/or an `IEventSink` port.
217
- - **OpenTelemetry** — Optional `ITelemetrySink` / `OtelTelemetrySink` via `@opentelemetry/api` (optional peer).
360
+ - **OpenTelemetry** — The `ITelemetrySink` port, which you implement over `@opentelemetry/api` (optional peer); the library ships no sink and imports nothing from `@opentelemetry/api`.
218
361
  - **PostgreSQL adapter** — `PrismaAiTokensStore` (`./prisma` subpath) implementing all four storage ports via parameterized raw SQL. Seven-table schema with migrations.
219
362
  - **Five subpaths** — `.` (server), `./shared` (zero-dep), `./prices` (data-only), `./prisma` (adapter), `./redis` (counter).
220
363
  - **Zero runtime dependencies** — `"dependencies": {}`. All runtime functionality via peer dependencies.
@@ -297,7 +440,9 @@ have regressed from. They are kept because the reasoning is worth having.
297
440
  [1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
298
441
  [1.1.0]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...v1.1.0
299
442
  [1.1.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.1...v1.1.2
443
+ [1.1.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.2...v1.1.3
444
+ [1.1.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.3...v1.1.4
300
445
  [1.1.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.0...v1.1.1
301
446
  [1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
302
447
  [1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
303
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.2...HEAD
448
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.4...HEAD
package/README.md CHANGED
@@ -134,10 +134,10 @@ the feature that needs them is enabled.
134
134
 
135
135
  | Artifact | Brotli size | Budget |
136
136
  | ------------------------------------------- | ----------- | ------- |
137
- | server (`@bymax-one/nest-ai-tokens`) | 37 KB | < 40 KB |
137
+ | server (`@bymax-one/nest-ai-tokens`) | 38 KB | < 40 KB |
138
138
  | shared (`@bymax-one/nest-ai-tokens/shared`) | 5 KB | < 10 KB |
139
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 |
140
+ | redis (`@bymax-one/nest-ai-tokens/redis`) | 1.3 KB | < 5 KB |
141
141
  | prices (data-only, exempt from budget) | 1.3 KB | exempt |
142
142
 
143
143
  ---
@@ -180,6 +180,20 @@ npm i @prisma/adapter-pg
180
180
  > moves to a `prisma.config.ts` at your project root. Neither change reaches this
181
181
  > library's API — `PrismaAiTokensStore` receives whatever client you built.
182
182
 
183
+ #### Create the tables
184
+
185
+ `PrismaAiTokensStore` needs seven tables, and the package ships what creates them, inside the
186
+ installed package:
187
+
188
+ - `node_modules/@bymax-one/nest-ai-tokens/dist/prisma/migrations/0001_init.sql` — the SQL for all
189
+ seven tables. Apply it with `psql -f` or `prisma db execute --file` before the first boot.
190
+ - `node_modules/@bymax-one/nest-ai-tokens/dist/prisma/schema.prisma.fragment` — the same models
191
+ for merging into your own Prisma schema if you manage migrations with `prisma migrate`.
192
+
193
+ The SQL also creates two partial indexes (exactly one open price row per model, and a fast scan
194
+ of pending holds) that a Prisma schema cannot express. If you migrate from the fragment, add them
195
+ yourself.
196
+
183
197
  ### 2 — Register the module
184
198
 
185
199
  ```typescript
@@ -284,19 +298,26 @@ export class AiController {
284
298
 
285
299
  `BymaxAiTokensModule.forRoot(options)` / `.forRootAsync({ useFactory })` accept `BymaxAiTokensModuleOptions`:
286
300
 
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`) |
301
+ | Option | Type | Default | Purpose |
302
+ | --------------------------- | ------------------------------------- | ---------------- | ------------------------------------------------------------------------------- |
303
+ | `store` | `IAiTokensStore` | — (required) | Persistence adapter (use `PrismaAiTokensStore`) |
304
+ | `scopeResolver` | `(ctx) => MeteringContext` | — | Resolves the caller's scope from the request (required for guard/interceptor) |
305
+ | `ratingMode` | `'rate-table' \| 'provider-reported'` | `'rate-table'` | Default cost rating mode |
306
+ | `markup` | `number \| IMarkupPolicy` | `1.0` | The resale multiplier (e.g. `4.0` = 4× provider cost) |
307
+ | `pricing.seedFromSnapshot` | `boolean` | `true` | Add seed rows only for combinations with no price row; never changes a price |
308
+ | `wallets.creditRateNanoUsd` | `bigint` | `1_000_000_000n` | Exchange rate: 1 credit = this many nano-USD |
309
+ | `wallets.burnOrder` | `'expiry' \| 'priority' \| 'fifo'` | `'expiry'` | Order in which grants are consumed |
310
+ | `budgets.defaultPolicy` | `'block' \| 'throttle' \| 'allow'` | `'block'` | Default enforcement when a budget is exhausted |
311
+ | `budgets.alertThresholds` | `number[]` | `[0.8, 1.0]` | Soft-threshold events at these fractions |
312
+ | `holds.ttlSeconds` | `number` | `3600` | Spend-hold TTL; the reaper voids expired holds |
313
+ | `telemetry.sink` | `ITelemetrySink` | — (off) | Your OpenTelemetry sink; the library ships none and telemetry is off without it |
314
+ | `telemetry.metrics` | `boolean` | `true` | Emit token-usage and duration metrics when a sink is present |
315
+
316
+ With `forRootAsync`, the options arrive after the providers are declared, so `WalletService`,
317
+ `BudgetService` and `BudgetGuard` are always registered. When the options leave `wallets` or
318
+ `budgets` out, the injected object throws `AI_TOKENS_INVALID_CONFIG` on any method call, with a
319
+ reason naming the missing block. With `forRoot` they are not registered, so injecting one fails
320
+ when the application boots.
300
321
 
301
322
  ---
302
323
 
@@ -309,13 +330,20 @@ export class AiController {
309
330
  | OpenAI-compatible | DeepSeek, xAI, Groq, Azure OpenAI | `normalizeOpenAiCompatibleUsage` / `providerPresets.openaiCompatible` |
310
331
  | Anthropic | Messages API | `normalizeAnthropicUsage` / `providerPresets.anthropic` |
311
332
  | Google | Gemini / Vertex AI | `normalizeGeminiUsage` / `providerPresets.gemini` |
312
- | AWS | Bedrock Converse | `normalizeBedrockConverseUsage` / `providerPresets.bedrockConverse` |
333
+ | AWS | Bedrock Converse | `normalizeBedrockConverseUsage` / `providerPresets.bedrock` |
313
334
  | Mistral | Chat API | `normalizeMistralUsage` / `providerPresets.mistral` |
314
335
  | OpenRouter | Chat | `normalizeOpenRouterUsage` / `providerPresets.openrouter` (provider-reported cost) |
315
336
  | Vercel | AI SDK v5/v6 | `normalizeVercelAiSdkUsage` / `providerPresets.vercelAiSdk` |
316
337
 
317
338
  All normalizers are pure functions over plain objects — no provider SDK peer dependency. Pass the raw response object and get back a `NormalizedUsage`.
318
339
 
340
+ `MODEL_PRICES_SEED` has no rows for `azure-openai`, `vertex`, `bedrock` or `openrouter`; it covers
341
+ `openai`, `anthropic`, `gemini`, `mistral`, `deepseek`, `xai` and `groq`. With `pricing.strict` on
342
+ (the default), a call through the `azure-openai`, `vertex` or `bedrock` preset throws
343
+ `AI_TOKENS_PRICE_NOT_FOUND` until you add its price. The `openrouter` preset rates from the cost the
344
+ provider reports, so it needs no price unless a call sets `ratingMode: 'rate-table'`. See
345
+ [Pricing & Markup](#-pricing--markup).
346
+
319
347
  ---
320
348
 
321
349
  ## 💰 Pricing & Markup
@@ -325,7 +353,7 @@ The fundamental difference between "what the provider charged you" and "what you
325
353
  ```typescript
326
354
  // Configure at module level:
327
355
  BymaxAiTokensModule.forRootAsync({
328
- useFactory: (prisma) => ({
356
+ useFactory: (prisma: PrismaService) => ({
329
357
  store: new PrismaAiTokensStore(prisma),
330
358
  markup: 4.0, // 4× resale margin
331
359
  wallets: { creditRateNanoUsd: 5_000_000_000n }, // sell credits at $5 each
@@ -338,11 +366,25 @@ BymaxAiTokensModule.forRootAsync({
338
366
  // Margin: $0.015 per call
339
367
  ```
340
368
 
369
+ A provider or model the snapshot does not price needs a rate before it can be rated. Add one with
370
+ `PricingService.upsertPrice`; the new row closes the previous one at its `effectiveFrom`, which
371
+ defaults to now. Rates are nano-USD per million tokens:
372
+
373
+ ```typescript
374
+ await pricing.upsertPrice({
375
+ provider: 'bedrock',
376
+ model: 'my-model', // the model id your calls report
377
+ operation: 'chat',
378
+ inputNanoUsdPerMillion: 3_000_000_000n, // $3 per million input tokens
379
+ outputNanoUsdPerMillion: 15_000_000_000n, // $15 per million output tokens
380
+ })
381
+ ```
382
+
341
383
  For per-tenant or per-feature pricing, implement `IMarkupPolicy`:
342
384
 
343
385
  ```typescript
344
386
  class MyMarkupPolicy implements IMarkupPolicy {
345
- resolve(ctx: MeteringContext): number | Promise<number> {
387
+ resolve(ctx: { feature?: string }): number | Promise<number> {
346
388
  return ctx.feature === 'premium.summarize' ? 5.0 : 3.0
347
389
  }
348
390
  }
@@ -370,8 +412,8 @@ await wallets.grant(
370
412
 
371
413
  // Credits are debited automatically when the ledger posts a billed cost.
372
414
  // Check balance:
373
- const wallet = await wallets.getOrCreate({ tenantId, ownerType: 'user', ownerId: userId })
374
- console.log(wallet.balanceNanoUsd) // current balance
415
+ const balance = await wallets.getBalance({ tenantId, ownerType: 'user', ownerId: userId })
416
+ console.log(balance.nanoUsd) // current balance
375
417
  ```
376
418
 
377
419
  ### Multi-Dimension Budgets
@@ -443,7 +485,7 @@ On abort, the collector falls back to a tokenizer estimate of the output tokens
443
485
 
444
486
  ```typescript
445
487
  // Usage summary — by feature, for the last 30 days:
446
- const summary = await metering.summarize({
488
+ const summary = await reports.summarize({
447
489
  tenantId,
448
490
  scope: { type: 'user', id: userId },
449
491
  from: thirtyDaysAgo,
@@ -452,12 +494,7 @@ const summary = await metering.summarize({
452
494
  })
453
495
 
454
496
  // Export as CSV (streaming):
455
- const csv = await reports.export({
456
- tenantId,
457
- from,
458
- to,
459
- format: 'csv',
460
- })
497
+ const csv = await reports.export({ tenantId, from, to }, 'csv')
461
498
  csv.pipe(res)
462
499
 
463
500
  // Access status / remaining budget:
@@ -495,11 +532,11 @@ All errors are `AiTokensException extends HttpException`. Use the exported `AI_T
495
532
 
496
533
  | Code | HTTP | When |
497
534
  | -------------------------------- | ---- | ------------------------------------------------------------------------------------- |
498
- | `AI_TOKENS_NOT_CONFIGURED` | 503 | Service invoked before async init completed |
535
+ | `AI_TOKENS_NOT_CONFIGURED` | 503 | Reserved; nothing raises it |
499
536
  | `AI_TOKENS_INVALID_CONFIG` | 500 | Bad markup, negative limits, missing `scopeResolver`, missing fx |
500
537
  | `AI_TOKENS_UNKNOWN_PROVIDER` | 400 | No preset/normalizer and usage is not already `NormalizedUsage` |
501
538
  | `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) |
539
+ | `AI_TOKENS_PRICE_NOT_FOUND` | 422 | No effective-dated rate after the model-resolution chain (strict mode) |
503
540
  | `AI_TOKENS_FX_REQUIRED` | 500 | `currency !== 'USD'` with no `fx` resolver |
504
541
  | `AI_TOKENS_BUDGET_EXCEEDED` | 402 | A hard spend budget blocks the call |
505
542
  | `AI_TOKENS_QUOTA_EXCEEDED` | 429 | A hard token/count quota blocks the call |
@@ -522,7 +559,7 @@ import { toJsonSafe } from '@bymax-one/nest-ai-tokens'
522
559
 
523
560
  // In your controller:
524
561
  return toJsonSafe(await metering.getStatus(tenantId, scope))
525
- // bigint fields (balanceNanoUsd, billedCostNanoUsd, etc.) become decimal strings.
562
+ // bigint fields (wallet.balanceNanoUsd, budgets[].spent.nanoUsd, etc.) become decimal strings.
526
563
 
527
564
  // For display/formatting:
528
565
  import { formatNanoUsd } from '@bymax-one/nest-ai-tokens/shared'
@@ -548,6 +585,55 @@ The sections above document each of these with a runnable example. This is the i
548
585
  | `UsageReportService` | `summarize` · `export` |
549
586
  | `StreamUsageCollector` | Accumulates streamed chunks; prefers the provider's final usage over its own count |
550
587
 
588
+ #### Verifying the hash chain
589
+
590
+ `LedgerService.verifyChain(tenantId, from?, to?)` resolves to a `ChainVerification`:
591
+
592
+ - `{ valid: true, unchainedCount, chainStartsAtRecordId? }` — every hashed record recomputes and, on a call
593
+ without `from`/`to`, the hashed records form one chain. `chainStartsAtRecordId` is the genesis record
594
+ when the verified records include it.
595
+ - `{ valid: false, brokenAtRecordId, unchainedCount }` — the record that fails: a record whose hash was
596
+ removed while its link stayed, a changed record, or, without `from`/`to`, a gap (a second chain start)
597
+ or a chain that does not start at a genesis record (its first head). Those checks run in that order,
598
+ and within each the earliest record in listing order is reported. Two records may share a predecessor:
599
+ a hold that settles after a later record links to the record created last, as the next record does.
600
+ - `{ valid: false, status: 'never-enabled', unchainedCount }` — settled records exist and none carries a hash or a link. This
601
+ is not evidence of tampering; `ledger.hashChain` was simply never on for this tenant.
602
+
603
+ Enable `ledger.hashChain` before the first write. Records written before it was enabled are **unchained**:
604
+ they are counted in `unchainedCount`, not verified and not protected.
605
+
606
+ What it does not catch, plainly:
607
+
608
+ 1. A record that no other record names as its predecessor can be deleted without detection. That is
609
+ the newest record, and a hold that settled while a later-created record was already hashed (a record
610
+ links to the hashed record created last, so that hold is never linked to). A hold that settles as
611
+ the newest-created record is linked by the next record and is not affected.
612
+ 2. The chain is unkeyed. Someone with write access who recomputes the whole tail is not detected, so
613
+ anchor the latest hash outside the database.
614
+ 3. An `unchainedCount` that rises after the chain was enabled is a signal to investigate. A rolling deploy
615
+ with mixed `hashChain` settings can also cause it.
616
+ 4. A call with `from` or `to` checks each record's own hash only, not the links between records.
617
+
618
+ ### Decorators, helpers and catalogs
619
+
620
+ | Export | Role |
621
+ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
622
+ | `@Meter` · `@RequireBudget` · `@AiFeature(name)` | Handler metadata read by the interceptor and the guard; `@AiFeature` only tags the feature name |
623
+ | `deriveIdempotencyKey` · `toJsonSafe` · `formatNanoUsd` | Idempotency key from content, `bigint` to decimal strings for JSON, and display formatting |
624
+ | `floatUsdToNanoUsd` · `perMillion` · `computeCostNanoUsd` | The money math: the only float-to-`bigint` entry, a cost at a nano-USD-per-million rate, and a usage priced against a rate |
625
+ | `applyMarkup` · `resolveMultiplier4dp` | A markup applied in exact `bigint`, and a multiplier validated and rounded to 4 decimal places |
626
+ | `isLegalLedgerTransition` · `isLegalTransitionPatchKey` | The ledger lifecycle rules a custom store can reuse: which status changes and which columns each may touch |
627
+ | `PROVIDER_IDS` · `SERVICE_TIERS` · `AI_OPERATIONS` · `TOKEN_CATEGORIES` · `WALLET_ENTRY_TYPES` | The catalogs the types are derived from |
628
+ | `CounterValueOutOfRangeError` (`./redis`) | Thrown when a counter value leaves the safe-integer range; the budget service then falls back to the database consume |
629
+
630
+ ### Injection tokens
631
+
632
+ `BYMAX_AI_TOKENS_OPTIONS` (the resolved options) and the port tokens `BYMAX_AI_TOKENS_LEDGER_STORE`,
633
+ `_PRICING_STORE`, `_WALLET_STORE`, `_BUDGET_STORE`, `_BUDGET_COUNTER`, `_TOKENIZER`, `_TELEMETRY`,
634
+ `_EVENT_SINK`, `_CONTENT_STORE` and `_LOGGER`. The wallet and budget tokens exist only when their
635
+ options block is set (with `forRoot`; `forRootAsync` always exports them).
636
+
551
637
  ### Request-scoped enforcement
552
638
 
553
639
  | Class | Role |
@@ -672,7 +758,9 @@ There is one place text can be persisted, and it is opt-in: `IContentStore`, the
672
758
  sidecar. A host that enables it stores **masked** text under a short TTL, separate from the
673
759
  ledger, with a `purge()` that deletes by tenant, record or subject so an erasure request can
674
760
  be honoured without touching the accounting. It is off unless you provide a store — and if
675
- you do, text is being written, and where it is written is your implementation.
761
+ you do, text is being written, and where it is written is your implementation. The library ships
762
+ no `IContentStore` implementation and creates no table for one: you bring the store, its schema
763
+ and its retention job.
676
764
 
677
765
  ### Budget checks are atomic
678
766
 
@@ -713,7 +801,7 @@ that reads a tenant id from request input lets a caller bill someone else.
713
801
  ## 🧱 Tech Stack
714
802
 
715
803
  - **Runtime:** Node.js 24+
716
- - **Framework:** NestJS 11 (guard + interceptor, `ConfigurableModuleBuilder`, `Symbol()` tokens)
804
+ - **Framework:** NestJS 11 (guard + interceptor, `Symbol()` tokens)
717
805
  - **Persistence:** `@prisma/client ^6 || ^7` (peer) over PostgreSQL — both majors supported
718
806
  - **Budget acceleration:** `ioredis ^6` (peer), optional
719
807
  - **Events:** `@nestjs/event-emitter >=2` (peer), optional
@@ -421,6 +421,9 @@ function mapPriceRow(row) {
421
421
  source: row.source
422
422
  };
423
423
  }
424
+ function priceLockKey(provider, model, operation, serviceTier) {
425
+ return JSON.stringify([provider, model, operation, serviceTier]);
426
+ }
424
427
  function firstOrThrow(rows) {
425
428
  const row = rows[0];
426
429
  if (row === void 0) throw new nestAiTokens.AiTokensException("AI_TOKENS_STORE_ERROR", void 0, {});
@@ -828,7 +831,7 @@ var PrismaAiTokensStore = class {
828
831
  async upsertPrice(input) {
829
832
  const serviceTier = input.serviceTier ?? "standard";
830
833
  const effectiveFrom = input.effectiveFrom ?? /* @__PURE__ */ new Date();
831
- const key = `${input.provider}|${input.model}|${input.operation}|${serviceTier}`;
834
+ const key = priceLockKey(input.provider, input.model, input.operation, serviceTier);
832
835
  return this.prisma.$transaction(async (tx) => {
833
836
  await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${key})::bigint)`;
834
837
  await tx.$executeRaw(client.Prisma.sql`
@@ -843,6 +846,33 @@ var PrismaAiTokensStore = class {
843
846
  return mapPriceRow(firstOrThrow(rows));
844
847
  });
845
848
  }
849
+ /**
850
+ * Insert a price row only when its (provider, model, operation, serviceTier) tuple has no
851
+ * row yet. The check and the insert run in one transaction under the same per-tuple
852
+ * `pg_advisory_xact_lock` as {@link upsertPrice}, so concurrent boots serialize on the
853
+ * tuple, the loser finds the winner's row and inserts nothing, and the lock is released
854
+ * when the transaction ends rather than when the connection does.
855
+ */
856
+ async seedPrice(input) {
857
+ const serviceTier = input.serviceTier ?? "standard";
858
+ const effectiveFrom = input.effectiveFrom ?? /* @__PURE__ */ new Date();
859
+ const key = priceLockKey(input.provider, input.model, input.operation, serviceTier);
860
+ return this.prisma.$transaction(async (tx) => {
861
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${key})::bigint)`;
862
+ const existing = await tx.$queryRaw(client.Prisma.sql`
863
+ SELECT "id" FROM "ai_model_prices"
864
+ WHERE "provider" = ${input.provider} AND "model" = ${input.model} AND "operation" = ${input.operation}
865
+ AND "serviceTier" = ${serviceTier}
866
+ LIMIT 1
867
+ `);
868
+ if (existing.length > 0) return false;
869
+ await tx.$queryRaw(client.Prisma.sql`
870
+ INSERT INTO "ai_model_prices" (${PRICE_COLUMNS_SQL}) VALUES (${client.Prisma.join(priceValues(input, serviceTier, effectiveFrom))})
871
+ RETURNING *
872
+ `);
873
+ return true;
874
+ });
875
+ }
846
876
  async getPriceHistory(provider, model, operation, serviceTier) {
847
877
  const tier = serviceTier === void 0 ? client.Prisma.empty : client.Prisma.sql`AND "serviceTier" = ${serviceTier}`;
848
878
  const rows = await this.prisma.$queryRaw(client.Prisma.sql`
@@ -858,7 +888,12 @@ var PrismaAiTokensStore = class {
858
888
  `);
859
889
  return rows.map((row) => ({ model: row.model, operation: row.operation, serviceTier: row.serviceTier }));
860
890
  }
861
- /** Advisory seed lock (§6.4): the first caller wins; concurrent boots seed exactly once. */
891
+ /**
892
+ * Session-scoped advisory lock: the first caller on a connection wins, and the lock is held
893
+ * until that connection closes, not when a boot ends.
894
+ *
895
+ * @deprecated Use {@link seedPrice}, which seeds atomically under a transaction-scoped lock.
896
+ */
862
897
  async acquireSeedLock(key) {
863
898
  const rows = await this.prisma.$queryRaw(
864
899
  client.Prisma.sql`SELECT pg_try_advisory_lock(hashtext(${key})::bigint) AS "acquired"`
@@ -505,6 +505,15 @@ interface IPricingStore {
505
505
  resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
506
506
  /** Close the current open row (`effectiveTo = now`) and insert the new open row. */
507
507
  upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
508
+ /**
509
+ * Insert `input` only when no row, open or closed, exists for its
510
+ * (provider, model, operation, serviceTier) tuple; resolves `true` when it inserted.
511
+ * Atomic per tuple, so concurrent boots cannot both insert, and it never closes or
512
+ * alters an existing row. The snapshot seed uses it so a restart cannot revert a rate
513
+ * the host set with {@link IPricingStore.upsertPrice}. A store without it is seeded
514
+ * from {@link IPricingStore.getPriceHistory} and `upsertPrice`, which is not atomic.
515
+ */
516
+ seedPrice?(input: NewPriceVersion): Promise<boolean>;
508
517
  /** Full effective-dated history for a tuple (newest first). */
509
518
  getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
510
519
  /** All distinct (model, operation, serviceTier) for a provider — powers §6.6 prefix matching. */
@@ -615,9 +624,22 @@ declare class PrismaAiTokensStore implements IAiTokensStore {
615
624
  lastHash(tenantId: string): Promise<string | null>;
616
625
  resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
617
626
  upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
627
+ /**
628
+ * Insert a price row only when its (provider, model, operation, serviceTier) tuple has no
629
+ * row yet. The check and the insert run in one transaction under the same per-tuple
630
+ * `pg_advisory_xact_lock` as {@link upsertPrice}, so concurrent boots serialize on the
631
+ * tuple, the loser finds the winner's row and inserts nothing, and the lock is released
632
+ * when the transaction ends rather than when the connection does.
633
+ */
634
+ seedPrice(input: NewPriceVersion): Promise<boolean>;
618
635
  getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
619
636
  listModels(provider: ProviderId): Promise<PricedModel[]>;
620
- /** Advisory seed lock (§6.4): the first caller wins; concurrent boots seed exactly once. */
637
+ /**
638
+ * Session-scoped advisory lock: the first caller on a connection wins, and the lock is held
639
+ * until that connection closes, not when a boot ends.
640
+ *
641
+ * @deprecated Use {@link seedPrice}, which seeds atomically under a transaction-scoped lock.
642
+ */
621
643
  acquireSeedLock(key: string): Promise<boolean>;
622
644
  /** Insert one usage row and return it; shared by the plain and chained paths. */
623
645
  private insert;
@@ -505,6 +505,15 @@ interface IPricingStore {
505
505
  resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
506
506
  /** Close the current open row (`effectiveTo = now`) and insert the new open row. */
507
507
  upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
508
+ /**
509
+ * Insert `input` only when no row, open or closed, exists for its
510
+ * (provider, model, operation, serviceTier) tuple; resolves `true` when it inserted.
511
+ * Atomic per tuple, so concurrent boots cannot both insert, and it never closes or
512
+ * alters an existing row. The snapshot seed uses it so a restart cannot revert a rate
513
+ * the host set with {@link IPricingStore.upsertPrice}. A store without it is seeded
514
+ * from {@link IPricingStore.getPriceHistory} and `upsertPrice`, which is not atomic.
515
+ */
516
+ seedPrice?(input: NewPriceVersion): Promise<boolean>;
508
517
  /** Full effective-dated history for a tuple (newest first). */
509
518
  getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
510
519
  /** All distinct (model, operation, serviceTier) for a provider — powers §6.6 prefix matching. */
@@ -615,9 +624,22 @@ declare class PrismaAiTokensStore implements IAiTokensStore {
615
624
  lastHash(tenantId: string): Promise<string | null>;
616
625
  resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
617
626
  upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
627
+ /**
628
+ * Insert a price row only when its (provider, model, operation, serviceTier) tuple has no
629
+ * row yet. The check and the insert run in one transaction under the same per-tuple
630
+ * `pg_advisory_xact_lock` as {@link upsertPrice}, so concurrent boots serialize on the
631
+ * tuple, the loser finds the winner's row and inserts nothing, and the lock is released
632
+ * when the transaction ends rather than when the connection does.
633
+ */
634
+ seedPrice(input: NewPriceVersion): Promise<boolean>;
618
635
  getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
619
636
  listModels(provider: ProviderId): Promise<PricedModel[]>;
620
- /** Advisory seed lock (§6.4): the first caller wins; concurrent boots seed exactly once. */
637
+ /**
638
+ * Session-scoped advisory lock: the first caller on a connection wins, and the lock is held
639
+ * until that connection closes, not when a boot ends.
640
+ *
641
+ * @deprecated Use {@link seedPrice}, which seeds atomically under a transaction-scoped lock.
642
+ */
621
643
  acquireSeedLock(key: string): Promise<boolean>;
622
644
  /** Insert one usage row and return it; shared by the plain and chained paths. */
623
645
  private insert;