@bymax-one/nest-ai-tokens 1.1.0 → 1.1.2

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,6 +6,76 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
6
6
 
7
7
  ---
8
8
 
9
+ ## [Unreleased]
10
+
11
+ ## [1.1.2] - 2026-10-30
12
+
13
+ ### Fixed
14
+
15
+ - **`PricingService.resolveRate` no longer serves one request's price to another.** The
16
+ resolution cache key joined provider, model, baseModel, operation, tier and time bucket
17
+ with `|`, which can also appear inside a model id. Two different requests could spell the
18
+ same key — `model: 'b|c'` with no `baseModel` against `model: 'b'` with `baseModel: 'c|'` —
19
+ and the second received the first's rate from the cache. The key is now a JSON array of
20
+ the fields, so no value can move the boundary between them. (#117)
21
+
22
+ **Apply:** no action required. Requests whose model names contain `|` now resolve their
23
+ own price instead of a colliding entry's.
24
+
25
+ ### Security
26
+
27
+ - Advisories on transitive **dev** packages are cleared through `pnpm-workspace.yaml`
28
+ overrides: body-parser, multer, qs and zod (#115). deepmerge GHSA-gcqx-w48m-w2h4 has no
29
+ patched release and is reached only through `jest-config`, so it is ignored in
30
+ `osv-scanner.toml` until 2027-01-27. None of these reach a consumer install: the package
31
+ has no runtime dependencies.
32
+
33
+ ### Documentation
34
+
35
+ - `SECURITY.md`: only the latest minor line (1.1.x) receives security fixes; 1.0.x is
36
+ unsupported and consumers should upgrade.
37
+
38
+ ## [1.1.1] - 2026-10-23
39
+
40
+ ### Fixed
41
+
42
+ - **A failed expired-hold scan no longer crashes the process.** `HoldReaper` read the
43
+ expired holds outside any `try`, and its interval callback discarded the promise
44
+ `sweep()` returned. A database error during that read — a failover or a dropped
45
+ connection — became an unhandled rejection, which terminates Node in every process that
46
+ loads the module. The scan is now guarded: a failure is logged once under the stable key
47
+ `ai_tokens.hold_reaper.scan_failed` with the error's `name`, `message` and string/number
48
+ `code` only, and the sweep ends; the next tick retries. Every timer-driven sweep also
49
+ carries a `catch` (logged as `ai_tokens.hold_reaper.sweep_failed`), so no rejection from
50
+ `sweep()` can escape the interval. Per-hold isolation, the multi-replica claim, `unref()`
51
+ and shutdown are unchanged. (#108)
52
+
53
+ **Apply to a derived backend:** a consumer that wrapped its store's `findExpiredHolds`
54
+ in a local guard (catching the error and returning `[]`) to survive this can drop that
55
+ guard once it runs 1.1.1. The reaper now absorbs and logs the failure itself.
56
+
57
+ ### Security
58
+
59
+ - Advisories on transitive **dev** packages are cleared through `pnpm-workspace.yaml`
60
+ overrides: handlebars, proxy-addr, multer, undici, @grpc/grpc-js, fast-uri, js-yaml and
61
+ brace-expansion (#109); fast-uri and qs (#87); browserslist and mysql2 (#85);
62
+ deepmerge-ts (#80). osv-scanner reports no issues on the lockfile. None of these reach a
63
+ consumer install: the package has no runtime dependencies.
64
+
65
+ ### Changed
66
+
67
+ - The budget counter-key construction moved to its own module, and its injectivity is
68
+ now asserted in tests: the keys are built from a caller-supplied `budgetId` and a
69
+ caller-chosen timestamp, and the specs fail if a field is reordered or a delimiter
70
+ changes. `COUNTER_DIMENSIONS` is the single runtime list the dimension type derives
71
+ from. No exported type or value changed. (#91)
72
+ - Dev dependencies and CI actions updated (#79, #83, #111, and the pinned-action bumps). The
73
+ mutation gate now runs every spec rather than only Jest's related tests, which had silently
74
+ dropped specs reaching the code through helpers under `test/` (#112).
75
+ - Published artifact: every `.d.ts` is byte-identical to 1.1.0, and so are the `shared`,
76
+ `prisma`, `redis` and `prices` bundles. Only the `server` bundle changed, from 38,204 to
77
+ 38,417 B brotli, inside its 40,000 B budget.
78
+
9
79
  ## [1.1.0] - 2026-08-11
10
80
 
11
81
  ### Changed
@@ -21,7 +91,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
21
91
 
22
92
  ## [1.0.4] - 2026-08-10
23
93
 
24
- Remediation of a local audit's settlement findings (merged in #65). No API changed.
94
+ Remediation of a local audit's settlement findings (merged in #65). No API changes.
25
95
 
26
96
  ### Fixed
27
97
 
@@ -226,6 +296,8 @@ have regressed from. They are kept because the reasoning is worth having.
226
296
  [1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
227
297
  [1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
228
298
  [1.1.0]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...v1.1.0
299
+ [1.1.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.1...v1.1.2
300
+ [1.1.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.0...v1.1.1
229
301
  [1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
230
302
  [1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
231
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...HEAD
303
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.2...HEAD
@@ -1224,7 +1224,7 @@ exports.PricingService = class PricingService {
1224
1224
  const serviceTier = input.serviceTier ?? "standard";
1225
1225
  const operation = input.operation === "responses" ? "chat" : input.operation;
1226
1226
  const bucket = Math.floor(input.at.getTime() / this.options.pricing.cacheTtlMs);
1227
- const cacheKey = `${input.provider}|${input.model}|${input.baseModel ?? ""}|${operation}|${serviceTier}|${String(bucket)}`;
1227
+ const cacheKey = JSON.stringify([input.provider, input.model, input.baseModel, operation, serviceTier, bucket]);
1228
1228
  let resolved;
1229
1229
  const cached = this.cache.get(cacheKey);
1230
1230
  if (cached !== void 0 && this.now() < cached.expiresAt) {
@@ -2775,10 +2775,10 @@ exports.WalletService = class WalletService {
2775
2775
  options;
2776
2776
  events;
2777
2777
  /**
2778
- * The current balance, excluding not-yet-effective and expired grants (§9.2).
2779
- * Reads the materialized balance; expiry is applied lazily on debit, so a grant
2780
- * that lapses purely with the passage of time is reflected at the next debit or
2781
- * `reconcile` (the documented §9.3 lazy-materialization trade-off).
2778
+ * The current balance (§9.2). Reads the materialized balance; expiry is applied
2779
+ * lazily on debit, so a grant that lapses purely with the passage of time is still
2780
+ * counted until the next debit or `reconcile` (the documented §9.3
2781
+ * lazy-materialization trade-off).
2782
2782
  *
2783
2783
  * @param ref The wallet owner (a `'tenant' | 'team' | 'user'`, never `'key'`).
2784
2784
  * @returns The nano-USD balance and its presentation credits.
@@ -2965,7 +2965,7 @@ exports.WalletService = class WalletService {
2965
2965
  * `ai_tokens.audit`.
2966
2966
  *
2967
2967
  * @param ref The wallet owner.
2968
- * @returns The reconciled wallet.
2968
+ * @returns The reconciled nano-USD balance and its presentation credits.
2969
2969
  */
2970
2970
  async reconcile(ref) {
2971
2971
  this.assertOwner(ref);
@@ -3036,6 +3036,36 @@ function isWalletMissing(error) {
3036
3036
  return error.isWalletMissing === true;
3037
3037
  }
3038
3038
 
3039
+ // src/server/services/budget.keys.ts
3040
+ var COUNTER_GRACE_SECONDS = 3600;
3041
+ var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3042
+ function windowKey(budgetId, windowStart) {
3043
+ return `${budgetId}|${windowStart.toISOString()}`;
3044
+ }
3045
+ function counterKey(budgetId, windowStart, dimension) {
3046
+ return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3047
+ }
3048
+ function counterTtlSeconds(windowStart, windowEnd) {
3049
+ if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3050
+ return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3051
+ }
3052
+ function dimensionSpends(spend) {
3053
+ return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3054
+ }
3055
+ function counterDimensions(delta, limits) {
3056
+ const dimensions = [];
3057
+ if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3058
+ dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3059
+ }
3060
+ if (limits.tokens !== void 0 && delta.tokens !== 0) {
3061
+ dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3062
+ }
3063
+ if (limits.count !== void 0 && delta.count !== 0) {
3064
+ dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3065
+ }
3066
+ return dimensions;
3067
+ }
3068
+
3039
3069
  // src/server/services/budget-predicate.ts
3040
3070
  function recordConsumesBudget(record, budget, windowStart, windowEnd) {
3041
3071
  if (!record.enforced) return false;
@@ -3119,8 +3149,6 @@ var NOOP_BUDGET_HOOKS = {
3119
3149
  projectedExceeded: () => void 0,
3120
3150
  audit: () => void 0
3121
3151
  };
3122
- var COUNTER_GRACE_SECONDS = 3600;
3123
- var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3124
3152
  var UNBOUNDED_COUNTER_LIMIT = 9223372036854775807n;
3125
3153
  exports.BudgetService = class BudgetService {
3126
3154
  /**
@@ -3654,32 +3682,6 @@ function buildStatus(budget, windowStart, resetsAt, spend) {
3654
3682
  usedFraction: usedFraction(spend, limits)
3655
3683
  };
3656
3684
  }
3657
- function windowKey(budgetId, windowStart) {
3658
- return `${budgetId}|${windowStart.toISOString()}`;
3659
- }
3660
- function counterKey(budgetId, windowStart, dimension) {
3661
- return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3662
- }
3663
- function counterTtlSeconds(windowStart, windowEnd) {
3664
- if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3665
- return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3666
- }
3667
- function dimensionSpends(spend) {
3668
- return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3669
- }
3670
- function counterDimensions(delta, limits) {
3671
- const dimensions = [];
3672
- if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3673
- dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3674
- }
3675
- if (limits.tokens !== void 0 && delta.tokens !== 0) {
3676
- dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3677
- }
3678
- if (limits.count !== void 0 && delta.count !== 0) {
3679
- dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3680
- }
3681
- return dimensions;
3682
- }
3683
3685
  function max0(value) {
3684
3686
  return value < 0n ? 0n : value;
3685
3687
  }
@@ -4078,6 +4080,14 @@ function writeHeader(response, name, value) {
4078
4080
  }
4079
4081
  var REAP_BATCH_SIZE = 100;
4080
4082
  var MS_PER_SECOND = 1e3;
4083
+ var SCAN_FAILED_LOG_KEY = "ai_tokens.hold_reaper.scan_failed";
4084
+ var SWEEP_FAILED_LOG_KEY = "ai_tokens.hold_reaper.sweep_failed";
4085
+ function describeError(error) {
4086
+ if (!(error instanceof Error)) return "non-Error rejection";
4087
+ const { code } = error;
4088
+ const suffix = typeof code === "string" || typeof code === "number" ? ` code=${String(code)}` : "";
4089
+ return `name=${error.name} message=${JSON.stringify(error.message)}${suffix}`;
4090
+ }
4081
4091
  var HoldReaper = class {
4082
4092
  /**
4083
4093
  * @param ledger The ledger service (expired-hold scan + atomic void claim).
@@ -4100,7 +4110,11 @@ var HoldReaper = class {
4100
4110
  /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
4101
4111
  onApplicationBootstrap() {
4102
4112
  const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
4103
- this.timer = setInterval(() => void this.sweep(), intervalMs);
4113
+ this.timer = setInterval(() => {
4114
+ void this.sweep().catch((error) => {
4115
+ this.logger.warn(`${SWEEP_FAILED_LOG_KEY} ${describeError(error)}`);
4116
+ });
4117
+ }, intervalMs);
4104
4118
  this.timer.unref();
4105
4119
  }
4106
4120
  /** Stop the sweep on shutdown (no open handles left for Jest). */
@@ -4112,12 +4126,19 @@ var HoldReaper = class {
4112
4126
  * Reclaim one batch of expired pending holds. For each: win the atomic
4113
4127
  * `pending → released` claim (a `null` result means another replica won — skip),
4114
4128
  * then run the shared restoration + `hold.released` emission. A failure on one
4115
- * hold is logged and never aborts the batch (deterministic, idempotent, safe to
4116
- * run repeatedly).
4129
+ * hold is logged and never aborts the batch; a failure of the expired-hold scan
4130
+ * itself is logged and ends this sweep without throwing (deterministic, idempotent,
4131
+ * safe to run repeatedly).
4117
4132
  */
4118
4133
  async sweep() {
4119
4134
  const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
4120
- const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4135
+ let expired;
4136
+ try {
4137
+ expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4138
+ } catch (error) {
4139
+ this.logger.warn(`${SCAN_FAILED_LOG_KEY} ${describeError(error)}`);
4140
+ return;
4141
+ }
4121
4142
  for (const record of expired) {
4122
4143
  try {
4123
4144
  const claimed = await this.ledger.transition(record.id, "pending", "released");
@@ -2224,10 +2224,10 @@ declare class WalletService {
2224
2224
  */
2225
2225
  constructor(store: IWalletStore, options: WalletServiceOptions, events?: WalletEventHooks);
2226
2226
  /**
2227
- * The current balance, excluding not-yet-effective and expired grants (§9.2).
2228
- * Reads the materialized balance; expiry is applied lazily on debit, so a grant
2229
- * that lapses purely with the passage of time is reflected at the next debit or
2230
- * `reconcile` (the documented §9.3 lazy-materialization trade-off).
2227
+ * The current balance (§9.2). Reads the materialized balance; expiry is applied
2228
+ * lazily on debit, so a grant that lapses purely with the passage of time is still
2229
+ * counted until the next debit or `reconcile` (the documented §9.3
2230
+ * lazy-materialization trade-off).
2231
2231
  *
2232
2232
  * @param ref The wallet owner (a `'tenant' | 'team' | 'user'`, never `'key'`).
2233
2233
  * @returns The nano-USD balance and its presentation credits.
@@ -2306,7 +2306,7 @@ declare class WalletService {
2306
2306
  * `ai_tokens.audit`.
2307
2307
  *
2308
2308
  * @param ref The wallet owner.
2309
- * @returns The reconciled wallet.
2309
+ * @returns The reconciled nano-USD balance and its presentation credits.
2310
2310
  */
2311
2311
  reconcile(ref: WalletRef): Promise<WalletBalance>;
2312
2312
  /** Append a non-debit entry, mapping a store idempotency/missing-wallet signal to the catalog. */
@@ -3119,7 +3119,7 @@ declare class MeteringInterceptor implements NestInterceptor {
3119
3119
  * @fileoverview The controller-level metering/enforcement decorators (spec §11.4).
3120
3120
  * `@Meter` marks a handler for the `MeteringInterceptor` and is metadata-only
3121
3121
  * here; `@RequireBudget` marks it for the `BudgetGuard`; `@AiFeature`
3122
- * is a lightweight feature tag. Each stores its config under an exported symbol key
3122
+ * is a lightweight feature tag. Each stores its config under an exported string key
3123
3123
  * that the guard/interceptor read via the NestJS `Reflector`. Feature precedence is
3124
3124
  * `@RequireBudget.feature` > `@Meter.feature` > `@AiFeature` (§11.4). These are pure
3125
3125
  * metadata; they never touch a wallet, budget, or the request.
@@ -2224,10 +2224,10 @@ declare class WalletService {
2224
2224
  */
2225
2225
  constructor(store: IWalletStore, options: WalletServiceOptions, events?: WalletEventHooks);
2226
2226
  /**
2227
- * The current balance, excluding not-yet-effective and expired grants (§9.2).
2228
- * Reads the materialized balance; expiry is applied lazily on debit, so a grant
2229
- * that lapses purely with the passage of time is reflected at the next debit or
2230
- * `reconcile` (the documented §9.3 lazy-materialization trade-off).
2227
+ * The current balance (§9.2). Reads the materialized balance; expiry is applied
2228
+ * lazily on debit, so a grant that lapses purely with the passage of time is still
2229
+ * counted until the next debit or `reconcile` (the documented §9.3
2230
+ * lazy-materialization trade-off).
2231
2231
  *
2232
2232
  * @param ref The wallet owner (a `'tenant' | 'team' | 'user'`, never `'key'`).
2233
2233
  * @returns The nano-USD balance and its presentation credits.
@@ -2306,7 +2306,7 @@ declare class WalletService {
2306
2306
  * `ai_tokens.audit`.
2307
2307
  *
2308
2308
  * @param ref The wallet owner.
2309
- * @returns The reconciled wallet.
2309
+ * @returns The reconciled nano-USD balance and its presentation credits.
2310
2310
  */
2311
2311
  reconcile(ref: WalletRef): Promise<WalletBalance>;
2312
2312
  /** Append a non-debit entry, mapping a store idempotency/missing-wallet signal to the catalog. */
@@ -3119,7 +3119,7 @@ declare class MeteringInterceptor implements NestInterceptor {
3119
3119
  * @fileoverview The controller-level metering/enforcement decorators (spec §11.4).
3120
3120
  * `@Meter` marks a handler for the `MeteringInterceptor` and is metadata-only
3121
3121
  * here; `@RequireBudget` marks it for the `BudgetGuard`; `@AiFeature`
3122
- * is a lightweight feature tag. Each stores its config under an exported symbol key
3122
+ * is a lightweight feature tag. Each stores its config under an exported string key
3123
3123
  * that the guard/interceptor read via the NestJS `Reflector`. Feature precedence is
3124
3124
  * `@RequireBudget.feature` > `@Meter.feature` > `@AiFeature` (§11.4). These are pure
3125
3125
  * metadata; they never touch a wallet, budget, or the request.
@@ -1222,7 +1222,7 @@ var PricingService = class {
1222
1222
  const serviceTier = input.serviceTier ?? "standard";
1223
1223
  const operation = input.operation === "responses" ? "chat" : input.operation;
1224
1224
  const bucket = Math.floor(input.at.getTime() / this.options.pricing.cacheTtlMs);
1225
- const cacheKey = `${input.provider}|${input.model}|${input.baseModel ?? ""}|${operation}|${serviceTier}|${String(bucket)}`;
1225
+ const cacheKey = JSON.stringify([input.provider, input.model, input.baseModel, operation, serviceTier, bucket]);
1226
1226
  let resolved;
1227
1227
  const cached = this.cache.get(cacheKey);
1228
1228
  if (cached !== void 0 && this.now() < cached.expiresAt) {
@@ -2773,10 +2773,10 @@ var WalletService = class {
2773
2773
  options;
2774
2774
  events;
2775
2775
  /**
2776
- * The current balance, excluding not-yet-effective and expired grants (§9.2).
2777
- * Reads the materialized balance; expiry is applied lazily on debit, so a grant
2778
- * that lapses purely with the passage of time is reflected at the next debit or
2779
- * `reconcile` (the documented §9.3 lazy-materialization trade-off).
2776
+ * The current balance (§9.2). Reads the materialized balance; expiry is applied
2777
+ * lazily on debit, so a grant that lapses purely with the passage of time is still
2778
+ * counted until the next debit or `reconcile` (the documented §9.3
2779
+ * lazy-materialization trade-off).
2780
2780
  *
2781
2781
  * @param ref The wallet owner (a `'tenant' | 'team' | 'user'`, never `'key'`).
2782
2782
  * @returns The nano-USD balance and its presentation credits.
@@ -2963,7 +2963,7 @@ var WalletService = class {
2963
2963
  * `ai_tokens.audit`.
2964
2964
  *
2965
2965
  * @param ref The wallet owner.
2966
- * @returns The reconciled wallet.
2966
+ * @returns The reconciled nano-USD balance and its presentation credits.
2967
2967
  */
2968
2968
  async reconcile(ref) {
2969
2969
  this.assertOwner(ref);
@@ -3034,6 +3034,36 @@ function isWalletMissing(error) {
3034
3034
  return error.isWalletMissing === true;
3035
3035
  }
3036
3036
 
3037
+ // src/server/services/budget.keys.ts
3038
+ var COUNTER_GRACE_SECONDS = 3600;
3039
+ var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3040
+ function windowKey(budgetId, windowStart) {
3041
+ return `${budgetId}|${windowStart.toISOString()}`;
3042
+ }
3043
+ function counterKey(budgetId, windowStart, dimension) {
3044
+ return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3045
+ }
3046
+ function counterTtlSeconds(windowStart, windowEnd) {
3047
+ if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3048
+ return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3049
+ }
3050
+ function dimensionSpends(spend) {
3051
+ return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3052
+ }
3053
+ function counterDimensions(delta, limits) {
3054
+ const dimensions = [];
3055
+ if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3056
+ dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3057
+ }
3058
+ if (limits.tokens !== void 0 && delta.tokens !== 0) {
3059
+ dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3060
+ }
3061
+ if (limits.count !== void 0 && delta.count !== 0) {
3062
+ dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3063
+ }
3064
+ return dimensions;
3065
+ }
3066
+
3037
3067
  // src/server/services/budget-predicate.ts
3038
3068
  function recordConsumesBudget(record, budget, windowStart, windowEnd) {
3039
3069
  if (!record.enforced) return false;
@@ -3117,8 +3147,6 @@ var NOOP_BUDGET_HOOKS = {
3117
3147
  projectedExceeded: () => void 0,
3118
3148
  audit: () => void 0
3119
3149
  };
3120
- var COUNTER_GRACE_SECONDS = 3600;
3121
- var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3122
3150
  var UNBOUNDED_COUNTER_LIMIT = 9223372036854775807n;
3123
3151
  var BudgetService = class {
3124
3152
  /**
@@ -3652,32 +3680,6 @@ function buildStatus(budget, windowStart, resetsAt, spend) {
3652
3680
  usedFraction: usedFraction(spend, limits)
3653
3681
  };
3654
3682
  }
3655
- function windowKey(budgetId, windowStart) {
3656
- return `${budgetId}|${windowStart.toISOString()}`;
3657
- }
3658
- function counterKey(budgetId, windowStart, dimension) {
3659
- return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3660
- }
3661
- function counterTtlSeconds(windowStart, windowEnd) {
3662
- if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3663
- return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3664
- }
3665
- function dimensionSpends(spend) {
3666
- return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3667
- }
3668
- function counterDimensions(delta, limits) {
3669
- const dimensions = [];
3670
- if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3671
- dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3672
- }
3673
- if (limits.tokens !== void 0 && delta.tokens !== 0) {
3674
- dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3675
- }
3676
- if (limits.count !== void 0 && delta.count !== 0) {
3677
- dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3678
- }
3679
- return dimensions;
3680
- }
3681
3683
  function max0(value) {
3682
3684
  return value < 0n ? 0n : value;
3683
3685
  }
@@ -4076,6 +4078,14 @@ function writeHeader(response, name, value) {
4076
4078
  }
4077
4079
  var REAP_BATCH_SIZE = 100;
4078
4080
  var MS_PER_SECOND = 1e3;
4081
+ var SCAN_FAILED_LOG_KEY = "ai_tokens.hold_reaper.scan_failed";
4082
+ var SWEEP_FAILED_LOG_KEY = "ai_tokens.hold_reaper.sweep_failed";
4083
+ function describeError(error) {
4084
+ if (!(error instanceof Error)) return "non-Error rejection";
4085
+ const { code } = error;
4086
+ const suffix = typeof code === "string" || typeof code === "number" ? ` code=${String(code)}` : "";
4087
+ return `name=${error.name} message=${JSON.stringify(error.message)}${suffix}`;
4088
+ }
4079
4089
  var HoldReaper = class {
4080
4090
  /**
4081
4091
  * @param ledger The ledger service (expired-hold scan + atomic void claim).
@@ -4098,7 +4108,11 @@ var HoldReaper = class {
4098
4108
  /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
4099
4109
  onApplicationBootstrap() {
4100
4110
  const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
4101
- this.timer = setInterval(() => void this.sweep(), intervalMs);
4111
+ this.timer = setInterval(() => {
4112
+ void this.sweep().catch((error) => {
4113
+ this.logger.warn(`${SWEEP_FAILED_LOG_KEY} ${describeError(error)}`);
4114
+ });
4115
+ }, intervalMs);
4102
4116
  this.timer.unref();
4103
4117
  }
4104
4118
  /** Stop the sweep on shutdown (no open handles left for Jest). */
@@ -4110,12 +4124,19 @@ var HoldReaper = class {
4110
4124
  * Reclaim one batch of expired pending holds. For each: win the atomic
4111
4125
  * `pending → released` claim (a `null` result means another replica won — skip),
4112
4126
  * then run the shared restoration + `hold.released` emission. A failure on one
4113
- * hold is logged and never aborts the batch (deterministic, idempotent, safe to
4114
- * run repeatedly).
4127
+ * hold is logged and never aborts the batch; a failure of the expired-hold scan
4128
+ * itself is logged and ends this sweep without throwing (deterministic, idempotent,
4129
+ * safe to run repeatedly).
4115
4130
  */
4116
4131
  async sweep() {
4117
4132
  const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
4118
- const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4133
+ let expired;
4134
+ try {
4135
+ expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4136
+ } catch (error) {
4137
+ this.logger.warn(`${SCAN_FAILED_LOG_KEY} ${describeError(error)}`);
4138
+ return;
4139
+ }
4119
4140
  for (const record of expired) {
4120
4141
  try {
4121
4142
  const claimed = await this.ledger.transition(record.id, "pending", "released");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-ai-tokens",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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",