@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 +30 -1
- package/dist/server/index.cjs +238 -234
- package/dist/server/index.mjs +240 -236
- package/package.json +2 -22
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
|
-
[
|
|
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/dist/server/index.cjs
CHANGED
|
@@ -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,
|
package/dist/server/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { HttpStatus, Injectable, Inject, Global, Module, HttpException, SetMetadata
|
|
2
|
-
import {
|
|
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.
|
|
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@
|
|
191
|
+
"packageManager": "pnpm@11.20.0",
|
|
212
192
|
"engines": {
|
|
213
193
|
"node": ">=24.0.0"
|
|
214
194
|
},
|