@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 +42 -8
- package/README.md +3 -3
- package/dist/server/index.cjs +17 -4
- package/dist/server/index.d.cts +8 -1
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.mjs +17 -4
- package/package.json +4 -4
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
|
-
## [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 ^
|
|
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:
|
|
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:
|
|
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
|
package/dist/server/index.cjs
CHANGED
|
@@ -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
|
}
|
package/dist/server/index.d.cts
CHANGED
|
@@ -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
|
-
/**
|
|
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 +
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 +
|
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
+
"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": "^
|
|
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": "^
|
|
161
|
+
"ioredis": "^6.0.0",
|
|
162
162
|
"jest": "^30.4.2",
|
|
163
163
|
"prettier": "^3.9.6",
|
|
164
164
|
"prisma": "^7.9.1",
|