@bymax-one/nest-ai-tokens 1.0.3 → 1.1.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 CHANGED
@@ -6,16 +6,49 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
6
6
 
7
7
  ---
8
8
 
9
- ## [Unreleased]
9
+ ## [1.1.0] - 2026-08-11
10
+
11
+ ### Changed
12
+
13
+ - **BREAKING: `ioredis` peer moved to `^6.0.0`** (was `^5.0.0`; the ranges are disjoint, so an
14
+ ioredis 5 consumer no longer satisfies the contract). It aligns the `./redis` budget counter
15
+ with `@bymax-one/nest-queue@1.2.0`, so a backend that consumes both resolves one shared `ioredis`.
16
+ ioredis 6 defaults to RESP3 but keeps `replyMapping: "legacy"`, leaving reply shapes unchanged at
17
+ runtime; the store's URL-only `new Redis(url)` construction is untouched by the RESP3 options
18
+ typing break, so no source changed.
19
+ - **Mutation gate raised to a perfect score.** `stryker.config.json` now breaks below 100 (`break`,
20
+ `high` and `low` are all 100), so no surviving mutant can pass the gate.
21
+
22
+ ## [1.0.4] - 2026-08-10
23
+
24
+ Remediation of a local audit's settlement findings (merged in #65). No API changed.
25
+
26
+ ### Fixed
27
+
28
+ - **Negative usage can no longer credit the wallet or budget.** `clampUsageCounts` floors every
29
+ token count — and the provider-reported cost (`providerReportedCostNanoUsd`) — at zero before the
30
+ signed settlement leg. A normalizer that computes a field by subtraction (OpenRouter's
31
+ `prompt - cached`), or a provider that reports a negative `usage.cost`, would otherwise credit
32
+ back more than was reserved and drive budget consumption negative.
33
+ - **Streaming captures are clamped like non-stream captures.** `finalizeCaptureUsage` now routes
34
+ the `StreamUsageCollector` final through `normalizeCaptureUsage`, so a provider-final stream
35
+ carrying negative counts or cost no longer bypasses the floor and reaches settlement raw.
36
+
37
+ ### Documentation
38
+
39
+ - `RecordInput.occurredAt` documents its trust model: it is a deliberate backfill feature, honoured
40
+ as given because `record()`'s input is trusted host input, and a host that forwards a value an
41
+ untrusted end user controls must validate it first — a back-dated timestamp selects a historical
42
+ price and an earlier budget window.
10
43
 
11
44
  ## [1.0.3] - 2026-08-06
12
45
 
13
46
  ### Fixed
14
47
 
15
48
  - **Every suppression reason was dropped from the mutation report.** All 249 `// Stryker
16
- disable` directives in the source wrote their justification after `--`. Stryker reads a
49
+ disable` directives in the source wrote their justification after `--`. Stryker reads a
17
50
  directive with one regular expression, `/^\s?Stryker (disable|restore)(?: (next-line))?
18
- ([a-zA-Z, ]+)(?::(.+)?)?/`, whose mutator list accepts letters, commas and spaces only
51
+ ([a-zA-Z, ]+)(?::(.+)?)?/`, whose mutator list accepts letters, commas and spaces only
19
52
  and which captures the reason exclusively after a colon. A `-` therefore closed the
20
53
  mutator list and left the reason unmatched, so each directive still silenced its mutant
21
54
  but the report recorded Stryker's fallback text, `Ignored using a comment`. The README
@@ -49,14 +82,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
49
82
  implement `emitDecoratorMetadata` because it does not replicate TypeScript's type
50
83
  system — so the second key was never there. The first two parameters had neither, and
51
84
  the container threw `Nest can't resolve dependencies of the MeteringInterceptor
52
- (?, +, Symbol(BYMAX_AI_TOKENS_OPTIONS))` before any provider was created. Both now
85
+ (?, +, Symbol(BYMAX_AI_TOKENS_OPTIONS))` before any provider was created. Both now
53
86
  carry `@Inject`.
54
87
 
55
88
  ### Changed
56
89
 
57
90
  - `emitDecoratorMetadata` is `false` in `tsconfig.json`. It was `true`, which was never
58
91
  true of the artifact: tsup prints `You have emitDecoratorMetadata enabled but
59
- @swc/core was not installed, skipping swc plugin` on every build, and that warning had
92
+ @swc/core was not installed, skipping swc plugin` on every build, and that warning had
60
93
  been printed on every build of this package. Turning it off makes the source compile
61
94
  the way the bundle is built, so a parameter that depends on reflected types now fails
62
95
  where it is cheap to see.
@@ -117,7 +150,6 @@ have regressed from. They are kept because the reasoning is worth having.
117
150
  - **Zero runtime dependencies** — `"dependencies": {}`. All runtime functionality via peer dependencies.
118
151
  - **100% unit test coverage** — 804 tests; 10-scenario Testcontainers e2e suite; Stryker mutation gate at 100.00% (0 surviving mutants, break 95).
119
152
 
120
-
121
153
  - **Prisma 7 support, alongside Prisma 6.** The declared peer range has always
122
154
  been `>=6.0.0`, but nothing verified the claim; the end-to-end suite now runs
123
155
  against Prisma 7 and a real PostgreSQL, and it is what surfaced the conflict
@@ -133,7 +165,7 @@ have regressed from. They are kept because the reasoning is worth having.
133
165
  - **`pnpm check:exports`** runs `attw --pack . --profile strict` against the packed
134
166
  tarball, which is what surfaced both resolution defects above.
135
167
  - **`pnpm check:runtime`** packs the tarball, lays it out the way npm would, and
136
- asserts in ESM *and* CommonJS that an error thrown by `PrismaAiTokensStore`
168
+ asserts in ESM _and_ CommonJS that an error thrown by `PrismaAiTokensStore`
137
169
  satisfies `instanceof AiTokensException` against the class the root exports. No
138
170
  source-based suite can observe this: the unit tests map the subpath specifiers to
139
171
  `src` and therefore see a single copy. Both gates run in CI.
@@ -193,5 +225,7 @@ have regressed from. They are kept because the reasoning is worth having.
193
225
  [1.0.0]: https://github.com/bymaxone/nest-ai-tokens/releases/tag/v1.0.0
194
226
  [1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
195
227
  [1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
228
+ [1.1.0]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...v1.1.0
229
+ [1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
196
230
  [1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
197
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...HEAD
231
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...HEAD
package/README.md CHANGED
@@ -715,7 +715,7 @@ that reads a tenant id from request input lets a caller bill someone else.
715
715
  - **Runtime:** Node.js 24+
716
716
  - **Framework:** NestJS 11 (guard + interceptor, `ConfigurableModuleBuilder`, `Symbol()` tokens)
717
717
  - **Persistence:** `@prisma/client ^6 || ^7` (peer) over PostgreSQL — both majors supported
718
- - **Budget acceleration:** `ioredis ^5` (peer), optional
718
+ - **Budget acceleration:** `ioredis ^6` (peer), optional
719
719
  - **Events:** `@nestjs/event-emitter >=2` (peer), optional
720
720
  - **Telemetry:** `@opentelemetry/api ^1.9` (peer), optional
721
721
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
@@ -730,7 +730,7 @@ This library decides what a customer is charged, so the suite is held to a bar b
730
730
  tests pass".
731
731
 
732
732
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
733
- - ✅ **100% mutation score against a `break: 95` gate** — the gate is the floor a run must
733
+ - ✅ **100% mutation score against a `break: 100` gate** — the gate is the floor a run must
734
734
  clear; the score the suite actually reaches is 100%, verified with
735
735
  [Stryker](https://stryker-mutator.io/) ([report](./docs/mutation_testing_results.md))
736
736
  - ✅ **Real PostgreSQL in e2e** — Testcontainers, so the unique constraint that prevents a
@@ -752,7 +752,7 @@ tests pass".
752
752
  pnpm test # unit tests
753
753
  pnpm test:cov # unit tests with the 100% coverage gate
754
754
  pnpm test:e2e # end-to-end against PostgreSQL (requires Docker)
755
- pnpm mutation # Stryker mutation testing (break: 95)
755
+ pnpm mutation # Stryker mutation testing (break: 100)
756
756
  pnpm typecheck # tsc strict check
757
757
  pnpm lint # ESLint
758
758
  pnpm build && pnpm size # bundle-size budgets
@@ -2126,6 +2126,19 @@ function tokenCountsOf(usage) {
2126
2126
  function sumTokenCounts(counts) {
2127
2127
  return TOKEN_FIELDS.reduce((total, field) => total + counts[field], 0);
2128
2128
  }
2129
+ function clampUsageCounts(usage) {
2130
+ const counts = tokenCountsOf(usage);
2131
+ const clamped = zeroTokenCounts();
2132
+ for (const field of TOKEN_FIELDS) {
2133
+ clamped[field] = counts[field] < 0 ? 0 : counts[field];
2134
+ }
2135
+ const result = { ...usage, ...clamped };
2136
+ const cost = usage.providerReportedCostNanoUsd;
2137
+ if (cost !== void 0 && cost < 0n) {
2138
+ return { ...result, providerReportedCostNanoUsd: 0n };
2139
+ }
2140
+ return result;
2141
+ }
2129
2142
  function isNormalizedUsage(usage) {
2130
2143
  if (typeof usage !== "object" || usage === null) return false;
2131
2144
  const candidate = usage;
@@ -2202,10 +2215,10 @@ async function resolveTokenEstimate(pricing, context, tokens, serviceTier, at) {
2202
2215
  return { provider, model, operation: "chat", serviceTier, rawEstimateNanoUsd, estimatedTokens: tokens, tokenCounts };
2203
2216
  }
2204
2217
  function normalizeCaptureUsage(usage, normalizer) {
2205
- if (isNormalizedUsage(usage)) return usage;
2218
+ if (isNormalizedUsage(usage)) return clampUsageCounts(usage);
2206
2219
  if (normalizer !== void 0) {
2207
2220
  try {
2208
- return normalizer(usage);
2221
+ return clampUsageCounts(normalizer(usage));
2209
2222
  } catch (error) {
2210
2223
  if (error instanceof AiTokensException) throw error;
2211
2224
  throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the normalizer could not read the usage token fields" });
@@ -2659,9 +2672,9 @@ exports.MeteringService = class MeteringService {
2659
2672
  if (usage instanceof StreamUsageCollector) {
2660
2673
  const finalized = await usage.finalize();
2661
2674
  if (usage.usedFallback && finalized.inputTokens === 0 && hold.estimatedTokens > 0) {
2662
- return { ...finalized, inputTokens: hold.estimatedTokens };
2675
+ return normalizeCaptureUsage({ ...finalized, inputTokens: hold.estimatedTokens });
2663
2676
  }
2664
- return finalized;
2677
+ return normalizeCaptureUsage(finalized);
2665
2678
  }
2666
2679
  return normalizeCaptureUsage(usage, preset2?.normalizer);
2667
2680
  }
@@ -2553,7 +2553,14 @@ interface RecordInput {
2553
2553
  normalizer?: UsageNormalizer;
2554
2554
  /** The per-call metering context (trusted input from the host's auth layer). */
2555
2555
  context: MeteringContext;
2556
- /** When the call happened; defaults to now (backfills pass the original time). */
2556
+ /**
2557
+ * When the call happened; defaults to now. Backfills deliberately pass the original time so a
2558
+ * usage record imported late still lands in the price version and budget window in effect when
2559
+ * it actually occurred. Because that timestamp selects both, it is honoured as given — it is
2560
+ * trusted host input, like the rest of {@link MeteringContext}. A host that forwards a value an
2561
+ * untrusted end user controls must validate it first, since a back-dated `occurredAt` would pick
2562
+ * a historical price and an earlier budget window.
2563
+ */
2557
2564
  occurredAt?: Date;
2558
2565
  /**
2559
2566
  * The ONLY path prompt/completion text enters the library (spec §14.2): masked +
@@ -2553,7 +2553,14 @@ interface RecordInput {
2553
2553
  normalizer?: UsageNormalizer;
2554
2554
  /** The per-call metering context (trusted input from the host's auth layer). */
2555
2555
  context: MeteringContext;
2556
- /** When the call happened; defaults to now (backfills pass the original time). */
2556
+ /**
2557
+ * When the call happened; defaults to now. Backfills deliberately pass the original time so a
2558
+ * usage record imported late still lands in the price version and budget window in effect when
2559
+ * it actually occurred. Because that timestamp selects both, it is honoured as given — it is
2560
+ * trusted host input, like the rest of {@link MeteringContext}. A host that forwards a value an
2561
+ * untrusted end user controls must validate it first, since a back-dated `occurredAt` would pick
2562
+ * a historical price and an earlier budget window.
2563
+ */
2557
2564
  occurredAt?: Date;
2558
2565
  /**
2559
2566
  * The ONLY path prompt/completion text enters the library (spec §14.2): masked +
@@ -2124,6 +2124,19 @@ function tokenCountsOf(usage) {
2124
2124
  function sumTokenCounts(counts) {
2125
2125
  return TOKEN_FIELDS.reduce((total, field) => total + counts[field], 0);
2126
2126
  }
2127
+ function clampUsageCounts(usage) {
2128
+ const counts = tokenCountsOf(usage);
2129
+ const clamped = zeroTokenCounts();
2130
+ for (const field of TOKEN_FIELDS) {
2131
+ clamped[field] = counts[field] < 0 ? 0 : counts[field];
2132
+ }
2133
+ const result = { ...usage, ...clamped };
2134
+ const cost = usage.providerReportedCostNanoUsd;
2135
+ if (cost !== void 0 && cost < 0n) {
2136
+ return { ...result, providerReportedCostNanoUsd: 0n };
2137
+ }
2138
+ return result;
2139
+ }
2127
2140
  function isNormalizedUsage(usage) {
2128
2141
  if (typeof usage !== "object" || usage === null) return false;
2129
2142
  const candidate = usage;
@@ -2200,10 +2213,10 @@ async function resolveTokenEstimate(pricing, context, tokens, serviceTier, at) {
2200
2213
  return { provider, model, operation: "chat", serviceTier, rawEstimateNanoUsd, estimatedTokens: tokens, tokenCounts };
2201
2214
  }
2202
2215
  function normalizeCaptureUsage(usage, normalizer) {
2203
- if (isNormalizedUsage(usage)) return usage;
2216
+ if (isNormalizedUsage(usage)) return clampUsageCounts(usage);
2204
2217
  if (normalizer !== void 0) {
2205
2218
  try {
2206
- return normalizer(usage);
2219
+ return clampUsageCounts(normalizer(usage));
2207
2220
  } catch (error) {
2208
2221
  if (error instanceof AiTokensException) throw error;
2209
2222
  throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the normalizer could not read the usage token fields" });
@@ -2657,9 +2670,9 @@ var MeteringService = class {
2657
2670
  if (usage instanceof StreamUsageCollector) {
2658
2671
  const finalized = await usage.finalize();
2659
2672
  if (usage.usedFallback && finalized.inputTokens === 0 && hold.estimatedTokens > 0) {
2660
- return { ...finalized, inputTokens: hold.estimatedTokens };
2673
+ return normalizeCaptureUsage({ ...finalized, inputTokens: hold.estimatedTokens });
2661
2674
  }
2662
- return finalized;
2675
+ return normalizeCaptureUsage(finalized);
2663
2676
  }
2664
2677
  return normalizeCaptureUsage(usage, preset2?.normalizer);
2665
2678
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-ai-tokens",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "AI token metering & usage-based billing for NestJS 11: provider usage normalizer (×9), versioned effective-dated pricing, immutable append-only ledger, prepaid wallets, multi-dimension budgets, and markup/margin. Zero runtime dependencies.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -85,8 +85,8 @@
85
85
  "lint": "eslint src",
86
86
  "lint:fix": "eslint src --fix",
87
87
  "mutation": "stryker run",
88
+ "mutation:full": "node -e \"require('node:fs').rmSync('reports/stryker-incremental.json',{force:true,recursive:true})\" && stryker run",
88
89
  "mutation:dry-run": "stryker run --dryRunOnly",
89
- "mutation:incremental": "stryker run --incremental",
90
90
  "prepare": "prisma generate",
91
91
  "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm check:mutants && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:published",
92
92
  "release": "npm publish --provenance --access public",
@@ -106,7 +106,7 @@
106
106
  "reflect-metadata": "^0.2.0",
107
107
  "rxjs": "^7.8.0",
108
108
  "@prisma/client": "^6.0.0 || ^7.0.0",
109
- "ioredis": "^5.0.0",
109
+ "ioredis": "^6.0.0",
110
110
  "@nestjs/event-emitter": ">=2.0.0",
111
111
  "@opentelemetry/api": "^1.9.0"
112
112
  },
@@ -158,7 +158,7 @@
158
158
  "eslint": "^9.39.5",
159
159
  "eslint-config-prettier": "^10.1.8",
160
160
  "fast-check": "^4.9.0",
161
- "ioredis": "^5.4.2",
161
+ "ioredis": "^6.0.0",
162
162
  "jest": "^30.4.2",
163
163
  "prettier": "^3.9.6",
164
164
  "prisma": "^7.9.1",