@bymax-one/nest-ai-tokens 1.0.3 → 1.0.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 +24 -1
- 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 +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.0.4] - 2026-08-10
|
|
12
|
+
|
|
13
|
+
Remediation of a local audit's settlement findings (merged in #65). No API changed.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Negative usage can no longer credit the wallet or budget.** `clampUsageCounts` floors every
|
|
18
|
+
token count — and the provider-reported cost (`providerReportedCostNanoUsd`) — at zero before the
|
|
19
|
+
signed settlement leg. A normalizer that computes a field by subtraction (OpenRouter's
|
|
20
|
+
`prompt - cached`), or a provider that reports a negative `usage.cost`, would otherwise credit
|
|
21
|
+
back more than was reserved and drive budget consumption negative.
|
|
22
|
+
- **Streaming captures are clamped like non-stream captures.** `finalizeCaptureUsage` now routes
|
|
23
|
+
the `StreamUsageCollector` final through `normalizeCaptureUsage`, so a provider-final stream
|
|
24
|
+
carrying negative counts or cost no longer bypasses the floor and reaches settlement raw.
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
- `RecordInput.occurredAt` documents its trust model: it is a deliberate backfill feature, honoured
|
|
29
|
+
as given because `record()`'s input is trusted host input, and a host that forwards a value an
|
|
30
|
+
untrusted end user controls must validate it first — a back-dated timestamp selects a historical
|
|
31
|
+
price and an earlier budget window.
|
|
32
|
+
|
|
11
33
|
## [1.0.3] - 2026-08-06
|
|
12
34
|
|
|
13
35
|
### Fixed
|
|
@@ -193,5 +215,6 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
193
215
|
[1.0.0]: https://github.com/bymaxone/nest-ai-tokens/releases/tag/v1.0.0
|
|
194
216
|
[1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
|
|
195
217
|
[1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
|
|
218
|
+
[1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
|
|
196
219
|
[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.
|
|
220
|
+
[Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...HEAD
|
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.0.4",
|
|
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",
|