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