@bymax-one/nest-ai-tokens 1.0.1 → 1.0.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
@@ -8,6 +8,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.0.2] - 2026-08-05
12
+
13
+ ### Fixed
14
+
15
+ - **The module could not be initialised by a consumer at all.** `MeteringInterceptor` is
16
+ registered as a plain class provider, so Nest resolves its constructor — but only the
17
+ third parameter carried a token. Nest reads two separate metadata keys for this:
18
+ `self:paramtypes`, written by `@Inject()`, and `design:paramtypes`, written by
19
+ TypeScript. The published bundle is built by tsup/esbuild, and esbuild states it cannot
20
+ implement `emitDecoratorMetadata` because it does not replicate TypeScript's type
21
+ system — so the second key was never there. The first two parameters had neither, and
22
+ the container threw `Nest can't resolve dependencies of the MeteringInterceptor
23
+ (?, +, Symbol(BYMAX_AI_TOKENS_OPTIONS))` before any provider was created. Both now
24
+ carry `@Inject`.
25
+
26
+ ### Changed
27
+
28
+ - `emitDecoratorMetadata` is `false` in `tsconfig.json`. It was `true`, which was never
29
+ true of the artifact: tsup prints `You have emitDecoratorMetadata enabled but
30
+ @swc/core was not installed, skipping swc plugin` on every build, and that warning had
31
+ been printed on every build of this package. Turning it off makes the source compile
32
+ the way the bundle is built, so a parameter that depends on reflected types now fails
33
+ where it is cheap to see.
34
+ - The imports that existed only to feed that metadata are `import type` again. The lint
35
+ rule reported each one the moment the flag went off, which makes it a precise detector
36
+ for this defect: an import that is value-only because a constructor parameter needs its
37
+ type is an import that will not survive the bundle.
38
+
11
39
  ## [1.0.1] - 2026-08-04
12
40
 
13
41
  ### Removed
@@ -135,4 +163,5 @@ have regressed from. They are kept because the reasoning is worth having.
135
163
 
136
164
  [1.0.0]: https://github.com/bymaxone/nest-ai-tokens/releases/tag/v1.0.0
137
165
  [1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
138
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...HEAD
166
+ [1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
167
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...HEAD
@@ -1185,240 +1185,6 @@ function exhaustedError(status, feature) {
1185
1185
  }
1186
1186
  return null;
1187
1187
  }
1188
- exports.MeteringInterceptor = class MeteringInterceptor {
1189
- /**
1190
- * @param reflector The NestJS reflector (`@Meter` metadata).
1191
- * @param metering The metering facade (capture/record).
1192
- * @param options The resolved options (fallback `scopeResolver` when no guard ran).
1193
- */
1194
- constructor(reflector, metering, options) {
1195
- this.reflector = reflector;
1196
- this.metering = metering;
1197
- this.scopeResolver = options.scopeResolver;
1198
- }
1199
- reflector;
1200
- metering;
1201
- logger = new common.Logger(exports.MeteringInterceptor.name);
1202
- scopeResolver;
1203
- /**
1204
- * Capture (or record) the handler's usage after it resolves; release the guard's
1205
- * hold on a handler error.
1206
- *
1207
- * @param executionContext The request execution context.
1208
- * @param next The downstream handler.
1209
- * @returns The handler's result stream, unchanged in value.
1210
- */
1211
- intercept(executionContext, next) {
1212
- const targets = [executionContext.getHandler(), executionContext.getClass()];
1213
- const config = this.reflector.getAllAndOverride(METER_METADATA, targets);
1214
- if (config === void 0) return next.handle();
1215
- const request = executionContext.switchToHttp().getRequest();
1216
- const enrichment = request.aiTokens;
1217
- return next.handle().pipe(
1218
- rxjs.mergeMap((result) => rxjs.from(this.settle(executionContext, config, enrichment, result))),
1219
- operators.catchError((error) => this.onError(enrichment, error))
1220
- );
1221
- }
1222
- /** Settle the handler's usage and set the cost headers; returns the untouched result. */
1223
- async settle(executionContext, config, enrichment, result) {
1224
- const usage = extractUsage(result, config);
1225
- const record = await this.capture(executionContext, config, enrichment, usage);
1226
- if (config.exposeHeaders === true) this.setHeaders(executionContext, record, enrichment);
1227
- return result;
1228
- }
1229
- /** Capture the guard's hold when present, else record enforcing post-hoc. */
1230
- async capture(executionContext, config, enrichment, usage) {
1231
- if (enrichment?.hold !== void 0) return this.metering.capture(enrichment.hold, usage, config.preset);
1232
- const context = await this.resolveContext(executionContext, config, enrichment);
1233
- return this.metering.record({
1234
- usage,
1235
- // Stryker disable next-line ConditionalExpression -- CE true: spreading { preset: undefined } is equivalent to {} because MeteringService checks preset !== undefined
1236
- ...config.preset !== void 0 ? { preset: config.preset } : {},
1237
- // Stryker disable next-line ConditionalExpression -- CE true: spreading { tags: undefined } is equivalent to {} because MeteringService checks tags !== undefined
1238
- context: { ...context, enforce: true, isSystemCost: config.isSystemCost ?? false, ...config.tags !== void 0 ? { tags: config.tags } : {} }
1239
- });
1240
- }
1241
- /** The metering context: the guard's enrichment, else the host `scopeResolver`. */
1242
- async resolveContext(executionContext, config, enrichment) {
1243
- if (enrichment !== void 0) return enrichment.context;
1244
- if (this.scopeResolver === void 0) {
1245
- throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: "MeteringInterceptor requires options.scopeResolver when no BudgetGuard ran" });
1246
- }
1247
- const resolved = await this.scopeResolver(executionContext);
1248
- return { ...resolved, feature: config.feature, ...config.preset !== void 0 ? { preset: config.preset } : {} };
1249
- }
1250
- /** Release the guard's hold (if any) and rethrow the ORIGINAL handler error. */
1251
- onError(enrichment, error) {
1252
- if (enrichment?.hold === void 0) return rxjs.throwError(() => error);
1253
- const holdId = enrichment.hold.id;
1254
- const released = this.metering.release(enrichment.hold, "handler threw").catch(() => {
1255
- this.logger.warn(`failed to release hold ${holdId} after a handler error; the reaper will reclaim it`);
1256
- });
1257
- return rxjs.from(released).pipe(rxjs.mergeMap(() => rxjs.throwError(() => error)));
1258
- }
1259
- /** Set the three `x-ai-tokens-*` headers as decimal strings via the HTTP adapter. */
1260
- setHeaders(executionContext, record, enrichment) {
1261
- const response = executionContext.switchToHttp().getResponse();
1262
- writeHeader(response, "x-ai-tokens-cost", record.rawCostNanoUsd.toString());
1263
- writeHeader(response, "x-ai-tokens-billed-cost", record.billedCostNanoUsd.toString());
1264
- const remaining = minBudgetRemaining(enrichment);
1265
- if (remaining !== void 0) writeHeader(response, "x-ai-tokens-budget-remaining", remaining.toString());
1266
- }
1267
- };
1268
- exports.MeteringInterceptor = __decorateClass([
1269
- common.Injectable(),
1270
- __decorateParam(2, common.Inject(BYMAX_AI_TOKENS_OPTIONS))
1271
- ], exports.MeteringInterceptor);
1272
- function extractUsage(result, config) {
1273
- const extract = config.extract ?? ((value) => typeof value === "object" && value !== null ? value.usage : void 0);
1274
- const usage = extract(result);
1275
- if (usage === void 0 || usage === null) {
1276
- throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the handler result carried no extractable usage" });
1277
- }
1278
- return usage;
1279
- }
1280
- function minBudgetRemaining(enrichment) {
1281
- let min;
1282
- for (const status of enrichment?.status ?? []) {
1283
- const remaining = status.remaining.nanoUsd;
1284
- if (remaining !== void 0 && (min === void 0 || remaining < min)) min = remaining;
1285
- }
1286
- return min;
1287
- }
1288
- function writeHeader(response, name, value) {
1289
- if (typeof response.setHeader === "function") response.setHeader(name, value);
1290
- else if (typeof response.header === "function") response.header(name, value);
1291
- }
1292
- var REAP_BATCH_SIZE = 100;
1293
- var MS_PER_SECOND = 1e3;
1294
- var HoldReaper = class {
1295
- /**
1296
- * @param ledger The ledger service (expired-hold scan + atomic void claim).
1297
- * @param metering The metering facade (shared hold-restoration path).
1298
- * @param options The resolved options carrying the hold TTL + sweep interval.
1299
- * @param now The injected clock (testability).
1300
- */
1301
- constructor(ledger, metering, options, now = () => /* @__PURE__ */ new Date()) {
1302
- this.ledger = ledger;
1303
- this.metering = metering;
1304
- this.options = options;
1305
- this.now = now;
1306
- }
1307
- ledger;
1308
- metering;
1309
- options;
1310
- now;
1311
- logger = new common.Logger(HoldReaper.name);
1312
- timer;
1313
- /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
1314
- onApplicationBootstrap() {
1315
- const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
1316
- this.timer = setInterval(() => void this.sweep(), intervalMs);
1317
- this.timer.unref();
1318
- }
1319
- /** Stop the sweep on shutdown (no open handles left for Jest). */
1320
- onApplicationShutdown() {
1321
- if (this.timer !== void 0) clearInterval(this.timer);
1322
- this.timer = void 0;
1323
- }
1324
- /**
1325
- * Reclaim one batch of expired pending holds. For each: win the atomic
1326
- * `pending → released` claim (a `null` result means another replica won — skip),
1327
- * then run the shared restoration + `hold.released` emission. A failure on one
1328
- * hold is logged and never aborts the batch (deterministic, idempotent, safe to
1329
- * run repeatedly).
1330
- */
1331
- async sweep() {
1332
- const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
1333
- const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
1334
- for (const record of expired) {
1335
- try {
1336
- const claimed = await this.ledger.transition(record.id, "pending", "released");
1337
- if (claimed === null) continue;
1338
- await this.metering.restoreReleasedHold(claimed, "expired", true);
1339
- } catch {
1340
- this.logger.warn(`failed to reap expired hold ${record.id}`);
1341
- }
1342
- }
1343
- }
1344
- };
1345
- HoldReaper = __decorateClass([
1346
- common.Injectable()
1347
- ], HoldReaper);
1348
-
1349
- // src/server/telemetry/otel-emitter.ts
1350
- function buildGenAiAttributes(record) {
1351
- return {
1352
- "gen_ai.provider.name": record.provider,
1353
- "gen_ai.request.model": record.requestedModel ?? record.model,
1354
- "gen_ai.response.model": record.model,
1355
- "gen_ai.operation.name": record.operation,
1356
- "gen_ai.request.service_tier": record.serviceTier,
1357
- "gen_ai.usage.input_tokens": record.inputTokens,
1358
- "gen_ai.usage.output_tokens": record.outputTokens
1359
- };
1360
- }
1361
- var TelemetryEmitter = class {
1362
- /** @param sink The host telemetry sink, or `null` to disable telemetry. */
1363
- constructor(sink) {
1364
- this.sink = sink;
1365
- }
1366
- sink;
1367
- /**
1368
- * Record a usage measurement for a posted record (no-op without a sink).
1369
- *
1370
- * @param record The posted usage record.
1371
- */
1372
- recordUsage(record) {
1373
- if (this.sink === null) return;
1374
- this.sink.recordUsage(buildGenAiAttributes(record), record);
1375
- }
1376
- /**
1377
- * Record an operation duration for a posted record (no-op without a sink, or when
1378
- * the sink does not implement `recordDuration`).
1379
- *
1380
- * @param record The posted usage record.
1381
- * @param milliseconds The measured operation duration.
1382
- */
1383
- recordDuration(record, milliseconds) {
1384
- const sink = this.sink;
1385
- if (sink?.recordDuration === void 0) return;
1386
- sink.recordDuration(buildGenAiAttributes(record), milliseconds);
1387
- }
1388
- };
1389
- var IDENTITY = (text) => text;
1390
- var ContentCapture = class _ContentCapture {
1391
- /** @param options The resolved content-sidecar settings (disabled by default). */
1392
- constructor(options) {
1393
- this.options = options;
1394
- }
1395
- options;
1396
- logger = new common.Logger(_ContentCapture.name);
1397
- /**
1398
- * Persist the masked prompt/completion to the content sidecar. A no-op when the
1399
- * feature is disabled or no text was supplied. Failures are logged (never the
1400
- * text) and never rethrown — content capture must not break metering.
1401
- *
1402
- * @param input The record id, tenant, and the host-supplied text.
1403
- */
1404
- async capture(input) {
1405
- if (!this.options.enabled) return;
1406
- if (input.prompt === void 0 && input.completion === void 0) return;
1407
- const { store, ttlSeconds } = this.options;
1408
- const mask = this.options.mask ?? IDENTITY;
1409
- try {
1410
- if (input.prompt !== void 0) {
1411
- await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "prompt", text: mask(input.prompt), ttlSeconds });
1412
- }
1413
- if (input.completion !== void 0) {
1414
- await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "completion", text: mask(input.completion), ttlSeconds });
1415
- }
1416
- } catch {
1417
- this.logger.error(`failed to write content sidecar for record ${input.usageRecordId}`);
1418
- }
1419
- }
1420
- };
1421
- var NO_OP_CONTENT_CAPTURE = new ContentCapture({ enabled: false });
1422
1188
 
1423
1189
  // src/server/utils/model-id.ts
1424
1190
  function normalizeModelId(id) {
@@ -2248,8 +2014,82 @@ function numberOf(value) {
2248
2014
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
2249
2015
  }
2250
2016
 
2017
+ // src/server/telemetry/otel-emitter.ts
2018
+ function buildGenAiAttributes(record) {
2019
+ return {
2020
+ "gen_ai.provider.name": record.provider,
2021
+ "gen_ai.request.model": record.requestedModel ?? record.model,
2022
+ "gen_ai.response.model": record.model,
2023
+ "gen_ai.operation.name": record.operation,
2024
+ "gen_ai.request.service_tier": record.serviceTier,
2025
+ "gen_ai.usage.input_tokens": record.inputTokens,
2026
+ "gen_ai.usage.output_tokens": record.outputTokens
2027
+ };
2028
+ }
2029
+ var TelemetryEmitter = class {
2030
+ /** @param sink The host telemetry sink, or `null` to disable telemetry. */
2031
+ constructor(sink) {
2032
+ this.sink = sink;
2033
+ }
2034
+ sink;
2035
+ /**
2036
+ * Record a usage measurement for a posted record (no-op without a sink).
2037
+ *
2038
+ * @param record The posted usage record.
2039
+ */
2040
+ recordUsage(record) {
2041
+ if (this.sink === null) return;
2042
+ this.sink.recordUsage(buildGenAiAttributes(record), record);
2043
+ }
2044
+ /**
2045
+ * Record an operation duration for a posted record (no-op without a sink, or when
2046
+ * the sink does not implement `recordDuration`).
2047
+ *
2048
+ * @param record The posted usage record.
2049
+ * @param milliseconds The measured operation duration.
2050
+ */
2051
+ recordDuration(record, milliseconds) {
2052
+ const sink = this.sink;
2053
+ if (sink?.recordDuration === void 0) return;
2054
+ sink.recordDuration(buildGenAiAttributes(record), milliseconds);
2055
+ }
2056
+ };
2057
+
2251
2058
  // src/server/telemetry/no-op-telemetry.ts
2252
2059
  var NO_OP_TELEMETRY = new TelemetryEmitter(null);
2060
+ var IDENTITY = (text) => text;
2061
+ var ContentCapture = class _ContentCapture {
2062
+ /** @param options The resolved content-sidecar settings (disabled by default). */
2063
+ constructor(options) {
2064
+ this.options = options;
2065
+ }
2066
+ options;
2067
+ logger = new common.Logger(_ContentCapture.name);
2068
+ /**
2069
+ * Persist the masked prompt/completion to the content sidecar. A no-op when the
2070
+ * feature is disabled or no text was supplied. Failures are logged (never the
2071
+ * text) and never rethrown — content capture must not break metering.
2072
+ *
2073
+ * @param input The record id, tenant, and the host-supplied text.
2074
+ */
2075
+ async capture(input) {
2076
+ if (!this.options.enabled) return;
2077
+ if (input.prompt === void 0 && input.completion === void 0) return;
2078
+ const { store, ttlSeconds } = this.options;
2079
+ const mask = this.options.mask ?? IDENTITY;
2080
+ try {
2081
+ if (input.prompt !== void 0) {
2082
+ await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "prompt", text: mask(input.prompt), ttlSeconds });
2083
+ }
2084
+ if (input.completion !== void 0) {
2085
+ await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "completion", text: mask(input.completion), ttlSeconds });
2086
+ }
2087
+ } catch {
2088
+ this.logger.error(`failed to write content sidecar for record ${input.usageRecordId}`);
2089
+ }
2090
+ }
2091
+ };
2092
+ var NO_OP_CONTENT_CAPTURE = new ContentCapture({ enabled: false });
2253
2093
 
2254
2094
  // src/server/services/hold-support.ts
2255
2095
  var UNSPECIFIED = "unspecified";
@@ -4116,6 +3956,170 @@ var exampleRecord = {
4116
3956
  updatedAt: /* @__PURE__ */ new Date(0)
4117
3957
  };
4118
3958
 
3959
+ // src/server/enforcement/metering.interceptor.ts
3960
+ exports.MeteringInterceptor = class MeteringInterceptor {
3961
+ /**
3962
+ * @param reflector The NestJS reflector (`@Meter` metadata).
3963
+ * @param metering The metering facade (capture/record).
3964
+ * @param options The resolved options (fallback `scopeResolver` when no guard ran).
3965
+ */
3966
+ constructor(reflector, metering, options) {
3967
+ this.reflector = reflector;
3968
+ this.metering = metering;
3969
+ this.scopeResolver = options.scopeResolver;
3970
+ }
3971
+ reflector;
3972
+ metering;
3973
+ logger = new common.Logger(exports.MeteringInterceptor.name);
3974
+ scopeResolver;
3975
+ /**
3976
+ * Capture (or record) the handler's usage after it resolves; release the guard's
3977
+ * hold on a handler error.
3978
+ *
3979
+ * @param executionContext The request execution context.
3980
+ * @param next The downstream handler.
3981
+ * @returns The handler's result stream, unchanged in value.
3982
+ */
3983
+ intercept(executionContext, next) {
3984
+ const targets = [executionContext.getHandler(), executionContext.getClass()];
3985
+ const config = this.reflector.getAllAndOverride(METER_METADATA, targets);
3986
+ if (config === void 0) return next.handle();
3987
+ const request = executionContext.switchToHttp().getRequest();
3988
+ const enrichment = request.aiTokens;
3989
+ return next.handle().pipe(
3990
+ rxjs.mergeMap((result) => rxjs.from(this.settle(executionContext, config, enrichment, result))),
3991
+ operators.catchError((error) => this.onError(enrichment, error))
3992
+ );
3993
+ }
3994
+ /** Settle the handler's usage and set the cost headers; returns the untouched result. */
3995
+ async settle(executionContext, config, enrichment, result) {
3996
+ const usage = extractUsage(result, config);
3997
+ const record = await this.capture(executionContext, config, enrichment, usage);
3998
+ if (config.exposeHeaders === true) this.setHeaders(executionContext, record, enrichment);
3999
+ return result;
4000
+ }
4001
+ /** Capture the guard's hold when present, else record enforcing post-hoc. */
4002
+ async capture(executionContext, config, enrichment, usage) {
4003
+ if (enrichment?.hold !== void 0) return this.metering.capture(enrichment.hold, usage, config.preset);
4004
+ const context = await this.resolveContext(executionContext, config, enrichment);
4005
+ return this.metering.record({
4006
+ usage,
4007
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { preset: undefined } is equivalent to {} because MeteringService checks preset !== undefined
4008
+ ...config.preset !== void 0 ? { preset: config.preset } : {},
4009
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { tags: undefined } is equivalent to {} because MeteringService checks tags !== undefined
4010
+ context: { ...context, enforce: true, isSystemCost: config.isSystemCost ?? false, ...config.tags !== void 0 ? { tags: config.tags } : {} }
4011
+ });
4012
+ }
4013
+ /** The metering context: the guard's enrichment, else the host `scopeResolver`. */
4014
+ async resolveContext(executionContext, config, enrichment) {
4015
+ if (enrichment !== void 0) return enrichment.context;
4016
+ if (this.scopeResolver === void 0) {
4017
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: "MeteringInterceptor requires options.scopeResolver when no BudgetGuard ran" });
4018
+ }
4019
+ const resolved = await this.scopeResolver(executionContext);
4020
+ return { ...resolved, feature: config.feature, ...config.preset !== void 0 ? { preset: config.preset } : {} };
4021
+ }
4022
+ /** Release the guard's hold (if any) and rethrow the ORIGINAL handler error. */
4023
+ onError(enrichment, error) {
4024
+ if (enrichment?.hold === void 0) return rxjs.throwError(() => error);
4025
+ const holdId = enrichment.hold.id;
4026
+ const released = this.metering.release(enrichment.hold, "handler threw").catch(() => {
4027
+ this.logger.warn(`failed to release hold ${holdId} after a handler error; the reaper will reclaim it`);
4028
+ });
4029
+ return rxjs.from(released).pipe(rxjs.mergeMap(() => rxjs.throwError(() => error)));
4030
+ }
4031
+ /** Set the three `x-ai-tokens-*` headers as decimal strings via the HTTP adapter. */
4032
+ setHeaders(executionContext, record, enrichment) {
4033
+ const response = executionContext.switchToHttp().getResponse();
4034
+ writeHeader(response, "x-ai-tokens-cost", record.rawCostNanoUsd.toString());
4035
+ writeHeader(response, "x-ai-tokens-billed-cost", record.billedCostNanoUsd.toString());
4036
+ const remaining = minBudgetRemaining(enrichment);
4037
+ if (remaining !== void 0) writeHeader(response, "x-ai-tokens-budget-remaining", remaining.toString());
4038
+ }
4039
+ };
4040
+ exports.MeteringInterceptor = __decorateClass([
4041
+ common.Injectable(),
4042
+ __decorateParam(0, common.Inject(core.Reflector)),
4043
+ __decorateParam(1, common.Inject(exports.MeteringService)),
4044
+ __decorateParam(2, common.Inject(BYMAX_AI_TOKENS_OPTIONS))
4045
+ ], exports.MeteringInterceptor);
4046
+ function extractUsage(result, config) {
4047
+ const extract = config.extract ?? ((value) => typeof value === "object" && value !== null ? value.usage : void 0);
4048
+ const usage = extract(result);
4049
+ if (usage === void 0 || usage === null) {
4050
+ throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the handler result carried no extractable usage" });
4051
+ }
4052
+ return usage;
4053
+ }
4054
+ function minBudgetRemaining(enrichment) {
4055
+ let min;
4056
+ for (const status of enrichment?.status ?? []) {
4057
+ const remaining = status.remaining.nanoUsd;
4058
+ if (remaining !== void 0 && (min === void 0 || remaining < min)) min = remaining;
4059
+ }
4060
+ return min;
4061
+ }
4062
+ function writeHeader(response, name, value) {
4063
+ if (typeof response.setHeader === "function") response.setHeader(name, value);
4064
+ else if (typeof response.header === "function") response.header(name, value);
4065
+ }
4066
+ var REAP_BATCH_SIZE = 100;
4067
+ var MS_PER_SECOND = 1e3;
4068
+ var HoldReaper = class {
4069
+ /**
4070
+ * @param ledger The ledger service (expired-hold scan + atomic void claim).
4071
+ * @param metering The metering facade (shared hold-restoration path).
4072
+ * @param options The resolved options carrying the hold TTL + sweep interval.
4073
+ * @param now The injected clock (testability).
4074
+ */
4075
+ constructor(ledger, metering, options, now = () => /* @__PURE__ */ new Date()) {
4076
+ this.ledger = ledger;
4077
+ this.metering = metering;
4078
+ this.options = options;
4079
+ this.now = now;
4080
+ }
4081
+ ledger;
4082
+ metering;
4083
+ options;
4084
+ now;
4085
+ logger = new common.Logger(HoldReaper.name);
4086
+ timer;
4087
+ /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
4088
+ onApplicationBootstrap() {
4089
+ const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
4090
+ this.timer = setInterval(() => void this.sweep(), intervalMs);
4091
+ this.timer.unref();
4092
+ }
4093
+ /** Stop the sweep on shutdown (no open handles left for Jest). */
4094
+ onApplicationShutdown() {
4095
+ if (this.timer !== void 0) clearInterval(this.timer);
4096
+ this.timer = void 0;
4097
+ }
4098
+ /**
4099
+ * Reclaim one batch of expired pending holds. For each: win the atomic
4100
+ * `pending → released` claim (a `null` result means another replica won — skip),
4101
+ * then run the shared restoration + `hold.released` emission. A failure on one
4102
+ * hold is logged and never aborts the batch (deterministic, idempotent, safe to
4103
+ * run repeatedly).
4104
+ */
4105
+ async sweep() {
4106
+ const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
4107
+ const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4108
+ for (const record of expired) {
4109
+ try {
4110
+ const claimed = await this.ledger.transition(record.id, "pending", "released");
4111
+ if (claimed === null) continue;
4112
+ await this.metering.restoreReleasedHold(claimed, "expired", true);
4113
+ } catch {
4114
+ this.logger.warn(`failed to reap expired hold ${record.id}`);
4115
+ }
4116
+ }
4117
+ }
4118
+ };
4119
+ HoldReaper = __decorateClass([
4120
+ common.Injectable()
4121
+ ], HoldReaper);
4122
+
4119
4123
  // src/server/bymax-ai-tokens.module.ts
4120
4124
  var CORE_TOKENS = [
4121
4125
  BYMAX_AI_TOKENS_OPTIONS,
@@ -1,5 +1,5 @@
1
- import { HttpStatus, Injectable, Inject, Global, Module, HttpException, SetMetadata, Logger } from '@nestjs/common';
2
- import { ModuleRef, Reflector } from '@nestjs/core';
1
+ import { HttpStatus, Injectable, Inject, Global, Module, Logger, HttpException, SetMetadata } from '@nestjs/common';
2
+ import { Reflector, ModuleRef } from '@nestjs/core';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { mergeMap, from, throwError } from 'rxjs';
5
5
  import { catchError } from 'rxjs/operators';
@@ -1183,240 +1183,6 @@ function exhaustedError(status, feature) {
1183
1183
  }
1184
1184
  return null;
1185
1185
  }
1186
- var MeteringInterceptor = class {
1187
- /**
1188
- * @param reflector The NestJS reflector (`@Meter` metadata).
1189
- * @param metering The metering facade (capture/record).
1190
- * @param options The resolved options (fallback `scopeResolver` when no guard ran).
1191
- */
1192
- constructor(reflector, metering, options) {
1193
- this.reflector = reflector;
1194
- this.metering = metering;
1195
- this.scopeResolver = options.scopeResolver;
1196
- }
1197
- reflector;
1198
- metering;
1199
- logger = new Logger(MeteringInterceptor.name);
1200
- scopeResolver;
1201
- /**
1202
- * Capture (or record) the handler's usage after it resolves; release the guard's
1203
- * hold on a handler error.
1204
- *
1205
- * @param executionContext The request execution context.
1206
- * @param next The downstream handler.
1207
- * @returns The handler's result stream, unchanged in value.
1208
- */
1209
- intercept(executionContext, next) {
1210
- const targets = [executionContext.getHandler(), executionContext.getClass()];
1211
- const config = this.reflector.getAllAndOverride(METER_METADATA, targets);
1212
- if (config === void 0) return next.handle();
1213
- const request = executionContext.switchToHttp().getRequest();
1214
- const enrichment = request.aiTokens;
1215
- return next.handle().pipe(
1216
- mergeMap((result) => from(this.settle(executionContext, config, enrichment, result))),
1217
- catchError((error) => this.onError(enrichment, error))
1218
- );
1219
- }
1220
- /** Settle the handler's usage and set the cost headers; returns the untouched result. */
1221
- async settle(executionContext, config, enrichment, result) {
1222
- const usage = extractUsage(result, config);
1223
- const record = await this.capture(executionContext, config, enrichment, usage);
1224
- if (config.exposeHeaders === true) this.setHeaders(executionContext, record, enrichment);
1225
- return result;
1226
- }
1227
- /** Capture the guard's hold when present, else record enforcing post-hoc. */
1228
- async capture(executionContext, config, enrichment, usage) {
1229
- if (enrichment?.hold !== void 0) return this.metering.capture(enrichment.hold, usage, config.preset);
1230
- const context = await this.resolveContext(executionContext, config, enrichment);
1231
- return this.metering.record({
1232
- usage,
1233
- // Stryker disable next-line ConditionalExpression -- CE true: spreading { preset: undefined } is equivalent to {} because MeteringService checks preset !== undefined
1234
- ...config.preset !== void 0 ? { preset: config.preset } : {},
1235
- // Stryker disable next-line ConditionalExpression -- CE true: spreading { tags: undefined } is equivalent to {} because MeteringService checks tags !== undefined
1236
- context: { ...context, enforce: true, isSystemCost: config.isSystemCost ?? false, ...config.tags !== void 0 ? { tags: config.tags } : {} }
1237
- });
1238
- }
1239
- /** The metering context: the guard's enrichment, else the host `scopeResolver`. */
1240
- async resolveContext(executionContext, config, enrichment) {
1241
- if (enrichment !== void 0) return enrichment.context;
1242
- if (this.scopeResolver === void 0) {
1243
- throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: "MeteringInterceptor requires options.scopeResolver when no BudgetGuard ran" });
1244
- }
1245
- const resolved = await this.scopeResolver(executionContext);
1246
- return { ...resolved, feature: config.feature, ...config.preset !== void 0 ? { preset: config.preset } : {} };
1247
- }
1248
- /** Release the guard's hold (if any) and rethrow the ORIGINAL handler error. */
1249
- onError(enrichment, error) {
1250
- if (enrichment?.hold === void 0) return throwError(() => error);
1251
- const holdId = enrichment.hold.id;
1252
- const released = this.metering.release(enrichment.hold, "handler threw").catch(() => {
1253
- this.logger.warn(`failed to release hold ${holdId} after a handler error; the reaper will reclaim it`);
1254
- });
1255
- return from(released).pipe(mergeMap(() => throwError(() => error)));
1256
- }
1257
- /** Set the three `x-ai-tokens-*` headers as decimal strings via the HTTP adapter. */
1258
- setHeaders(executionContext, record, enrichment) {
1259
- const response = executionContext.switchToHttp().getResponse();
1260
- writeHeader(response, "x-ai-tokens-cost", record.rawCostNanoUsd.toString());
1261
- writeHeader(response, "x-ai-tokens-billed-cost", record.billedCostNanoUsd.toString());
1262
- const remaining = minBudgetRemaining(enrichment);
1263
- if (remaining !== void 0) writeHeader(response, "x-ai-tokens-budget-remaining", remaining.toString());
1264
- }
1265
- };
1266
- MeteringInterceptor = __decorateClass([
1267
- Injectable(),
1268
- __decorateParam(2, Inject(BYMAX_AI_TOKENS_OPTIONS))
1269
- ], MeteringInterceptor);
1270
- function extractUsage(result, config) {
1271
- const extract = config.extract ?? ((value) => typeof value === "object" && value !== null ? value.usage : void 0);
1272
- const usage = extract(result);
1273
- if (usage === void 0 || usage === null) {
1274
- throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the handler result carried no extractable usage" });
1275
- }
1276
- return usage;
1277
- }
1278
- function minBudgetRemaining(enrichment) {
1279
- let min;
1280
- for (const status of enrichment?.status ?? []) {
1281
- const remaining = status.remaining.nanoUsd;
1282
- if (remaining !== void 0 && (min === void 0 || remaining < min)) min = remaining;
1283
- }
1284
- return min;
1285
- }
1286
- function writeHeader(response, name, value) {
1287
- if (typeof response.setHeader === "function") response.setHeader(name, value);
1288
- else if (typeof response.header === "function") response.header(name, value);
1289
- }
1290
- var REAP_BATCH_SIZE = 100;
1291
- var MS_PER_SECOND = 1e3;
1292
- var HoldReaper = class {
1293
- /**
1294
- * @param ledger The ledger service (expired-hold scan + atomic void claim).
1295
- * @param metering The metering facade (shared hold-restoration path).
1296
- * @param options The resolved options carrying the hold TTL + sweep interval.
1297
- * @param now The injected clock (testability).
1298
- */
1299
- constructor(ledger, metering, options, now = () => /* @__PURE__ */ new Date()) {
1300
- this.ledger = ledger;
1301
- this.metering = metering;
1302
- this.options = options;
1303
- this.now = now;
1304
- }
1305
- ledger;
1306
- metering;
1307
- options;
1308
- now;
1309
- logger = new Logger(HoldReaper.name);
1310
- timer;
1311
- /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
1312
- onApplicationBootstrap() {
1313
- const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
1314
- this.timer = setInterval(() => void this.sweep(), intervalMs);
1315
- this.timer.unref();
1316
- }
1317
- /** Stop the sweep on shutdown (no open handles left for Jest). */
1318
- onApplicationShutdown() {
1319
- if (this.timer !== void 0) clearInterval(this.timer);
1320
- this.timer = void 0;
1321
- }
1322
- /**
1323
- * Reclaim one batch of expired pending holds. For each: win the atomic
1324
- * `pending → released` claim (a `null` result means another replica won — skip),
1325
- * then run the shared restoration + `hold.released` emission. A failure on one
1326
- * hold is logged and never aborts the batch (deterministic, idempotent, safe to
1327
- * run repeatedly).
1328
- */
1329
- async sweep() {
1330
- const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
1331
- const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
1332
- for (const record of expired) {
1333
- try {
1334
- const claimed = await this.ledger.transition(record.id, "pending", "released");
1335
- if (claimed === null) continue;
1336
- await this.metering.restoreReleasedHold(claimed, "expired", true);
1337
- } catch {
1338
- this.logger.warn(`failed to reap expired hold ${record.id}`);
1339
- }
1340
- }
1341
- }
1342
- };
1343
- HoldReaper = __decorateClass([
1344
- Injectable()
1345
- ], HoldReaper);
1346
-
1347
- // src/server/telemetry/otel-emitter.ts
1348
- function buildGenAiAttributes(record) {
1349
- return {
1350
- "gen_ai.provider.name": record.provider,
1351
- "gen_ai.request.model": record.requestedModel ?? record.model,
1352
- "gen_ai.response.model": record.model,
1353
- "gen_ai.operation.name": record.operation,
1354
- "gen_ai.request.service_tier": record.serviceTier,
1355
- "gen_ai.usage.input_tokens": record.inputTokens,
1356
- "gen_ai.usage.output_tokens": record.outputTokens
1357
- };
1358
- }
1359
- var TelemetryEmitter = class {
1360
- /** @param sink The host telemetry sink, or `null` to disable telemetry. */
1361
- constructor(sink) {
1362
- this.sink = sink;
1363
- }
1364
- sink;
1365
- /**
1366
- * Record a usage measurement for a posted record (no-op without a sink).
1367
- *
1368
- * @param record The posted usage record.
1369
- */
1370
- recordUsage(record) {
1371
- if (this.sink === null) return;
1372
- this.sink.recordUsage(buildGenAiAttributes(record), record);
1373
- }
1374
- /**
1375
- * Record an operation duration for a posted record (no-op without a sink, or when
1376
- * the sink does not implement `recordDuration`).
1377
- *
1378
- * @param record The posted usage record.
1379
- * @param milliseconds The measured operation duration.
1380
- */
1381
- recordDuration(record, milliseconds) {
1382
- const sink = this.sink;
1383
- if (sink?.recordDuration === void 0) return;
1384
- sink.recordDuration(buildGenAiAttributes(record), milliseconds);
1385
- }
1386
- };
1387
- var IDENTITY = (text) => text;
1388
- var ContentCapture = class _ContentCapture {
1389
- /** @param options The resolved content-sidecar settings (disabled by default). */
1390
- constructor(options) {
1391
- this.options = options;
1392
- }
1393
- options;
1394
- logger = new Logger(_ContentCapture.name);
1395
- /**
1396
- * Persist the masked prompt/completion to the content sidecar. A no-op when the
1397
- * feature is disabled or no text was supplied. Failures are logged (never the
1398
- * text) and never rethrown — content capture must not break metering.
1399
- *
1400
- * @param input The record id, tenant, and the host-supplied text.
1401
- */
1402
- async capture(input) {
1403
- if (!this.options.enabled) return;
1404
- if (input.prompt === void 0 && input.completion === void 0) return;
1405
- const { store, ttlSeconds } = this.options;
1406
- const mask = this.options.mask ?? IDENTITY;
1407
- try {
1408
- if (input.prompt !== void 0) {
1409
- await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "prompt", text: mask(input.prompt), ttlSeconds });
1410
- }
1411
- if (input.completion !== void 0) {
1412
- await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "completion", text: mask(input.completion), ttlSeconds });
1413
- }
1414
- } catch {
1415
- this.logger.error(`failed to write content sidecar for record ${input.usageRecordId}`);
1416
- }
1417
- }
1418
- };
1419
- var NO_OP_CONTENT_CAPTURE = new ContentCapture({ enabled: false });
1420
1186
 
1421
1187
  // src/server/utils/model-id.ts
1422
1188
  function normalizeModelId(id) {
@@ -2246,8 +2012,82 @@ function numberOf(value) {
2246
2012
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
2247
2013
  }
2248
2014
 
2015
+ // src/server/telemetry/otel-emitter.ts
2016
+ function buildGenAiAttributes(record) {
2017
+ return {
2018
+ "gen_ai.provider.name": record.provider,
2019
+ "gen_ai.request.model": record.requestedModel ?? record.model,
2020
+ "gen_ai.response.model": record.model,
2021
+ "gen_ai.operation.name": record.operation,
2022
+ "gen_ai.request.service_tier": record.serviceTier,
2023
+ "gen_ai.usage.input_tokens": record.inputTokens,
2024
+ "gen_ai.usage.output_tokens": record.outputTokens
2025
+ };
2026
+ }
2027
+ var TelemetryEmitter = class {
2028
+ /** @param sink The host telemetry sink, or `null` to disable telemetry. */
2029
+ constructor(sink) {
2030
+ this.sink = sink;
2031
+ }
2032
+ sink;
2033
+ /**
2034
+ * Record a usage measurement for a posted record (no-op without a sink).
2035
+ *
2036
+ * @param record The posted usage record.
2037
+ */
2038
+ recordUsage(record) {
2039
+ if (this.sink === null) return;
2040
+ this.sink.recordUsage(buildGenAiAttributes(record), record);
2041
+ }
2042
+ /**
2043
+ * Record an operation duration for a posted record (no-op without a sink, or when
2044
+ * the sink does not implement `recordDuration`).
2045
+ *
2046
+ * @param record The posted usage record.
2047
+ * @param milliseconds The measured operation duration.
2048
+ */
2049
+ recordDuration(record, milliseconds) {
2050
+ const sink = this.sink;
2051
+ if (sink?.recordDuration === void 0) return;
2052
+ sink.recordDuration(buildGenAiAttributes(record), milliseconds);
2053
+ }
2054
+ };
2055
+
2249
2056
  // src/server/telemetry/no-op-telemetry.ts
2250
2057
  var NO_OP_TELEMETRY = new TelemetryEmitter(null);
2058
+ var IDENTITY = (text) => text;
2059
+ var ContentCapture = class _ContentCapture {
2060
+ /** @param options The resolved content-sidecar settings (disabled by default). */
2061
+ constructor(options) {
2062
+ this.options = options;
2063
+ }
2064
+ options;
2065
+ logger = new Logger(_ContentCapture.name);
2066
+ /**
2067
+ * Persist the masked prompt/completion to the content sidecar. A no-op when the
2068
+ * feature is disabled or no text was supplied. Failures are logged (never the
2069
+ * text) and never rethrown — content capture must not break metering.
2070
+ *
2071
+ * @param input The record id, tenant, and the host-supplied text.
2072
+ */
2073
+ async capture(input) {
2074
+ if (!this.options.enabled) return;
2075
+ if (input.prompt === void 0 && input.completion === void 0) return;
2076
+ const { store, ttlSeconds } = this.options;
2077
+ const mask = this.options.mask ?? IDENTITY;
2078
+ try {
2079
+ if (input.prompt !== void 0) {
2080
+ await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "prompt", text: mask(input.prompt), ttlSeconds });
2081
+ }
2082
+ if (input.completion !== void 0) {
2083
+ await store.put({ usageRecordId: input.usageRecordId, tenantId: input.tenantId, role: "completion", text: mask(input.completion), ttlSeconds });
2084
+ }
2085
+ } catch {
2086
+ this.logger.error(`failed to write content sidecar for record ${input.usageRecordId}`);
2087
+ }
2088
+ }
2089
+ };
2090
+ var NO_OP_CONTENT_CAPTURE = new ContentCapture({ enabled: false });
2251
2091
 
2252
2092
  // src/server/services/hold-support.ts
2253
2093
  var UNSPECIFIED = "unspecified";
@@ -4114,6 +3954,170 @@ var exampleRecord = {
4114
3954
  updatedAt: /* @__PURE__ */ new Date(0)
4115
3955
  };
4116
3956
 
3957
+ // src/server/enforcement/metering.interceptor.ts
3958
+ var MeteringInterceptor = class {
3959
+ /**
3960
+ * @param reflector The NestJS reflector (`@Meter` metadata).
3961
+ * @param metering The metering facade (capture/record).
3962
+ * @param options The resolved options (fallback `scopeResolver` when no guard ran).
3963
+ */
3964
+ constructor(reflector, metering, options) {
3965
+ this.reflector = reflector;
3966
+ this.metering = metering;
3967
+ this.scopeResolver = options.scopeResolver;
3968
+ }
3969
+ reflector;
3970
+ metering;
3971
+ logger = new Logger(MeteringInterceptor.name);
3972
+ scopeResolver;
3973
+ /**
3974
+ * Capture (or record) the handler's usage after it resolves; release the guard's
3975
+ * hold on a handler error.
3976
+ *
3977
+ * @param executionContext The request execution context.
3978
+ * @param next The downstream handler.
3979
+ * @returns The handler's result stream, unchanged in value.
3980
+ */
3981
+ intercept(executionContext, next) {
3982
+ const targets = [executionContext.getHandler(), executionContext.getClass()];
3983
+ const config = this.reflector.getAllAndOverride(METER_METADATA, targets);
3984
+ if (config === void 0) return next.handle();
3985
+ const request = executionContext.switchToHttp().getRequest();
3986
+ const enrichment = request.aiTokens;
3987
+ return next.handle().pipe(
3988
+ mergeMap((result) => from(this.settle(executionContext, config, enrichment, result))),
3989
+ catchError((error) => this.onError(enrichment, error))
3990
+ );
3991
+ }
3992
+ /** Settle the handler's usage and set the cost headers; returns the untouched result. */
3993
+ async settle(executionContext, config, enrichment, result) {
3994
+ const usage = extractUsage(result, config);
3995
+ const record = await this.capture(executionContext, config, enrichment, usage);
3996
+ if (config.exposeHeaders === true) this.setHeaders(executionContext, record, enrichment);
3997
+ return result;
3998
+ }
3999
+ /** Capture the guard's hold when present, else record enforcing post-hoc. */
4000
+ async capture(executionContext, config, enrichment, usage) {
4001
+ if (enrichment?.hold !== void 0) return this.metering.capture(enrichment.hold, usage, config.preset);
4002
+ const context = await this.resolveContext(executionContext, config, enrichment);
4003
+ return this.metering.record({
4004
+ usage,
4005
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { preset: undefined } is equivalent to {} because MeteringService checks preset !== undefined
4006
+ ...config.preset !== void 0 ? { preset: config.preset } : {},
4007
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { tags: undefined } is equivalent to {} because MeteringService checks tags !== undefined
4008
+ context: { ...context, enforce: true, isSystemCost: config.isSystemCost ?? false, ...config.tags !== void 0 ? { tags: config.tags } : {} }
4009
+ });
4010
+ }
4011
+ /** The metering context: the guard's enrichment, else the host `scopeResolver`. */
4012
+ async resolveContext(executionContext, config, enrichment) {
4013
+ if (enrichment !== void 0) return enrichment.context;
4014
+ if (this.scopeResolver === void 0) {
4015
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: "MeteringInterceptor requires options.scopeResolver when no BudgetGuard ran" });
4016
+ }
4017
+ const resolved = await this.scopeResolver(executionContext);
4018
+ return { ...resolved, feature: config.feature, ...config.preset !== void 0 ? { preset: config.preset } : {} };
4019
+ }
4020
+ /** Release the guard's hold (if any) and rethrow the ORIGINAL handler error. */
4021
+ onError(enrichment, error) {
4022
+ if (enrichment?.hold === void 0) return throwError(() => error);
4023
+ const holdId = enrichment.hold.id;
4024
+ const released = this.metering.release(enrichment.hold, "handler threw").catch(() => {
4025
+ this.logger.warn(`failed to release hold ${holdId} after a handler error; the reaper will reclaim it`);
4026
+ });
4027
+ return from(released).pipe(mergeMap(() => throwError(() => error)));
4028
+ }
4029
+ /** Set the three `x-ai-tokens-*` headers as decimal strings via the HTTP adapter. */
4030
+ setHeaders(executionContext, record, enrichment) {
4031
+ const response = executionContext.switchToHttp().getResponse();
4032
+ writeHeader(response, "x-ai-tokens-cost", record.rawCostNanoUsd.toString());
4033
+ writeHeader(response, "x-ai-tokens-billed-cost", record.billedCostNanoUsd.toString());
4034
+ const remaining = minBudgetRemaining(enrichment);
4035
+ if (remaining !== void 0) writeHeader(response, "x-ai-tokens-budget-remaining", remaining.toString());
4036
+ }
4037
+ };
4038
+ MeteringInterceptor = __decorateClass([
4039
+ Injectable(),
4040
+ __decorateParam(0, Inject(Reflector)),
4041
+ __decorateParam(1, Inject(MeteringService)),
4042
+ __decorateParam(2, Inject(BYMAX_AI_TOKENS_OPTIONS))
4043
+ ], MeteringInterceptor);
4044
+ function extractUsage(result, config) {
4045
+ const extract = config.extract ?? ((value) => typeof value === "object" && value !== null ? value.usage : void 0);
4046
+ const usage = extract(result);
4047
+ if (usage === void 0 || usage === null) {
4048
+ throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the handler result carried no extractable usage" });
4049
+ }
4050
+ return usage;
4051
+ }
4052
+ function minBudgetRemaining(enrichment) {
4053
+ let min;
4054
+ for (const status of enrichment?.status ?? []) {
4055
+ const remaining = status.remaining.nanoUsd;
4056
+ if (remaining !== void 0 && (min === void 0 || remaining < min)) min = remaining;
4057
+ }
4058
+ return min;
4059
+ }
4060
+ function writeHeader(response, name, value) {
4061
+ if (typeof response.setHeader === "function") response.setHeader(name, value);
4062
+ else if (typeof response.header === "function") response.header(name, value);
4063
+ }
4064
+ var REAP_BATCH_SIZE = 100;
4065
+ var MS_PER_SECOND = 1e3;
4066
+ var HoldReaper = class {
4067
+ /**
4068
+ * @param ledger The ledger service (expired-hold scan + atomic void claim).
4069
+ * @param metering The metering facade (shared hold-restoration path).
4070
+ * @param options The resolved options carrying the hold TTL + sweep interval.
4071
+ * @param now The injected clock (testability).
4072
+ */
4073
+ constructor(ledger, metering, options, now = () => /* @__PURE__ */ new Date()) {
4074
+ this.ledger = ledger;
4075
+ this.metering = metering;
4076
+ this.options = options;
4077
+ this.now = now;
4078
+ }
4079
+ ledger;
4080
+ metering;
4081
+ options;
4082
+ now;
4083
+ logger = new Logger(HoldReaper.name);
4084
+ timer;
4085
+ /** Start the periodic sweep on module boot (interval `unref()`'d so it never blocks exit). */
4086
+ onApplicationBootstrap() {
4087
+ const intervalMs = this.options.holds.reaperIntervalSeconds * MS_PER_SECOND;
4088
+ this.timer = setInterval(() => void this.sweep(), intervalMs);
4089
+ this.timer.unref();
4090
+ }
4091
+ /** Stop the sweep on shutdown (no open handles left for Jest). */
4092
+ onApplicationShutdown() {
4093
+ if (this.timer !== void 0) clearInterval(this.timer);
4094
+ this.timer = void 0;
4095
+ }
4096
+ /**
4097
+ * Reclaim one batch of expired pending holds. For each: win the atomic
4098
+ * `pending → released` claim (a `null` result means another replica won — skip),
4099
+ * then run the shared restoration + `hold.released` emission. A failure on one
4100
+ * hold is logged and never aborts the batch (deterministic, idempotent, safe to
4101
+ * run repeatedly).
4102
+ */
4103
+ async sweep() {
4104
+ const cutoff = new Date(this.now().getTime() - this.options.holds.ttlSeconds * MS_PER_SECOND);
4105
+ const expired = await this.ledger.findExpiredHolds(cutoff, REAP_BATCH_SIZE);
4106
+ for (const record of expired) {
4107
+ try {
4108
+ const claimed = await this.ledger.transition(record.id, "pending", "released");
4109
+ if (claimed === null) continue;
4110
+ await this.metering.restoreReleasedHold(claimed, "expired", true);
4111
+ } catch {
4112
+ this.logger.warn(`failed to reap expired hold ${record.id}`);
4113
+ }
4114
+ }
4115
+ }
4116
+ };
4117
+ HoldReaper = __decorateClass([
4118
+ Injectable()
4119
+ ], HoldReaper);
4120
+
4117
4121
  // src/server/bymax-ai-tokens.module.ts
4118
4122
  var CORE_TOKENS = [
4119
4123
  BYMAX_AI_TOKENS_OPTIONS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-ai-tokens",
3
- "version": "1.0.1",
3
+ "version": "1.0.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",
@@ -171,26 +171,6 @@
171
171
  "typescript": "^5.9.3",
172
172
  "typescript-eslint": "^8.65.0"
173
173
  },
174
- "pnpm": {
175
- "overrides": {
176
- "brace-expansion@1": "^1.1.17",
177
- "brace-expansion@2": "^2.1.3",
178
- "brace-expansion@5": "^5.0.8",
179
- "esbuild": "0.28.1",
180
- "fast-uri": "^3.1.4",
181
- "multer": "2.2.0",
182
- "protobufjs": "^7.6.5",
183
- "qs": "6.15.2",
184
- "undici": "^8.9.0",
185
- "uuid": "11.1.1"
186
- },
187
- "onlyBuiltDependencies": [
188
- "esbuild",
189
- "@prisma/client",
190
- "@prisma/engines",
191
- "prisma"
192
- ]
193
- },
194
174
  "keywords": [
195
175
  "nestjs",
196
176
  "ai",
@@ -208,7 +188,7 @@
208
188
  "budget",
209
189
  "markup"
210
190
  ],
211
- "packageManager": "pnpm@10.8.1",
191
+ "packageManager": "pnpm@11.20.0",
212
192
  "engines": {
213
193
  "node": ">=24.0.0"
214
194
  },