@bymax-one/nest-ai-tokens 1.0.0 → 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,58 @@ 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
+
39
+ ## [1.0.1] - 2026-08-04
40
+
41
+ ### Removed
42
+
43
+ - The README's `Migration` section, which described migrating an AI cost layer out of a
44
+ named private monorepo. It reached the npm page of a public package, where it named a
45
+ repository its readers cannot see and cannot act on. The normative rule it carried —
46
+ translate `0`/`null` limits to no budget row — is already stated under the budget
47
+ semantics and is unchanged.
48
+
49
+ ### Security
50
+
51
+ - The Redis credentials are no longer disclosed when `RedisBudgetCounterStore` is
52
+ serialized. The store kept the live client — or the connection URL it is opened from —
53
+ in a TypeScript `private` property, which is erased at runtime and leaves an enumerable
54
+ own property. A connection URL carries the password inline and an ioredis instance
55
+ carries `options.password` as a plain field, so `JSON.stringify`, object spread and
56
+ `util.inspect` emitted the password in plaintext, which is what a structured logger does
57
+ when it renders its arguments and what an error reporter does when it captures the scope
58
+ of a throw. The source and the in-flight lazy connection both move to ECMAScript private
59
+ fields.
60
+
61
+ Reading on purpose is unchanged and no public type or export moved.
62
+
11
63
  ## [1.0.0] - 2026-08-03
12
64
 
13
65
  First published release. Everything below ships in it.
@@ -110,4 +162,6 @@ have regressed from. They are kept because the reasoning is worth having.
110
162
  ---
111
163
 
112
164
  [1.0.0]: https://github.com/bymaxone/nest-ai-tokens/releases/tag/v1.0.0
113
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...HEAD
165
+ [1.0.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.0...v1.0.1
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
package/README.md CHANGED
@@ -395,7 +395,7 @@ const status = await metering.getStatus(tenantId, { type: 'user', id: userId })
395
395
  // status[].remaining, status[].exhausted, status[].policy
396
396
  ```
397
397
 
398
- **Unlimited semantics:** no budget row = unlimited. `limit = 0` = hard block. Never use `null` or `0` to mean unlimited (see §22 migration notes).
398
+ **Unlimited semantics:** no budget row = unlimited. `limit = 0` = hard block. Never use `null` or `0` to mean unlimited.
399
399
 
400
400
  ---
401
401
 
@@ -532,14 +532,6 @@ formatNanoUsd(5_000_000n, { currency: 'BRL', fxRateNano: 5_000_000_000n }) // '0
532
532
 
533
533
  ---
534
534
 
535
- ## 🚚 Migration
536
-
537
- If you are migrating the `_commons_/ai/*` layer from `bymax-fitness`, see [docs/technical_specification.md §22](./docs/technical_specification.md) for the full field-by-field mapping table, the zero/null → unlimited translation rules, and backfill notes.
538
-
539
- Key caution: `bymax-fitness` treats `0` as unlimited on most paths and as blocked on one path. Do not import plan rows verbatim. Translate `0`/`null` limits to **no budget row** (the normative unlimited representation in this library).
540
-
541
- ---
542
-
543
535
  ## 📖 API Reference
544
536
 
545
537
  The sections above document each of these with a runnable example. This is the index.
@@ -36,15 +36,26 @@ var CounterValueOutOfRangeError = class extends Error {
36
36
  };
37
37
  var RedisBudgetCounterStore = class {
38
38
  keyPrefix;
39
- source;
40
- connecting = null;
39
+ /**
40
+ * The live client or the connection URL it is opened from.
41
+ *
42
+ * An ECMAScript private field rather than a TypeScript `private` one, which is
43
+ * erased at runtime: a connection URL carries the Redis password inline, and an
44
+ * ioredis instance carries `options.password` as a plain field. Leaving either
45
+ * enumerable would let anything that serializes this store — a structured
46
+ * logger rendering its arguments, an error reporter capturing the scope of a
47
+ * throw — emit the credentials in plaintext.
48
+ */
49
+ #source;
50
+ /** The in-flight lazy connection, which resolves to the same credentialed client. */
51
+ #connecting = null;
41
52
  /**
42
53
  * @param redis A live `ioredis` client, or a connection URL to lazily connect.
43
54
  * @param options Optional key prefix.
44
55
  */
45
56
  constructor(redis, options = {}) {
46
57
  this.keyPrefix = options.keyPrefix ?? "";
47
- this.source = redis;
58
+ this.#source = redis;
48
59
  }
49
60
  /**
50
61
  * Atomically increment `key` by `amount` iff the result stays `<= limit`, setting
@@ -95,9 +106,9 @@ var RedisBudgetCounterStore = class {
95
106
  }
96
107
  /** Resolve the live client, lazily connecting a URL via a dynamic `ioredis` import. */
97
108
  resolve() {
98
- if (typeof this.source !== "string") return Promise.resolve(this.source);
99
- this.connecting ??= this.connect(this.source);
100
- return this.connecting;
109
+ if (typeof this.#source !== "string") return Promise.resolve(this.#source);
110
+ this.#connecting ??= this.connect(this.#source);
111
+ return this.#connecting;
101
112
  }
102
113
  /** Dynamically import `ioredis` and connect the given URL. */
103
114
  async connect(url) {
@@ -54,9 +54,8 @@ declare class CounterValueOutOfRangeError extends Error {
54
54
  }
55
55
  /** The official Redis adapter for the live budget counter port. */
56
56
  declare class RedisBudgetCounterStore implements IBudgetCounterStore {
57
+ #private;
57
58
  private readonly keyPrefix;
58
- private readonly source;
59
- private connecting;
60
59
  /**
61
60
  * @param redis A live `ioredis` client, or a connection URL to lazily connect.
62
61
  * @param options Optional key prefix.
@@ -54,9 +54,8 @@ declare class CounterValueOutOfRangeError extends Error {
54
54
  }
55
55
  /** The official Redis adapter for the live budget counter port. */
56
56
  declare class RedisBudgetCounterStore implements IBudgetCounterStore {
57
+ #private;
57
58
  private readonly keyPrefix;
58
- private readonly source;
59
- private connecting;
60
59
  /**
61
60
  * @param redis A live `ioredis` client, or a connection URL to lazily connect.
62
61
  * @param options Optional key prefix.
@@ -34,15 +34,26 @@ var CounterValueOutOfRangeError = class extends Error {
34
34
  };
35
35
  var RedisBudgetCounterStore = class {
36
36
  keyPrefix;
37
- source;
38
- connecting = null;
37
+ /**
38
+ * The live client or the connection URL it is opened from.
39
+ *
40
+ * An ECMAScript private field rather than a TypeScript `private` one, which is
41
+ * erased at runtime: a connection URL carries the Redis password inline, and an
42
+ * ioredis instance carries `options.password` as a plain field. Leaving either
43
+ * enumerable would let anything that serializes this store — a structured
44
+ * logger rendering its arguments, an error reporter capturing the scope of a
45
+ * throw — emit the credentials in plaintext.
46
+ */
47
+ #source;
48
+ /** The in-flight lazy connection, which resolves to the same credentialed client. */
49
+ #connecting = null;
39
50
  /**
40
51
  * @param redis A live `ioredis` client, or a connection URL to lazily connect.
41
52
  * @param options Optional key prefix.
42
53
  */
43
54
  constructor(redis, options = {}) {
44
55
  this.keyPrefix = options.keyPrefix ?? "";
45
- this.source = redis;
56
+ this.#source = redis;
46
57
  }
47
58
  /**
48
59
  * Atomically increment `key` by `amount` iff the result stays `<= limit`, setting
@@ -93,9 +104,9 @@ var RedisBudgetCounterStore = class {
93
104
  }
94
105
  /** Resolve the live client, lazily connecting a URL via a dynamic `ioredis` import. */
95
106
  resolve() {
96
- if (typeof this.source !== "string") return Promise.resolve(this.source);
97
- this.connecting ??= this.connect(this.source);
98
- return this.connecting;
107
+ if (typeof this.#source !== "string") return Promise.resolve(this.#source);
108
+ this.#connecting ??= this.connect(this.#source);
109
+ return this.#connecting;
99
110
  }
100
111
  /** Dynamically import `ioredis` and connect the given URL. */
101
112
  async connect(url) {
@@ -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.0",
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",
@@ -73,6 +73,31 @@
73
73
  },
74
74
  "./package.json": "./package.json"
75
75
  },
76
+ "scripts": {
77
+ "build": "pnpm clean && tsup",
78
+ "check:exports": "attw --pack . --profile strict",
79
+ "check:published": "node scripts/check-published-surface.mjs",
80
+ "check:runtime": "node scripts/check-consumer-runtime.mjs",
81
+ "clean": "node -e \"const {rmSync}=require('node:fs');for(const d of ['dist','coverage'])rmSync(d,{recursive:true,force:true})\"",
82
+ "db:generate": "prisma generate",
83
+ "docs:check": "node scripts/check-jsdoc.mjs && tsc --noEmit -p tsconfig.e2e.json",
84
+ "lint": "eslint src",
85
+ "lint:fix": "eslint src --fix",
86
+ "mutation": "stryker run",
87
+ "mutation:dry-run": "stryker run --dryRunOnly",
88
+ "mutation:incremental": "stryker run --incremental",
89
+ "prepare": "prisma generate",
90
+ "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:published",
91
+ "release": "npm publish --provenance --access public",
92
+ "size": "node scripts/check-size.mjs",
93
+ "test": "jest",
94
+ "test:all": "pnpm test && pnpm test:e2e",
95
+ "test:cov": "jest --coverage",
96
+ "test:cov:all": "jest --config jest.coverage.config.ts --coverage",
97
+ "test:e2e": "jest --config jest.e2e.config.ts",
98
+ "test:watch": "jest --watch",
99
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json"
100
+ },
76
101
  "dependencies": {},
77
102
  "peerDependencies": {
78
103
  "@nestjs/common": "^11.0.16",
@@ -163,6 +188,7 @@
163
188
  "budget",
164
189
  "markup"
165
190
  ],
191
+ "packageManager": "pnpm@11.20.0",
166
192
  "engines": {
167
193
  "node": ">=24.0.0"
168
194
  },
@@ -189,28 +215,5 @@
189
215
  "./dist/redis/index.d.cts"
190
216
  ]
191
217
  }
192
- },
193
- "scripts": {
194
- "build": "pnpm clean && tsup",
195
- "check:exports": "attw --pack . --profile strict",
196
- "check:published": "node scripts/check-published-surface.mjs",
197
- "check:runtime": "node scripts/check-consumer-runtime.mjs",
198
- "clean": "node -e \"const {rmSync}=require('node:fs');for(const d of ['dist','coverage'])rmSync(d,{recursive:true,force:true})\"",
199
- "db:generate": "prisma generate",
200
- "docs:check": "node scripts/check-jsdoc.mjs && tsc --noEmit -p tsconfig.e2e.json",
201
- "lint": "eslint src",
202
- "lint:fix": "eslint src --fix",
203
- "mutation": "stryker run",
204
- "mutation:dry-run": "stryker run --dryRunOnly",
205
- "mutation:incremental": "stryker run --incremental",
206
- "release": "npm publish --provenance --access public",
207
- "size": "node scripts/check-size.mjs",
208
- "test": "jest",
209
- "test:all": "pnpm test && pnpm test:e2e",
210
- "test:cov": "jest --coverage",
211
- "test:cov:all": "jest --config jest.coverage.config.ts --coverage",
212
- "test:e2e": "jest --config jest.e2e.config.ts",
213
- "test:watch": "jest --watch",
214
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json"
215
218
  }
216
- }
219
+ }