@bymax-one/nest-ai-tokens 1.0.0

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.
@@ -0,0 +1,4354 @@
1
+ import { HttpStatus, Injectable, Inject, Global, Module, HttpException, SetMetadata, Logger } from '@nestjs/common';
2
+ import { ModuleRef, Reflector } from '@nestjs/core';
3
+ import { randomUUID } from 'crypto';
4
+ import { mergeMap, from, throwError } from 'rxjs';
5
+ import { catchError } from 'rxjs/operators';
6
+ import { Readable } from 'stream';
7
+
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __decorateClass = (decorators, target, key, kind) => {
10
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
11
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
12
+ if (decorator = decorators[i])
13
+ result = (decorator(result)) || result;
14
+ return result;
15
+ };
16
+ var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
17
+
18
+ // src/server/bymax-ai-tokens.constants.ts
19
+ var BYMAX_AI_TOKENS_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_OPTIONS");
20
+ var BYMAX_AI_TOKENS_LEDGER_STORE = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_LEDGER_STORE");
21
+ var BYMAX_AI_TOKENS_PRICING_STORE = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_PRICING_STORE");
22
+ var BYMAX_AI_TOKENS_WALLET_STORE = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_WALLET_STORE");
23
+ var BYMAX_AI_TOKENS_BUDGET_STORE = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_BUDGET_STORE");
24
+ var BYMAX_AI_TOKENS_BUDGET_COUNTER = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_BUDGET_COUNTER");
25
+ var BYMAX_AI_TOKENS_TOKENIZER = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_TOKENIZER");
26
+ var BYMAX_AI_TOKENS_TELEMETRY = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_TELEMETRY");
27
+ var BYMAX_AI_TOKENS_EVENT_SINK = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_EVENT_SINK");
28
+ var BYMAX_AI_TOKENS_CONTENT_STORE = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_CONTENT_STORE");
29
+ var BYMAX_AI_TOKENS_LOGGER = /* @__PURE__ */ Symbol("BYMAX_AI_TOKENS_LOGGER");
30
+
31
+ // src/shared/constants/provider-ids.constants.ts
32
+ var PROVIDER_IDS = [
33
+ "openai",
34
+ "azure-openai",
35
+ "anthropic",
36
+ "gemini",
37
+ "vertex",
38
+ "mistral",
39
+ "bedrock",
40
+ "openrouter",
41
+ "deepseek",
42
+ "xai",
43
+ "groq"
44
+ ];
45
+
46
+ // src/shared/constants/operations.constants.ts
47
+ var AI_OPERATIONS = [
48
+ "chat",
49
+ "responses",
50
+ "embeddings",
51
+ "image",
52
+ "video",
53
+ "audio",
54
+ "rerank",
55
+ "moderation"
56
+ ];
57
+
58
+ // src/shared/constants/service-tiers.constants.ts
59
+ var SERVICE_TIERS = ["standard", "batch", "flex", "priority"];
60
+
61
+ // src/shared/constants/token-categories.constants.ts
62
+ var TOKEN_CATEGORIES = [
63
+ "input",
64
+ "output",
65
+ "cacheRead",
66
+ "cacheWrite5m",
67
+ "cacheWrite1h",
68
+ "reasoning",
69
+ "audioIn",
70
+ "audioOut",
71
+ "imageIn",
72
+ "imageOut"
73
+ ];
74
+
75
+ // src/shared/constants/wallet-entry-types.constants.ts
76
+ var WALLET_ENTRY_TYPES = ["grant", "debit", "refund", "adjustment", "expiry"];
77
+
78
+ // src/shared/constants/error-codes.constants.ts
79
+ var AI_TOKENS_ERROR_CODES = {
80
+ AI_TOKENS_NOT_CONFIGURED: "AI_TOKENS_NOT_CONFIGURED",
81
+ AI_TOKENS_INVALID_CONFIG: "AI_TOKENS_INVALID_CONFIG",
82
+ AI_TOKENS_UNKNOWN_PROVIDER: "AI_TOKENS_UNKNOWN_PROVIDER",
83
+ AI_TOKENS_USAGE_MALFORMED: "AI_TOKENS_USAGE_MALFORMED",
84
+ AI_TOKENS_PRICE_NOT_FOUND: "AI_TOKENS_PRICE_NOT_FOUND",
85
+ AI_TOKENS_FX_REQUIRED: "AI_TOKENS_FX_REQUIRED",
86
+ AI_TOKENS_BUDGET_EXCEEDED: "AI_TOKENS_BUDGET_EXCEEDED",
87
+ AI_TOKENS_QUOTA_EXCEEDED: "AI_TOKENS_QUOTA_EXCEEDED",
88
+ AI_TOKENS_INSUFFICIENT_CREDITS: "AI_TOKENS_INSUFFICIENT_CREDITS",
89
+ AI_TOKENS_HOLD_NOT_FOUND: "AI_TOKENS_HOLD_NOT_FOUND",
90
+ AI_TOKENS_HOLD_EXPIRED: "AI_TOKENS_HOLD_EXPIRED",
91
+ AI_TOKENS_HOLD_ALREADY_SETTLED: "AI_TOKENS_HOLD_ALREADY_SETTLED",
92
+ AI_TOKENS_IDEMPOTENCY_CONFLICT: "AI_TOKENS_IDEMPOTENCY_CONFLICT",
93
+ AI_TOKENS_STREAM_USAGE_MISSING: "AI_TOKENS_STREAM_USAGE_MISSING",
94
+ AI_TOKENS_STORE_ERROR: "AI_TOKENS_STORE_ERROR"
95
+ };
96
+
97
+ // src/shared/constants/ledger-fields.constants.ts
98
+ var AMOUNT_FIELDS = [
99
+ "inputTokens",
100
+ "outputTokens",
101
+ "cacheReadTokens",
102
+ "cacheWrite5mTokens",
103
+ "cacheWrite1hTokens",
104
+ "reasoningTokens",
105
+ "audioInTokens",
106
+ "audioOutTokens",
107
+ "imageInTokens",
108
+ "imageOutTokens",
109
+ "totalTokens",
110
+ "rawCostNanoUsd",
111
+ "surchargeNanoUsd",
112
+ "billedCostNanoUsd"
113
+ ];
114
+ var SETTLEMENT_FIELDS = [
115
+ ...AMOUNT_FIELDS,
116
+ "priceVersionId",
117
+ "priceMissing",
118
+ "markupMultiplier"
119
+ ];
120
+ var RELEASE_FIELDS = ["correlationId", "requestId"];
121
+ var REVERSAL_LINKAGE_FIELD = "reversedByRecordId";
122
+ var SETTLEMENT_PATCH_KEYS = /* @__PURE__ */ new Set([...SETTLEMENT_FIELDS, REVERSAL_LINKAGE_FIELD]);
123
+ var RELEASE_FIELD_SET = new Set(RELEASE_FIELDS);
124
+ function isLegalLedgerTransition(from2, to) {
125
+ if (from2 === "pending") return to === "posted" || to === "released";
126
+ return from2 === "posted" && to === "reversed";
127
+ }
128
+ function isLegalTransitionPatchKey(from2, to, key) {
129
+ if (from2 === "pending" && to === "posted") return SETTLEMENT_PATCH_KEYS.has(key);
130
+ if (from2 === "pending" && to === "released") return RELEASE_FIELD_SET.has(key);
131
+ if (from2 === "posted" && to === "reversed") return key === REVERSAL_LINKAGE_FIELD;
132
+ return false;
133
+ }
134
+
135
+ // src/shared/normalizers/usage-fields.ts
136
+ function asObject(value) {
137
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
138
+ }
139
+ function asArray(value) {
140
+ return Array.isArray(value) ? value : void 0;
141
+ }
142
+ function num(value) {
143
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
144
+ }
145
+ function requireNum(value, provider, field) {
146
+ if (typeof value === "number" && Number.isFinite(value)) return value;
147
+ throw new Error(`${provider}: missing or invalid numeric field "${field}"`);
148
+ }
149
+ function str(value) {
150
+ return typeof value === "string" ? value : void 0;
151
+ }
152
+ function knownServiceTier(value) {
153
+ const candidate = str(value);
154
+ return candidate !== void 0 && SERVICE_TIERS.includes(candidate) ? candidate : void 0;
155
+ }
156
+ function openAiServiceTier(value) {
157
+ const candidate = str(value);
158
+ return knownServiceTier(candidate === "default" ? "standard" : candidate);
159
+ }
160
+ function toolUseCounts(value) {
161
+ const source = asObject(value);
162
+ if (source === void 0) return void 0;
163
+ const counts = {};
164
+ for (const [key, raw] of Object.entries(source)) {
165
+ if (typeof raw === "number" && Number.isFinite(raw)) counts[key] = raw;
166
+ }
167
+ return Object.keys(counts).length > 0 ? counts : void 0;
168
+ }
169
+ function buildUsage(draft) {
170
+ const usage = {
171
+ provider: draft.provider,
172
+ model: draft.model,
173
+ operation: draft.operation,
174
+ inputTokens: draft.inputTokens,
175
+ outputTokens: draft.outputTokens,
176
+ cacheReadTokens: draft.cacheReadTokens ?? 0,
177
+ cacheWrite5mTokens: draft.cacheWrite5mTokens ?? 0,
178
+ cacheWrite1hTokens: draft.cacheWrite1hTokens ?? 0,
179
+ reasoningTokens: draft.reasoningTokens ?? 0,
180
+ audioInTokens: draft.audioInTokens ?? 0,
181
+ audioOutTokens: draft.audioOutTokens ?? 0,
182
+ imageInTokens: draft.imageInTokens ?? 0,
183
+ imageOutTokens: draft.imageOutTokens ?? 0
184
+ };
185
+ if (draft.serviceTier !== void 0) usage.serviceTier = draft.serviceTier;
186
+ if (draft.serverToolUse !== void 0) usage.serverToolUse = draft.serverToolUse;
187
+ if (draft.providerReportedCostNanoUsd !== void 0) {
188
+ usage.providerReportedCostNanoUsd = draft.providerReportedCostNanoUsd;
189
+ }
190
+ if (draft.raw !== void 0) usage.raw = draft.raw;
191
+ return usage;
192
+ }
193
+ function readResponse(raw, provider, usageKey = "usage") {
194
+ const response = asObject(raw) ?? {};
195
+ const usage = asObject(response[usageKey]);
196
+ if (usage === void 0) throw new Error(`${provider}: missing "${usageKey}" object`);
197
+ return { response, usage };
198
+ }
199
+
200
+ // src/shared/normalizers/openai-chat.normalizer.ts
201
+ function normalizeOpenAiChatUsage(raw) {
202
+ const { response, usage } = readResponse(raw, "openai-chat");
203
+ const promptTokens = requireNum(usage.prompt_tokens, "openai-chat", "usage.prompt_tokens");
204
+ const completionTokens = requireNum(usage.completion_tokens, "openai-chat", "usage.completion_tokens");
205
+ const promptDetails = asObject(usage.prompt_tokens_details);
206
+ const completionDetails = asObject(usage.completion_tokens_details);
207
+ const cacheReadTokens = num(promptDetails?.cached_tokens);
208
+ const audioInTokens = num(promptDetails?.audio_tokens);
209
+ const reasoningTokens = num(completionDetails?.reasoning_tokens);
210
+ const audioOutTokens = num(completionDetails?.audio_tokens);
211
+ const serviceTier = openAiServiceTier(response.service_tier);
212
+ return buildUsage({
213
+ provider: "openai",
214
+ model: str(response.model) ?? "",
215
+ operation: "chat",
216
+ inputTokens: promptTokens - cacheReadTokens - audioInTokens,
217
+ outputTokens: completionTokens - reasoningTokens,
218
+ cacheReadTokens,
219
+ reasoningTokens,
220
+ audioInTokens,
221
+ audioOutTokens,
222
+ serviceTier,
223
+ raw: usage
224
+ });
225
+ }
226
+
227
+ // src/shared/normalizers/openai-responses.normalizer.ts
228
+ function normalizeOpenAiResponsesUsage(raw) {
229
+ const { response, usage } = readResponse(raw, "openai-responses");
230
+ const inputTokensTotal = requireNum(usage.input_tokens, "openai-responses", "usage.input_tokens");
231
+ const outputTokensTotal = requireNum(usage.output_tokens, "openai-responses", "usage.output_tokens");
232
+ const inputDetails = asObject(usage.input_tokens_details);
233
+ const outputDetails = asObject(usage.output_tokens_details);
234
+ const cacheReadTokens = num(inputDetails?.cached_tokens);
235
+ const audioInTokens = num(inputDetails?.audio_tokens);
236
+ const reasoningTokens = num(outputDetails?.reasoning_tokens);
237
+ const audioOutTokens = num(outputDetails?.audio_tokens);
238
+ const serviceTier = openAiServiceTier(response.service_tier);
239
+ return buildUsage({
240
+ provider: "openai",
241
+ model: str(response.model) ?? "",
242
+ operation: "responses",
243
+ inputTokens: inputTokensTotal - cacheReadTokens - audioInTokens,
244
+ outputTokens: outputTokensTotal - reasoningTokens,
245
+ cacheReadTokens,
246
+ reasoningTokens,
247
+ audioInTokens,
248
+ audioOutTokens,
249
+ serviceTier,
250
+ raw: usage
251
+ });
252
+ }
253
+
254
+ // src/shared/normalizers/openai-compatible.normalizer.ts
255
+ function normalizeOpenAiCompatibleUsage(raw) {
256
+ const { response, usage } = readResponse(raw, "openai-compatible");
257
+ const promptTokens = requireNum(usage.prompt_tokens, "openai-compatible", "usage.prompt_tokens");
258
+ const completionTokens = requireNum(usage.completion_tokens, "openai-compatible", "usage.completion_tokens");
259
+ const promptDetails = asObject(usage.prompt_tokens_details);
260
+ const completionDetails = asObject(usage.completion_tokens_details);
261
+ const cacheReadTokens = num(usage.prompt_cache_hit_tokens) + num(promptDetails?.cached_tokens);
262
+ const reasoningTokens = num(completionDetails?.reasoning_tokens);
263
+ const serviceTier = openAiServiceTier(response.service_tier);
264
+ return buildUsage({
265
+ provider: "",
266
+ model: str(response.model) ?? "",
267
+ operation: "chat",
268
+ inputTokens: promptTokens - cacheReadTokens,
269
+ outputTokens: completionTokens - reasoningTokens,
270
+ cacheReadTokens,
271
+ reasoningTokens,
272
+ serviceTier,
273
+ raw: usage
274
+ });
275
+ }
276
+
277
+ // src/shared/normalizers/anthropic.normalizer.ts
278
+ function normalizeAnthropicUsage(raw) {
279
+ const { response, usage } = readResponse(raw, "anthropic");
280
+ const inputTokens = requireNum(usage.input_tokens, "anthropic", "usage.input_tokens");
281
+ const outputTokens = requireNum(usage.output_tokens, "anthropic", "usage.output_tokens");
282
+ const cacheReadTokens = num(usage.cache_read_input_tokens);
283
+ const cacheCreation = asObject(usage.cache_creation);
284
+ const cacheWrite5mTokens = cacheCreation ? num(cacheCreation.ephemeral_5m_input_tokens) : num(usage.cache_creation_input_tokens);
285
+ const cacheWrite1hTokens = cacheCreation ? num(cacheCreation.ephemeral_1h_input_tokens) : 0;
286
+ return buildUsage({
287
+ provider: "anthropic",
288
+ model: str(response.model) ?? "",
289
+ operation: "chat",
290
+ inputTokens,
291
+ outputTokens,
292
+ cacheReadTokens,
293
+ cacheWrite5mTokens,
294
+ cacheWrite1hTokens,
295
+ serviceTier: knownServiceTier(usage.service_tier),
296
+ serverToolUse: toolUseCounts(usage.server_tool_use),
297
+ raw: usage
298
+ });
299
+ }
300
+
301
+ // src/shared/normalizers/gemini.normalizer.ts
302
+ function normalizeGeminiUsage(raw) {
303
+ const { response, usage } = readResponse(raw, "gemini", "usageMetadata");
304
+ const promptTokenCount = requireNum(usage.promptTokenCount, "gemini", "usageMetadata.promptTokenCount");
305
+ const candidatesTokenCount = num(usage.candidatesTokenCount);
306
+ const cacheReadTokens = num(usage.cachedContentTokenCount);
307
+ const reasoningTokens = num(usage.thoughtsTokenCount);
308
+ const toolUseInputTokens = num(usage.toolUsePromptTokenCount);
309
+ return buildUsage({
310
+ provider: "gemini",
311
+ model: str(response.modelVersion) ?? str(response.model) ?? "",
312
+ operation: "chat",
313
+ inputTokens: promptTokenCount - cacheReadTokens + toolUseInputTokens,
314
+ outputTokens: candidatesTokenCount,
315
+ cacheReadTokens,
316
+ reasoningTokens,
317
+ raw: usage
318
+ });
319
+ }
320
+
321
+ // src/shared/normalizers/bedrock-converse.normalizer.ts
322
+ function splitCacheWrite(usage) {
323
+ const details = asArray(usage.cacheDetails);
324
+ if (details === void 0) {
325
+ return { cacheWrite5mTokens: num(usage.cacheWriteInputTokens), cacheWrite1hTokens: 0 };
326
+ }
327
+ let cacheWrite5mTokens = 0;
328
+ let cacheWrite1hTokens = 0;
329
+ for (const item of details) {
330
+ const entry = asObject(item);
331
+ if (entry === void 0) continue;
332
+ const count = num(entry.cacheWriteInputTokens);
333
+ if (str(entry.ttl) === "1h") cacheWrite1hTokens += count;
334
+ else cacheWrite5mTokens += count;
335
+ }
336
+ return { cacheWrite5mTokens, cacheWrite1hTokens };
337
+ }
338
+ function normalizeBedrockConverseUsage(raw) {
339
+ const { response, usage } = readResponse(raw, "bedrock-converse");
340
+ const inputTokens = requireNum(usage.inputTokens, "bedrock-converse", "usage.inputTokens");
341
+ const outputTokens = requireNum(usage.outputTokens, "bedrock-converse", "usage.outputTokens");
342
+ const { cacheWrite5mTokens, cacheWrite1hTokens } = splitCacheWrite(usage);
343
+ return buildUsage({
344
+ provider: "bedrock",
345
+ model: str(response.model) ?? "",
346
+ operation: "chat",
347
+ inputTokens,
348
+ outputTokens,
349
+ cacheReadTokens: num(usage.cacheReadInputTokens),
350
+ cacheWrite5mTokens,
351
+ cacheWrite1hTokens,
352
+ raw: usage
353
+ });
354
+ }
355
+
356
+ // src/shared/normalizers/mistral.normalizer.ts
357
+ function normalizeMistralUsage(raw) {
358
+ const { response, usage } = readResponse(raw, "mistral");
359
+ return buildUsage({
360
+ provider: "mistral",
361
+ model: str(response.model) ?? "",
362
+ operation: "chat",
363
+ // Stryker disable next-line StringLiteral -- provider and field names in error messages are internal diagnostics
364
+ inputTokens: requireNum(usage.prompt_tokens, "mistral", "usage.prompt_tokens"),
365
+ // Stryker disable next-line StringLiteral -- provider and field names in error messages are internal diagnostics
366
+ outputTokens: requireNum(usage.completion_tokens, "mistral", "usage.completion_tokens"),
367
+ raw: usage
368
+ });
369
+ }
370
+
371
+ // src/shared/pricing/money.ts
372
+ var NANO_PER_USD = 1000000000n;
373
+ var DEFAULT_DISPLAY_DECIMALS = 6;
374
+ function perMillion(tokens, ratePerMillionNano) {
375
+ return BigInt(tokens) * ratePerMillionNano / 1000000n;
376
+ }
377
+ function floatUsdToNanoUsd(usd) {
378
+ if (!Number.isFinite(usd)) {
379
+ throw new RangeError(`floatUsdToNanoUsd: expected a finite number, received ${String(usd)}`);
380
+ }
381
+ const sign = usd < 0 ? -1n : 1n;
382
+ const abs = Math.abs(usd);
383
+ const wholeDollars = Math.floor(abs);
384
+ const fractionalDollars = abs - wholeDollars;
385
+ const wholeNano = BigInt(wholeDollars) * NANO_PER_USD;
386
+ const fractionalNano = BigInt(Math.round(fractionalDollars * 1e9));
387
+ return sign * (wholeNano + fractionalNano);
388
+ }
389
+ function formatNanoUsd(nanoUsd, opts) {
390
+ const decimals = opts?.decimals ?? DEFAULT_DISPLAY_DECIMALS;
391
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
392
+ throw new RangeError(`formatNanoUsd: decimals must be an integer in [0, 9], received ${String(decimals)}`);
393
+ }
394
+ const currency = opts?.currency ?? "USD";
395
+ const converted = opts?.fxRateNano == null ? nanoUsd : nanoUsd * opts.fxRateNano / NANO_PER_USD;
396
+ const negative = converted < 0n;
397
+ const magnitude = negative ? -converted : converted;
398
+ const nanoPerDisplayUnit = 10n ** BigInt(9 - decimals);
399
+ const displayUnits = (magnitude + nanoPerDisplayUnit / 2n) / nanoPerDisplayUnit;
400
+ const scale = 10n ** BigInt(decimals);
401
+ const integerPart = displayUnits / scale;
402
+ const fractionalPart = displayUnits % scale;
403
+ const fractionalText = decimals > 0 ? `.${fractionalPart.toString().padStart(decimals, "0")}` : "";
404
+ const body = `${integerPart.toString()}${fractionalText}`;
405
+ const signText = negative ? "-" : "";
406
+ return currency === "USD" ? `${signText}$${body}` : `${signText}${body} ${currency}`;
407
+ }
408
+
409
+ // src/shared/normalizers/openrouter.normalizer.ts
410
+ function normalizeOpenRouterUsage(raw) {
411
+ const { response, usage } = readResponse(raw, "openrouter");
412
+ const promptTokens = requireNum(usage.prompt_tokens, "openrouter", "usage.prompt_tokens");
413
+ const completionTokens = requireNum(usage.completion_tokens, "openrouter", "usage.completion_tokens");
414
+ const promptDetails = asObject(usage.prompt_tokens_details);
415
+ const completionDetails = asObject(usage.completion_tokens_details);
416
+ const cacheReadTokens = num(promptDetails?.cached_tokens);
417
+ const cacheWrite5mTokens = num(promptDetails?.cache_write_tokens);
418
+ const reasoningTokens = num(completionDetails?.reasoning_tokens);
419
+ const cost = usage.cost;
420
+ const providerReportedCostNanoUsd = typeof cost === "number" && Number.isFinite(cost) ? floatUsdToNanoUsd(cost) : void 0;
421
+ return buildUsage({
422
+ provider: "openrouter",
423
+ model: str(response.model) ?? "",
424
+ operation: "chat",
425
+ inputTokens: promptTokens - cacheReadTokens - cacheWrite5mTokens,
426
+ outputTokens: completionTokens - reasoningTokens,
427
+ cacheReadTokens,
428
+ cacheWrite5mTokens,
429
+ reasoningTokens,
430
+ serviceTier: openAiServiceTier(response.service_tier),
431
+ providerReportedCostNanoUsd,
432
+ raw: usage
433
+ });
434
+ }
435
+
436
+ // src/shared/normalizers/vercel-ai-sdk.normalizer.ts
437
+ function normalizeVercelAiSdkUsage(raw) {
438
+ const { response, usage } = readResponse(raw, "vercel-ai-sdk");
439
+ const inputTokensTotal = requireNum(usage.inputTokens, "vercel-ai-sdk", "usage.inputTokens");
440
+ const outputTokensTotal = requireNum(usage.outputTokens, "vercel-ai-sdk", "usage.outputTokens");
441
+ const inputDetails = asObject(usage.inputTokenDetails);
442
+ const outputDetails = asObject(usage.outputTokenDetails);
443
+ const cacheReadTokens = num(usage.cachedInputTokens) + num(inputDetails?.cacheReadTokens);
444
+ const cacheWrite5mTokens = num(inputDetails?.cacheWriteTokens);
445
+ const reasoningTokens = num(usage.reasoningTokens) + num(outputDetails?.reasoningTokens);
446
+ return buildUsage({
447
+ provider: "",
448
+ model: str(response.model) ?? "",
449
+ operation: "chat",
450
+ inputTokens: inputTokensTotal - cacheReadTokens - cacheWrite5mTokens,
451
+ outputTokens: outputTokensTotal - reasoningTokens,
452
+ cacheReadTokens,
453
+ cacheWrite5mTokens,
454
+ reasoningTokens,
455
+ raw: usage
456
+ });
457
+ }
458
+
459
+ // src/shared/pricing/compute-cost.ts
460
+ function computeCostNanoUsd(usage, rate) {
461
+ const totalInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens;
462
+ const overTier = rate.tierThresholdTokens != null && totalInput > rate.tierThresholdTokens;
463
+ const inputRate = overTier ? rate.tierInputNanoUsdPerMillion ?? rate.inputNanoUsdPerMillion : rate.inputNanoUsdPerMillion;
464
+ const outputRate = overTier ? rate.tierOutputNanoUsdPerMillion ?? rate.outputNanoUsdPerMillion : rate.outputNanoUsdPerMillion;
465
+ const tokenNanoUsd = perMillion(usage.inputTokens, inputRate) + perMillion(usage.outputTokens, outputRate) + perMillion(usage.cacheReadTokens, rate.cacheReadNanoUsdPerMillion) + perMillion(usage.cacheWrite5mTokens, rate.cacheWrite5mNanoUsdPerMillion) + perMillion(usage.cacheWrite1hTokens, rate.cacheWrite1hNanoUsdPerMillion) + perMillion(usage.reasoningTokens, rate.reasoningNanoUsdPerMillion) + perMillion(usage.audioInTokens, rate.audioInNanoUsdPerMillion) + perMillion(usage.audioOutTokens, rate.audioOutNanoUsdPerMillion) + perMillion(usage.imageInTokens, rate.imageInNanoUsdPerMillion) + perMillion(usage.imageOutTokens, rate.imageOutNanoUsdPerMillion);
466
+ let surchargeNanoUsd = 0n;
467
+ for (const [unit, count] of Object.entries({ ...usage.serverToolUse })) {
468
+ const unitRate = rate.unitRates?.[unit];
469
+ if (unitRate != null) surchargeNanoUsd += BigInt(count) * unitRate;
470
+ }
471
+ return { totalNanoUsd: tokenNanoUsd + surchargeNanoUsd, tokenNanoUsd, surchargeNanoUsd };
472
+ }
473
+
474
+ // src/shared/pricing/apply-markup.ts
475
+ var MARKUP_SCALE = 10000n;
476
+ function assertValidMultiplier(multiplier) {
477
+ if (!Number.isFinite(multiplier) || multiplier <= 0) {
478
+ throw new RangeError(`applyMarkup: multiplier must be finite and > 0, received ${String(multiplier)}`);
479
+ }
480
+ }
481
+ function resolveMultiplier4dp(multiplier) {
482
+ assertValidMultiplier(multiplier);
483
+ return Math.round(multiplier * 1e4) / 1e4;
484
+ }
485
+ function applyMarkup(rawCostNanoUsd, multiplier) {
486
+ assertValidMultiplier(multiplier);
487
+ return rawCostNanoUsd * BigInt(Math.round(multiplier * 1e4)) / MARKUP_SCALE;
488
+ }
489
+
490
+ // src/shared/utils/sha256.ts
491
+ var K_WORDS = [
492
+ 1116352408,
493
+ 1899447441,
494
+ 3049323471,
495
+ 3921009573,
496
+ 961987163,
497
+ 1508970993,
498
+ 2453635748,
499
+ 2870763221,
500
+ 3624381080,
501
+ 310598401,
502
+ 607225278,
503
+ 1426881987,
504
+ 1925078388,
505
+ 2162078206,
506
+ 2614888103,
507
+ 3248222580,
508
+ 3835390401,
509
+ 4022224774,
510
+ 264347078,
511
+ 604807628,
512
+ 770255983,
513
+ 1249150122,
514
+ 1555081692,
515
+ 1996064986,
516
+ 2554220882,
517
+ 2821834349,
518
+ 2952996808,
519
+ 3210313671,
520
+ 3336571891,
521
+ 3584528711,
522
+ 113926993,
523
+ 338241895,
524
+ 666307205,
525
+ 773529912,
526
+ 1294757372,
527
+ 1396182291,
528
+ 1695183700,
529
+ 1986661051,
530
+ 2177026350,
531
+ 2456956037,
532
+ 2730485921,
533
+ 2820302411,
534
+ 3259730800,
535
+ 3345764771,
536
+ 3516065817,
537
+ 3600352804,
538
+ 4094571909,
539
+ 275423344,
540
+ 430227734,
541
+ 506948616,
542
+ 659060556,
543
+ 883997877,
544
+ 958139571,
545
+ 1322822218,
546
+ 1537002063,
547
+ 1747873779,
548
+ 1955562222,
549
+ 2024104815,
550
+ 2227730452,
551
+ 2361852424,
552
+ 2428436474,
553
+ 2756734187,
554
+ 3204031479,
555
+ 3329325298
556
+ ];
557
+ var INITIAL_HASH = [
558
+ 1779033703,
559
+ 3144134277,
560
+ 1013904242,
561
+ 2773480762,
562
+ 1359893119,
563
+ 2600822924,
564
+ 528734635,
565
+ 1541459225
566
+ ];
567
+ var kView = new DataView(new ArrayBuffer(64 * 4));
568
+ K_WORDS.forEach((value, index) => {
569
+ kView.setUint32(index * 4, value);
570
+ });
571
+ function rotr(x, n) {
572
+ return (x >>> n | x << 32 - n) >>> 0;
573
+ }
574
+ function expandSchedule(w) {
575
+ for (let i = 16; i < 64; i++) {
576
+ const w15 = w.getUint32((i - 15) * 4);
577
+ const w2 = w.getUint32((i - 2) * 4);
578
+ const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ w15 >>> 3;
579
+ const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ w2 >>> 10;
580
+ w.setUint32(i * 4, w.getUint32((i - 16) * 4) + s0 + w.getUint32((i - 7) * 4) + s1 >>> 0);
581
+ }
582
+ }
583
+ function compressBlock(h, w) {
584
+ let a = h.getUint32(0);
585
+ let b = h.getUint32(4);
586
+ let c = h.getUint32(8);
587
+ let d = h.getUint32(12);
588
+ let e = h.getUint32(16);
589
+ let f = h.getUint32(20);
590
+ let g = h.getUint32(24);
591
+ let hh = h.getUint32(28);
592
+ for (let i = 0; i < 64; i++) {
593
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
594
+ const ch = e & f ^ ~e & g;
595
+ const t1 = hh + s1 + ch + kView.getUint32(i * 4) + w.getUint32(i * 4) >>> 0;
596
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
597
+ const maj = a & b ^ a & c ^ b & c;
598
+ const t2 = s0 + maj >>> 0;
599
+ hh = g;
600
+ g = f;
601
+ f = e;
602
+ e = d + t1 >>> 0;
603
+ d = c;
604
+ c = b;
605
+ b = a;
606
+ a = t1 + t2 >>> 0;
607
+ }
608
+ const next = [a, b, c, d, e, f, g, hh];
609
+ next.forEach((value, index) => {
610
+ h.setUint32(index * 4, h.getUint32(index * 4) + value >>> 0);
611
+ });
612
+ }
613
+ function sha256Hex(input) {
614
+ const message = new TextEncoder().encode(input);
615
+ const bitLength = message.length * 8;
616
+ const paddedLength = Math.ceil((message.length + 9) / 64) * 64;
617
+ const bytes = new Uint8Array(paddedLength);
618
+ bytes.set(message);
619
+ bytes[message.length] = 128;
620
+ const view = new DataView(bytes.buffer);
621
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 4294967296));
622
+ view.setUint32(paddedLength - 4, bitLength >>> 0);
623
+ const hView = new DataView(new ArrayBuffer(32));
624
+ INITIAL_HASH.forEach((value, index) => {
625
+ hView.setUint32(index * 4, value);
626
+ });
627
+ const wView = new DataView(new ArrayBuffer(64 * 4));
628
+ for (let offset = 0; offset < paddedLength; offset += 64) {
629
+ for (let i = 0; i < 16; i++) wView.setUint32(i * 4, view.getUint32(offset + i * 4));
630
+ expandSchedule(wView);
631
+ compressBlock(hView, wView);
632
+ }
633
+ let hex = "";
634
+ for (let i = 0; i < 8; i++) hex += hView.getUint32(i * 4).toString(16).padStart(8, "0");
635
+ return hex;
636
+ }
637
+
638
+ // src/shared/utils/idempotency.ts
639
+ function canonicalize(value) {
640
+ if (value === null || value === void 0) return "null";
641
+ if (typeof value === "bigint") return JSON.stringify(`${value.toString()}n`);
642
+ if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
643
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
644
+ if (value instanceof Date) return JSON.stringify(value.toISOString());
645
+ if (Array.isArray(value)) {
646
+ return `[${value.map((element) => canonicalize(element)).join(",")}]`;
647
+ }
648
+ if (typeof value === "object") {
649
+ const record = value;
650
+ const members = Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`);
651
+ return `{${members.join(",")}}`;
652
+ }
653
+ return "null";
654
+ }
655
+ function deriveIdempotencyKey(payload) {
656
+ return sha256Hex(canonicalize(payload));
657
+ }
658
+
659
+ // src/server/errors/ai-tokens-error-messages.ts
660
+ var AI_TOKENS_ERROR_MESSAGES = {
661
+ AI_TOKENS_NOT_CONFIGURED: "The AI tokens module was invoked before its asynchronous configuration finished initializing.",
662
+ AI_TOKENS_INVALID_CONFIG: "The AI tokens module configuration is invalid; check markup, limits, currency/fx, and store port methods.",
663
+ AI_TOKENS_UNKNOWN_PROVIDER: "Raw usage was provided without a preset or normalizer and is not already a NormalizedUsage.",
664
+ AI_TOKENS_USAGE_MALFORMED: "The provider usage payload is missing required token fields.",
665
+ AI_TOKENS_PRICE_NOT_FOUND: "No effective-dated price was found for the requested model, operation, and service tier.",
666
+ AI_TOKENS_FX_REQUIRED: "A non-USD presentation currency requires an fx resolver, which is not configured.",
667
+ AI_TOKENS_BUDGET_EXCEEDED: "A hard spend budget blocks this call.",
668
+ AI_TOKENS_QUOTA_EXCEEDED: "A hard token or operation-count quota blocks this call.",
669
+ AI_TOKENS_INSUFFICIENT_CREDITS: "The wallet balance, including any overdraft, is below the required amount.",
670
+ AI_TOKENS_HOLD_NOT_FOUND: "The referenced hold does not exist for this tenant and scope.",
671
+ AI_TOKENS_HOLD_EXPIRED: "The referenced hold expired and was swept; retry via record().",
672
+ AI_TOKENS_HOLD_ALREADY_SETTLED: "The referenced hold was already released and cannot be captured.",
673
+ AI_TOKENS_IDEMPOTENCY_CONFLICT: "The idempotency key was reused with a different payload, or the record was already reversed.",
674
+ AI_TOKENS_STREAM_USAGE_MISSING: "The stream ended without provider usage and no tokenizer fallback was available.",
675
+ AI_TOKENS_STORE_ERROR: "The persistence adapter raised an unexpected error."
676
+ };
677
+ var AI_TOKENS_ERROR_STATUS = {
678
+ AI_TOKENS_NOT_CONFIGURED: HttpStatus.SERVICE_UNAVAILABLE,
679
+ AI_TOKENS_INVALID_CONFIG: HttpStatus.INTERNAL_SERVER_ERROR,
680
+ AI_TOKENS_UNKNOWN_PROVIDER: HttpStatus.BAD_REQUEST,
681
+ AI_TOKENS_USAGE_MALFORMED: HttpStatus.UNPROCESSABLE_ENTITY,
682
+ AI_TOKENS_PRICE_NOT_FOUND: HttpStatus.UNPROCESSABLE_ENTITY,
683
+ AI_TOKENS_FX_REQUIRED: HttpStatus.INTERNAL_SERVER_ERROR,
684
+ AI_TOKENS_BUDGET_EXCEEDED: HttpStatus.PAYMENT_REQUIRED,
685
+ AI_TOKENS_QUOTA_EXCEEDED: HttpStatus.TOO_MANY_REQUESTS,
686
+ AI_TOKENS_INSUFFICIENT_CREDITS: HttpStatus.PAYMENT_REQUIRED,
687
+ AI_TOKENS_HOLD_NOT_FOUND: HttpStatus.NOT_FOUND,
688
+ AI_TOKENS_HOLD_EXPIRED: HttpStatus.GONE,
689
+ AI_TOKENS_HOLD_ALREADY_SETTLED: HttpStatus.CONFLICT,
690
+ AI_TOKENS_IDEMPOTENCY_CONFLICT: HttpStatus.CONFLICT,
691
+ AI_TOKENS_STREAM_USAGE_MISSING: HttpStatus.UNPROCESSABLE_ENTITY,
692
+ AI_TOKENS_STORE_ERROR: HttpStatus.BAD_GATEWAY
693
+ };
694
+
695
+ // src/server/errors/ai-tokens-exception.ts
696
+ var AiTokensException = class extends HttpException {
697
+ /**
698
+ * @param code The error code.
699
+ * @param statusCode The HTTP status; defaults to the code's mapped status.
700
+ * @param details Optional structured context (never includes prompt/response text).
701
+ */
702
+ constructor(code, statusCode = AI_TOKENS_ERROR_STATUS[code], details) {
703
+ super({ error: { code, message: AI_TOKENS_ERROR_MESSAGES[code], details } }, statusCode);
704
+ }
705
+ };
706
+
707
+ // src/server/config/validate-options.ts
708
+ var LEDGER_METHODS = [
709
+ "append",
710
+ "transition",
711
+ "findByIdempotencyKey",
712
+ "findById",
713
+ "findExpiredHolds",
714
+ "query",
715
+ "sumCost",
716
+ "lastHash"
717
+ ];
718
+ var PRICING_METHODS = ["resolveRate", "upsertPrice", "getPriceHistory", "listModels"];
719
+ var WALLET_METHODS = [
720
+ "getWallet",
721
+ "appendEntry",
722
+ "conditionalDebit",
723
+ "openGrants",
724
+ "listEntries",
725
+ "reconcile"
726
+ ];
727
+ var BUDGET_METHODS = [
728
+ "upsert",
729
+ "remove",
730
+ "findBudgetById",
731
+ "findMatching",
732
+ "conditionalConsume",
733
+ "adjustWindow",
734
+ "getWindow",
735
+ "setWindowStart"
736
+ ];
737
+ function invalidConfig(reason) {
738
+ return new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason });
739
+ }
740
+ function firstMissingMethod(store, methods) {
741
+ const record = store;
742
+ return methods.find((method) => typeof record[method] !== "function");
743
+ }
744
+ function validateStorePorts(options) {
745
+ const store = options.store;
746
+ if (store === void 0 || store === null) throw invalidConfig("options.store is required");
747
+ const missingLedger = firstMissingMethod(options.store, LEDGER_METHODS);
748
+ if (missingLedger !== void 0) {
749
+ throw invalidConfig(`store is missing the required ledger method "${missingLedger}"`);
750
+ }
751
+ const missingPricing = firstMissingMethod(options.store, PRICING_METHODS);
752
+ if (missingPricing !== void 0) {
753
+ throw invalidConfig(`store is missing the required pricing method "${missingPricing}"`);
754
+ }
755
+ }
756
+ function validateFeaturePorts(options) {
757
+ if (options.wallets !== void 0) {
758
+ const missing = firstMissingMethod(options.store, WALLET_METHODS);
759
+ if (missing !== void 0) throw invalidConfig(`wallets enabled but store is missing "${missing}"`);
760
+ }
761
+ if (options.budgets !== void 0) {
762
+ const missing = firstMissingMethod(options.store, BUDGET_METHODS);
763
+ if (missing !== void 0) throw invalidConfig(`budgets enabled but store is missing "${missing}"`);
764
+ }
765
+ }
766
+ function validateCurrency(options) {
767
+ if (options.currency !== void 0 && options.currency !== "USD" && options.fx === void 0) {
768
+ throw new AiTokensException("AI_TOKENS_FX_REQUIRED", void 0, { currency: options.currency });
769
+ }
770
+ }
771
+ function validateMarkup(markup) {
772
+ if (markup === void 0) return;
773
+ if (typeof markup === "number") {
774
+ try {
775
+ resolveMultiplier4dp(markup);
776
+ } catch {
777
+ throw invalidConfig(`markup must be a finite number greater than 0, received ${String(markup)}`);
778
+ }
779
+ return;
780
+ }
781
+ if (typeof markup.resolve !== "function") {
782
+ throw invalidConfig("markup policy must implement a resolve() method");
783
+ }
784
+ }
785
+ function validateBudgetThresholds(options) {
786
+ const thresholds = options.budgets?.alertThresholds;
787
+ if (thresholds === void 0) return;
788
+ for (const threshold of thresholds) {
789
+ if (!(threshold > 0 && threshold <= 1)) {
790
+ throw invalidConfig(`budget alert thresholds must be within (0, 1], received ${String(threshold)}`);
791
+ }
792
+ }
793
+ }
794
+ function validateHolds(options) {
795
+ const holds = options.holds;
796
+ if (holds === void 0) return;
797
+ if (holds.ttlSeconds !== void 0 && holds.ttlSeconds <= 0) {
798
+ throw invalidConfig("holds.ttlSeconds must be greater than 0");
799
+ }
800
+ if (holds.reaperIntervalSeconds !== void 0 && holds.reaperIntervalSeconds <= 0) {
801
+ throw invalidConfig("holds.reaperIntervalSeconds must be greater than 0");
802
+ }
803
+ }
804
+ function validatePricing(options) {
805
+ const cacheTtlMs = options.pricing?.cacheTtlMs;
806
+ if (cacheTtlMs === void 0) return;
807
+ if (!Number.isInteger(cacheTtlMs) || cacheTtlMs <= 0) {
808
+ throw invalidConfig(
809
+ // Stryker disable next-line StringLiteral -- error reason template is internal diagnostics
810
+ `pricing.cacheTtlMs must be a positive integer number of milliseconds, received ${String(cacheTtlMs)}`
811
+ );
812
+ }
813
+ }
814
+ function validateWallets(options) {
815
+ const wallets = options.wallets;
816
+ if (wallets === void 0) return;
817
+ if (wallets.creditRateNanoUsd !== void 0 && wallets.creditRateNanoUsd <= 0n) {
818
+ throw invalidConfig("wallets.creditRateNanoUsd must be greater than 0");
819
+ }
820
+ if (wallets.overdraftNanoUsd !== void 0 && wallets.overdraftNanoUsd < 0n) {
821
+ throw invalidConfig("wallets.overdraftNanoUsd must not be negative");
822
+ }
823
+ }
824
+ function validateOptions(options) {
825
+ validateStorePorts(options);
826
+ validateFeaturePorts(options);
827
+ validateCurrency(options);
828
+ validateMarkup(options.markup);
829
+ validateBudgetThresholds(options);
830
+ validateHolds(options);
831
+ validateWallets(options);
832
+ validatePricing(options);
833
+ }
834
+
835
+ // src/server/config/default-options.constants.ts
836
+ var DEFAULT_AI_TOKENS_OPTIONS = {
837
+ ratingMode: "rate-table",
838
+ currency: "USD",
839
+ markup: 1,
840
+ pricing: { seedFromSnapshot: true, strict: true, cacheTtlMs: 3e5 },
841
+ wallets: { creditRateNanoUsd: 1000000000n, overdraftNanoUsd: 0n, burnOrder: "expiry" },
842
+ budgets: { defaultPolicy: "block", alertThresholds: [0.8, 1], failClosed: true },
843
+ holds: { ttlSeconds: 3600, reaperIntervalSeconds: 300 },
844
+ ledger: { hashChain: false },
845
+ events: { emitter: true },
846
+ telemetry: { metrics: true },
847
+ reporting: { maxExportRows: 1e6 },
848
+ content: { ttlSeconds: 604800 }
849
+ };
850
+
851
+ // src/server/config/apply-defaults.ts
852
+ function resolveMarkup(markup) {
853
+ if (markup === void 0) return DEFAULT_AI_TOKENS_OPTIONS.markup;
854
+ if (typeof markup === "number") return resolveMultiplier4dp(markup);
855
+ return markup;
856
+ }
857
+ function resolvePricing(pricing) {
858
+ return {
859
+ seedFromSnapshot: pricing?.seedFromSnapshot ?? DEFAULT_AI_TOKENS_OPTIONS.pricing.seedFromSnapshot,
860
+ strict: pricing?.strict ?? DEFAULT_AI_TOKENS_OPTIONS.pricing.strict,
861
+ cacheTtlMs: pricing?.cacheTtlMs ?? DEFAULT_AI_TOKENS_OPTIONS.pricing.cacheTtlMs,
862
+ modelAliases: pricing?.modelAliases ?? {}
863
+ };
864
+ }
865
+ function resolveWallets(wallets) {
866
+ if (wallets === void 0) return { enabled: false };
867
+ return {
868
+ enabled: true,
869
+ creditRateNanoUsd: wallets.creditRateNanoUsd ?? DEFAULT_AI_TOKENS_OPTIONS.wallets.creditRateNanoUsd,
870
+ overdraftNanoUsd: wallets.overdraftNanoUsd ?? DEFAULT_AI_TOKENS_OPTIONS.wallets.overdraftNanoUsd,
871
+ burnOrder: wallets.burnOrder ?? DEFAULT_AI_TOKENS_OPTIONS.wallets.burnOrder
872
+ };
873
+ }
874
+ function resolveBudgets(budgets) {
875
+ if (budgets === void 0) return { enabled: false };
876
+ return {
877
+ enabled: true,
878
+ defaultPolicy: budgets.defaultPolicy ?? DEFAULT_AI_TOKENS_OPTIONS.budgets.defaultPolicy,
879
+ alertThresholds: budgets.alertThresholds ?? DEFAULT_AI_TOKENS_OPTIONS.budgets.alertThresholds,
880
+ failClosed: budgets.failClosed ?? DEFAULT_AI_TOKENS_OPTIONS.budgets.failClosed,
881
+ counter: budgets.counter,
882
+ onThrottle: budgets.onThrottle
883
+ };
884
+ }
885
+ function resolveTelemetry(telemetry) {
886
+ if (telemetry?.sink === void 0) return { enabled: false };
887
+ return { enabled: true, sink: telemetry.sink, metrics: telemetry.metrics ?? DEFAULT_AI_TOKENS_OPTIONS.telemetry.metrics };
888
+ }
889
+ function resolveContent(content) {
890
+ if (content === void 0) return { enabled: false };
891
+ return {
892
+ enabled: true,
893
+ store: content.store,
894
+ mask: content.mask,
895
+ ttlSeconds: content.ttlSeconds ?? DEFAULT_AI_TOKENS_OPTIONS.content.ttlSeconds
896
+ };
897
+ }
898
+ function resolveEvents(events) {
899
+ return { emitter: events?.emitter ?? DEFAULT_AI_TOKENS_OPTIONS.events.emitter, sink: events?.sink };
900
+ }
901
+ function resolveHolds(holds) {
902
+ return {
903
+ ttlSeconds: holds?.ttlSeconds ?? DEFAULT_AI_TOKENS_OPTIONS.holds.ttlSeconds,
904
+ reaperIntervalSeconds: holds?.reaperIntervalSeconds ?? DEFAULT_AI_TOKENS_OPTIONS.holds.reaperIntervalSeconds
905
+ };
906
+ }
907
+ function resolveLedger(ledger) {
908
+ return { hashChain: ledger?.hashChain ?? DEFAULT_AI_TOKENS_OPTIONS.ledger.hashChain };
909
+ }
910
+ function resolveReporting(reporting) {
911
+ return { maxExportRows: reporting?.maxExportRows ?? DEFAULT_AI_TOKENS_OPTIONS.reporting.maxExportRows };
912
+ }
913
+ function applyDefaults(options) {
914
+ return Object.freeze({
915
+ store: options.store,
916
+ scopeResolver: options.scopeResolver,
917
+ ratingMode: options.ratingMode ?? DEFAULT_AI_TOKENS_OPTIONS.ratingMode,
918
+ currency: options.currency ?? DEFAULT_AI_TOKENS_OPTIONS.currency,
919
+ fx: options.fx,
920
+ markup: resolveMarkup(options.markup),
921
+ pricing: resolvePricing(options.pricing),
922
+ wallets: resolveWallets(options.wallets),
923
+ budgets: resolveBudgets(options.budgets),
924
+ holds: resolveHolds(options.holds),
925
+ ledger: resolveLedger(options.ledger),
926
+ tokenizer: options.tokenizer,
927
+ events: resolveEvents(options.events),
928
+ telemetry: resolveTelemetry(options.telemetry),
929
+ reporting: resolveReporting(options.reporting),
930
+ content: resolveContent(options.content)
931
+ });
932
+ }
933
+
934
+ // src/server/utils/to-json-safe.ts
935
+ function convert(value) {
936
+ if (typeof value === "bigint") return value.toString();
937
+ if (value instanceof Date) return new Date(value.getTime());
938
+ if (Array.isArray(value)) return value.map((element) => convert(element));
939
+ if (typeof value === "object" && value !== null) {
940
+ const output = {};
941
+ for (const [key, member] of Object.entries(value)) output[key] = convert(member);
942
+ return output;
943
+ }
944
+ return value;
945
+ }
946
+ function toJsonSafe(value) {
947
+ return convert(value);
948
+ }
949
+
950
+ // src/server/events/event-emitter.bridge.ts
951
+ async function resolveEmitterChannel(moduleRef, enabled) {
952
+ if (!enabled) return null;
953
+ try {
954
+ const emitterModule = await import('@nestjs/event-emitter');
955
+ const emitter = moduleRef.get(emitterModule.EventEmitter2, { strict: false });
956
+ return {
957
+ emit: (type, envelope) => {
958
+ emitter.emit(type, envelope);
959
+ }
960
+ };
961
+ } catch {
962
+ return null;
963
+ }
964
+ }
965
+
966
+ // src/server/events/event-dispatcher.ts
967
+ var EventDispatcher = class {
968
+ /**
969
+ * @param moduleRef The DI container, used to resolve the optional emitter peer.
970
+ * @param options The resolved options carrying the events configuration.
971
+ */
972
+ constructor(moduleRef, options) {
973
+ this.moduleRef = moduleRef;
974
+ this.options = options;
975
+ }
976
+ moduleRef;
977
+ options;
978
+ logger = new Logger(EventDispatcher.name);
979
+ emitterChannel = null;
980
+ /** Resolve the optional emitter channel once, at module init. */
981
+ async onModuleInit() {
982
+ this.emitterChannel = await resolveEmitterChannel(this.moduleRef, this.options.events.emitter);
983
+ }
984
+ /**
985
+ * Build and deliver one event on every configured channel. Never throws. The
986
+ * `data` payload is typed at each call site against the §12.2 catalog.
987
+ *
988
+ * @param type The event type.
989
+ * @param tenantId The owning tenant.
990
+ * @param scope The optional payer scope.
991
+ * @param data The event payload.
992
+ */
993
+ async emit(type, tenantId, scope, data) {
994
+ const envelope = {
995
+ id: randomUUID(),
996
+ type,
997
+ occurredAt: /* @__PURE__ */ new Date(),
998
+ tenantId,
999
+ ...scope !== void 0 ? { scope } : {},
1000
+ data
1001
+ };
1002
+ this.emitViaEmitter(envelope);
1003
+ await this.emitViaSink(envelope);
1004
+ }
1005
+ /**
1006
+ * Emit an admin-plane audit event (§14.4). The tenant is read from `details`.
1007
+ *
1008
+ * @param action The admin action performed.
1009
+ * @param details Structured context (never prompt/response text).
1010
+ */
1011
+ audit(action, details) {
1012
+ const tenantId = typeof details.tenantId === "string" ? details.tenantId : "";
1013
+ const data = { action, details };
1014
+ return this.emit("ai_tokens.audit", tenantId, void 0, data);
1015
+ }
1016
+ /** Deliver on the in-process emitter channel (bigints intact); log a listener error. */
1017
+ emitViaEmitter(envelope) {
1018
+ try {
1019
+ this.emitterChannel?.emit(envelope.type, envelope);
1020
+ } catch {
1021
+ this.logger.error(`event emitter listener failed for ${envelope.type} (${envelope.id})`);
1022
+ }
1023
+ }
1024
+ /** Deliver on the programmatic sink (bigints as decimal strings); log a delivery failure. */
1025
+ async emitViaSink(envelope) {
1026
+ const { sink } = this.options.events;
1027
+ if (sink === void 0) return;
1028
+ try {
1029
+ await sink.deliver(toJsonSafe(envelope));
1030
+ } catch {
1031
+ this.logger.error(`event sink delivery failed for ${envelope.type} (${envelope.id})`);
1032
+ }
1033
+ }
1034
+ };
1035
+ EventDispatcher = __decorateClass([
1036
+ Injectable()
1037
+ ], EventDispatcher);
1038
+
1039
+ // src/server/events/event-hooks.ts
1040
+ function scopeOfWallet(ref) {
1041
+ return { type: ref.ownerType, id: ref.ownerId };
1042
+ }
1043
+ function createMeteringEventHooks(dispatcher) {
1044
+ return {
1045
+ usageRecorded: (record) => {
1046
+ const data = {
1047
+ usageRecordId: record.id,
1048
+ feature: record.feature,
1049
+ provider: record.provider,
1050
+ model: record.model,
1051
+ serviceTier: record.serviceTier,
1052
+ totalTokens: record.totalTokens,
1053
+ rawCostNanoUsd: record.rawCostNanoUsd,
1054
+ billedCostNanoUsd: record.billedCostNanoUsd,
1055
+ enforced: record.enforced,
1056
+ isSystemCost: record.isSystemCost
1057
+ };
1058
+ return dispatcher.emit("ai_tokens.usage.recorded", record.tenantId, record.scope, data);
1059
+ },
1060
+ priceMissing: (record) => {
1061
+ const data = {
1062
+ provider: record.provider,
1063
+ model: record.model,
1064
+ operation: record.operation,
1065
+ serviceTier: record.serviceTier,
1066
+ usageRecordId: record.id
1067
+ };
1068
+ return dispatcher.emit("ai_tokens.price.missing", record.tenantId, record.scope, data);
1069
+ },
1070
+ holdReleased: (record, reason, expired) => {
1071
+ const data = { holdId: record.id, reason, expired };
1072
+ return dispatcher.emit("ai_tokens.hold.released", record.tenantId, record.scope, data);
1073
+ },
1074
+ usageReversed: (original, reversalRecordId, reason) => {
1075
+ const data = { usageRecordId: original.id, reversalRecordId, reason };
1076
+ return dispatcher.emit("ai_tokens.usage.reversed", original.tenantId, original.scope, data);
1077
+ },
1078
+ audit: (action, details) => dispatcher.audit(action, details)
1079
+ };
1080
+ }
1081
+ function createLedgerAuditHook(dispatcher) {
1082
+ return (action, details) => {
1083
+ void dispatcher.audit(action, details);
1084
+ };
1085
+ }
1086
+ function createWalletEventHooks(dispatcher) {
1087
+ return {
1088
+ granted: (ref, data) => dispatcher.emit("ai_tokens.wallet.granted", ref.tenantId, scopeOfWallet(ref), data),
1089
+ depleted: (ref, data) => dispatcher.emit("ai_tokens.wallet.depleted", ref.tenantId, scopeOfWallet(ref), data),
1090
+ audit: (action, details) => dispatcher.audit(action, details)
1091
+ };
1092
+ }
1093
+ function createBudgetEventHooks(dispatcher) {
1094
+ return {
1095
+ thresholdCrossed: (tenantId, scope, data) => dispatcher.emit("ai_tokens.budget.threshold_crossed", tenantId, scope, data),
1096
+ exceeded: (tenantId, scope, data) => dispatcher.emit("ai_tokens.budget.exceeded", tenantId, scope, data),
1097
+ projectedExceeded: (tenantId, scope, data) => dispatcher.emit("ai_tokens.budget.projected_exceeded", tenantId, scope, data),
1098
+ audit: (action, details) => dispatcher.audit(action, details)
1099
+ };
1100
+ }
1101
+ var METER_METADATA = "bymax:ai-tokens:meter";
1102
+ var REQUIRE_BUDGET_METADATA = "bymax:ai-tokens:require-budget";
1103
+ var AI_FEATURE_METADATA = "bymax:ai-tokens:ai-feature";
1104
+ function Meter(config) {
1105
+ return SetMetadata(METER_METADATA, config);
1106
+ }
1107
+ function RequireBudget(config = {}) {
1108
+ return SetMetadata(REQUIRE_BUDGET_METADATA, config);
1109
+ }
1110
+ function AiFeature(name) {
1111
+ return SetMetadata(AI_FEATURE_METADATA, name);
1112
+ }
1113
+
1114
+ // src/server/enforcement/budget.guard.ts
1115
+ var BudgetGuard = class {
1116
+ /**
1117
+ * @param budgets The budget service (status check).
1118
+ * @param metering The metering facade (hold placement in `@RequireBudget.estimate` mode).
1119
+ * @param reflector The NestJS reflector (decorator metadata).
1120
+ * @param options The resolved options carrying the required `scopeResolver`.
1121
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when `scopeResolver` is absent (fail-fast at init).
1122
+ */
1123
+ constructor(budgets, metering, reflector, options) {
1124
+ this.budgets = budgets;
1125
+ this.metering = metering;
1126
+ this.reflector = reflector;
1127
+ if (options.scopeResolver === void 0) {
1128
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, {
1129
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
1130
+ reason: "BudgetGuard requires options.scopeResolver to resolve the caller scope"
1131
+ });
1132
+ }
1133
+ this.scopeResolver = options.scopeResolver;
1134
+ }
1135
+ budgets;
1136
+ metering;
1137
+ reflector;
1138
+ scopeResolver;
1139
+ /**
1140
+ * Resolve the scope, merge decorator config, and block on any exhausted hard
1141
+ * budget; otherwise enrich the request and allow.
1142
+ *
1143
+ * @param executionContext The request execution context.
1144
+ * @returns `true` when the request may proceed.
1145
+ * @throws {AiTokensException} `AI_TOKENS_BUDGET_EXCEEDED` / `AI_TOKENS_QUOTA_EXCEEDED` when a hard budget is exhausted (or an estimate shortfall from the placed hold).
1146
+ */
1147
+ async canActivate(executionContext) {
1148
+ const context = await this.scopeResolver(executionContext);
1149
+ const targets = [executionContext.getHandler(), executionContext.getClass()];
1150
+ const meter = this.reflector.getAllAndOverride(METER_METADATA, targets);
1151
+ const requireBudget = this.reflector.getAllAndOverride(REQUIRE_BUDGET_METADATA, targets);
1152
+ const aiFeature = this.reflector.getAllAndOverride(AI_FEATURE_METADATA, targets);
1153
+ const feature = requireBudget?.feature ?? meter?.feature ?? aiFeature ?? context.feature;
1154
+ const statuses = await this.budgets.status(context.tenantId, context.scope);
1155
+ for (const status of statuses) {
1156
+ const error = exhaustedError(status, feature);
1157
+ if (error !== null) throw error;
1158
+ }
1159
+ const meterContext = { ...context, feature, ...meter?.preset !== void 0 ? { preset: meter.preset } : {} };
1160
+ const hold = requireBudget?.estimate !== void 0 ? await this.metering.hold(meterContext, requireBudget.estimate) : void 0;
1161
+ const request = executionContext.switchToHttp().getRequest();
1162
+ request.aiTokens = { status: statuses, context: meterContext, ...hold !== void 0 ? { hold } : {} };
1163
+ return true;
1164
+ }
1165
+ };
1166
+ BudgetGuard = __decorateClass([
1167
+ Injectable(),
1168
+ __decorateParam(3, Inject(BYMAX_AI_TOKENS_OPTIONS))
1169
+ ], BudgetGuard);
1170
+ function featureMatches(features, feature) {
1171
+ return features === void 0 || features.length === 0 || features.includes(feature);
1172
+ }
1173
+ function exhaustedError(status, feature) {
1174
+ if (status.policy !== "block" || !featureMatches(status.features, feature)) return null;
1175
+ if (status.limit.nanoUsd !== void 0 && status.spent.nanoUsd >= status.limit.nanoUsd) {
1176
+ return new AiTokensException("AI_TOKENS_BUDGET_EXCEEDED", void 0, { budgetId: status.budgetId, dimension: "cost" });
1177
+ }
1178
+ if (status.limit.tokens !== void 0 && status.spent.tokens >= status.limit.tokens) {
1179
+ return new AiTokensException("AI_TOKENS_QUOTA_EXCEEDED", void 0, { budgetId: status.budgetId, dimension: "tokens" });
1180
+ }
1181
+ if (status.limit.count !== void 0 && status.spent.count >= status.limit.count) {
1182
+ return new AiTokensException("AI_TOKENS_QUOTA_EXCEEDED", void 0, { budgetId: status.budgetId, dimension: "count" });
1183
+ }
1184
+ return null;
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
+
1421
+ // src/server/utils/model-id.ts
1422
+ function normalizeModelId(id) {
1423
+ return id.trim().replace(/^models\//, "").replace(/^(us|eu|apac|ap|sa|ca|me|af)\./i, "").replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, "").toLowerCase();
1424
+ }
1425
+
1426
+ // src/server/services/pricing.service.ts
1427
+ var SEED_EFFECTIVE_FROM = /* @__PURE__ */ new Date(0);
1428
+ var SEED_LOCK_KEY = "ai-tokens:model-prices";
1429
+ var PricingService = class {
1430
+ /**
1431
+ * @param options The resolved module options (pricing config lives here).
1432
+ * @param store The pricing store port.
1433
+ * @param now A clock returning epoch milliseconds; injected for testability.
1434
+ */
1435
+ constructor(options, store, now = () => Date.now()) {
1436
+ this.options = options;
1437
+ this.store = store;
1438
+ this.now = now;
1439
+ }
1440
+ options;
1441
+ store;
1442
+ now;
1443
+ cache = /* @__PURE__ */ new Map();
1444
+ /** Seed the registry on boot when enabled. */
1445
+ async onModuleInit() {
1446
+ if (this.options.pricing.seedFromSnapshot) await this.seedFromSnapshot();
1447
+ }
1448
+ /**
1449
+ * Resolve the price version in effect at `input.at`, applying the §6.6 chain.
1450
+ *
1451
+ * @param input The provider/model/operation/timestamp (+ optional tier/baseModel).
1452
+ * @returns The matching price version, or `null` in non-strict mode on a miss.
1453
+ * @throws {AiTokensException} `AI_TOKENS_PRICE_NOT_FOUND` on a miss in strict mode.
1454
+ */
1455
+ async resolveRate(input) {
1456
+ const serviceTier = input.serviceTier ?? "standard";
1457
+ const operation = input.operation === "responses" ? "chat" : input.operation;
1458
+ const bucket = Math.floor(input.at.getTime() / this.options.pricing.cacheTtlMs);
1459
+ const cacheKey = `${input.provider}|${input.model}|${input.baseModel ?? ""}|${operation}|${serviceTier}|${String(bucket)}`;
1460
+ let resolved;
1461
+ const cached = this.cache.get(cacheKey);
1462
+ if (cached !== void 0 && this.now() < cached.expiresAt) {
1463
+ resolved = cached.value;
1464
+ } else {
1465
+ resolved = await this.resolveUncached(input.provider, input.model, operation, serviceTier, input.at, input.baseModel);
1466
+ this.cache.set(cacheKey, { value: resolved, expiresAt: this.now() + this.options.pricing.cacheTtlMs });
1467
+ }
1468
+ if (resolved === null && this.options.pricing.strict) {
1469
+ throw new AiTokensException("AI_TOKENS_PRICE_NOT_FOUND", void 0, {
1470
+ provider: input.provider,
1471
+ model: input.model,
1472
+ operation: input.operation,
1473
+ serviceTier
1474
+ });
1475
+ }
1476
+ return resolved;
1477
+ }
1478
+ /**
1479
+ * Close the current open row and insert a new one; clears the resolution cache.
1480
+ * ADMIN PLANE: this sets prices — the host MUST restrict it to privileged roles
1481
+ * (§14.4) and should audit every call.
1482
+ *
1483
+ * @param input The new price version (rates default to `0n`, tier to `'standard'`).
1484
+ * @returns The newly-inserted open price row.
1485
+ */
1486
+ async upsertPrice(input) {
1487
+ const row = await this.store.upsertPrice(input);
1488
+ this.cache.clear();
1489
+ return row;
1490
+ }
1491
+ /** Full effective-dated history for a tuple. */
1492
+ getPriceHistory(provider, model, operation, serviceTier) {
1493
+ return this.store.getPriceHistory(provider, model, operation, serviceTier);
1494
+ }
1495
+ /** Seed the registry from the pinned snapshot; idempotent and advisory-locked. */
1496
+ async seedFromSnapshot() {
1497
+ const lockable = this.store;
1498
+ if (typeof lockable.acquireSeedLock === "function") {
1499
+ const acquired = await lockable.acquireSeedLock(SEED_LOCK_KEY);
1500
+ if (!acquired) return;
1501
+ }
1502
+ const { MODEL_PRICES_SEED } = await import('@bymax-one/nest-ai-tokens/prices');
1503
+ for (const row of MODEL_PRICES_SEED) {
1504
+ await this.store.upsertPrice({ ...row, effectiveFrom: SEED_EFFECTIVE_FROM });
1505
+ }
1506
+ this.cache.clear();
1507
+ }
1508
+ /** The §6.6 six-step model-resolution chain (exact → baseModel → alias → normalized → prefix). */
1509
+ async resolveUncached(provider, model, operation, serviceTier, at, baseModel) {
1510
+ const exact = await this.store.resolveRate(provider, model, operation, serviceTier, at);
1511
+ if (exact !== null) return exact;
1512
+ if (baseModel !== void 0) {
1513
+ const byBase = await this.store.resolveRate(provider, baseModel, operation, serviceTier, at);
1514
+ if (byBase !== null) return byBase;
1515
+ }
1516
+ const alias = this.options.pricing.modelAliases[model];
1517
+ if (alias !== void 0) {
1518
+ const byAlias = await this.store.resolveRate(provider, alias, operation, serviceTier, at);
1519
+ if (byAlias !== null) return byAlias;
1520
+ }
1521
+ const normalized = normalizeModelId(model);
1522
+ if (normalized !== model) {
1523
+ const byNormalized = await this.store.resolveRate(provider, normalized, operation, serviceTier, at);
1524
+ if (byNormalized !== null) return byNormalized;
1525
+ }
1526
+ return this.resolveByLongestPrefix(provider, normalized, operation, serviceTier, at);
1527
+ }
1528
+ /** §6.6 step 5: longest-`startsWith` match among priced models for provider+operation+tier. */
1529
+ async resolveByLongestPrefix(provider, normalizedModel, operation, serviceTier, at) {
1530
+ const models = await this.store.listModels(provider);
1531
+ let best;
1532
+ let bestLength = -1;
1533
+ for (const priced of models) {
1534
+ if (priced.operation !== operation || priced.serviceTier !== serviceTier) continue;
1535
+ const candidate = normalizeModelId(priced.model);
1536
+ if (normalizedModel.startsWith(candidate) && candidate.length > bestLength) {
1537
+ best = priced.model;
1538
+ bestLength = candidate.length;
1539
+ }
1540
+ }
1541
+ if (best === void 0) return null;
1542
+ return this.store.resolveRate(provider, best, operation, serviceTier, at);
1543
+ }
1544
+ };
1545
+ PricingService = __decorateClass([
1546
+ Injectable()
1547
+ ], PricingService);
1548
+
1549
+ // src/server/utils/payload-hash.ts
1550
+ function computePayloadHash(record) {
1551
+ return deriveIdempotencyKey({
1552
+ scope: record.scope,
1553
+ beneficiary: record.beneficiary,
1554
+ requestedBy: record.requestedBy,
1555
+ provider: record.provider,
1556
+ model: record.model,
1557
+ requestedModel: record.requestedModel,
1558
+ operation: record.operation,
1559
+ serviceTier: record.serviceTier,
1560
+ feature: record.feature,
1561
+ tags: record.tags,
1562
+ inputTokens: record.inputTokens,
1563
+ outputTokens: record.outputTokens,
1564
+ cacheReadTokens: record.cacheReadTokens,
1565
+ cacheWrite5mTokens: record.cacheWrite5mTokens,
1566
+ cacheWrite1hTokens: record.cacheWrite1hTokens,
1567
+ reasoningTokens: record.reasoningTokens,
1568
+ audioInTokens: record.audioInTokens,
1569
+ audioOutTokens: record.audioOutTokens,
1570
+ imageInTokens: record.imageInTokens,
1571
+ imageOutTokens: record.imageOutTokens,
1572
+ totalTokens: record.totalTokens,
1573
+ extraUnits: record.extraUnits,
1574
+ priceVersionId: record.priceVersionId,
1575
+ rawCostNanoUsd: record.rawCostNanoUsd,
1576
+ surchargeNanoUsd: record.surchargeNanoUsd,
1577
+ billedCostNanoUsd: record.billedCostNanoUsd,
1578
+ markupMultiplier: record.markupMultiplier,
1579
+ currency: record.currency,
1580
+ priceMissing: record.priceMissing,
1581
+ correlationId: record.correlationId,
1582
+ requestId: record.requestId,
1583
+ isSystemCost: record.isSystemCost,
1584
+ systemCostCategory: record.systemCostCategory,
1585
+ enforced: record.enforced,
1586
+ reversesRecordId: record.reversesRecordId,
1587
+ occurredAt: record.occurredAt
1588
+ });
1589
+ }
1590
+
1591
+ // src/server/utils/hash-chain.ts
1592
+ function chainHash(prevHash, record) {
1593
+ return deriveIdempotencyKey({
1594
+ // Stryker disable next-line StringLiteral -- empty-string fallback for null prevHash; tests only verify hash determinism and inequality, not the specific hash value for a genesis block
1595
+ prevHash: prevHash ?? "",
1596
+ id: record.id,
1597
+ content: computePayloadHash(record)
1598
+ });
1599
+ }
1600
+
1601
+ // src/server/services/ledger-idempotency-conflict.ts
1602
+ function isLedgerIdempotencyConflict(error) {
1603
+ if (typeof error !== "object" || error === null) return false;
1604
+ return error.isAiTokensLedgerConflict === true;
1605
+ }
1606
+
1607
+ // src/server/services/ledger.service.ts
1608
+ var BALANCE_STATUSES = ["posted", "reversed"];
1609
+ function sumTokens(counts) {
1610
+ return counts.inputTokens + counts.outputTokens + counts.cacheReadTokens + counts.cacheWrite5mTokens + counts.cacheWrite1hTokens + counts.reasoningTokens + counts.audioInTokens + counts.audioOutTokens + counts.imageInTokens + counts.imageOutTokens;
1611
+ }
1612
+ var LedgerService = class {
1613
+ /**
1614
+ * @param store The append-only ledger store port.
1615
+ * @param options The resolved options carrying the hash-chain flag (§8.6).
1616
+ * @param audit The audit hook for chain verification; wired by the module.
1617
+ */
1618
+ constructor(store, options = { ledger: { hashChain: false } }, audit = () => void 0) {
1619
+ this.store = store;
1620
+ this.options = options;
1621
+ this.audit = audit;
1622
+ }
1623
+ store;
1624
+ options;
1625
+ audit;
1626
+ /** Whether the per-tenant tamper-evident hash chain is enabled (§8.6). */
1627
+ get hashChainEnabled() {
1628
+ return this.options.ledger.hashChain;
1629
+ }
1630
+ /**
1631
+ * Append a record exactly once. The idempotency key is `ctxKey` when supplied
1632
+ * — a content-derived key deduplicates retries (§8.4) — or a random UUID
1633
+ * otherwise, in which case the record is still written once but retries are NOT
1634
+ * deduplicated. A replay with a matching payload hash returns the stored record
1635
+ * unchanged and writes nothing; the same key with a different payload throws
1636
+ * `AI_TOKENS_IDEMPOTENCY_CONFLICT`.
1637
+ *
1638
+ * @param input The record content (without `idempotencyKey`/`totalTokens`).
1639
+ * @param ctxKey The host-supplied idempotency key, when available.
1640
+ * @returns The stored record (new or the existing one on a matching replay).
1641
+ * @throws {AiTokensException} `AI_TOKENS_IDEMPOTENCY_CONFLICT` on a payload mismatch.
1642
+ */
1643
+ async append(input, ctxKey) {
1644
+ const record = {
1645
+ ...input,
1646
+ idempotencyKey: ctxKey ?? randomUUID(),
1647
+ totalTokens: sumTokens(input)
1648
+ };
1649
+ const payloadHash = computePayloadHash(record);
1650
+ try {
1651
+ return await this.store.append(record, payloadHash, this.hashChainEnabled);
1652
+ } catch (error) {
1653
+ if (isLedgerIdempotencyConflict(error)) {
1654
+ throw new AiTokensException("AI_TOKENS_IDEMPOTENCY_CONFLICT", void 0, {
1655
+ tenantId: error.tenantId,
1656
+ idempotencyKey: error.idempotencyKey
1657
+ });
1658
+ }
1659
+ throw error;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Look up a record by its per-tenant idempotency key.
1664
+ *
1665
+ * @param tenantId The owning tenant.
1666
+ * @param key The idempotency key.
1667
+ * @returns The record, or `null` when none exists.
1668
+ */
1669
+ findByIdempotencyKey(tenantId, key) {
1670
+ return this.store.findByIdempotencyKey(tenantId, key);
1671
+ }
1672
+ /**
1673
+ * Load one record by its global id. The metering lifecycle loads a pending hold
1674
+ * by its id to settle or void it, and the orchestrated reversal loads the
1675
+ * (now-annotated) original to restore its wallet/budget effects (§8.5).
1676
+ *
1677
+ * @param id The record id.
1678
+ * @returns The record, or `null` when none exists.
1679
+ */
1680
+ findById(id) {
1681
+ return this.store.findById(id);
1682
+ }
1683
+ /**
1684
+ * Find pending holds whose TTL has elapsed — the reaper's sweep source (§8.3).
1685
+ * Returns at most `limit` records so the reaper reclaims in bounded batches.
1686
+ *
1687
+ * @param olderThan The cutoff instant (records created at or before are expired).
1688
+ * @param limit The maximum batch size.
1689
+ * @returns The expired pending records.
1690
+ */
1691
+ findExpiredHolds(olderThan, limit) {
1692
+ return this.store.findExpiredHolds(olderThan, limit);
1693
+ }
1694
+ /**
1695
+ * Query records. When the filter omits `status`, it defaults to the
1696
+ * balance-contributing statuses (`posted`, `reversed`) so callers see settled
1697
+ * spend without special-casing (§8.3).
1698
+ *
1699
+ * @param filter The ledger query filter.
1700
+ * @returns The matching records.
1701
+ */
1702
+ query(filter) {
1703
+ return this.store.query(this.withBalanceStatuses(filter));
1704
+ }
1705
+ /**
1706
+ * Aggregate cost/token totals. Defaults to `posted` + `reversed` records, so a
1707
+ * reversed record and its compensating negation net to zero (§8.3).
1708
+ *
1709
+ * @param filter The ledger query filter.
1710
+ * @returns The aggregate cost/token summary.
1711
+ */
1712
+ sumCost(filter) {
1713
+ return this.store.sumCost(this.withBalanceStatuses(filter));
1714
+ }
1715
+ /** Default the status filter to the balance-contributing statuses when unset. */
1716
+ withBalanceStatuses(filter) {
1717
+ return filter.status === void 0 ? { ...filter, status: [...BALANCE_STATUSES] } : filter;
1718
+ }
1719
+ /**
1720
+ * Move a record between lifecycle states (§8.3). Only three transitions are
1721
+ * legal — `pending → posted` (settlement; only the settlement amount/cost fields
1722
+ * may be patched), `pending → released` (void, no amount patch), and
1723
+ * `posted → reversed` (annotation with `reversedByRecordId` only). An illegal
1724
+ * `(from, to)` pair or an out-of-contract patch — including any attempt to mutate
1725
+ * an immutable field such as `id`/`tenantId`/`idempotencyKey` — is a caller bug
1726
+ * and throws `AI_TOKENS_IDEMPOTENCY_CONFLICT`. A legal
1727
+ * transition whose record is NOT currently in `from` returns `null` — the atomic
1728
+ * claim that lets exactly one reaper replica win an expired hold (§8.3).
1729
+ *
1730
+ * @param id The record id.
1731
+ * @param from The required current status.
1732
+ * @param to The target status.
1733
+ * @param patch The fields to set alongside the status flip.
1734
+ * @returns The updated record, or `null` on a from-state mismatch.
1735
+ * @throws {AiTokensException} `AI_TOKENS_IDEMPOTENCY_CONFLICT` on an illegal transition/patch.
1736
+ */
1737
+ async transition(id, from2, to, patch) {
1738
+ this.assertLegalTransition(from2, to, patch);
1739
+ return this.store.transition(id, from2, to, patch, this.hashChainEnabled);
1740
+ }
1741
+ /**
1742
+ * Verify a tenant's tamper-evident hash chain (§8.6). Walks its settled records
1743
+ * (`posted` + `reversed`) in append order and recomputes each record's chain hash
1744
+ * from its stored `prevHash` link, comparing it to the stored `hash`; reports the
1745
+ * first record whose hash does not match (a post-hoc content modification).
1746
+ * Recomputing from each record's own stored link makes verification independent
1747
+ * of walk order and of any `from`/`to` window (the first in-range record keeps
1748
+ * its real predecessor link). Always emits an `ai_tokens.audit` hook.
1749
+ *
1750
+ * @param tenantId The tenant whose chain to verify.
1751
+ * @param from Optional inclusive lower bound on `occurredAt`.
1752
+ * @param to Optional inclusive upper bound on `occurredAt`.
1753
+ * @returns `{ valid: true }`, or `{ valid: false, brokenAtRecordId }`.
1754
+ */
1755
+ async verifyChain(tenantId, from2, to) {
1756
+ const filter = { tenantId, status: ["posted", "reversed"] };
1757
+ if (from2 !== void 0) filter.from = from2;
1758
+ if (to !== void 0) filter.to = to;
1759
+ const settled = await this.store.query(filter);
1760
+ let result = { valid: true };
1761
+ for (const record of settled) {
1762
+ if (record.hash !== chainHash(record.prevHash ?? null, record)) {
1763
+ result = { valid: false, brokenAtRecordId: record.id };
1764
+ break;
1765
+ }
1766
+ }
1767
+ const details = { tenantId, valid: result.valid };
1768
+ if (!result.valid) details.brokenAtRecordId = result.brokenAtRecordId;
1769
+ this.audit("ai_tokens.chain.verified", details);
1770
+ return result;
1771
+ }
1772
+ /**
1773
+ * Reverse a posted record with a compensating record (§8.5, ledger-only steps
1774
+ * 1–2). Appends a `posted` record that exactly negates the original's token
1775
+ * counts and costs (keyed `reverse:<id>`, so a retry is a clean replay), then
1776
+ * annotates the original `posted → reversed`. Reversing a `pending`/`released`
1777
+ * or already-`reversed` record is invalid and throws
1778
+ * `AI_TOKENS_IDEMPOTENCY_CONFLICT`.
1779
+ *
1780
+ * ADMIN PLANE: reversal is a privileged mutation — the host MUST restrict it to
1781
+ * privileged roles (§14.4). This ledger-only primitive does NOT touch wallets,
1782
+ * budgets, counters, or events, and does not persist `reason` (the immutable
1783
+ * record has no reason column); the orchestrating `MeteringService.reverse`
1784
+ * surfaces `reason` on the `ai_tokens.usage.reversed` event.
1785
+ *
1786
+ * @param recordId The id of the posted record to reverse.
1787
+ * @param reason The caller's stated reason (echoed in the conflict details when the record cannot be reversed).
1788
+ * @returns The compensating record.
1789
+ * @throws {AiTokensException} `AI_TOKENS_IDEMPOTENCY_CONFLICT` when the record is missing or not `posted`.
1790
+ */
1791
+ async reverse(recordId, reason) {
1792
+ const original = await this.store.findById(recordId);
1793
+ if (original?.status !== "posted") {
1794
+ throw new AiTokensException("AI_TOKENS_IDEMPOTENCY_CONFLICT", void 0, {
1795
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
1796
+ reason: original === null ? "record not found" : `cannot reverse a ${original.status} record`,
1797
+ recordId,
1798
+ requestedReason: reason
1799
+ });
1800
+ }
1801
+ const compensating = await this.append(buildCompensatingRecord(original), `reverse:${recordId}`);
1802
+ await this.transition(original.id, "posted", "reversed", { reversedByRecordId: compensating.id });
1803
+ return compensating;
1804
+ }
1805
+ /**
1806
+ * Enforce the legality table and per-transition patch contract (§8.3/§8.5) via
1807
+ * the shared guard the store adapters also consume, so the service layer and the
1808
+ * store boundary can never drift. Store-agnostic: it runs before the store
1809
+ * applies the patch, so even a store that assigns patches broadly can never
1810
+ * mutate an immutable field (`id`/`tenantId`/`idempotencyKey`) or write an
1811
+ * out-of-contract column.
1812
+ */
1813
+ assertLegalTransition(from2, to, patch) {
1814
+ if (!isLegalLedgerTransition(from2, to)) {
1815
+ throw new AiTokensException("AI_TOKENS_IDEMPOTENCY_CONFLICT", void 0, {
1816
+ // Stryker disable next-line StringLiteral -- error reason template is internal diagnostics
1817
+ reason: `illegal transition ${from2} \u2192 ${to}`
1818
+ });
1819
+ }
1820
+ if (patch === void 0) return;
1821
+ for (const key of Object.keys(patch)) {
1822
+ if (!isLegalTransitionPatchKey(from2, to, key)) {
1823
+ throw new AiTokensException("AI_TOKENS_IDEMPOTENCY_CONFLICT", void 0, {
1824
+ // Stryker disable next-line StringLiteral -- error reason template is internal diagnostics
1825
+ reason: `${from2} \u2192 ${to} may not patch "${key}"`
1826
+ });
1827
+ }
1828
+ }
1829
+ }
1830
+ };
1831
+ LedgerService = __decorateClass([
1832
+ Injectable()
1833
+ ], LedgerService);
1834
+ function negateUnits(units) {
1835
+ const negated = {};
1836
+ for (const [unit, count] of Object.entries(units)) negated[unit] = -count;
1837
+ return negated;
1838
+ }
1839
+ function buildCompensatingRecord(original) {
1840
+ return {
1841
+ tenantId: original.tenantId,
1842
+ scope: original.scope,
1843
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { beneficiary: undefined } is equivalent to {} because downstream consumers check !== undefined
1844
+ ...original.beneficiary !== void 0 ? { beneficiary: original.beneficiary } : {},
1845
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { requestedBy: undefined } is equivalent to {}
1846
+ ...original.requestedBy !== void 0 ? { requestedBy: original.requestedBy } : {},
1847
+ provider: original.provider,
1848
+ model: original.model,
1849
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { requestedModel: undefined } is equivalent to {}
1850
+ ...original.requestedModel !== void 0 ? { requestedModel: original.requestedModel } : {},
1851
+ operation: original.operation,
1852
+ serviceTier: original.serviceTier,
1853
+ feature: original.feature,
1854
+ tags: original.tags,
1855
+ inputTokens: -original.inputTokens,
1856
+ outputTokens: -original.outputTokens,
1857
+ cacheReadTokens: -original.cacheReadTokens,
1858
+ cacheWrite5mTokens: -original.cacheWrite5mTokens,
1859
+ cacheWrite1hTokens: -original.cacheWrite1hTokens,
1860
+ reasoningTokens: -original.reasoningTokens,
1861
+ audioInTokens: -original.audioInTokens,
1862
+ audioOutTokens: -original.audioOutTokens,
1863
+ imageInTokens: -original.imageInTokens,
1864
+ imageOutTokens: -original.imageOutTokens,
1865
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { extraUnits: undefined } is equivalent to {} because consumers check !== undefined
1866
+ ...original.extraUnits !== void 0 ? { extraUnits: negateUnits(original.extraUnits) } : {},
1867
+ priceVersionId: original.priceVersionId,
1868
+ rawCostNanoUsd: -original.rawCostNanoUsd,
1869
+ surchargeNanoUsd: -original.surchargeNanoUsd,
1870
+ billedCostNanoUsd: -original.billedCostNanoUsd,
1871
+ markupMultiplier: original.markupMultiplier,
1872
+ currency: original.currency,
1873
+ priceMissing: original.priceMissing,
1874
+ status: "posted",
1875
+ reversesRecordId: original.id,
1876
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { correlationId: undefined } is equivalent to {}
1877
+ ...original.correlationId !== void 0 ? { correlationId: original.correlationId } : {},
1878
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { requestId: undefined } is equivalent to {}
1879
+ ...original.requestId !== void 0 ? { requestId: original.requestId } : {},
1880
+ isSystemCost: original.isSystemCost,
1881
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { systemCostCategory: undefined } is equivalent to {}
1882
+ ...original.systemCostCategory !== void 0 ? { systemCostCategory: original.systemCostCategory } : {},
1883
+ enforced: original.enforced,
1884
+ occurredAt: original.occurredAt
1885
+ };
1886
+ }
1887
+ function buildResolvedMarkup(multiplier) {
1888
+ return { multiplier, apply: (rawCostNanoUsd) => applyMarkup(rawCostNanoUsd, multiplier) };
1889
+ }
1890
+ var MarkupResolver = class {
1891
+ /**
1892
+ * @param options The resolved options carrying the markup (a 4-dp number or a policy).
1893
+ */
1894
+ constructor(options) {
1895
+ this.options = options;
1896
+ }
1897
+ options;
1898
+ /**
1899
+ * Resolve the markup for one call. A static multiplier is returned as-is (it was
1900
+ * validated and rounded to 4 dp at init); a policy is invoked with the full
1901
+ * context, and its result is validated and rounded to 4 dp.
1902
+ *
1903
+ * @param context The scope/provider/model/operation/serviceTier/feature context.
1904
+ * @returns The resolved 4-dp multiplier and a bound applier.
1905
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when a policy throws or returns an invalid value.
1906
+ */
1907
+ async resolve(context) {
1908
+ const { markup } = this.options;
1909
+ if (typeof markup === "number") return buildResolvedMarkup(markup);
1910
+ return buildResolvedMarkup(this.validate(await this.invokePolicy(markup, context)));
1911
+ }
1912
+ /** Invoke a host policy, wrapping any failure as an invalid-config error. */
1913
+ async invokePolicy(policy, context) {
1914
+ try {
1915
+ return await policy.resolve(context);
1916
+ } catch {
1917
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, {
1918
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
1919
+ reason: "markup policy failed to resolve a multiplier"
1920
+ });
1921
+ }
1922
+ }
1923
+ /** Validate + round a policy's multiplier to 4 dp, or fail the call. */
1924
+ validate(raw) {
1925
+ try {
1926
+ return resolveMultiplier4dp(raw);
1927
+ } catch {
1928
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, {
1929
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
1930
+ reason: "markup policy returned an invalid multiplier",
1931
+ value: raw
1932
+ });
1933
+ }
1934
+ }
1935
+ };
1936
+ MarkupResolver = __decorateClass([
1937
+ Injectable()
1938
+ ], MarkupResolver);
1939
+
1940
+ // src/server/utils/scope-wallet.ts
1941
+ function scopeOwnsWallet(scope) {
1942
+ return scope.type !== "key";
1943
+ }
1944
+ function scopeToWalletRef(tenantId, scope) {
1945
+ return { tenantId, ownerType: scope.type, ownerId: scope.id };
1946
+ }
1947
+ function budgetDelta(delta) {
1948
+ return { nanoUsd: delta.billedNanoUsd, tokens: delta.tokens, count: delta.count };
1949
+ }
1950
+ function contextOf(record) {
1951
+ return { tenantId: record.tenantId, scope: record.scope, feature: record.feature };
1952
+ }
1953
+ var MeteringEffects = class _MeteringEffects {
1954
+ /**
1955
+ * @param wallets The wallet service, when the wallet feature is enabled.
1956
+ * @param budgets The budget service, when the budget feature is enabled.
1957
+ */
1958
+ constructor(wallets, budgets) {
1959
+ this.wallets = wallets;
1960
+ this.budgets = budgets;
1961
+ }
1962
+ wallets;
1963
+ budgets;
1964
+ logger = new Logger(_MeteringEffects.name);
1965
+ /**
1966
+ * The wallet leg for a payer scope, or `null` when there is nothing to move:
1967
+ * wallets disabled, a `'key'` scope (spends its owner's wallet), or a
1968
+ * non-billable amount (`≤ 0` for a debit/refund, `= 0` for a signed settlement).
1969
+ */
1970
+ legFor(tenantId, scope, amount, signed) {
1971
+ if (this.wallets === void 0 || !scopeOwnsWallet(scope)) return null;
1972
+ if (signed ? amount === 0n : amount <= 0n) return null;
1973
+ return { wallets: this.wallets, ref: scopeToWalletRef(tenantId, scope) };
1974
+ }
1975
+ /**
1976
+ * Reserve a hold: consume the budget window/counter, then debit the wallet — the
1977
+ * §11.2 ordering (cheapest to roll back first). A wallet shortfall compensates the
1978
+ * budget consumption before rethrowing.
1979
+ *
1980
+ * @param context The metering context (payer scope, feature).
1981
+ * @param delta The estimated reservation.
1982
+ * @param ledgerKey The stable key deriving the wallet debit idempotency key.
1983
+ * @throws {AiTokensException} The dimension's budget/quota/credits error on a shortfall (prior steps compensated).
1984
+ */
1985
+ async reserveHold(context, delta, ledgerKey) {
1986
+ if (this.budgets !== void 0) await this.budgets.consume(context, budgetDelta(delta));
1987
+ const leg = this.legFor(context.tenantId, context.scope, delta.billedNanoUsd, false);
1988
+ if (leg === null) return;
1989
+ try {
1990
+ await leg.wallets.debit(leg.ref, { amountNanoUsd: delta.billedNanoUsd, idempotencyKey: `hold:${ledgerKey}`, reason: "auth-hold reservation" });
1991
+ } catch (error) {
1992
+ if (this.budgets !== void 0) await this.budgets.release(context, budgetDelta(delta));
1993
+ throw error;
1994
+ }
1995
+ }
1996
+ /**
1997
+ * Compensate a hold whose pending ledger insert failed: refund the wallet debit
1998
+ * and release the budget consumption. Best-effort — failures are logged so the
1999
+ * original insert error surfaces unmasked.
2000
+ *
2001
+ * @param context The metering context.
2002
+ * @param delta The reservation to unwind.
2003
+ * @param ledgerKey The stable key deriving the wallet rollback idempotency key.
2004
+ */
2005
+ async compensateHold(context, delta, ledgerKey) {
2006
+ const leg = this.legFor(context.tenantId, context.scope, delta.billedNanoUsd, false);
2007
+ if (leg !== null) {
2008
+ try {
2009
+ await leg.wallets.refund(leg.ref, { amountNanoUsd: delta.billedNanoUsd, idempotencyKey: `hold-rollback:${ledgerKey}`, reason: "hold rollback" });
2010
+ } catch {
2011
+ this.logger.warn("failed to refund a hold debit during rollback");
2012
+ }
2013
+ }
2014
+ if (this.budgets === void 0) return;
2015
+ try {
2016
+ await this.budgets.release(context, budgetDelta(delta));
2017
+ } catch {
2018
+ this.logger.warn("failed to release a budget reservation during rollback");
2019
+ }
2020
+ }
2021
+ /**
2022
+ * Settle a capture: apply the signed ±delta between the actual and the reserved
2023
+ * estimate to the budget window/counter and the wallet — never re-blocking.
2024
+ *
2025
+ * @param record The now-posted record (the payer/feature source).
2026
+ * @param reservedBilledNanoUsd The hold's billed estimate.
2027
+ * @param reservedTokens The hold's estimated tokens.
2028
+ * @param actualBilledNanoUsd The settled billed cost.
2029
+ * @param actualTokens The settled token total.
2030
+ */
2031
+ async settleCapture(record, reservedBilledNanoUsd, reservedTokens, actualBilledNanoUsd, actualTokens) {
2032
+ if (record.isSystemCost) return;
2033
+ if (this.budgets !== void 0) {
2034
+ await this.budgets.adjust(contextOf(record), { nanoUsd: actualBilledNanoUsd - reservedBilledNanoUsd, tokens: actualTokens - reservedTokens, count: 0 });
2035
+ }
2036
+ const walletDelta = reservedBilledNanoUsd - actualBilledNanoUsd;
2037
+ const leg = this.legFor(record.tenantId, record.scope, walletDelta, true);
2038
+ if (leg === null) return;
2039
+ await leg.wallets.settleAdjustment(leg.ref, { amountNanoUsd: walletDelta, usageRecordId: record.id, idempotencyKey: `capture:${record.id}`, reason: "hold capture settlement" });
2040
+ }
2041
+ /**
2042
+ * Restore a hold in full — the shared code path for `release()` and the reaper.
2043
+ * Releases the reserved budget and refunds the reserved wallet debit.
2044
+ *
2045
+ * @param record The pending record being voided (carries the reserved amounts).
2046
+ */
2047
+ async restoreHold(record) {
2048
+ if (record.isSystemCost) return;
2049
+ if (this.budgets !== void 0) {
2050
+ await this.budgets.release(contextOf(record), { nanoUsd: record.billedCostNanoUsd, tokens: record.totalTokens, count: 1 });
2051
+ }
2052
+ const leg = this.legFor(record.tenantId, record.scope, record.billedCostNanoUsd, false);
2053
+ if (leg === null) return;
2054
+ await leg.wallets.refund(leg.ref, { amountNanoUsd: record.billedCostNanoUsd, usageRecordId: record.id, idempotencyKey: `release:${record.id}`, reason: "hold released" });
2055
+ }
2056
+ /**
2057
+ * Compensate a reversed posted record — best-effort across stores (§8.5 step 3;
2058
+ * cross-store 2PC is out of scope, deterministic keys make retries safe).
2059
+ *
2060
+ * @param record The reversed (annotated) original record.
2061
+ * @param reason The caller's stated reason (echoed on the wallet refund).
2062
+ */
2063
+ async reverseEffects(record, reason) {
2064
+ const leg = this.legFor(record.tenantId, record.scope, record.billedCostNanoUsd, false);
2065
+ if (leg !== null) {
2066
+ try {
2067
+ await leg.wallets.refund(leg.ref, { amountNanoUsd: record.billedCostNanoUsd, usageRecordId: record.id, idempotencyKey: `reverse:${record.id}`, reason });
2068
+ } catch {
2069
+ this.logger.warn("failed to refund a wallet debit during reversal");
2070
+ }
2071
+ }
2072
+ if (this.budgets === void 0) return;
2073
+ try {
2074
+ await this.budgets.release(contextOf(record), { nanoUsd: record.billedCostNanoUsd, tokens: record.totalTokens, count: 1 });
2075
+ } catch {
2076
+ this.logger.warn("failed to release a budget consumption during reversal");
2077
+ }
2078
+ }
2079
+ /**
2080
+ * Post-hoc enforcement for `record({ enforce: true })` — consume the budget then
2081
+ * debit the wallet AFTER the ledger write (the §11.2 trade-off; both may throw). A
2082
+ * wallet shortfall rolls the budget consumption back before rethrowing.
2083
+ *
2084
+ * @param record The just-persisted enforced record.
2085
+ * @throws {AiTokensException} A budget/quota/credits error when a limit is exhausted.
2086
+ */
2087
+ async enforceRecord(record) {
2088
+ if (record.isSystemCost) return;
2089
+ const delta = budgetDelta({ billedNanoUsd: record.billedCostNanoUsd, tokens: record.totalTokens, count: 1 });
2090
+ if (this.budgets !== void 0) await this.budgets.consume(contextOf(record), delta);
2091
+ const leg = this.legFor(record.tenantId, record.scope, record.billedCostNanoUsd, false);
2092
+ if (leg === null) return;
2093
+ try {
2094
+ await leg.wallets.debit(leg.ref, { amountNanoUsd: record.billedCostNanoUsd, usageRecordId: record.id, idempotencyKey: `record:${record.id}`, reason: "post-hoc enforcement" });
2095
+ } catch (error) {
2096
+ if (this.budgets !== void 0) await this.budgets.release(contextOf(record), delta);
2097
+ throw error;
2098
+ }
2099
+ }
2100
+ };
2101
+
2102
+ // src/server/streaming/stream-usage-collector.ts
2103
+ function zeroUsage(provider, model, operation) {
2104
+ return {
2105
+ provider,
2106
+ model,
2107
+ operation,
2108
+ inputTokens: 0,
2109
+ outputTokens: 0,
2110
+ cacheReadTokens: 0,
2111
+ cacheWrite5mTokens: 0,
2112
+ cacheWrite1hTokens: 0,
2113
+ reasoningTokens: 0,
2114
+ audioInTokens: 0,
2115
+ audioOutTokens: 0,
2116
+ imageInTokens: 0,
2117
+ imageOutTokens: 0
2118
+ };
2119
+ }
2120
+ function asRecord(value) {
2121
+ return typeof value === "object" && value !== null ? value : null;
2122
+ }
2123
+ var StreamUsageCollector = class {
2124
+ /** @param opts The provider/model + optional preset/tokenizer/extractor. */
2125
+ constructor(opts) {
2126
+ this.opts = opts;
2127
+ this.operation = opts.operation ?? "chat";
2128
+ this.anthropicModel = opts.model;
2129
+ }
2130
+ opts;
2131
+ operation;
2132
+ outputText = "";
2133
+ /** The provider-final response object to normalize, once seen. */
2134
+ finalResponse = null;
2135
+ /** Anthropic streaming state accumulated across events (seeded from the constructed identity). */
2136
+ anthropicModel;
2137
+ anthropicBaseUsage = {};
2138
+ anthropicOutputTokens = 0;
2139
+ promptTokens;
2140
+ promptText;
2141
+ finalized = false;
2142
+ malformed = 0;
2143
+ /** True after `finalize()` when the tokenizer fallback (not provider-final usage) was used. */
2144
+ usedFallback = false;
2145
+ /** How many chunks were skipped as malformed (debug/observability). */
2146
+ get malformedCount() {
2147
+ return this.malformed;
2148
+ }
2149
+ /** Provide the prompt text so an aborted stream can count input tokens via the tokenizer. */
2150
+ setPromptText(text) {
2151
+ this.promptText = text;
2152
+ }
2153
+ /** Provide a pre-counted prompt token total (takes precedence over `setPromptText`). */
2154
+ setPromptTokens(count) {
2155
+ this.promptTokens = count;
2156
+ }
2157
+ /**
2158
+ * Feed one stream chunk: accumulate output text and watch for the provider-final
2159
+ * usage. Never throws — a malformed chunk is skipped and counted.
2160
+ *
2161
+ * @param chunk One raw provider stream chunk/event.
2162
+ */
2163
+ push(chunk) {
2164
+ try {
2165
+ const record = asRecord(chunk);
2166
+ if (record === null) {
2167
+ this.malformed += 1;
2168
+ return;
2169
+ }
2170
+ if (this.opts.provider === "anthropic") this.pushAnthropic(record);
2171
+ else this.pushOpenAiLike(record);
2172
+ } catch {
2173
+ this.malformed += 1;
2174
+ }
2175
+ }
2176
+ /**
2177
+ * Resolve the final usage: provider-final when seen (normalized), else a tokenizer
2178
+ * count of the accumulated output (input tokens from the prompt count when
2179
+ * provided, else 0 — the hold estimate is applied by `capture()`), else a
2180
+ * missing-usage error. Async to support async tokenizers (§14.2.1). Single-use.
2181
+ *
2182
+ * @returns The finalized normalized usage.
2183
+ * @throws {AiTokensException} `AI_TOKENS_STREAM_USAGE_MISSING` on an abort with no tokenizer; a re-use error on a second call.
2184
+ */
2185
+ async finalize() {
2186
+ if (this.finalized) throw new AiTokensException("AI_TOKENS_STREAM_USAGE_MISSING", void 0, { reason: "the collector was already finalized" });
2187
+ this.finalized = true;
2188
+ const provider = this.opts.provider;
2189
+ if (this.finalResponse !== null) return this.normalizeFinal(this.finalResponse);
2190
+ const tokenizer = this.opts.tokenizer;
2191
+ if (tokenizer !== void 0) return this.fallbackUsage(provider, tokenizer);
2192
+ throw new AiTokensException("AI_TOKENS_STREAM_USAGE_MISSING", void 0, { provider, model: this.opts.model });
2193
+ }
2194
+ /** Accumulate an OpenAI-shaped chunk and capture its trailing usage. */
2195
+ pushOpenAiLike(record) {
2196
+ const text = this.opts.outputText?.(record) ?? openAiDelta(record);
2197
+ if (text !== void 0) this.outputText += text;
2198
+ if (asRecord(record.usage) !== null) this.finalResponse = record;
2199
+ }
2200
+ /** Accumulate an Anthropic-shaped event and reconstruct its final usage at message_stop. */
2201
+ pushAnthropic(record) {
2202
+ const type = record.type;
2203
+ if (type === "message_start") {
2204
+ const message = asRecord(record.message);
2205
+ const usage = asRecord(message?.usage);
2206
+ this.anthropicModel = typeof message?.model === "string" ? message.model : this.opts.model;
2207
+ this.anthropicBaseUsage = usage ?? {};
2208
+ this.anthropicOutputTokens = numberOf(usage?.output_tokens);
2209
+ } else if (type === "content_block_delta") {
2210
+ const delta = asRecord(record.delta);
2211
+ const text = this.opts.outputText?.(record) ?? (typeof delta?.text === "string" ? delta.text : void 0);
2212
+ if (text !== void 0) this.outputText += text;
2213
+ } else if (type === "message_delta") {
2214
+ const usage = asRecord(record.usage);
2215
+ if (usage !== null) this.anthropicOutputTokens = numberOf(usage.output_tokens);
2216
+ } else if (type === "message_stop") {
2217
+ this.finalResponse = {
2218
+ model: this.anthropicModel,
2219
+ usage: { ...this.anthropicBaseUsage, output_tokens: this.anthropicOutputTokens }
2220
+ };
2221
+ }
2222
+ }
2223
+ /** Normalize a captured provider-final response via the preset or the provider default. */
2224
+ normalizeFinal(response) {
2225
+ const normalizer = this.opts.preset?.normalizer ?? (this.opts.provider === "anthropic" ? normalizeAnthropicUsage : normalizeOpenAiChatUsage);
2226
+ return normalizer(response);
2227
+ }
2228
+ /** Build the tokenizer-counted fallback usage for an aborted stream. */
2229
+ async fallbackUsage(provider, tokenizer) {
2230
+ this.usedFallback = true;
2231
+ const outputTokens = await tokenizer.countTokens({ text: this.outputText, model: this.opts.model, provider });
2232
+ const inputTokens = this.promptTokens ?? (this.promptText !== void 0 ? await tokenizer.countTokens({ text: this.promptText, model: this.opts.model, provider }) : 0);
2233
+ return { ...zeroUsage(provider, this.opts.model, this.operation), inputTokens, outputTokens };
2234
+ }
2235
+ };
2236
+ function openAiDelta(record) {
2237
+ if (!Array.isArray(record.choices)) return void 0;
2238
+ let text = "";
2239
+ for (const choice of record.choices) {
2240
+ const delta = asRecord(asRecord(choice)?.delta);
2241
+ if (typeof delta?.content === "string") text += delta.content;
2242
+ }
2243
+ return text === "" ? void 0 : text;
2244
+ }
2245
+ function numberOf(value) {
2246
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2247
+ }
2248
+
2249
+ // src/server/telemetry/no-op-telemetry.ts
2250
+ var NO_OP_TELEMETRY = new TelemetryEmitter(null);
2251
+
2252
+ // src/server/services/hold-support.ts
2253
+ var UNSPECIFIED = "unspecified";
2254
+ var PER_MILLION = 1000000n;
2255
+ var TOKEN_FIELDS = TOKEN_CATEGORIES.map((category) => `${category}Tokens`);
2256
+ function zeroTokenCounts() {
2257
+ return {
2258
+ inputTokens: 0,
2259
+ outputTokens: 0,
2260
+ cacheReadTokens: 0,
2261
+ cacheWrite5mTokens: 0,
2262
+ cacheWrite1hTokens: 0,
2263
+ reasoningTokens: 0,
2264
+ audioInTokens: 0,
2265
+ audioOutTokens: 0,
2266
+ imageInTokens: 0,
2267
+ imageOutTokens: 0
2268
+ };
2269
+ }
2270
+ function tokenCountsOf(usage) {
2271
+ return {
2272
+ inputTokens: usage.inputTokens,
2273
+ outputTokens: usage.outputTokens,
2274
+ cacheReadTokens: usage.cacheReadTokens,
2275
+ cacheWrite5mTokens: usage.cacheWrite5mTokens,
2276
+ cacheWrite1hTokens: usage.cacheWrite1hTokens,
2277
+ reasoningTokens: usage.reasoningTokens,
2278
+ audioInTokens: usage.audioInTokens,
2279
+ audioOutTokens: usage.audioOutTokens,
2280
+ imageInTokens: usage.imageInTokens,
2281
+ imageOutTokens: usage.imageOutTokens
2282
+ };
2283
+ }
2284
+ function sumTokenCounts(counts) {
2285
+ return TOKEN_FIELDS.reduce((total, field) => total + counts[field], 0);
2286
+ }
2287
+ function isNormalizedUsage(usage) {
2288
+ if (typeof usage !== "object" || usage === null) return false;
2289
+ const candidate = usage;
2290
+ if (typeof candidate.provider !== "string" || typeof candidate.model !== "string") return false;
2291
+ if (typeof candidate.operation !== "string" || !AI_OPERATIONS.includes(candidate.operation)) {
2292
+ return false;
2293
+ }
2294
+ return TOKEN_FIELDS.every((field) => typeof candidate[field] === "number" && Number.isFinite(candidate[field]));
2295
+ }
2296
+ function isRatedEstimate(estimate) {
2297
+ return "provider" in estimate;
2298
+ }
2299
+ function isTokenEstimate(estimate) {
2300
+ return "tokens" in estimate;
2301
+ }
2302
+ function syntheticUsage(estimate, serviceTier) {
2303
+ return {
2304
+ provider: estimate.provider,
2305
+ model: estimate.model,
2306
+ operation: estimate.operation,
2307
+ serviceTier,
2308
+ ...zeroTokenCounts(),
2309
+ inputTokens: estimate.inputTokens,
2310
+ outputTokens: estimate.maxOutputTokens
2311
+ };
2312
+ }
2313
+ async function resolveHoldEstimate(pricing, context, estimate, at) {
2314
+ const serviceTier = context.serviceTier ?? "standard";
2315
+ if (isRatedEstimate(estimate)) {
2316
+ const tier = estimate.serviceTier ?? serviceTier;
2317
+ const usage = syntheticUsage(estimate, tier);
2318
+ const rate = await pricing.resolveRate({
2319
+ provider: estimate.provider,
2320
+ model: estimate.model,
2321
+ operation: estimate.operation,
2322
+ serviceTier: tier,
2323
+ at,
2324
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { baseModel: undefined } is equivalent to {} because resolveRate reads input.baseModel (undefined either way) — same cache key and same `baseModel !== undefined` resolution branch
2325
+ ...context.baseModel !== void 0 ? { baseModel: context.baseModel } : {}
2326
+ });
2327
+ const rawEstimateNanoUsd = rate === null ? 0n : computeCostNanoUsd(usage, rate).totalNanoUsd;
2328
+ const tokenCounts2 = { ...zeroTokenCounts(), inputTokens: estimate.inputTokens, outputTokens: estimate.maxOutputTokens };
2329
+ return { provider: estimate.provider, model: estimate.model, operation: estimate.operation, serviceTier: tier, rawEstimateNanoUsd, estimatedTokens: sumTokenCounts(tokenCounts2), tokenCounts: tokenCounts2 };
2330
+ }
2331
+ if (isTokenEstimate(estimate)) {
2332
+ return resolveTokenEstimate(pricing, context, estimate.tokens, serviceTier, at);
2333
+ }
2334
+ const tokenCounts = zeroTokenCounts();
2335
+ return {
2336
+ provider: context.preset?.provider ?? UNSPECIFIED,
2337
+ model: context.baseModel ?? UNSPECIFIED,
2338
+ operation: "chat",
2339
+ serviceTier,
2340
+ rawEstimateNanoUsd: estimate.amountNanoUsd,
2341
+ estimatedTokens: 0,
2342
+ tokenCounts
2343
+ };
2344
+ }
2345
+ async function resolveTokenEstimate(pricing, context, tokens, serviceTier, at) {
2346
+ if (context.preset === void 0) {
2347
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, {
2348
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
2349
+ reason: "a { tokens } hold estimate requires context.preset to identify the provider"
2350
+ });
2351
+ }
2352
+ const provider = context.preset.provider;
2353
+ const model = context.baseModel ?? UNSPECIFIED;
2354
+ let rawEstimateNanoUsd = 0n;
2355
+ if (context.baseModel !== void 0) {
2356
+ const rate = await pricing.resolveRate({ provider, model, operation: "chat", serviceTier, at });
2357
+ if (rate !== null) rawEstimateNanoUsd = BigInt(tokens) * rate.inputNanoUsdPerMillion / PER_MILLION;
2358
+ }
2359
+ const tokenCounts = { ...zeroTokenCounts(), inputTokens: tokens };
2360
+ return { provider, model, operation: "chat", serviceTier, rawEstimateNanoUsd, estimatedTokens: tokens, tokenCounts };
2361
+ }
2362
+ function normalizeCaptureUsage(usage, normalizer) {
2363
+ if (isNormalizedUsage(usage)) return usage;
2364
+ if (normalizer !== void 0) {
2365
+ try {
2366
+ return normalizer(usage);
2367
+ } catch (error) {
2368
+ if (error instanceof AiTokensException) throw error;
2369
+ throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the normalizer could not read the usage token fields" });
2370
+ }
2371
+ }
2372
+ throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, {
2373
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
2374
+ reason: "capture requires an already-normalized usage or a preset normalizer"
2375
+ });
2376
+ }
2377
+
2378
+ // src/server/services/metering.service.ts
2379
+ var NOOP_EVENT_HOOKS = {
2380
+ usageRecorded: () => Promise.resolve(),
2381
+ priceMissing: () => Promise.resolve(),
2382
+ holdReleased: () => Promise.resolve(),
2383
+ usageReversed: () => Promise.resolve(),
2384
+ audit: () => Promise.resolve()
2385
+ };
2386
+ var ESTIMATE_SCOPE = { type: "tenant", id: "" };
2387
+ var DEFAULT_HOLDS = { ttlSeconds: 3600};
2388
+ function buildAppendInput(args) {
2389
+ const { normalized, context, serviceTier, occurredAt, rating, markup, ratedUnits, enforced } = args;
2390
+ return {
2391
+ ...baseColumns(normalized, context, serviceTier),
2392
+ inputTokens: normalized.inputTokens,
2393
+ outputTokens: normalized.outputTokens,
2394
+ cacheReadTokens: normalized.cacheReadTokens,
2395
+ cacheWrite5mTokens: normalized.cacheWrite5mTokens,
2396
+ cacheWrite1hTokens: normalized.cacheWrite1hTokens,
2397
+ reasoningTokens: normalized.reasoningTokens,
2398
+ audioInTokens: normalized.audioInTokens,
2399
+ audioOutTokens: normalized.audioOutTokens,
2400
+ imageInTokens: normalized.imageInTokens,
2401
+ imageOutTokens: normalized.imageOutTokens,
2402
+ // Stryker disable next-line ConditionalExpression,EqualityOperator -- CE/EQ: when ratedUnits is empty, spreading { extraUnits: {} } vs omitting the key; downstream consumers check record.extraUnits !== undefined (absent vs present {}); unit tests don't use server tool use, so ratedUnits={} always → no observable difference
2403
+ ...Object.keys(ratedUnits).length > 0 ? { extraUnits: ratedUnits } : {},
2404
+ priceVersionId: rating.priceVersionId,
2405
+ rawCostNanoUsd: rating.rawCostNanoUsd,
2406
+ surchargeNanoUsd: rating.surchargeNanoUsd,
2407
+ billedCostNanoUsd: markup.apply(rating.rawCostNanoUsd),
2408
+ markupMultiplier: markup.multiplier,
2409
+ currency: "USD",
2410
+ priceMissing: rating.priceMissing,
2411
+ status: "posted",
2412
+ isSystemCost: context.isSystemCost ?? false,
2413
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { systemCostCategory: undefined } is equivalent to {} because JS property access treats absent and undefined the same
2414
+ ...context.systemCostCategory !== void 0 ? { systemCostCategory: context.systemCostCategory } : {},
2415
+ enforced,
2416
+ occurredAt
2417
+ };
2418
+ }
2419
+ function baseColumns(usage, context, serviceTier) {
2420
+ return {
2421
+ tenantId: context.tenantId,
2422
+ scope: context.scope,
2423
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { beneficiary: undefined } is equivalent to {} for property access and truthiness-based filters
2424
+ ...context.beneficiary !== void 0 ? { beneficiary: context.beneficiary } : {},
2425
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { requestedBy: undefined } is equivalent to {}
2426
+ ...context.requestedBy !== void 0 ? { requestedBy: context.requestedBy } : {},
2427
+ provider: usage.provider,
2428
+ model: usage.model,
2429
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { requestedModel: undefined } is equivalent to {} for downstream consumers
2430
+ ...context.baseModel !== void 0 ? { requestedModel: context.baseModel } : {},
2431
+ operation: usage.operation,
2432
+ serviceTier,
2433
+ feature: context.feature,
2434
+ tags: context.tags ?? [],
2435
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { correlationId: undefined } is equivalent to {} because it's only read via record.correlationId
2436
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {}
2437
+ };
2438
+ }
2439
+ var MeteringService = class {
2440
+ /**
2441
+ * @param ledger The append-only ledger service.
2442
+ * @param pricing The pricing service (rate resolution).
2443
+ * @param markup The per-call markup resolver.
2444
+ * @param options The resolved options (rating mode, hold TTL, wallet overdraft).
2445
+ * @param events The event hooks; the module wires them to the dispatcher.
2446
+ * @param wallets The wallet service, when the wallet feature is enabled.
2447
+ * @param budgets The budget service, when the budget feature is enabled.
2448
+ * @param now The injected clock (hold expiry).
2449
+ * @param telemetry The GenAI telemetry emitter (no-op by default).
2450
+ */
2451
+ constructor(ledger, pricing, markup, options, events = NOOP_EVENT_HOOKS, wallets, budgets, now = () => /* @__PURE__ */ new Date(), telemetry = NO_OP_TELEMETRY, content = NO_OP_CONTENT_CAPTURE) {
2452
+ this.ledger = ledger;
2453
+ this.pricing = pricing;
2454
+ this.markup = markup;
2455
+ this.options = options;
2456
+ this.events = events;
2457
+ this.now = now;
2458
+ this.telemetry = telemetry;
2459
+ this.content = content;
2460
+ this.wallets = wallets ?? void 0;
2461
+ this.budgets = budgets ?? void 0;
2462
+ this.effects = new MeteringEffects(this.wallets, this.budgets);
2463
+ this.overdraftNanoUsd = options.wallets?.enabled === true ? options.wallets.overdraftNanoUsd : 0n;
2464
+ }
2465
+ ledger;
2466
+ pricing;
2467
+ markup;
2468
+ options;
2469
+ events;
2470
+ now;
2471
+ telemetry;
2472
+ content;
2473
+ logger = new Logger(MeteringService.name);
2474
+ effects;
2475
+ /** The wallet service, or `undefined` when the wallet feature is disabled. */
2476
+ wallets;
2477
+ /** The budget service, or `undefined` when the budget feature is disabled. */
2478
+ budgets;
2479
+ /** The wallet overdraft headroom (0 when wallets are disabled). */
2480
+ overdraftNanoUsd;
2481
+ /**
2482
+ * Post-hoc metering: normalize, rate, apply markup, append a `posted` record, and
2483
+ * emit `ai_tokens.usage.recorded`. Observe-only by default; `enforce: true` also
2484
+ * consumes budgets and debits the wallet AFTER the ledger write (§11.2 trade-off:
2485
+ * a call that already ran can still exceed a limit) — it requires the wallet or
2486
+ * budget feature.
2487
+ *
2488
+ * @param input The usage, its preset/normalizer, and the metering context.
2489
+ * @returns The posted usage record.
2490
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when `enforce: true` without wallets/budgets; normalize/rate errors; post-hoc budget/quota/credits errors.
2491
+ */
2492
+ async record(input) {
2493
+ const { context } = input;
2494
+ const enforcing = context.enforce === true;
2495
+ if (enforcing && this.wallets === void 0 && this.budgets === void 0) {
2496
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, {
2497
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
2498
+ reason: "enforce requires the wallets or budgets feature to be enabled"
2499
+ });
2500
+ }
2501
+ const normalized = this.resolveNormalizedUsage(input);
2502
+ const serviceTier = context.serviceTier ?? normalized.serviceTier ?? "standard";
2503
+ const occurredAt = input.occurredAt ?? this.now();
2504
+ const mode = context.ratingMode ?? input.preset?.ratingMode ?? this.options.ratingMode;
2505
+ const ratedUnits = { ...normalized.serverToolUse, ...context.extraUnits };
2506
+ const usageForRating = { ...normalized, serverToolUse: ratedUnits };
2507
+ const rating = await this.rate(usageForRating, serviceTier, occurredAt, mode, context.baseModel);
2508
+ const markup = await this.resolveMarkup(context, normalized, serviceTier);
2509
+ const record = await this.ledger.append(
2510
+ buildAppendInput({ normalized, context, serviceTier, occurredAt, rating, markup, ratedUnits, enforced: enforcing }),
2511
+ context.idempotencyKey
2512
+ );
2513
+ if (rating.priceMissing) await this.events.priceMissing(record);
2514
+ await this.events.usageRecorded(record);
2515
+ this.telemetry.recordUsage(record);
2516
+ await this.captureContent(record.id, context.tenantId, input.content);
2517
+ if (enforcing) await this.effects.enforceRecord(record);
2518
+ return record;
2519
+ }
2520
+ /**
2521
+ * Pure pre-flight cost estimate — no ledger or event side effects.
2522
+ *
2523
+ * @param input The hypothetical call.
2524
+ * @returns The raw and billed nano-USD estimate.
2525
+ */
2526
+ async estimateCost(input) {
2527
+ const serviceTier = input.serviceTier ?? "standard";
2528
+ const at = input.at ?? this.now();
2529
+ const usage = {
2530
+ provider: input.provider,
2531
+ model: input.model,
2532
+ operation: input.operation,
2533
+ inputTokens: input.inputTokens,
2534
+ outputTokens: input.maxOutputTokens,
2535
+ cacheReadTokens: 0,
2536
+ cacheWrite5mTokens: 0,
2537
+ cacheWrite1hTokens: 0,
2538
+ reasoningTokens: 0,
2539
+ audioInTokens: 0,
2540
+ audioOutTokens: 0,
2541
+ imageInTokens: 0,
2542
+ imageOutTokens: 0
2543
+ };
2544
+ const rate = await this.pricing.resolveRate({ provider: input.provider, model: input.model, operation: input.operation, serviceTier, at });
2545
+ const rawCostNanoUsd = rate === null ? 0n : computeCostNanoUsd(usage, rate).totalNanoUsd;
2546
+ const markup = await this.markup.resolve({
2547
+ scope: input.scope ?? ESTIMATE_SCOPE,
2548
+ provider: input.provider,
2549
+ model: input.model,
2550
+ operation: input.operation,
2551
+ serviceTier,
2552
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { feature: undefined } is equivalent to {} because markup.resolve treats absent and undefined feature the same
2553
+ ...input.feature !== void 0 ? { feature: input.feature } : {}
2554
+ });
2555
+ return { rawCostNanoUsd, billedCostNanoUsd: markup.apply(rawCostNanoUsd) };
2556
+ }
2557
+ /**
2558
+ * Place an auth-hold: rate the estimate, apply markup, reserve budget + wallet
2559
+ * headroom (compensated on any step's failure), and write a `pending` ledger
2560
+ * record with a TTL (§2.2 step 1). A repeat with the same `idempotencyKey`
2561
+ * returns the EXISTING hold without re-reserving — this prioritizes not
2562
+ * double-consuming budget/wallet over parameter-conflict detection, so a replay
2563
+ * with a CHANGED estimate silently keeps the original reservation (unlike
2564
+ * `record()`, whose payload hash rejects a changed payload with 409); settlement
2565
+ * still corrects to actuals at capture. Concurrent holds sharing one key can both
2566
+ * pass the pre-check and reserve; the losing ledger insert compensates its
2567
+ * reservation (a brief over-reserve window under high contention).
2568
+ *
2569
+ * @param context The metering context (payer scope, feature).
2570
+ * @param estimate One of the three {@link HoldEstimate} variants.
2571
+ * @returns The plain, serializable {@link Hold}.
2572
+ * @throws {AiTokensException} `AI_TOKENS_BUDGET_EXCEEDED` / `_QUOTA_EXCEEDED` / `_INSUFFICIENT_CREDITS` on a shortfall (prior steps compensated).
2573
+ */
2574
+ async hold(context, estimate) {
2575
+ if (context.idempotencyKey !== void 0) {
2576
+ const existing = await this.ledger.findByIdempotencyKey(context.tenantId, context.idempotencyKey);
2577
+ if (existing !== null) return this.holdOf(existing);
2578
+ }
2579
+ const occurredAt = this.now();
2580
+ const resolved = await resolveHoldEstimate(this.pricing, context, estimate, occurredAt);
2581
+ const markup = await this.markup.resolve({ scope: context.scope, provider: resolved.provider, model: resolved.model, operation: resolved.operation, serviceTier: resolved.serviceTier, feature: context.feature });
2582
+ const billedEstimate = markup.apply(resolved.rawEstimateNanoUsd);
2583
+ const isSystemCost = context.isSystemCost ?? false;
2584
+ const ledgerKey = context.idempotencyKey ?? `hold:${randomUUID()}`;
2585
+ const delta = { billedNanoUsd: billedEstimate, tokens: resolved.estimatedTokens, count: 1 };
2586
+ if (!isSystemCost) await this.effects.reserveHold(context, delta, ledgerKey);
2587
+ const record = await this.appendPending(context, resolved, billedEstimate, markup.multiplier, occurredAt, ledgerKey, isSystemCost, delta);
2588
+ return this.holdOf(record);
2589
+ }
2590
+ /**
2591
+ * Settle a hold with provider-reported actuals (§2.2 step 3). IDEMPOTENT: a repeat
2592
+ * on an already-settled hold returns the posted record unchanged. Applies the
2593
+ * ±delta between the actual and the reserved estimate to wallet/budget/counter.
2594
+ *
2595
+ * @param hold The hold to settle (revalidated against the store + caller tenant).
2596
+ * @param usage The raw provider usage or an already-normalized usage.
2597
+ * @param preset The preset normalizing the usage (or provider-reported mode).
2598
+ * @param content Optional prompt/completion text for the sidecar (§14.2) — the ONLY text path.
2599
+ * @returns The posted usage record.
2600
+ * @throws {AiTokensException} `AI_TOKENS_HOLD_NOT_FOUND` (cross-tenant/missing), `_HOLD_EXPIRED` (reaped), `_HOLD_ALREADY_SETTLED` (released).
2601
+ */
2602
+ async capture(hold, usage, preset2, content) {
2603
+ const record = await this.loadHoldRecord(hold);
2604
+ if (record.status !== "pending") return this.settledOrConflict(record);
2605
+ const normalized = await this.finalizeCaptureUsage(usage, hold, preset2);
2606
+ const usageForRating = {
2607
+ ...normalized,
2608
+ provider: record.provider,
2609
+ model: record.model,
2610
+ operation: record.operation,
2611
+ serviceTier: record.serviceTier,
2612
+ serverToolUse: record.extraUnits ?? {}
2613
+ };
2614
+ const mode = preset2?.ratingMode ?? this.options.ratingMode;
2615
+ const rating = await this.rate(usageForRating, record.serviceTier, record.occurredAt, mode, record.requestedModel);
2616
+ const markup = await this.markup.resolve({ scope: record.scope, provider: record.provider, model: record.model, operation: record.operation, serviceTier: record.serviceTier, feature: record.feature });
2617
+ const counts = tokenCountsOf(usageForRating);
2618
+ const patch = this.settlementPatch(counts, rating, markup);
2619
+ const reservedBilled = record.billedCostNanoUsd;
2620
+ const reservedTokens = record.totalTokens;
2621
+ const settled = await this.ledger.transition(record.id, "pending", "posted", patch);
2622
+ if (settled === null) return this.settledOrConflict(await this.reload(record.id));
2623
+ await this.events.usageRecorded(settled);
2624
+ this.telemetry.recordUsage(settled);
2625
+ await this.captureContent(settled.id, settled.tenantId, content);
2626
+ await this.effects.settleCapture(settled, reservedBilled, reservedTokens, settled.billedCostNanoUsd, settled.totalTokens);
2627
+ return settled;
2628
+ }
2629
+ /**
2630
+ * Void a hold: `pending → released`, restoring wallet/budget in full (§2.2 step
2631
+ * 4). NEVER bills. A no-op (with a warning) on an already-captured hold; a no-op
2632
+ * on an already-released hold. Multi-caller safe via the atomic transition claim.
2633
+ *
2634
+ * @param hold The hold to void (revalidated against the store + caller tenant).
2635
+ * @param reason The caller's stated reason (surfaced on `ai_tokens.hold.released`).
2636
+ * @throws {AiTokensException} `AI_TOKENS_HOLD_NOT_FOUND` on a cross-tenant/missing hold.
2637
+ */
2638
+ async release(hold, reason) {
2639
+ const record = await this.loadHoldRecord(hold);
2640
+ if (record.status === "posted") {
2641
+ this.logger.warn(`release() called on an already-captured hold ${record.id}; ignoring (release never bills)`);
2642
+ return;
2643
+ }
2644
+ if (record.status !== "pending") return;
2645
+ const claimed = await this.ledger.transition(record.id, "pending", "released");
2646
+ if (claimed === null) return;
2647
+ await this.restoreReleasedHold(claimed, reason, false);
2648
+ }
2649
+ /**
2650
+ * The most common entry point: hold → run `fn` → capture (release on error). With
2651
+ * no estimate it skips the hold and runs `record({ enforce: true })` post-hoc.
2652
+ *
2653
+ * @param fn The metered async function (the LLM call).
2654
+ * @param context The metering context (payer scope, feature, preset).
2655
+ * @param extract Pull the raw usage out of `fn`'s result.
2656
+ * @param estimate The optional hold estimate; omit for the post-hoc enforce path.
2657
+ * @returns The function result and its settled usage record.
2658
+ */
2659
+ async meter(fn, context, extract, estimate) {
2660
+ const startedAt = this.now().getTime();
2661
+ if (estimate === void 0) {
2662
+ const result2 = await fn();
2663
+ const usage = await this.record({ usage: extract(result2), ...context.preset !== void 0 ? { preset: context.preset } : {}, context: { ...context, enforce: true } });
2664
+ this.telemetry.recordDuration(usage, this.now().getTime() - startedAt);
2665
+ return { result: result2, usage };
2666
+ }
2667
+ const hold = await this.hold(context, estimate);
2668
+ let result;
2669
+ try {
2670
+ result = await fn();
2671
+ } catch (error) {
2672
+ await this.release(hold, "metered function threw");
2673
+ throw error;
2674
+ }
2675
+ try {
2676
+ const usage = await this.capture(hold, extract(result), context.preset);
2677
+ this.telemetry.recordDuration(usage, this.now().getTime() - startedAt);
2678
+ return { result, usage };
2679
+ } catch (error) {
2680
+ await this.release(hold, "capture failed").catch(() => void 0);
2681
+ throw error;
2682
+ }
2683
+ }
2684
+ /**
2685
+ * Orchestrated compensation (§8.5 step 3): reverse the ledger record, then — for
2686
+ * an enforced, non-system record — refund the wallet and release the budget
2687
+ * (cost, tokens, and count). ADMIN PLANE (§14.4): the host MUST restrict this to
2688
+ * privileged roles; it emits `ai_tokens.usage.reversed` and `ai_tokens.audit`.
2689
+ * Cross-store effects are best-effort sequential with logged partial failures;
2690
+ * deterministic keys make retries safe.
2691
+ *
2692
+ * @param usageRecordId The posted record to reverse.
2693
+ * @param reason The caller's stated reason.
2694
+ * @returns The compensating record.
2695
+ * @throws {AiTokensException} `AI_TOKENS_IDEMPOTENCY_CONFLICT` when the record is missing or not `posted`.
2696
+ */
2697
+ async reverse(usageRecordId, reason) {
2698
+ const compensating = await this.ledger.reverse(usageRecordId, reason);
2699
+ const original = await this.ledger.findById(usageRecordId);
2700
+ if (original !== null && original.enforced && !original.isSystemCost) {
2701
+ await this.effects.reverseEffects(original, reason);
2702
+ }
2703
+ await this.events.usageReversed(original ?? compensating, compensating.id, reason);
2704
+ await this.events.audit("ai_tokens.usage.reversed", { tenantId: compensating.tenantId, usageRecordId, reversalRecordId: compensating.id, reason });
2705
+ return compensating;
2706
+ }
2707
+ /**
2708
+ * Combined wallet + budget status (§10.6) — the usage-meter data source. No side
2709
+ * effects. The wallet section is absent when wallets are disabled or the scope is
2710
+ * a `'key'` (keys spend their owner's wallet). `hasAccess` is false with
2711
+ * `blockedBy` set when the wallet or a hard budget is exhausted.
2712
+ *
2713
+ * @param tenantId The owning tenant.
2714
+ * @param scope The scope to report for.
2715
+ * @returns The combined access status.
2716
+ */
2717
+ async getStatus(tenantId, scope) {
2718
+ const budgets = this.budgets !== void 0 ? await this.budgets.status(tenantId, scope) : [];
2719
+ const wallet = await this.walletSection(tenantId, scope);
2720
+ const budgetBlocked = budgets.some((status) => isHardExhausted(status));
2721
+ const walletBlocked = wallet !== void 0 && wallet.balanceNanoUsd + wallet.overdraftRemainingNanoUsd <= 0n;
2722
+ const blockedBy = walletBlocked ? "wallet" : budgetBlocked ? "budget" : void 0;
2723
+ return { hasAccess: blockedBy === void 0, ...blockedBy !== void 0 ? { blockedBy } : {}, ...wallet !== void 0 ? { wallet } : {}, budgets };
2724
+ }
2725
+ /**
2726
+ * Restore a reclaimed hold and announce it — the shared code path for `release()`
2727
+ * and the hold reaper (§8.3). The caller has already won the `pending → released`
2728
+ * transition claim.
2729
+ *
2730
+ * @param record The just-released record (carries the reserved amounts).
2731
+ * @param reason The stated reason.
2732
+ * @param expired True when reclaimed by the reaper (vs an explicit release).
2733
+ */
2734
+ async restoreReleasedHold(record, reason, expired) {
2735
+ await this.effects.restoreHold(record);
2736
+ await this.events.holdReleased(record, reason, expired);
2737
+ }
2738
+ /** Reserve the pending ledger row, compensating the reservation if the insert fails. */
2739
+ async appendPending(context, resolved, billedEstimate, markupMultiplier, occurredAt, ledgerKey, isSystemCost, delta) {
2740
+ try {
2741
+ return await this.ledger.append(
2742
+ {
2743
+ ...baseColumns(resolved, context, resolved.serviceTier),
2744
+ ...resolved.tokenCounts,
2745
+ priceVersionId: null,
2746
+ rawCostNanoUsd: resolved.rawEstimateNanoUsd,
2747
+ surchargeNanoUsd: 0n,
2748
+ billedCostNanoUsd: billedEstimate,
2749
+ markupMultiplier,
2750
+ currency: "USD",
2751
+ priceMissing: false,
2752
+ status: "pending",
2753
+ isSystemCost,
2754
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { systemCostCategory: undefined } is equivalent to {}
2755
+ ...context.systemCostCategory !== void 0 ? { systemCostCategory: context.systemCostCategory } : {},
2756
+ enforced: !isSystemCost,
2757
+ occurredAt
2758
+ },
2759
+ ledgerKey
2760
+ );
2761
+ } catch (error) {
2762
+ if (!isSystemCost) await this.effects.compensateHold(context, delta, ledgerKey);
2763
+ throw error;
2764
+ }
2765
+ }
2766
+ /** Forward host-supplied prompt/completion text to the opt-in content sidecar (§14.2). */
2767
+ captureContent(usageRecordId, tenantId, content) {
2768
+ if (content === void 0) return Promise.resolve();
2769
+ return this.content.capture({ usageRecordId, tenantId, prompt: content.prompt, completion: content.completion });
2770
+ }
2771
+ /** Build the {@link Hold} view of a pending (or replayed) ledger record. */
2772
+ holdOf(record) {
2773
+ const ttlSeconds = (this.options.holds ?? DEFAULT_HOLDS).ttlSeconds;
2774
+ return {
2775
+ id: record.id,
2776
+ tenantId: record.tenantId,
2777
+ scope: record.scope,
2778
+ estimatedTokens: record.totalTokens,
2779
+ estimatedCostNanoUsd: record.billedCostNanoUsd,
2780
+ expiresAt: new Date(record.createdAt.getTime() + ttlSeconds * 1e3)
2781
+ };
2782
+ }
2783
+ /** Load a hold's record and reject a cross-tenant/scope or missing hold as HOLD_NOT_FOUND (§14.4). */
2784
+ async loadHoldRecord(hold) {
2785
+ const record = await this.ledger.findById(hold.id);
2786
+ if (record === null) throw new AiTokensException("AI_TOKENS_HOLD_NOT_FOUND", void 0, { holdId: hold.id });
2787
+ if (record.tenantId !== hold.tenantId || !sameScope(record.scope, hold.scope)) {
2788
+ throw new AiTokensException("AI_TOKENS_HOLD_NOT_FOUND", void 0, { holdId: hold.id });
2789
+ }
2790
+ return record;
2791
+ }
2792
+ /** Dispatch a non-pending hold record: posted → idempotent return; released → expired/settled. */
2793
+ settledOrConflict(record) {
2794
+ if (record.status === "posted") return record;
2795
+ if (record.status === "released") {
2796
+ const ttlSeconds = (this.options.holds ?? DEFAULT_HOLDS).ttlSeconds;
2797
+ const expiredAt = record.createdAt.getTime() + ttlSeconds * 1e3;
2798
+ if (this.now().getTime() >= expiredAt) {
2799
+ throw new AiTokensException("AI_TOKENS_HOLD_EXPIRED", void 0, { holdId: record.id });
2800
+ }
2801
+ }
2802
+ throw new AiTokensException("AI_TOKENS_HOLD_ALREADY_SETTLED", void 0, { holdId: record.id });
2803
+ }
2804
+ /** Reload a record by id, or fail HOLD_NOT_FOUND when it vanished mid-capture. */
2805
+ async reload(id) {
2806
+ const record = await this.ledger.findById(id);
2807
+ if (record === null) throw new AiTokensException("AI_TOKENS_HOLD_NOT_FOUND", void 0, { holdId: id });
2808
+ return record;
2809
+ }
2810
+ /**
2811
+ * Normalize the usage handed to `capture()`. A {@link StreamUsageCollector} is
2812
+ * finalized here; when it fell back to a tokenizer count with no prompt tokens
2813
+ * (input `0`), the hold's estimated tokens supply the aborted-stream input
2814
+ * fallback (spec §5.6 order: collector prompt count → hold estimate → 0).
2815
+ */
2816
+ async finalizeCaptureUsage(usage, hold, preset2) {
2817
+ if (usage instanceof StreamUsageCollector) {
2818
+ const finalized = await usage.finalize();
2819
+ if (usage.usedFallback && finalized.inputTokens === 0 && hold.estimatedTokens > 0) {
2820
+ return { ...finalized, inputTokens: hold.estimatedTokens };
2821
+ }
2822
+ return finalized;
2823
+ }
2824
+ return normalizeCaptureUsage(usage, preset2?.normalizer);
2825
+ }
2826
+ /** Build the settlement patch for a `pending → posted` transition. */
2827
+ settlementPatch(counts, rating, markup) {
2828
+ return {
2829
+ ...counts,
2830
+ totalTokens: sumTokenCounts(counts),
2831
+ rawCostNanoUsd: rating.rawCostNanoUsd,
2832
+ surchargeNanoUsd: rating.surchargeNanoUsd,
2833
+ billedCostNanoUsd: markup.apply(rating.rawCostNanoUsd),
2834
+ markupMultiplier: markup.multiplier,
2835
+ priceVersionId: rating.priceVersionId,
2836
+ priceMissing: rating.priceMissing
2837
+ };
2838
+ }
2839
+ /** Build the wallet section of an {@link AccessStatus}, or `undefined` when wallets are off / scope is a key. */
2840
+ async walletSection(tenantId, scope) {
2841
+ if (this.wallets === void 0 || !scopeOwnsWallet(scope)) return void 0;
2842
+ const balance = await this.wallets.getBalance(scopeToWalletRef(tenantId, scope));
2843
+ const overdraftRemainingNanoUsd = this.overdraftNanoUsd + (balance.nanoUsd < 0n ? balance.nanoUsd : 0n);
2844
+ return { balanceNanoUsd: balance.nanoUsd, credits: balance.credits, overdraftRemainingNanoUsd: overdraftRemainingNanoUsd < 0n ? 0n : overdraftRemainingNanoUsd };
2845
+ }
2846
+ /** Resolve the normalizer chain and produce the normalized usage. */
2847
+ resolveNormalizedUsage(input) {
2848
+ const normalizer = input.normalizer ?? input.preset?.normalizer;
2849
+ if (normalizer !== void 0) return this.normalize(normalizer, input.usage);
2850
+ if (isNormalizedUsage(input.usage)) return input.usage;
2851
+ throw new AiTokensException("AI_TOKENS_UNKNOWN_PROVIDER", void 0, {
2852
+ // Stryker disable next-line StringLiteral -- error reason text is internal diagnostics
2853
+ reason: "raw usage requires a preset or normalizer, or an already-normalized usage"
2854
+ });
2855
+ }
2856
+ /** Resolve markup for a settled/pending call from the normalized identity. */
2857
+ resolveMarkup(context, normalized, serviceTier) {
2858
+ return this.markup.resolve({ scope: context.scope, provider: normalized.provider, model: normalized.model, operation: normalized.operation, serviceTier, feature: context.feature });
2859
+ }
2860
+ /** Run a normalizer, wrapping a plain failure as a malformed-usage error. */
2861
+ normalize(normalizer, usage) {
2862
+ try {
2863
+ return normalizer(usage);
2864
+ } catch (error) {
2865
+ if (error instanceof AiTokensException) throw error;
2866
+ throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "the normalizer could not read the usage token fields" });
2867
+ }
2868
+ }
2869
+ /** Rate a call in the resolved mode; a non-strict rate miss yields zero cost + `priceMissing`. */
2870
+ async rate(usage, serviceTier, occurredAt, mode, baseModel) {
2871
+ if (mode === "provider-reported") {
2872
+ const cost = usage.providerReportedCostNanoUsd;
2873
+ if (cost === void 0) {
2874
+ throw new AiTokensException("AI_TOKENS_USAGE_MALFORMED", void 0, { reason: "provider-reported rating requires providerReportedCostNanoUsd" });
2875
+ }
2876
+ return { rawCostNanoUsd: cost, surchargeNanoUsd: 0n, priceVersionId: null, priceMissing: false };
2877
+ }
2878
+ const rate = await this.pricing.resolveRate({
2879
+ provider: usage.provider,
2880
+ model: usage.model,
2881
+ operation: usage.operation,
2882
+ serviceTier,
2883
+ at: occurredAt,
2884
+ ...baseModel !== void 0 ? { baseModel } : {}
2885
+ });
2886
+ if (rate === null) return { rawCostNanoUsd: 0n, surchargeNanoUsd: 0n, priceVersionId: null, priceMissing: true };
2887
+ const breakdown = computeCostNanoUsd(usage, rate);
2888
+ return { rawCostNanoUsd: breakdown.totalNanoUsd, surchargeNanoUsd: breakdown.surchargeNanoUsd, priceVersionId: rate.id, priceMissing: false };
2889
+ }
2890
+ };
2891
+ MeteringService = __decorateClass([
2892
+ Injectable()
2893
+ ], MeteringService);
2894
+ function sameScope(a, b) {
2895
+ return a.type === b.type && a.id === b.id;
2896
+ }
2897
+ function isHardExhausted(status) {
2898
+ if (status.policy !== "block") return false;
2899
+ if (status.limit.nanoUsd !== void 0 && status.spent.nanoUsd >= status.limit.nanoUsd) return true;
2900
+ if (status.limit.tokens !== void 0 && status.spent.tokens >= status.limit.tokens) return true;
2901
+ return status.limit.count !== void 0 && status.spent.count >= status.limit.count;
2902
+ }
2903
+ var NOOP_WALLET_HOOKS = {
2904
+ granted: () => void 0,
2905
+ depleted: () => void 0,
2906
+ audit: () => void 0
2907
+ };
2908
+ var WalletService = class {
2909
+ /**
2910
+ * @param store The wallet persistence port.
2911
+ * @param options The resolved wallet settings (credit rate, overdraft).
2912
+ * @param events The event hooks; the module wires them to the dispatcher.
2913
+ */
2914
+ constructor(store, options, events = NOOP_WALLET_HOOKS) {
2915
+ this.store = store;
2916
+ this.options = options;
2917
+ this.events = events;
2918
+ }
2919
+ store;
2920
+ options;
2921
+ events;
2922
+ /**
2923
+ * The current balance, excluding not-yet-effective and expired grants (§9.2).
2924
+ * Reads the materialized balance; expiry is applied lazily on debit, so a grant
2925
+ * that lapses purely with the passage of time is reflected at the next debit or
2926
+ * `reconcile` (the documented §9.3 lazy-materialization trade-off).
2927
+ *
2928
+ * @param ref The wallet owner (a `'tenant' | 'team' | 'user'`, never `'key'`).
2929
+ * @returns The nano-USD balance and its presentation credits.
2930
+ */
2931
+ async getBalance(ref) {
2932
+ this.assertOwner(ref);
2933
+ const wallet = await this.store.getWallet(ref);
2934
+ const nanoUsd = wallet?.balanceNanoUsd ?? 0n;
2935
+ return { nanoUsd, credits: this.toCredits(nanoUsd) };
2936
+ }
2937
+ /**
2938
+ * Grant credits (allowance, purchase, promo). Auto-creates the wallet on the
2939
+ * first grant. ADMIN PLANE (§14.4): the host MUST restrict this to privileged
2940
+ * roles. Emits `ai_tokens.wallet.granted` and `ai_tokens.audit`.
2941
+ *
2942
+ * @param ref The wallet owner.
2943
+ * @param input The grant amount (`> 0`), optional schedule, key, and reason.
2944
+ * @returns The appended grant entry.
2945
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` on a non-positive amount / `'key'` owner; `AI_TOKENS_IDEMPOTENCY_CONFLICT` on a key reuse with a different payload.
2946
+ */
2947
+ async grant(ref, input) {
2948
+ this.assertOwner(ref);
2949
+ if (input.amountNanoUsd <= 0n) throw this.invalid("grant amount must be greater than 0");
2950
+ const entry = await this.append(ref, {
2951
+ type: "grant",
2952
+ amountNanoUsd: input.amountNanoUsd,
2953
+ priority: input.priority ?? 0,
2954
+ effectiveAt: input.effectiveAt ?? /* @__PURE__ */ new Date(),
2955
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { expiresAt: undefined } is equivalent to {} because InMemoryWalletStore and consumers check expiresAt !== undefined
2956
+ ...input.expiresAt !== void 0 ? { expiresAt: input.expiresAt } : {},
2957
+ idempotencyKey: input.idempotencyKey,
2958
+ reason: input.reason
2959
+ });
2960
+ const granted = {
2961
+ walletId: entry.walletId,
2962
+ entryId: entry.id,
2963
+ amountNanoUsd: entry.amountNanoUsd,
2964
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { expiresAt: undefined } is equivalent to {} because event consumers check expiresAt !== undefined
2965
+ ...entry.expiresAt !== void 0 ? { expiresAt: entry.expiresAt } : {}
2966
+ };
2967
+ await this.events.granted(ref, granted);
2968
+ await this.events.audit("ai_tokens.wallet.granted", {
2969
+ tenantId: ref.tenantId,
2970
+ walletId: entry.walletId,
2971
+ entryId: entry.id,
2972
+ amountNanoUsd: entry.amountNanoUsd.toString(),
2973
+ reason: input.reason
2974
+ });
2975
+ return entry;
2976
+ }
2977
+ /**
2978
+ * Atomic conditional debit (§9.4). `usageRecordId` links usage-driven debits;
2979
+ * a non-usage debit (e.g. a voucher reservation) omits it and MUST carry a
2980
+ * `reason`. The store reserves against the materialized balance and records the
2981
+ * grant burn-down allocation in one atomic step — never a check-then-write.
2982
+ *
2983
+ * @param ref The wallet owner.
2984
+ * @param input The debit amount (`> 0`), optional `usageRecordId`, key, and reason.
2985
+ * @returns The appended debit entry.
2986
+ * @throws {AiTokensException} `AI_TOKENS_INSUFFICIENT_CREDITS` (402) when the balance would fall below `-overdraft`; `AI_TOKENS_INVALID_CONFIG` when neither `usageRecordId` nor `reason` is present, or the amount is not positive.
2987
+ */
2988
+ async debit(ref, input) {
2989
+ this.assertOwner(ref);
2990
+ if (input.amountNanoUsd <= 0n) throw this.invalid("debit amount must be greater than 0");
2991
+ if (input.usageRecordId === void 0 && input.reason === void 0) {
2992
+ throw this.invalid("a debit without a usageRecordId must carry a reason");
2993
+ }
2994
+ const entry = {
2995
+ // Stryker disable next-line StringLiteral -- placeholder walletId; the store's insert always assigns the real wallet id and never reads this sentinel, so any string here is equivalent
2996
+ walletId: "",
2997
+ type: "debit",
2998
+ amountNanoUsd: -input.amountNanoUsd,
2999
+ priority: 0,
3000
+ effectiveAt: /* @__PURE__ */ new Date(),
3001
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { usageRecordId: undefined } is equivalent to {} because store and consumers check usageRecordId !== undefined
3002
+ ...input.usageRecordId !== void 0 ? { usageRecordId: input.usageRecordId } : {},
3003
+ idempotencyKey: input.idempotencyKey,
3004
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { reason: undefined } is equivalent to {} because store and consumers check reason !== undefined
3005
+ ...input.reason !== void 0 ? { reason: input.reason } : {}
3006
+ };
3007
+ const debited = await this.conditionalDebit(ref, entry, input.amountNanoUsd);
3008
+ const balance = await this.store.getWallet(ref);
3009
+ if (balance !== null && balance.balanceNanoUsd <= 0n) {
3010
+ await this.events.depleted(ref, { walletId: debited.walletId, balanceNanoUsd: balance.balanceNanoUsd });
3011
+ }
3012
+ return debited;
3013
+ }
3014
+ /**
3015
+ * Refund a previous debit (a plain `refund` credit entry). Never resurrects an
3016
+ * expired grant — the credit is unallocated balance (§9.3). Refunds are always
3017
+ * allowed (they can restore a negative balance).
3018
+ *
3019
+ * @param ref The wallet owner.
3020
+ * @param input The refund amount (`> 0`), optional `usageRecordId`, key, and reason.
3021
+ * @returns The appended refund entry.
3022
+ */
3023
+ async refund(ref, input) {
3024
+ this.assertOwner(ref);
3025
+ if (input.amountNanoUsd <= 0n) throw this.invalid("refund amount must be greater than 0");
3026
+ return this.append(ref, {
3027
+ type: "refund",
3028
+ amountNanoUsd: input.amountNanoUsd,
3029
+ priority: 0,
3030
+ effectiveAt: /* @__PURE__ */ new Date(),
3031
+ // Stryker disable next-line ConditionalExpression,ObjectLiteral -- CE true / OL {}: spreading { usageRecordId: undefined } is equivalent to {} because store and consumers check usageRecordId !== undefined
3032
+ ...input.usageRecordId !== void 0 ? { usageRecordId: input.usageRecordId } : {},
3033
+ idempotencyKey: input.idempotencyKey,
3034
+ reason: input.reason
3035
+ });
3036
+ }
3037
+ /**
3038
+ * Signed manual correction (ADMIN PLANE — §14.4; the host MUST restrict this to
3039
+ * privileged roles). A positive adjustment auto-creates the wallet. Emits
3040
+ * `ai_tokens.audit`.
3041
+ *
3042
+ * @param ref The wallet owner.
3043
+ * @param input The signed amount (`!= 0`), key, and reason.
3044
+ * @returns The appended adjustment entry.
3045
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` on a zero amount / `'key'` owner.
3046
+ */
3047
+ async adjust(ref, input) {
3048
+ this.assertOwner(ref);
3049
+ if (input.amountNanoUsd === 0n) throw this.invalid("adjustment amount must not be 0");
3050
+ const entry = await this.append(ref, {
3051
+ type: "adjustment",
3052
+ amountNanoUsd: input.amountNanoUsd,
3053
+ priority: 0,
3054
+ effectiveAt: /* @__PURE__ */ new Date(),
3055
+ idempotencyKey: input.idempotencyKey,
3056
+ reason: input.reason
3057
+ });
3058
+ await this.events.audit("ai_tokens.wallet.adjusted", {
3059
+ tenantId: ref.tenantId,
3060
+ walletId: entry.walletId,
3061
+ entryId: entry.id,
3062
+ amountNanoUsd: entry.amountNanoUsd.toString(),
3063
+ reason: input.reason
3064
+ });
3065
+ return entry;
3066
+ }
3067
+ /**
3068
+ * Apply an unconditional signed settlement adjustment — the capture ±delta vs a
3069
+ * hold (§11.2). Unlike {@link debit} it NEVER blocks on the balance (capture must
3070
+ * settle actuals even into overdraft) and unlike {@link adjust} it emits NO admin
3071
+ * audit (it is a system settlement, not a manual correction). Appends an
3072
+ * `adjustment` entry; a positive amount credits (refund of an over-reservation), a
3073
+ * negative amount debits (top-up when actuals exceeded the estimate).
3074
+ *
3075
+ * @param ref The wallet owner.
3076
+ * @param input The signed amount (`!= 0`), optional `usageRecordId`, key, and reason.
3077
+ * @returns The appended adjustment entry.
3078
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` on a zero amount / `'key'` owner.
3079
+ */
3080
+ async settleAdjustment(ref, input) {
3081
+ this.assertOwner(ref);
3082
+ if (input.amountNanoUsd === 0n) throw this.invalid("settlement adjustment amount must not be 0");
3083
+ return this.append(ref, {
3084
+ type: "adjustment",
3085
+ amountNanoUsd: input.amountNanoUsd,
3086
+ priority: 0,
3087
+ effectiveAt: /* @__PURE__ */ new Date(),
3088
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { usageRecordId: undefined } is equivalent to {} because store and consumers check usageRecordId !== undefined
3089
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { usageRecordId: undefined } is equivalent to {} because store and consumers check usageRecordId !== undefined
3090
+ ...input.usageRecordId !== void 0 ? { usageRecordId: input.usageRecordId } : {},
3091
+ idempotencyKey: input.idempotencyKey,
3092
+ reason: input.reason
3093
+ });
3094
+ }
3095
+ /**
3096
+ * Paginated entry history with type/date filters — the transaction listing a
3097
+ * usage meter renders.
3098
+ *
3099
+ * @param ref The wallet owner.
3100
+ * @param filter Optional type/date filters and pagination.
3101
+ * @returns A page of entries and the total match count.
3102
+ */
3103
+ async getEntries(ref, filter) {
3104
+ this.assertOwner(ref);
3105
+ return this.store.listEntries(ref, filter);
3106
+ }
3107
+ /**
3108
+ * Recompute and repair the materialized balance from the entry ledger (§9.4).
3109
+ * ADMIN PLANE (§14.4): the host MUST restrict this to privileged roles. Emits
3110
+ * `ai_tokens.audit`.
3111
+ *
3112
+ * @param ref The wallet owner.
3113
+ * @returns The reconciled wallet.
3114
+ */
3115
+ async reconcile(ref) {
3116
+ this.assertOwner(ref);
3117
+ const wallet = await this.store.reconcile(ref);
3118
+ await this.events.audit("ai_tokens.wallet.reconciled", {
3119
+ tenantId: ref.tenantId,
3120
+ walletId: wallet.id,
3121
+ balanceNanoUsd: wallet.balanceNanoUsd.toString()
3122
+ });
3123
+ return { nanoUsd: wallet.balanceNanoUsd, credits: this.toCredits(wallet.balanceNanoUsd) };
3124
+ }
3125
+ /** Append a non-debit entry, mapping a store idempotency/missing-wallet signal to the catalog. */
3126
+ async append(ref, entry) {
3127
+ try {
3128
+ return await this.store.appendEntry(ref, { walletId: "", ...entry });
3129
+ } catch (error) {
3130
+ throw this.mapStoreError(error, ref, entry.idempotencyKey);
3131
+ }
3132
+ }
3133
+ /** Run the atomic conditional debit, mapping `null` to insufficient credits (§9.4). */
3134
+ async conditionalDebit(ref, entry, requested) {
3135
+ let result;
3136
+ try {
3137
+ result = await this.store.conditionalDebit(ref, entry, this.options.overdraftNanoUsd);
3138
+ } catch (error) {
3139
+ throw this.mapStoreError(error, ref, entry.idempotencyKey);
3140
+ }
3141
+ if (result === null) {
3142
+ const wallet = await this.store.getWallet(ref);
3143
+ throw new AiTokensException("AI_TOKENS_INSUFFICIENT_CREDITS", void 0, {
3144
+ balanceNanoUsd: (wallet?.balanceNanoUsd ?? 0n).toString(),
3145
+ requestedNanoUsd: requested.toString()
3146
+ });
3147
+ }
3148
+ return result;
3149
+ }
3150
+ /** Map a store idempotency conflict / missing-wallet signal to the typed catalog error. */
3151
+ mapStoreError(error, ref, idempotencyKey) {
3152
+ if (isLedgerIdempotencyConflict(error)) {
3153
+ return new AiTokensException("AI_TOKENS_IDEMPOTENCY_CONFLICT", void 0, {
3154
+ tenantId: ref.tenantId,
3155
+ idempotencyKey
3156
+ });
3157
+ }
3158
+ if (isWalletMissing(error)) {
3159
+ return new AiTokensException("AI_TOKENS_INSUFFICIENT_CREDITS", void 0, { reason: "wallet does not exist" });
3160
+ }
3161
+ return error instanceof AiTokensException ? error : new AiTokensException("AI_TOKENS_STORE_ERROR", void 0, {});
3162
+ }
3163
+ /** Reject a `'key'` owner at runtime (`'key'` scopes cannot own money, §9.1). */
3164
+ assertOwner(ref) {
3165
+ if (ref.ownerType === "key") throw this.invalid("'key' scopes cannot own a wallet");
3166
+ }
3167
+ /** Present a nano-USD balance as credits (presentation-only division). */
3168
+ toCredits(nanoUsd) {
3169
+ return Number(nanoUsd) / Number(this.options.creditRateNanoUsd);
3170
+ }
3171
+ /** Build the invalid-config exception with an actionable reason. */
3172
+ invalid(reason) {
3173
+ return new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason });
3174
+ }
3175
+ };
3176
+ WalletService = __decorateClass([
3177
+ Injectable()
3178
+ ], WalletService);
3179
+ function isWalletMissing(error) {
3180
+ if (typeof error !== "object" || error === null) return false;
3181
+ return error.isWalletMissing === true;
3182
+ }
3183
+
3184
+ // src/server/services/budget-predicate.ts
3185
+ function recordConsumesBudget(record, budget, windowStart, windowEnd) {
3186
+ if (!record.enforced) return false;
3187
+ if (record.isSystemCost) return false;
3188
+ if (!featureMatches2(budget.features, record.feature)) return false;
3189
+ if (record.status !== "posted" && record.status !== "reversed") return false;
3190
+ return record.occurredAt >= windowStart && (windowEnd === null || record.occurredAt < windowEnd);
3191
+ }
3192
+ function featureMatches2(features, feature) {
3193
+ return features === void 0 || features.length === 0 || features.includes(feature);
3194
+ }
3195
+
3196
+ // src/server/utils/window-anchor.ts
3197
+ var DAY_MS = 864e5;
3198
+ var WEEK_MS = DAY_MS * 7;
3199
+ function startOfUtcDay(at) {
3200
+ return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()));
3201
+ }
3202
+ function startOfUtcWeek(at) {
3203
+ const day = startOfUtcDay(at);
3204
+ return new Date(day.getTime() - day.getUTCDay() * DAY_MS);
3205
+ }
3206
+ function startOfUtcMonth(at) {
3207
+ return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1));
3208
+ }
3209
+ function daysInUtcMonth(year, month) {
3210
+ return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
3211
+ }
3212
+ function addMonthsClamped(anchor, months) {
3213
+ const targetIndex = anchor.getUTCMonth() + months;
3214
+ const year = anchor.getUTCFullYear() + Math.floor(targetIndex / 12);
3215
+ const month = (targetIndex % 12 + 12) % 12;
3216
+ const day = Math.min(anchor.getUTCDate(), daysInUtcMonth(year, month));
3217
+ return new Date(
3218
+ Date.UTC(year, month, day, anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), anchor.getUTCMilliseconds())
3219
+ );
3220
+ }
3221
+ function monthsBetween(anchor, windowStart) {
3222
+ return (windowStart.getUTCFullYear() - anchor.getUTCFullYear()) * 12 + (windowStart.getUTCMonth() - anchor.getUTCMonth());
3223
+ }
3224
+ function anchoredMonthIndex(anchor, at) {
3225
+ const estimate = monthsBetween(anchor, at);
3226
+ return addMonthsClamped(anchor, estimate).getTime() <= at.getTime() ? estimate : estimate - 1;
3227
+ }
3228
+ function periodMsOf(window) {
3229
+ if (window === "day") return DAY_MS;
3230
+ if (window === "week") return WEEK_MS;
3231
+ return window.customSeconds * 1e3;
3232
+ }
3233
+ function fixedWindowStart(anchor, periodMs, at) {
3234
+ const k = Math.floor((at.getTime() - anchor.getTime()) / periodMs);
3235
+ return new Date(anchor.getTime() + k * periodMs);
3236
+ }
3237
+ function windowStartFor(budget, at) {
3238
+ if (budget.window === "total") return new Date((budget.anchorAt ?? budget.createdAt).getTime());
3239
+ if (budget.window === "month") {
3240
+ if (budget.anchorAt === void 0) return startOfUtcMonth(at);
3241
+ return addMonthsClamped(budget.anchorAt, anchoredMonthIndex(budget.anchorAt, at));
3242
+ }
3243
+ if (budget.anchorAt !== void 0) return fixedWindowStart(budget.anchorAt, periodMsOf(budget.window), at);
3244
+ if (budget.window === "day") return startOfUtcDay(at);
3245
+ if (budget.window === "week") return startOfUtcWeek(at);
3246
+ return fixedWindowStart(budget.createdAt, periodMsOf(budget.window), at);
3247
+ }
3248
+ function resetsAtFor(budget, windowStart) {
3249
+ if (budget.window === "total") return null;
3250
+ if (budget.window === "month") {
3251
+ if (budget.anchorAt === void 0) {
3252
+ return new Date(Date.UTC(windowStart.getUTCFullYear(), windowStart.getUTCMonth() + 1, 1));
3253
+ }
3254
+ return addMonthsClamped(budget.anchorAt, monthsBetween(budget.anchorAt, windowStart) + 1);
3255
+ }
3256
+ return new Date(windowStart.getTime() + periodMsOf(budget.window));
3257
+ }
3258
+
3259
+ // src/server/services/budget.service.ts
3260
+ var ZERO_SPEND = { spentNanoUsd: 0n, spentTokens: 0, spentCount: 0 };
3261
+ var NOOP_BUDGET_HOOKS = {
3262
+ thresholdCrossed: () => void 0,
3263
+ exceeded: () => void 0,
3264
+ projectedExceeded: () => void 0,
3265
+ audit: () => void 0
3266
+ };
3267
+ var COUNTER_GRACE_SECONDS = 3600;
3268
+ var TOTAL_WINDOW_TTL_SECONDS = 60 * 60 * 24 * 400;
3269
+ var UNBOUNDED_COUNTER_LIMIT = 9223372036854775807n;
3270
+ var BudgetService = class {
3271
+ /**
3272
+ * @param store The budget persistence port.
3273
+ * @param ledger The ledger service (reconcileWindow reads the source of truth).
3274
+ * @param options The resolved budget settings (policy, thresholds, throttle callback).
3275
+ * @param now The injected clock.
3276
+ * @param events The event hooks; the module wires them to the dispatcher.
3277
+ */
3278
+ constructor(store, ledger, options, now = () => /* @__PURE__ */ new Date(), events = NOOP_BUDGET_HOOKS) {
3279
+ this.store = store;
3280
+ this.ledger = ledger;
3281
+ this.options = options;
3282
+ this.now = now;
3283
+ this.events = events;
3284
+ }
3285
+ store;
3286
+ ledger;
3287
+ options;
3288
+ now;
3289
+ events;
3290
+ logger = new Logger(BudgetService.name);
3291
+ /** Highest soft-threshold emitted per `(budgetId, windowStartISO)` — per-instance dedupe (§20). */
3292
+ notifiedThreshold = /* @__PURE__ */ new Map();
3293
+ /** Windows that have already emitted a projected-exceeded event, per instance. */
3294
+ projected = /* @__PURE__ */ new Set();
3295
+ /**
3296
+ * Create or replace a budget (ADMIN PLANE — §14.4; restrict to privileged roles).
3297
+ * Applies the §10.2 normative validation and emits `ai_tokens.audit`.
3298
+ *
3299
+ * @param input The budget definition (`softThresholds`/`policy` default from options).
3300
+ * @returns The stored budget.
3301
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` on a negative limit, no limit dimension, or a threshold outside `(0, 1]`.
3302
+ */
3303
+ async upsertBudget(input) {
3304
+ this.assertLimits(input);
3305
+ const softThresholds = input.softThresholds ?? [...this.options.alertThresholds];
3306
+ for (const threshold of softThresholds) {
3307
+ if (!(threshold > 0 && threshold <= 1)) throw invalid(`softThresholds must be within (0, 1], received ${String(threshold)}`);
3308
+ }
3309
+ const budget = await this.store.upsert({ ...input, softThresholds, policy: input.policy ?? this.options.defaultPolicy });
3310
+ await this.events.audit("ai_tokens.budget.upserted", { tenantId: budget.tenantId, budgetId: budget.id });
3311
+ return budget;
3312
+ }
3313
+ /**
3314
+ * Delete a budget (ADMIN PLANE — §14.4). Emits `ai_tokens.audit`.
3315
+ *
3316
+ * @param budgetId The budget id.
3317
+ * @param tenantId The owning tenant (audit context).
3318
+ */
3319
+ async removeBudget(budgetId, tenantId) {
3320
+ await this.store.remove(budgetId);
3321
+ await this.events.audit("ai_tokens.budget.removed", { tenantId, budgetId });
3322
+ }
3323
+ /**
3324
+ * List budgets applicable to a scope (the exact scope plus tenant-wide budgets).
3325
+ * Defaults to the tenant scope when no scope is supplied.
3326
+ *
3327
+ * @param tenantId The owning tenant.
3328
+ * @param scope The scope to list for; defaults to tenant-wide.
3329
+ * @returns The applicable budgets.
3330
+ */
3331
+ list(tenantId, scope) {
3332
+ return this.store.findMatching(tenantId, scope ?? { type: "tenant", id: tenantId });
3333
+ }
3334
+ /**
3335
+ * Force a fresh window now (or at `newWindowStart`) and re-anchor the budget so
3336
+ * subsequent windows follow the new cycle (ADMIN PLANE — §14.4). Emits
3337
+ * `ai_tokens.audit`.
3338
+ *
3339
+ * @param budgetId The budget id.
3340
+ * @param newWindowStart The new window start; defaults to now.
3341
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when the budget does not exist.
3342
+ */
3343
+ async rotateWindow(budgetId, newWindowStart) {
3344
+ const budget = await this.requireBudget(budgetId);
3345
+ const start = newWindowStart ?? this.now();
3346
+ const windowStart = windowStartFor({ ...budget, anchorAt: start }, start);
3347
+ await this.store.upsert({ ...budget, anchorAt: start });
3348
+ await this.store.setWindowStart(budgetId, windowStart);
3349
+ const counter = this.options.counter;
3350
+ if (counter !== void 0) {
3351
+ for (const dimension of counterDimensions({ nanoUsd: 1n, tokens: 1, count: 1 }, limitsOf(budget))) {
3352
+ try {
3353
+ await counter.reset(counterKey(budgetId, windowStart, dimension.name));
3354
+ } catch {
3355
+ this.logger.warn(`failed to reset budget counter for ${budgetId}`);
3356
+ }
3357
+ }
3358
+ }
3359
+ await this.events.audit("ai_tokens.budget.rotated", { tenantId: budget.tenantId, budgetId, windowStart: start.toISOString() });
3360
+ }
3361
+ /**
3362
+ * Consume every matching budget for a metered call (§10.3/§10.8). `'block'`
3363
+ * budgets use the atomic conditional consume and roll back all prior consumption
3364
+ * on a shortfall (throwing the dimension's typed error); `'allow'`/`'throttle'`
3365
+ * budgets record the spend and signal. Called by the hold/record enforcement paths.
3366
+ *
3367
+ * @param context The metering context (payer scope, feature — trusted input).
3368
+ * @param delta The spend to consume across the three dimensions.
3369
+ * @throws {AiTokensException} `AI_TOKENS_BUDGET_EXCEEDED` (402, cost) or `AI_TOKENS_QUOTA_EXCEEDED` (429, tokens/count) when a `'block'` budget is exhausted.
3370
+ */
3371
+ async consume(context, delta) {
3372
+ const windows = await this.matchingWindows(context.tenantId, context.scope, context.feature);
3373
+ const consumed = [];
3374
+ for (const located of windows) {
3375
+ const before = await this.store.getWindow(located.budget.id, located.windowStart) ?? ZERO_SPEND;
3376
+ await this.applyConsumption(context, located, delta, before, consumed);
3377
+ }
3378
+ }
3379
+ /**
3380
+ * Release a previously-consumed delta across every matching budget (capture
3381
+ * delta, hold release, reversal). Best-effort signed subtraction.
3382
+ *
3383
+ * @param context The metering context.
3384
+ * @param delta The spend to release.
3385
+ */
3386
+ async release(context, delta) {
3387
+ const windows = await this.matchingWindows(context.tenantId, context.scope, context.feature);
3388
+ const counter = this.options.counter;
3389
+ for (const located of windows) {
3390
+ await this.store.adjustWindow(located.budget.id, located.windowStart, negate(delta));
3391
+ if (counter !== void 0) {
3392
+ for (const dimension of counterDimensions(delta, located.limits)) {
3393
+ await this.decrCounters(counter, [{ key: counterKey(located.budget.id, located.windowStart, dimension.name), amount: dimension.amount }]);
3394
+ }
3395
+ }
3396
+ }
3397
+ }
3398
+ /**
3399
+ * Apply a SIGNED spend delta to every matching budget window WITHOUT enforcement
3400
+ * — the capture settlement ±delta (§11.2). A positive delta records extra spend
3401
+ * (actual above the hold estimate), a negative delta releases (actual below);
3402
+ * capture NEVER re-blocks, so this never throws a budget/quota error. Keeps the
3403
+ * live counter in sync (increment/decrement per dimension sign).
3404
+ *
3405
+ * @param context The metering context (payer scope, feature).
3406
+ * @param delta The signed spend delta across the three dimensions.
3407
+ */
3408
+ async adjust(context, delta) {
3409
+ const windows = await this.matchingWindows(context.tenantId, context.scope, context.feature);
3410
+ const counter = this.options.counter;
3411
+ for (const located of windows) {
3412
+ const before = await this.store.getWindow(located.budget.id, located.windowStart) ?? ZERO_SPEND;
3413
+ await this.store.adjustWindow(located.budget.id, located.windowStart, delta);
3414
+ if (counter === void 0) continue;
3415
+ const spends = dimensionSpends(addSpend(before, delta));
3416
+ const ttl = counterTtlSeconds(located.windowStart, located.windowEnd);
3417
+ for (const dimension of counterDimensions(delta, located.limits)) {
3418
+ const key = counterKey(located.budget.id, located.windowStart, dimension.name);
3419
+ await this.adjustCounter(counter, key, dimension.amount, spends[dimension.name], ttl);
3420
+ }
3421
+ }
3422
+ }
3423
+ /**
3424
+ * Unconditionally move a live counter by a signed capture ±delta (never re-blocks).
3425
+ * A positive move uses `incrIfBelow` against the int64 ceiling; a `false` result
3426
+ * means the increment would overflow int64 (or the backend treats the ceiling as a
3427
+ * hard cap), so the counter has DIVERGED from the authoritative DB window — it is
3428
+ * then resynced to `authoritative` (the post-adjust window spend) rather than left
3429
+ * holding a stale value the fast path would trust. A negative move decrements.
3430
+ */
3431
+ async adjustCounter(counter, key, amount, authoritative, ttl) {
3432
+ try {
3433
+ if (amount < 0n) {
3434
+ await counter.decr(key, -amount);
3435
+ return;
3436
+ }
3437
+ if (await counter.incrIfBelow(key, amount, UNBOUNDED_COUNTER_LIMIT, ttl)) return;
3438
+ await this.resyncCounter(counter, key, authoritative, ttl);
3439
+ } catch {
3440
+ this.logger.warn(`failed to adjust budget counter ${key}`);
3441
+ }
3442
+ }
3443
+ /** Discard a diverged counter and reseed it to the authoritative DB window spend (§10.8). */
3444
+ async resyncCounter(counter, key, authoritative, ttl) {
3445
+ await counter.reset(key);
3446
+ await counter.incrIfBelow(key, authoritative, UNBOUNDED_COUNTER_LIMIT, ttl);
3447
+ this.logger.warn(`budget counter ${key} overflowed the int64 ceiling; resynced to the database window spend`);
3448
+ }
3449
+ /**
3450
+ * Recompute a window's counters from the ledger using the §10.7 predicate — the
3451
+ * ledger is the reconcilable source of truth; the window row is a cache.
3452
+ *
3453
+ * @param budgetId The budget id.
3454
+ * @param windowStart A window start for the budget.
3455
+ */
3456
+ async reconcileWindow(budgetId, windowStart) {
3457
+ const budget = await this.requireBudget(budgetId);
3458
+ const windowEnd = resetsAtFor(budget, windowStart);
3459
+ const filter = { tenantId: budget.tenantId, from: windowStart, ...windowEnd !== null ? { to: windowEnd } : {} };
3460
+ const records = await this.ledger.query(filter);
3461
+ const computed = { spentNanoUsd: 0n, spentTokens: 0, spentCount: 0 };
3462
+ for (const record of records) {
3463
+ if (!recordConsumesBudget(record, budget, windowStart, windowEnd)) continue;
3464
+ computed.spentNanoUsd += record.billedCostNanoUsd;
3465
+ computed.spentTokens += record.totalTokens;
3466
+ computed.spentCount += 1;
3467
+ }
3468
+ const current = await this.store.getWindow(budgetId, windowStart) ?? ZERO_SPEND;
3469
+ await this.store.adjustWindow(budgetId, windowStart, {
3470
+ nanoUsd: computed.spentNanoUsd - current.spentNanoUsd,
3471
+ tokens: computed.spentTokens - current.spentTokens,
3472
+ count: computed.spentCount - current.spentCount
3473
+ });
3474
+ }
3475
+ /**
3476
+ * The read-side "how much is left" query across every matching budget (§10.6) —
3477
+ * the usage-meter data source. Reads live window rows (documented freshness
3478
+ * trade-off; no counter read).
3479
+ *
3480
+ * @param tenantId The owning tenant.
3481
+ * @param scope The scope to report for.
3482
+ * @returns One {@link BudgetStatus} per matching budget.
3483
+ * @example
3484
+ * const budgets = await budgetService.status(tenantId, scope)
3485
+ * res.json(toJsonSafe(budgets)) // bigint fields become decimal strings (§15.5)
3486
+ */
3487
+ async status(tenantId, scope) {
3488
+ const budgets = await this.store.findMatching(tenantId, scope);
3489
+ const statuses = [];
3490
+ for (const budget of budgets) {
3491
+ const windowStart = windowStartFor(budget, this.now());
3492
+ const spend = await this.store.getWindow(budget.id, windowStart) ?? ZERO_SPEND;
3493
+ statuses.push(buildStatus(budget, windowStart, resetsAtFor(budget, windowStart), spend));
3494
+ }
3495
+ return statuses;
3496
+ }
3497
+ /** Apply consumption for one located window (throws through {@link blockExceeded} on a hard shortfall). */
3498
+ async applyConsumption(context, located, delta, before, consumed) {
3499
+ let increments = [];
3500
+ if (located.budget.policy === "block") {
3501
+ const result = await this.guardedConsume(located, delta);
3502
+ if (!result.ok) {
3503
+ await this.rollback(consumed);
3504
+ await this.blockExceeded(context, located, before, delta, result.failing);
3505
+ }
3506
+ increments = result.increments;
3507
+ } else {
3508
+ await this.store.adjustWindow(located.budget.id, located.windowStart, delta);
3509
+ await this.softOverLimit(context, located, before, delta);
3510
+ }
3511
+ consumed.push({ budgetId: located.budget.id, windowStart: located.windowStart, delta, increments });
3512
+ await this.signalThresholds(context, located, before, addSpend(before, delta));
3513
+ }
3514
+ /**
3515
+ * The atomic §10.8 consume with the optional live-counter fast path and
3516
+ * fail-closed fallback. With a counter bound, each limited dimension is checked
3517
+ * cheaply via `incrIfBelow` FIRST (a reject touches no DB); on pass the DB
3518
+ * conditional consume remains authoritative and rolls the counter back on a DB
3519
+ * shortfall. A counter that is UNAVAILABLE falls back to the DB alone; if the DB
3520
+ * is also down, `failClosed` blocks (else it allows with a warning). Without a
3521
+ * counter it is the plain DB conditional consume.
3522
+ */
3523
+ async guardedConsume(located, delta) {
3524
+ const counter = this.options.counter;
3525
+ if (counter === void 0) {
3526
+ return { ok: await this.store.conditionalConsume(located.budget.id, located.windowStart, delta, located.limits), increments: [] };
3527
+ }
3528
+ const fast = await this.counterFastPath(counter, located, delta);
3529
+ if (fast.rejected) return { ok: false, increments: [], failing: fast.failing };
3530
+ return this.databaseConsume(counter, located, delta, fast.increments, fast.available);
3531
+ }
3532
+ /**
3533
+ * Try the live-counter fast path. On a hard reject it reports the FAILING dimension
3534
+ * (whose `incrIfBelow` returned `false`) so the block raises the correct §16 code
3535
+ * — a count/token quota is a 429, not the spend 402 the DB-window default would
3536
+ * infer when the counter is ahead of the window row. Otherwise it reports the
3537
+ * increments made and whether the counter was available (an outage falls back to DB).
3538
+ */
3539
+ async counterFastPath(counter, located, delta) {
3540
+ const ttl = counterTtlSeconds(located.windowStart, located.windowEnd);
3541
+ const increments = [];
3542
+ try {
3543
+ for (const dimension of counterDimensions(delta, located.limits)) {
3544
+ const key = counterKey(located.budget.id, located.windowStart, dimension.name);
3545
+ if (await counter.incrIfBelow(key, dimension.amount, dimension.limit, ttl)) {
3546
+ increments.push({ key, amount: dimension.amount });
3547
+ continue;
3548
+ }
3549
+ await this.decrCounters(counter, increments);
3550
+ return { rejected: true, failing: dimensionCode(dimension.name) };
3551
+ }
3552
+ return { rejected: false, increments, available: true };
3553
+ } catch {
3554
+ await this.decrCounters(counter, increments);
3555
+ this.logger.warn(`budget counter unavailable for ${located.budget.id}; falling back to the database`);
3556
+ return { rejected: false, increments: [], available: false };
3557
+ }
3558
+ }
3559
+ /** Run the authoritative DB consume, rolling the counter back on a shortfall and failing closed on an outage. */
3560
+ async databaseConsume(counter, located, delta, increments, counterAvailable) {
3561
+ try {
3562
+ const ok = await this.store.conditionalConsume(located.budget.id, located.windowStart, delta, located.limits);
3563
+ if (!ok) {
3564
+ if (counterAvailable) await this.decrCounters(counter, increments);
3565
+ return { ok: false, increments: [] };
3566
+ }
3567
+ return { ok: true, increments: counterAvailable ? increments : [] };
3568
+ } catch {
3569
+ if (counterAvailable) await this.decrCounters(counter, increments);
3570
+ if (this.options.failClosed) throw new AiTokensException("AI_TOKENS_STORE_ERROR", void 0, {});
3571
+ this.logger.warn(`budget store unavailable for ${located.budget.id}; allowing (failClosed disabled)`);
3572
+ return { ok: true, increments: [] };
3573
+ }
3574
+ }
3575
+ /** Best-effort counter decrement (rollback); failures are logged, never thrown. */
3576
+ async decrCounters(counter, increments) {
3577
+ for (const increment of increments) {
3578
+ try {
3579
+ await counter.decr(increment.key, increment.amount);
3580
+ } catch {
3581
+ this.logger.warn(`failed to roll back budget counter ${increment.key}`);
3582
+ }
3583
+ }
3584
+ }
3585
+ /**
3586
+ * Emit `exceeded` and throw the dimension's typed error for a blocked budget. The
3587
+ * failing dimension is threaded from the counter fast-path reject when it decided
3588
+ * the block; the DB conditional-consume path passes none, so it defaults to the
3589
+ * over-limit dimension of the DB window spend — both routes map to the same §16 code.
3590
+ */
3591
+ async blockExceeded(context, located, before, delta, failing = failingDimension(addSpend(before, delta), located.limits)) {
3592
+ await this.events.exceeded(context.tenantId, located.budget.scope, {
3593
+ budgetId: located.budget.id,
3594
+ policy: "block",
3595
+ dimension: failing.dimension,
3596
+ limit: limitSnapshot(located.limits),
3597
+ spent: spendSnapshot(before),
3598
+ resetsAt: located.windowEnd
3599
+ });
3600
+ throw new AiTokensException(failing.code, void 0, { budgetId: located.budget.id, dimension: failing.dimension });
3601
+ }
3602
+ /** Signal an over-limit `'allow'`/`'throttle'` budget (records spend, never throws). */
3603
+ async softOverLimit(context, located, before, delta) {
3604
+ const failing = failingDimensionOrNull(addSpend(before, delta), located.limits);
3605
+ if (failing === null) return;
3606
+ if (located.budget.policy === "throttle") {
3607
+ await this.throttle(context, located, before);
3608
+ return;
3609
+ }
3610
+ await this.events.exceeded(context.tenantId, located.budget.scope, {
3611
+ budgetId: located.budget.id,
3612
+ policy: "allow",
3613
+ dimension: failing.dimension,
3614
+ limit: limitSnapshot(located.limits),
3615
+ spent: spendSnapshot(before),
3616
+ resetsAt: located.windowEnd
3617
+ });
3618
+ }
3619
+ /** Invoke the host throttle callback (or warn + allow when none is configured). */
3620
+ async throttle(context, located, before) {
3621
+ const status = buildStatus(located.budget, located.windowStart, located.windowEnd, before);
3622
+ if (this.options.onThrottle === void 0) {
3623
+ this.logger.warn(`budget ${located.budget.id} exceeded with policy 'throttle' but no onThrottle callback is configured`);
3624
+ return;
3625
+ }
3626
+ await this.options.onThrottle({ context, budget: located.budget, status });
3627
+ }
3628
+ /** Emit each soft threshold crossed by this delta, once per window (per-instance dedupe). */
3629
+ async signalThresholds(context, located, before, after) {
3630
+ const key = windowKey(located.budget.id, located.windowStart);
3631
+ const fractionBefore = usedFraction(before, located.limits);
3632
+ const fractionAfter = usedFraction(after, located.limits);
3633
+ const emitted = this.notifiedThreshold.get(key) ?? 0;
3634
+ let highest = emitted;
3635
+ for (const threshold of [...located.budget.softThresholds].sort((a, b) => a - b)) {
3636
+ if (threshold > emitted && threshold > fractionBefore && threshold <= fractionAfter) {
3637
+ await this.emitThreshold(context, located, threshold, fractionAfter, after);
3638
+ highest = threshold;
3639
+ }
3640
+ }
3641
+ if (highest > emitted) this.notifiedThreshold.set(key, highest);
3642
+ await this.signalProjection(context, located, fractionAfter);
3643
+ }
3644
+ /** Emit one `threshold_crossed` event. */
3645
+ emitThreshold(context, located, threshold, fraction, after) {
3646
+ return this.events.thresholdCrossed(context.tenantId, located.budget.scope, {
3647
+ budgetId: located.budget.id,
3648
+ threshold,
3649
+ usedFraction: fraction,
3650
+ limit: limitSnapshot(located.limits),
3651
+ spent: spendSnapshot(after),
3652
+ remaining: remainingSnapshot(located.limits, after),
3653
+ resetsAt: located.windowEnd
3654
+ });
3655
+ }
3656
+ /** Emit `projected_exceeded` once per window when the burn rate projects crossing before reset. */
3657
+ async signalProjection(context, located, fraction) {
3658
+ if (located.windowEnd === null || fraction <= 0 || fraction >= 1) return;
3659
+ const key = windowKey(located.budget.id, located.windowStart);
3660
+ if (this.projected.has(key)) return;
3661
+ const elapsedMs = this.now().getTime() - located.windowStart.getTime();
3662
+ if (elapsedMs <= 0) return;
3663
+ const projectedAt = new Date(located.windowStart.getTime() + elapsedMs / fraction);
3664
+ if (projectedAt >= located.windowEnd) return;
3665
+ this.projected.add(key);
3666
+ await this.events.projectedExceeded(context.tenantId, located.budget.scope, {
3667
+ budgetId: located.budget.id,
3668
+ projectedAt,
3669
+ usedFraction: fraction,
3670
+ resetsAt: located.windowEnd
3671
+ });
3672
+ }
3673
+ /** The matching, feature-relevant budgets located in the current window. */
3674
+ async matchingWindows(tenantId, scope, feature) {
3675
+ const budgets = await this.store.findMatching(tenantId, scope);
3676
+ const now = this.now();
3677
+ return budgets.filter((budget) => featureMatches3(budget.features, feature)).map((budget) => {
3678
+ const windowStart = windowStartFor(budget, now);
3679
+ return { budget, windowStart, windowEnd: resetsAtFor(budget, windowStart), limits: limitsOf(budget) };
3680
+ });
3681
+ }
3682
+ /** Undo every recorded consumption (signed DB subtraction + live-counter decrement). */
3683
+ async rollback(consumed) {
3684
+ const counter = this.options.counter;
3685
+ for (const entry of consumed) {
3686
+ await this.store.adjustWindow(entry.budgetId, entry.windowStart, negate(entry.delta));
3687
+ if (counter !== void 0) await this.decrCounters(counter, entry.increments);
3688
+ }
3689
+ }
3690
+ /** Load a budget by id or throw an invalid-config error. */
3691
+ async requireBudget(budgetId) {
3692
+ const budget = await this.store.findBudgetById(budgetId);
3693
+ if (budget === null) throw invalid(`budget ${budgetId} does not exist`);
3694
+ return budget;
3695
+ }
3696
+ /** Validate the §10.2 limit rules: no negatives and at least one limit dimension. */
3697
+ assertLimits(input) {
3698
+ for (const [name, value] of [
3699
+ ["limitNanoUsd", input.limitNanoUsd],
3700
+ ["limitTokens", input.limitTokens],
3701
+ ["limitCount", input.limitCount]
3702
+ ]) {
3703
+ if (value !== void 0 && value < 0) throw invalid(`${name} must not be negative (\xA710.2)`);
3704
+ }
3705
+ if (input.limitNanoUsd === void 0 && input.limitTokens === void 0 && input.limitCount === void 0) {
3706
+ throw invalid("a budget must define at least one limit dimension");
3707
+ }
3708
+ }
3709
+ };
3710
+ BudgetService = __decorateClass([
3711
+ Injectable()
3712
+ ], BudgetService);
3713
+ function invalid(reason) {
3714
+ return new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason });
3715
+ }
3716
+ function featureMatches3(features, feature) {
3717
+ return features === void 0 || features.length === 0 || features.includes(feature);
3718
+ }
3719
+ function limitsOf(budget) {
3720
+ return {
3721
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { nanoUsd: undefined } is equivalent to {} because all consumers check limits.nanoUsd !== undefined
3722
+ ...budget.limitNanoUsd !== void 0 ? { nanoUsd: budget.limitNanoUsd } : {},
3723
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { tokens: undefined } is equivalent to {} because all consumers check limits.tokens !== undefined
3724
+ ...budget.limitTokens !== void 0 ? { tokens: budget.limitTokens } : {},
3725
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { count: undefined } is equivalent to {} because all consumers check limits.count !== undefined
3726
+ ...budget.limitCount !== void 0 ? { count: budget.limitCount } : {}
3727
+ };
3728
+ }
3729
+ function negate(delta) {
3730
+ return { nanoUsd: -delta.nanoUsd, tokens: -delta.tokens, count: -delta.count };
3731
+ }
3732
+ function addSpend(spend, delta) {
3733
+ return {
3734
+ spentNanoUsd: spend.spentNanoUsd + delta.nanoUsd,
3735
+ spentTokens: spend.spentTokens + delta.tokens,
3736
+ spentCount: spend.spentCount + delta.count
3737
+ };
3738
+ }
3739
+ function dimensionCode(name) {
3740
+ return name === "cost" ? { dimension: "cost", code: "AI_TOKENS_BUDGET_EXCEEDED" } : { dimension: name, code: "AI_TOKENS_QUOTA_EXCEEDED" };
3741
+ }
3742
+ function failingDimension(spend, limits) {
3743
+ return failingDimensionOrNull(spend, limits) ?? dimensionCode("cost");
3744
+ }
3745
+ function failingDimensionOrNull(spend, limits) {
3746
+ if (limits.nanoUsd !== void 0 && spend.spentNanoUsd > limits.nanoUsd) return dimensionCode("cost");
3747
+ if (limits.tokens !== void 0 && spend.spentTokens > limits.tokens) return dimensionCode("tokens");
3748
+ if (limits.count !== void 0 && spend.spentCount > limits.count) return dimensionCode("count");
3749
+ return null;
3750
+ }
3751
+ var USED_FRACTION_SCALE = 1000000n;
3752
+ function usedFraction(spend, limits) {
3753
+ const fractions = [];
3754
+ if (limits.nanoUsd !== void 0) fractions.push(ratio(spend.spentNanoUsd, limits.nanoUsd));
3755
+ if (limits.tokens !== void 0) fractions.push(ratio(BigInt(spend.spentTokens), BigInt(limits.tokens)));
3756
+ if (limits.count !== void 0) fractions.push(ratio(BigInt(spend.spentCount), BigInt(limits.count)));
3757
+ return fractions.length === 0 ? 0 : Math.max(...fractions);
3758
+ }
3759
+ function ratio(spent, limit) {
3760
+ if (limit === 0n) return spent > 0n ? Number.POSITIVE_INFINITY : 1;
3761
+ return Number(spent * USED_FRACTION_SCALE / limit) / Number(USED_FRACTION_SCALE);
3762
+ }
3763
+ function limitSnapshot(limits) {
3764
+ return {
3765
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { nanoUsd: undefined } is equivalent to {} for event consumers who check field !== undefined
3766
+ ...limits.nanoUsd !== void 0 ? { nanoUsd: limits.nanoUsd } : {},
3767
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { tokens: undefined } is equivalent to {} for event consumers who check field !== undefined
3768
+ ...limits.tokens !== void 0 ? { tokens: limits.tokens } : {},
3769
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { count: undefined } is equivalent to {} for event consumers who check field !== undefined
3770
+ ...limits.count !== void 0 ? { count: limits.count } : {}
3771
+ };
3772
+ }
3773
+ function spendSnapshot(spend) {
3774
+ return { nanoUsd: spend.spentNanoUsd, tokens: spend.spentTokens, count: spend.spentCount };
3775
+ }
3776
+ function remainingSnapshot(limits, spend) {
3777
+ return {
3778
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { nanoUsd: undefined } is equivalent to {} for event consumers who check field !== undefined
3779
+ ...limits.nanoUsd !== void 0 ? { nanoUsd: max0(limits.nanoUsd - spend.spentNanoUsd) } : {},
3780
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { tokens: undefined } is equivalent to {} for event consumers who check field !== undefined
3781
+ ...limits.tokens !== void 0 ? { tokens: Math.max(0, limits.tokens - spend.spentTokens) } : {},
3782
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { count: undefined } is equivalent to {} for event consumers who check field !== undefined
3783
+ ...limits.count !== void 0 ? { count: Math.max(0, limits.count - spend.spentCount) } : {}
3784
+ };
3785
+ }
3786
+ function buildStatus(budget, windowStart, resetsAt, spend) {
3787
+ const limits = limitsOf(budget);
3788
+ return {
3789
+ budgetId: budget.id,
3790
+ // Stryker disable next-line ConditionalExpression -- CE true: spreading { features: undefined } is equivalent to {} because status consumers check features !== undefined
3791
+ ...budget.features !== void 0 ? { features: budget.features } : {},
3792
+ window: budget.window,
3793
+ windowStart,
3794
+ resetsAt,
3795
+ policy: budget.policy,
3796
+ limit: limitSnapshot(limits),
3797
+ spent: spendSnapshot(spend),
3798
+ remaining: remainingSnapshot(limits, spend),
3799
+ usedFraction: usedFraction(spend, limits)
3800
+ };
3801
+ }
3802
+ function windowKey(budgetId, windowStart) {
3803
+ return `${budgetId}|${windowStart.toISOString()}`;
3804
+ }
3805
+ function counterKey(budgetId, windowStart, dimension) {
3806
+ return `ai_tokens:budget:${budgetId}:${windowStart.toISOString()}:${dimension}`;
3807
+ }
3808
+ function counterTtlSeconds(windowStart, windowEnd) {
3809
+ if (windowEnd === null) return TOTAL_WINDOW_TTL_SECONDS;
3810
+ return Math.ceil((windowEnd.getTime() - windowStart.getTime()) / 1e3) + COUNTER_GRACE_SECONDS;
3811
+ }
3812
+ function dimensionSpends(spend) {
3813
+ return { cost: spend.spentNanoUsd, tokens: BigInt(spend.spentTokens), count: BigInt(spend.spentCount) };
3814
+ }
3815
+ function counterDimensions(delta, limits) {
3816
+ const dimensions = [];
3817
+ if (limits.nanoUsd !== void 0 && delta.nanoUsd !== 0n) {
3818
+ dimensions.push({ name: "cost", amount: delta.nanoUsd, limit: limits.nanoUsd });
3819
+ }
3820
+ if (limits.tokens !== void 0 && delta.tokens !== 0) {
3821
+ dimensions.push({ name: "tokens", amount: BigInt(delta.tokens), limit: BigInt(limits.tokens) });
3822
+ }
3823
+ if (limits.count !== void 0 && delta.count !== 0) {
3824
+ dimensions.push({ name: "count", amount: BigInt(delta.count), limit: BigInt(limits.count) });
3825
+ }
3826
+ return dimensions;
3827
+ }
3828
+ function max0(value) {
3829
+ return value < 0n ? 0n : value;
3830
+ }
3831
+ var BALANCE_STATUSES2 = ["posted", "reversed"];
3832
+ var PER_MILLION2 = 1000000n;
3833
+ var NANO_PER_USD2 = 1000000000n;
3834
+ var UsageReportService = class {
3835
+ /**
3836
+ * @param store The ledger store port (query + optional SQL summarize).
3837
+ * @param pricing The pricing store port (effective rate for cache savings).
3838
+ * @param options The resolved currency/fx/reporting settings.
3839
+ * @param audit The audit hook; the module wires it to the dispatcher.
3840
+ */
3841
+ constructor(store, pricing, options, audit = () => void 0) {
3842
+ this.store = store;
3843
+ this.pricing = pricing;
3844
+ this.options = options;
3845
+ this.audit = audit;
3846
+ }
3847
+ store;
3848
+ pricing;
3849
+ options;
3850
+ audit;
3851
+ /**
3852
+ * Aggregate `SUM … GROUP BY` across the report dimensions. Delegates to the
3853
+ * store's SQL `summarize` when present, else aggregates in memory. The in-memory
3854
+ * fallback REFUSES to emit partial totals: like `export()` it throws when the
3855
+ * filter matches more than `reporting.maxExportRows` rows (paginate instead) — a
3856
+ * silent truncation would hand callers incorrect aggregates. An empty `groupBy`
3857
+ * yields one grand-total row.
3858
+ *
3859
+ * @param input The report filter plus the group-by dimensions.
3860
+ * @returns One {@link UsageSummary} per group.
3861
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when the matched rows exceed `reporting.maxExportRows`.
3862
+ */
3863
+ async summarize(input) {
3864
+ const { groupBy, ...filter } = input;
3865
+ const max = this.options.reporting.maxExportRows;
3866
+ if (this.store.summarize !== void 0) return this.store.summarize(toLedgerFilter(filter, max), groupBy);
3867
+ const records = await this.store.query(toLedgerFilter(filter, max + 1));
3868
+ this.assertWithinMaxRows(records.length, "summarize");
3869
+ const groups = /* @__PURE__ */ new Map();
3870
+ for (const record of records) {
3871
+ const savings = await this.cacheSavings(record);
3872
+ for (const group of expandGroups(record, groupBy)) {
3873
+ const key = JSON.stringify(group);
3874
+ const summary = groups.get(key) ?? emptySummary(group);
3875
+ accumulate(summary, record, savings);
3876
+ groups.set(key, summary);
3877
+ }
3878
+ }
3879
+ return [...groups.values()];
3880
+ }
3881
+ /**
3882
+ * Stream the §13.2 export field set as CSV or line-delimited JSON, bigints as
3883
+ * decimal strings, plus fx-converted columns for a non-USD currency. Emits an
3884
+ * `ai_tokens.audit` event. ADMIN PLANE (§14.4): restrict to privileged roles.
3885
+ *
3886
+ * @param filter The report filter.
3887
+ * @param format `'csv'` or `'json'` (ndjson).
3888
+ * @returns A readable stream of the export.
3889
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when the result exceeds `reporting.maxExportRows`.
3890
+ */
3891
+ async export(filter, format) {
3892
+ const max = this.options.reporting.maxExportRows;
3893
+ const records = await this.store.query(toLedgerFilter(filter, max + 1));
3894
+ this.assertWithinMaxRows(records.length, "export");
3895
+ this.audit("ai_tokens.audit", { action: "export", tenantId: filter.tenantId, format, rows: records.length });
3896
+ return Readable.from(this.serialize(records, format));
3897
+ }
3898
+ /**
3899
+ * Refuse a partial result: throw when a `maxExportRows + 1`-bounded query matched
3900
+ * more rows than `reporting.maxExportRows`, signaling the caller to paginate. Both
3901
+ * `summarize()` and `export()` share this guard so neither ever silently truncates.
3902
+ *
3903
+ * @param rowCount The number of rows the `(max + 1)`-bounded query returned.
3904
+ * @param operation The operation name for the error reason (`summarize`/`export`).
3905
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when `rowCount` exceeds the limit.
3906
+ */
3907
+ assertWithinMaxRows(rowCount, operation) {
3908
+ const max = this.options.reporting.maxExportRows;
3909
+ if (rowCount > max) {
3910
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: `${operation} exceeds reporting.maxExportRows (${String(max)}); paginate the filter` });
3911
+ }
3912
+ }
3913
+ /** Yield the serialized rows (CSV header + rows, or ndjson lines). */
3914
+ async *serialize(records, format) {
3915
+ const converting = this.options.currency !== "USD" && this.options.fx !== void 0;
3916
+ if (format === "csv") yield `${csvLine(this.headers(converting))}
3917
+ `;
3918
+ for (const record of records) {
3919
+ const row = await this.rowOf(record, converting);
3920
+ yield format === "csv" ? `${csvLine(row.map((cell) => cell[1]))}
3921
+ ` : `${JSON.stringify(Object.fromEntries(row))}
3922
+ `;
3923
+ }
3924
+ }
3925
+ /** The ordered header names for the export (with fx columns when converting). */
3926
+ headers(converting) {
3927
+ const base = baseCells(exampleRecord).map((cell) => cell[0]);
3928
+ return converting ? [...base, "presentationCurrency", "rawCostConverted", "surchargeConverted", "billedCostConverted"] : base;
3929
+ }
3930
+ /** Build one export row as ordered `[header, cell]` pairs (all cells strings). */
3931
+ async rowOf(record, converting) {
3932
+ const cells = baseCells(record);
3933
+ if (!converting || this.options.fx === void 0) return cells;
3934
+ const rate = await this.options.fx(record.occurredAt, this.options.currency);
3935
+ return [
3936
+ ...cells,
3937
+ ["presentationCurrency", this.options.currency],
3938
+ ["rawCostConverted", convert2(record.rawCostNanoUsd, rate).toString()],
3939
+ ["surchargeConverted", convert2(record.surchargeNanoUsd, rate).toString()],
3940
+ ["billedCostConverted", convert2(record.billedCostNanoUsd, rate).toString()]
3941
+ ];
3942
+ }
3943
+ /** The nano-USD cache savings for a record: `cacheReadTokens × (inputRate − cacheReadRate)`. */
3944
+ async cacheSavings(record) {
3945
+ if (record.priceVersionId === null) return 0n;
3946
+ const rate = await this.pricing.resolveRate(record.provider, record.model, record.operation, record.serviceTier, record.occurredAt);
3947
+ if (rate === null) return 0n;
3948
+ return BigInt(record.cacheReadTokens) * (rate.inputNanoUsdPerMillion - rate.cacheReadNanoUsdPerMillion) / PER_MILLION2;
3949
+ }
3950
+ };
3951
+ UsageReportService = __decorateClass([
3952
+ Injectable()
3953
+ ], UsageReportService);
3954
+ function toLedgerFilter(filter, limit) {
3955
+ return {
3956
+ tenantId: filter.tenantId,
3957
+ // Stryker disable next-line ConditionalExpression -- spreading { scope: undefined } is equivalent to {} because InMemoryLedgerStore uses truthiness; the 'false' variant is covered by the scope isolation test
3958
+ ...filter.scope !== void 0 ? { scope: filter.scope } : {},
3959
+ // Stryker disable next-line ConditionalExpression -- spreading { beneficiary: undefined } is equivalent to {} for the same reason
3960
+ ...filter.beneficiary !== void 0 ? { beneficiary: filter.beneficiary } : {},
3961
+ // Stryker disable next-line ConditionalExpression -- spreading { feature: undefined } does not change the !== undefined check in matches()
3962
+ ...filter.feature !== void 0 ? { feature: filter.feature } : {},
3963
+ // Stryker disable next-line ConditionalExpression -- spreading { features: undefined } does not change the truthiness check in matches()
3964
+ ...filter.features !== void 0 ? { features: filter.features } : {},
3965
+ // Stryker disable next-line ConditionalExpression -- spreading { provider: undefined } does not change the !== undefined check in matches()
3966
+ ...filter.provider !== void 0 ? { provider: filter.provider } : {},
3967
+ // Stryker disable next-line ConditionalExpression -- spreading { model: undefined } does not change the !== undefined check in matches()
3968
+ ...filter.model !== void 0 ? { model: filter.model } : {},
3969
+ // Stryker disable next-line ConditionalExpression -- spreading { operation: undefined } does not change the !== undefined check in matches()
3970
+ ...filter.operation !== void 0 ? { operation: filter.operation } : {},
3971
+ // Stryker disable next-line ConditionalExpression -- spreading { serviceTier: undefined } does not change the !== undefined check in matches()
3972
+ ...filter.serviceTier !== void 0 ? { serviceTier: filter.serviceTier } : {},
3973
+ // Stryker disable next-line ConditionalExpression -- spreading { tags: undefined } does not change the truthiness check in matches()
3974
+ ...filter.tags !== void 0 ? { tags: filter.tags } : {},
3975
+ // Stryker disable next-line ConditionalExpression -- spreading { isSystemCost: undefined } does not change the !== undefined check in matches()
3976
+ ...filter.isSystemCost !== void 0 ? { isSystemCost: filter.isSystemCost } : {},
3977
+ // Stryker disable next-line ConditionalExpression -- spreading { systemCostCategory: undefined } does not change the !== undefined check in matches()
3978
+ ...filter.systemCostCategory !== void 0 ? { systemCostCategory: filter.systemCostCategory } : {},
3979
+ // Stryker disable next-line ConditionalExpression -- spreading { enforcedOnly: undefined } does not change the === true check in matches()
3980
+ ...filter.enforcedOnly !== void 0 ? { enforcedOnly: filter.enforcedOnly } : {},
3981
+ status: filter.status ?? [...BALANCE_STATUSES2],
3982
+ from: filter.from,
3983
+ to: filter.to,
3984
+ limit
3985
+ };
3986
+ }
3987
+ function expandGroups(record, groupBy) {
3988
+ const base = {};
3989
+ for (const dim of groupBy) {
3990
+ if (dim !== "tag") base[dim] = dimensionValue(record, dim);
3991
+ }
3992
+ if (!groupBy.includes("tag")) return [base];
3993
+ return record.tags.map((tag) => ({ ...base, tag }));
3994
+ }
3995
+ function dimensionValue(record, dim) {
3996
+ switch (dim) {
3997
+ case "day":
3998
+ return record.occurredAt.toISOString().slice(0, 10);
3999
+ case "week":
4000
+ return weekStartUtc(record.occurredAt);
4001
+ case "month":
4002
+ return record.occurredAt.toISOString().slice(0, 7);
4003
+ case "feature":
4004
+ return record.feature;
4005
+ case "provider":
4006
+ return record.provider;
4007
+ case "model":
4008
+ return record.model;
4009
+ case "operation":
4010
+ return record.operation;
4011
+ case "serviceTier":
4012
+ return record.serviceTier;
4013
+ case "scope":
4014
+ return `${record.scope.type}:${record.scope.id}`;
4015
+ case "beneficiary":
4016
+ return record.beneficiary === void 0 ? "" : `${record.beneficiary.type}:${record.beneficiary.id}`;
4017
+ default:
4018
+ return record.systemCostCategory ?? "";
4019
+ }
4020
+ }
4021
+ function weekStartUtc(date) {
4022
+ const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
4023
+ const isoDay = (utc.getUTCDay() + 6) % 7;
4024
+ utc.setUTCDate(utc.getUTCDate() - isoDay);
4025
+ return utc.toISOString().slice(0, 10);
4026
+ }
4027
+ function emptySummary(group) {
4028
+ const tokens = Object.fromEntries(TOKEN_CATEGORIES.map((category) => [category, 0]));
4029
+ return { group, records: 0, totalTokens: 0, tokens, rawCostNanoUsd: 0n, surchargeNanoUsd: 0n, billedCostNanoUsd: 0n, cacheSavingsNanoUsd: 0n };
4030
+ }
4031
+ function accumulate(summary, record, savings) {
4032
+ summary.records += 1;
4033
+ summary.totalTokens += record.totalTokens;
4034
+ for (const category of TOKEN_CATEGORIES) summary.tokens[category] += record[`${category}Tokens`];
4035
+ summary.rawCostNanoUsd += record.rawCostNanoUsd;
4036
+ summary.surchargeNanoUsd += record.surchargeNanoUsd;
4037
+ summary.billedCostNanoUsd += record.billedCostNanoUsd;
4038
+ summary.cacheSavingsNanoUsd += savings;
4039
+ }
4040
+ function convert2(nanoUsd, fxRate) {
4041
+ return nanoUsd * fxRate / NANO_PER_USD2;
4042
+ }
4043
+ function csvLine(cells) {
4044
+ return cells.map((cell) => /[",\n]/.test(cell) ? `"${cell.replace(/"/g, '""')}"` : cell).join(",");
4045
+ }
4046
+ function baseCells(record) {
4047
+ return [
4048
+ ["tenantId", record.tenantId],
4049
+ ["scopeType", record.scope.type],
4050
+ ["scopeId", record.scope.id],
4051
+ ["beneficiaryType", record.beneficiary?.type ?? ""],
4052
+ ["beneficiaryId", record.beneficiary?.id ?? ""],
4053
+ ["requestedBy", record.requestedBy ?? ""],
4054
+ ["feature", record.feature],
4055
+ ["tags", record.tags.join(";")],
4056
+ ["provider", record.provider],
4057
+ ["model", record.model],
4058
+ ["requestedModel", record.requestedModel ?? ""],
4059
+ ["operation", record.operation],
4060
+ ["serviceTier", record.serviceTier],
4061
+ ...TOKEN_CATEGORIES.map((category) => [`${category}Tokens`, String(record[`${category}Tokens`])]),
4062
+ ["totalTokens", String(record.totalTokens)],
4063
+ ["extraUnits", record.extraUnits === void 0 ? "" : JSON.stringify(record.extraUnits)],
4064
+ ["rawCostNanoUsd", record.rawCostNanoUsd.toString()],
4065
+ ["surchargeNanoUsd", record.surchargeNanoUsd.toString()],
4066
+ ["billedCostNanoUsd", record.billedCostNanoUsd.toString()],
4067
+ ["markupMultiplier", String(record.markupMultiplier)],
4068
+ ["currency", record.currency],
4069
+ ["priceMissing", String(record.priceMissing)],
4070
+ ["occurredAt", record.occurredAt.toISOString()],
4071
+ ["idempotencyKey", record.idempotencyKey],
4072
+ ["correlationId", record.correlationId ?? ""],
4073
+ ["requestId", record.requestId ?? ""],
4074
+ ["isSystemCost", String(record.isSystemCost)],
4075
+ ["systemCostCategory", record.systemCostCategory ?? ""],
4076
+ ["enforced", String(record.enforced)],
4077
+ ["status", record.status]
4078
+ ];
4079
+ }
4080
+ var exampleRecord = {
4081
+ id: "",
4082
+ tenantId: "",
4083
+ scope: { type: "tenant", id: "" },
4084
+ provider: "",
4085
+ model: "",
4086
+ operation: "chat",
4087
+ serviceTier: "standard",
4088
+ feature: "",
4089
+ tags: [],
4090
+ inputTokens: 0,
4091
+ outputTokens: 0,
4092
+ cacheReadTokens: 0,
4093
+ cacheWrite5mTokens: 0,
4094
+ cacheWrite1hTokens: 0,
4095
+ reasoningTokens: 0,
4096
+ audioInTokens: 0,
4097
+ audioOutTokens: 0,
4098
+ imageInTokens: 0,
4099
+ imageOutTokens: 0,
4100
+ totalTokens: 0,
4101
+ priceVersionId: null,
4102
+ rawCostNanoUsd: 0n,
4103
+ surchargeNanoUsd: 0n,
4104
+ billedCostNanoUsd: 0n,
4105
+ markupMultiplier: 1,
4106
+ currency: "USD",
4107
+ priceMissing: false,
4108
+ status: "posted",
4109
+ idempotencyKey: "",
4110
+ isSystemCost: false,
4111
+ enforced: false,
4112
+ occurredAt: /* @__PURE__ */ new Date(0),
4113
+ createdAt: /* @__PURE__ */ new Date(0),
4114
+ updatedAt: /* @__PURE__ */ new Date(0)
4115
+ };
4116
+
4117
+ // src/server/bymax-ai-tokens.module.ts
4118
+ var CORE_TOKENS = [
4119
+ BYMAX_AI_TOKENS_OPTIONS,
4120
+ BYMAX_AI_TOKENS_LEDGER_STORE,
4121
+ BYMAX_AI_TOKENS_PRICING_STORE,
4122
+ BYMAX_AI_TOKENS_TOKENIZER,
4123
+ BYMAX_AI_TOKENS_EVENT_SINK,
4124
+ BYMAX_AI_TOKENS_TELEMETRY,
4125
+ BYMAX_AI_TOKENS_CONTENT_STORE,
4126
+ BYMAX_AI_TOKENS_LOGGER
4127
+ ];
4128
+ var CORE_SERVICE_EXPORTS = [PricingService, LedgerService, MeteringService, MeteringInterceptor, UsageReportService];
4129
+ var FEATURE_EXPORTS = [BYMAX_AI_TOKENS_WALLET_STORE, BYMAX_AI_TOKENS_BUDGET_STORE, BYMAX_AI_TOKENS_BUDGET_COUNTER, WalletService, BudgetService, BudgetGuard];
4130
+ function fromOptions(token, factory) {
4131
+ return { provide: token, useFactory: factory, inject: [BYMAX_AI_TOKENS_OPTIONS] };
4132
+ }
4133
+ function buildCoreProviders() {
4134
+ return [...buildInfraProviders(), ...buildServiceProviders()];
4135
+ }
4136
+ function buildInfraProviders() {
4137
+ return [
4138
+ fromOptions(BYMAX_AI_TOKENS_LEDGER_STORE, (o) => o.store),
4139
+ fromOptions(BYMAX_AI_TOKENS_PRICING_STORE, (o) => o.store),
4140
+ fromOptions(BYMAX_AI_TOKENS_TOKENIZER, (o) => o.tokenizer ?? null),
4141
+ fromOptions(BYMAX_AI_TOKENS_EVENT_SINK, (o) => o.events.sink ?? null),
4142
+ fromOptions(BYMAX_AI_TOKENS_TELEMETRY, (o) => o.telemetry.enabled ? o.telemetry.sink : null),
4143
+ fromOptions(BYMAX_AI_TOKENS_CONTENT_STORE, (o) => o.content.enabled ? o.content.store : null),
4144
+ { provide: BYMAX_AI_TOKENS_LOGGER, useValue: null },
4145
+ {
4146
+ provide: PricingService,
4147
+ useFactory: (options, store) => new PricingService(options, store),
4148
+ inject: [BYMAX_AI_TOKENS_OPTIONS, BYMAX_AI_TOKENS_PRICING_STORE]
4149
+ },
4150
+ {
4151
+ provide: EventDispatcher,
4152
+ useFactory: (moduleRef, options) => new EventDispatcher(moduleRef, options),
4153
+ inject: [ModuleRef, BYMAX_AI_TOKENS_OPTIONS]
4154
+ },
4155
+ {
4156
+ provide: LedgerService,
4157
+ useFactory: (options, store, dispatcher) => new LedgerService(store, options, createLedgerAuditHook(dispatcher)),
4158
+ inject: [BYMAX_AI_TOKENS_OPTIONS, BYMAX_AI_TOKENS_LEDGER_STORE, EventDispatcher]
4159
+ },
4160
+ {
4161
+ provide: MarkupResolver,
4162
+ useFactory: (options) => new MarkupResolver(options),
4163
+ inject: [BYMAX_AI_TOKENS_OPTIONS]
4164
+ }
4165
+ ];
4166
+ }
4167
+ function buildServiceProviders() {
4168
+ return [
4169
+ {
4170
+ provide: MeteringService,
4171
+ useFactory: (ledger, pricing, markup, options, dispatcher, telemetrySink, wallets, budgets) => new MeteringService(ledger, pricing, markup, options, createMeteringEventHooks(dispatcher), wallets, budgets, void 0, new TelemetryEmitter(telemetrySink), new ContentCapture(options.content)),
4172
+ inject: [
4173
+ LedgerService,
4174
+ PricingService,
4175
+ MarkupResolver,
4176
+ BYMAX_AI_TOKENS_OPTIONS,
4177
+ EventDispatcher,
4178
+ BYMAX_AI_TOKENS_TELEMETRY,
4179
+ { token: WalletService, optional: true },
4180
+ { token: BudgetService, optional: true }
4181
+ ]
4182
+ },
4183
+ {
4184
+ // Registered unconditionally: hold() writes pending rows whenever it is used,
4185
+ // even without wallets/budgets, and a stranded hold must still be reclaimed.
4186
+ provide: HoldReaper,
4187
+ useFactory: (ledger, metering, options) => new HoldReaper(ledger, metering, options),
4188
+ inject: [LedgerService, MeteringService, BYMAX_AI_TOKENS_OPTIONS]
4189
+ },
4190
+ {
4191
+ provide: UsageReportService,
4192
+ useFactory: (options, ledgerStore, pricingStore, dispatcher) => new UsageReportService(ledgerStore, pricingStore, options, (action, details) => void dispatcher.audit(action, details)),
4193
+ inject: [BYMAX_AI_TOKENS_OPTIONS, BYMAX_AI_TOKENS_LEDGER_STORE, BYMAX_AI_TOKENS_PRICING_STORE, EventDispatcher]
4194
+ },
4195
+ MeteringInterceptor
4196
+ ];
4197
+ }
4198
+ function walletServiceProvider() {
4199
+ return {
4200
+ provide: WalletService,
4201
+ useFactory: (options, store, dispatcher) => options.wallets.enabled ? new WalletService(store, options.wallets, createWalletEventHooks(dispatcher)) : null,
4202
+ inject: [BYMAX_AI_TOKENS_OPTIONS, BYMAX_AI_TOKENS_WALLET_STORE, EventDispatcher]
4203
+ };
4204
+ }
4205
+ function budgetServiceProvider() {
4206
+ return {
4207
+ provide: BudgetService,
4208
+ useFactory: (options, store, ledger, dispatcher) => options.budgets.enabled ? new BudgetService(store, ledger, options.budgets, void 0, createBudgetEventHooks(dispatcher)) : null,
4209
+ inject: [BYMAX_AI_TOKENS_OPTIONS, BYMAX_AI_TOKENS_BUDGET_STORE, LedgerService, EventDispatcher]
4210
+ };
4211
+ }
4212
+ function budgetGuardProvider() {
4213
+ return {
4214
+ provide: BudgetGuard,
4215
+ useFactory: (options, budgets, metering, reflector) => options.budgets.enabled ? new BudgetGuard(budgets, metering, reflector, options) : null,
4216
+ inject: [BYMAX_AI_TOKENS_OPTIONS, BudgetService, MeteringService, Reflector]
4217
+ };
4218
+ }
4219
+ function buildSyncFeatureProviders(resolved) {
4220
+ const providers = [];
4221
+ if (resolved.wallets.enabled) {
4222
+ providers.push({ provide: BYMAX_AI_TOKENS_WALLET_STORE, useValue: resolved.store }, walletServiceProvider());
4223
+ }
4224
+ if (resolved.budgets.enabled) {
4225
+ providers.push(
4226
+ { provide: BYMAX_AI_TOKENS_BUDGET_STORE, useValue: resolved.store },
4227
+ { provide: BYMAX_AI_TOKENS_BUDGET_COUNTER, useValue: resolved.budgets.counter ?? null },
4228
+ budgetServiceProvider(),
4229
+ budgetGuardProvider()
4230
+ );
4231
+ }
4232
+ return providers;
4233
+ }
4234
+ function buildAsyncFeatureProviders() {
4235
+ return [
4236
+ fromOptions(BYMAX_AI_TOKENS_WALLET_STORE, (o) => o.wallets.enabled ? o.store : null),
4237
+ fromOptions(BYMAX_AI_TOKENS_BUDGET_STORE, (o) => o.budgets.enabled ? o.store : null),
4238
+ fromOptions(BYMAX_AI_TOKENS_BUDGET_COUNTER, (o) => o.budgets.enabled ? o.budgets.counter ?? null : null),
4239
+ walletServiceProvider(),
4240
+ budgetServiceProvider(),
4241
+ budgetGuardProvider()
4242
+ ];
4243
+ }
4244
+ function buildSyncFeatureExports(resolved) {
4245
+ const tokens = [];
4246
+ if (resolved.wallets.enabled) tokens.push(BYMAX_AI_TOKENS_WALLET_STORE, WalletService);
4247
+ if (resolved.budgets.enabled) tokens.push(BYMAX_AI_TOKENS_BUDGET_STORE, BYMAX_AI_TOKENS_BUDGET_COUNTER, BudgetService, BudgetGuard);
4248
+ return tokens;
4249
+ }
4250
+ async function safeResolve(load) {
4251
+ try {
4252
+ const raw = await load;
4253
+ validateOptions(raw);
4254
+ return applyDefaults(raw);
4255
+ } catch (error) {
4256
+ if (error instanceof AiTokensException) throw error;
4257
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: "the async options factory rejected or produced invalid options" });
4258
+ }
4259
+ }
4260
+ function buildAsyncOptionsProviders(descriptor) {
4261
+ if (descriptor.useFactory !== void 0) {
4262
+ const factory = descriptor.useFactory;
4263
+ return [
4264
+ {
4265
+ provide: BYMAX_AI_TOKENS_OPTIONS,
4266
+ useFactory: (...args) => safeResolve(Promise.resolve(factory(...args))),
4267
+ inject: [...descriptor.inject ?? []]
4268
+ }
4269
+ ];
4270
+ }
4271
+ const factoryToken = descriptor.useClass ?? descriptor.useExisting;
4272
+ if (factoryToken === void 0) {
4273
+ throw new AiTokensException("AI_TOKENS_INVALID_CONFIG", void 0, { reason: "forRootAsync requires useFactory, useClass, or useExisting" });
4274
+ }
4275
+ const optionsProvider = {
4276
+ provide: BYMAX_AI_TOKENS_OPTIONS,
4277
+ useFactory: (factory) => safeResolve(Promise.resolve(factory.createAiTokensOptions())),
4278
+ inject: [factoryToken]
4279
+ };
4280
+ return descriptor.useClass !== void 0 ? [optionsProvider, { provide: descriptor.useClass, useClass: descriptor.useClass }] : [optionsProvider];
4281
+ }
4282
+ var BymaxAiTokensModule = class {
4283
+ /**
4284
+ * Configure the module synchronously. Validates and resolves the options, then
4285
+ * returns the wired dynamic module. `PricingService.onModuleInit` seeds the price
4286
+ * registry when `pricing.seedFromSnapshot` is enabled.
4287
+ *
4288
+ * @param options The module options.
4289
+ * @returns The configured dynamic module.
4290
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` / `AI_TOKENS_FX_REQUIRED` on invalid options.
4291
+ */
4292
+ static forRoot(options) {
4293
+ validateOptions(options);
4294
+ const resolved = applyDefaults(options);
4295
+ return {
4296
+ module: BymaxAiTokensModule,
4297
+ providers: [{ provide: BYMAX_AI_TOKENS_OPTIONS, useValue: resolved }, ...buildCoreProviders(), ...buildSyncFeatureProviders(resolved)],
4298
+ exports: [...CORE_TOKENS, ...CORE_SERVICE_EXPORTS, ...buildSyncFeatureExports(resolved)]
4299
+ };
4300
+ }
4301
+ /**
4302
+ * Configure the module asynchronously, resolving the options from injected
4303
+ * dependencies via `useFactory`/`useClass`/`useExisting` — the family pattern.
4304
+ * The resolved options feed the SAME core providers as {@link forRoot}; the
4305
+ * wallet/budget providers are registered unconditionally and resolve to `null`
4306
+ * when their feature is disabled.
4307
+ *
4308
+ * @param asyncOptions The async options descriptor.
4309
+ * @returns The configured dynamic module.
4310
+ * @throws {AiTokensException} `AI_TOKENS_INVALID_CONFIG` when no async style is supplied or the factory rejects/produces invalid options.
4311
+ */
4312
+ static forRootAsync(asyncOptions) {
4313
+ return {
4314
+ module: BymaxAiTokensModule,
4315
+ imports: [...asyncOptions.imports ?? []],
4316
+ providers: [...buildAsyncOptionsProviders(asyncOptions), ...buildCoreProviders(), ...buildAsyncFeatureProviders()],
4317
+ exports: [...CORE_TOKENS, ...CORE_SERVICE_EXPORTS, ...FEATURE_EXPORTS]
4318
+ };
4319
+ }
4320
+ };
4321
+ BymaxAiTokensModule = __decorateClass([
4322
+ Global(),
4323
+ Module({})
4324
+ ], BymaxAiTokensModule);
4325
+
4326
+ // src/server/config/provider-presets.ts
4327
+ function preset(provider, normalizer, ratingMode) {
4328
+ return { provider, normalizer, ratingMode };
4329
+ }
4330
+ var providerPresets = {
4331
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests that exercise the full preset→MeteringService→UsageRecord path
4332
+ openaiChat: preset("openai", normalizeOpenAiChatUsage, "rate-table"),
4333
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4334
+ openaiResponses: preset("openai", normalizeOpenAiResponsesUsage, "rate-table"),
4335
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4336
+ azureOpenai: preset("azure-openai", normalizeOpenAiChatUsage, "rate-table"),
4337
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4338
+ anthropic: preset("anthropic", normalizeAnthropicUsage, "rate-table"),
4339
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4340
+ gemini: preset("gemini", normalizeGeminiUsage, "rate-table"),
4341
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4342
+ vertex: preset("vertex", normalizeGeminiUsage, "rate-table"),
4343
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4344
+ bedrock: preset("bedrock", normalizeBedrockConverseUsage, "rate-table"),
4345
+ // Stryker disable next-line StringLiteral -- provider ID; only observable via integration tests
4346
+ mistral: preset("mistral", normalizeMistralUsage, "rate-table"),
4347
+ vercelAiSdk: preset("openai", normalizeVercelAiSdkUsage, "rate-table"),
4348
+ // Stryker disable next-line StringLiteral -- provider ID and rating mode; only observable via integration tests
4349
+ openrouter: preset("openrouter", normalizeOpenRouterUsage, "provider-reported"),
4350
+ /** A custom OpenAI-compatible provider (DeepSeek, xAI, Groq, …). */
4351
+ openaiCompatible: (id) => preset(id, normalizeOpenAiCompatibleUsage, "rate-table")
4352
+ };
4353
+
4354
+ export { AI_FEATURE_METADATA, AI_OPERATIONS, AI_TOKENS_ERROR_CODES, AMOUNT_FIELDS, AiFeature, AiTokensException, BYMAX_AI_TOKENS_BUDGET_COUNTER, BYMAX_AI_TOKENS_BUDGET_STORE, BYMAX_AI_TOKENS_CONTENT_STORE, BYMAX_AI_TOKENS_EVENT_SINK, BYMAX_AI_TOKENS_LEDGER_STORE, BYMAX_AI_TOKENS_LOGGER, BYMAX_AI_TOKENS_OPTIONS, BYMAX_AI_TOKENS_PRICING_STORE, BYMAX_AI_TOKENS_TELEMETRY, BYMAX_AI_TOKENS_TOKENIZER, BYMAX_AI_TOKENS_WALLET_STORE, BudgetGuard, BudgetService, BymaxAiTokensModule, LedgerService, METER_METADATA, Meter, MeteringInterceptor, MeteringService, PROVIDER_IDS, PricingService, RELEASE_FIELDS, REQUIRE_BUDGET_METADATA, REVERSAL_LINKAGE_FIELD, RequireBudget, SERVICE_TIERS, SETTLEMENT_FIELDS, SETTLEMENT_PATCH_KEYS, StreamUsageCollector, TOKEN_CATEGORIES, UsageReportService, WALLET_ENTRY_TYPES, WalletService, applyMarkup, computeCostNanoUsd, deriveIdempotencyKey, floatUsdToNanoUsd, formatNanoUsd, isLegalLedgerTransition, isLegalTransitionPatchKey, normalizeAnthropicUsage, normalizeBedrockConverseUsage, normalizeGeminiUsage, normalizeMistralUsage, normalizeOpenAiChatUsage, normalizeOpenAiCompatibleUsage, normalizeOpenAiResponsesUsage, normalizeOpenRouterUsage, normalizeVercelAiSdkUsage, perMillion, providerPresets, resolveMultiplier4dp, toJsonSafe };