@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/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/prices/index.cjs +218 -0
- package/dist/prices/index.d.cts +131 -0
- package/dist/prices/index.d.ts +131 -0
- package/dist/prices/index.mjs +216 -0
- package/dist/prisma/index.cjs +1197 -0
- package/dist/prisma/index.d.cts +677 -0
- package/dist/prisma/index.d.ts +677 -0
- package/dist/prisma/index.mjs +1195 -0
- package/dist/prisma/migrations/0001_init.sql +198 -0
- package/dist/prisma/schema.prisma.fragment +197 -0
- package/dist/redis/index.cjs +110 -0
- package/dist/redis/index.d.cts +98 -0
- package/dist/redis/index.d.ts +98 -0
- package/dist/redis/index.mjs +107 -0
- package/dist/server/index.cjs +4405 -0
- package/dist/server/index.d.cts +3202 -0
- package/dist/server/index.d.ts +3202 -0
- package/dist/server/index.mjs +4354 -0
- package/dist/shared/index.cjs +659 -0
- package/dist/shared/index.d.cts +1044 -0
- package/dist/shared/index.d.ts +1044 -0
- package/dist/shared/index.mjs +629 -0
- package/package.json +216 -0
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>
|