@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 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.3...HEAD
220
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...HEAD
@@ -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.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",