usage-meter-test 0.1.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,320 @@
1
+ import { HoldExpiredError, HoldNotFoundError, HoldStateError, IdempotencyConflictError, InsufficientCreditsError, InvalidCaptureAmountError, QuotaExceededError, } from "./errors.js";
2
+ import { clampNonNegative } from "./math.js";
3
+ import { estimateCost, resolveModelMultiplierBps } from "./pricing.js";
4
+ export class UsageMeter {
5
+ pricing;
6
+ persistence;
7
+ constructor(persistence, options) {
8
+ this.persistence = persistence;
9
+ this.pricing = options.pricing;
10
+ }
11
+ estimate(req) {
12
+ const estimated = estimateCost({
13
+ config: this.pricing,
14
+ model: req.model,
15
+ tokens: req.tokens,
16
+ ...(req.tools ? { tools: req.tools } : {}),
17
+ ...(req.riskBps ? { riskMultiplierBps: req.riskBps } : {}),
18
+ });
19
+ const maxTokens = { inputTokens: req.tokens.inputTokens, outputTokens: req.maxOutputTokens };
20
+ const maxPossible = estimateCost({
21
+ config: this.pricing,
22
+ model: req.model,
23
+ tokens: maxTokens,
24
+ ...(req.tools ? { tools: req.tools } : {}),
25
+ ...(req.riskBps ? { riskMultiplierBps: req.riskBps } : {}),
26
+ });
27
+ return {
28
+ estimatedCost: estimated.cost,
29
+ maxPossibleCost: maxPossible.cost,
30
+ breakdown: {
31
+ ...estimated.breakdown,
32
+ // keep the breakdown aligned with the estimate (not maxPossible)
33
+ modelMultiplierBps: resolveModelMultiplierBps(this.pricing, req.model),
34
+ },
35
+ };
36
+ }
37
+ async hold(req) {
38
+ if (req.amount <= 0n)
39
+ throw new Error("Hold amount must be > 0");
40
+ return this.persistence.withTransaction(async (tx) => {
41
+ const now = this.persistence.now();
42
+ if (req.expiresAt.getTime() <= now.getTime())
43
+ throw new HoldExpiredError("expiresAt must be in the future");
44
+ const existingHold = await tx.holds.getByRequestId(req.userId, req.requestId);
45
+ if (existingHold) {
46
+ // idempotency for holds is based on requestId (since clients may not have a stable key for hold itself)
47
+ if (existingHold.amount !== req.amount) {
48
+ throw new IdempotencyConflictError("Existing hold has different amount");
49
+ }
50
+ if (existingHold.idempotencyKey !== req.idempotencyKey) {
51
+ throw new IdempotencyConflictError("Existing hold has different idempotencyKey");
52
+ }
53
+ const base = {
54
+ hold: existingHold,
55
+ account: await tx.accounts.getOrCreate(req.userId),
56
+ };
57
+ if (tx.quota) {
58
+ const quotaBucket = await this.tryGetBucket(tx, req.userId, now);
59
+ if (quotaBucket)
60
+ return { ...base, quotaBucket };
61
+ }
62
+ return {
63
+ ...base,
64
+ };
65
+ }
66
+ const account = await tx.accounts.getOrCreate(req.userId);
67
+ const available = account.balance - account.frozen;
68
+ if (available < req.amount) {
69
+ throw new InsufficientCreditsError(`Insufficient credits: available=${available.toString()} need=${req.amount.toString()}`);
70
+ }
71
+ let quotaBucket = undefined;
72
+ if (tx.quota) {
73
+ quotaBucket = await this.reserveQuota(tx, req.userId, now, req.amount, 1);
74
+ }
75
+ const updatedAccount = await tx.accounts.update(req.userId, { frozen: account.frozen + req.amount });
76
+ const hold = await tx.holds.create({
77
+ userId: req.userId,
78
+ requestId: req.requestId,
79
+ idempotencyKey: req.idempotencyKey,
80
+ amount: req.amount,
81
+ status: "held",
82
+ expiresAt: req.expiresAt,
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ });
86
+ if (tx.usage) {
87
+ await tx.usage.recordUsage({
88
+ userId: req.userId,
89
+ usage: {
90
+ requestId: req.requestId,
91
+ model: "unknown",
92
+ tokens: { inputTokens: 0, outputTokens: 0 },
93
+ ...(req.meta ? { meta: req.meta } : {}),
94
+ },
95
+ estimatedCost: req.amount,
96
+ });
97
+ }
98
+ const result = { hold, account: updatedAccount };
99
+ if (quotaBucket)
100
+ return { ...result, quotaBucket };
101
+ return result;
102
+ });
103
+ }
104
+ async capture(req) {
105
+ clampNonNegative(req.actualCost, "actualCost");
106
+ if (req.actualCost === 0n)
107
+ throw new InvalidCaptureAmountError("actualCost must be > 0");
108
+ return this.persistence.withTransaction(async (tx) => {
109
+ const now = this.persistence.now();
110
+ const prior = await tx.ledger.getByIdempotencyKey(req.userId, req.idempotencyKey);
111
+ if (prior) {
112
+ if (prior.referenceId != null && prior.referenceId !== req.requestId) {
113
+ throw new IdempotencyConflictError("Idempotency key already used for a different requestId");
114
+ }
115
+ const hold = await tx.holds.getByRequestId(req.userId, req.requestId);
116
+ if (!hold)
117
+ throw new HoldNotFoundError();
118
+ const base = {
119
+ charged: prior.amount < 0n ? -prior.amount : prior.amount,
120
+ hold,
121
+ account: await tx.accounts.getOrCreate(req.userId),
122
+ ledgerEntry: prior,
123
+ };
124
+ if (tx.quota) {
125
+ const quotaBucket = await this.tryGetBucket(tx, req.userId, now);
126
+ if (quotaBucket)
127
+ return { ...base, quotaBucket };
128
+ }
129
+ return base;
130
+ }
131
+ const hold = await tx.holds.getByRequestId(req.userId, req.requestId);
132
+ if (!hold)
133
+ throw new HoldNotFoundError();
134
+ if (hold.status === "captured") {
135
+ if (hold.capturedIdempotencyKey) {
136
+ const settled = await tx.ledger.getByIdempotencyKey(req.userId, hold.capturedIdempotencyKey);
137
+ if (settled) {
138
+ const base = {
139
+ charged: settled.amount < 0n ? -settled.amount : settled.amount,
140
+ hold,
141
+ account: await tx.accounts.getOrCreate(req.userId),
142
+ ledgerEntry: settled,
143
+ };
144
+ if (tx.quota) {
145
+ const quotaBucket = await this.tryGetBucket(tx, req.userId, now);
146
+ if (quotaBucket)
147
+ return { ...base, quotaBucket };
148
+ }
149
+ return base;
150
+ }
151
+ }
152
+ throw new HoldStateError("Hold already captured");
153
+ }
154
+ if (hold.status === "released" || hold.status === "expired")
155
+ throw new HoldStateError(`Hold is ${hold.status}`);
156
+ if (hold.expiresAt.getTime() <= now.getTime())
157
+ throw new HoldExpiredError();
158
+ if (req.actualCost > hold.amount)
159
+ throw new InvalidCaptureAmountError("actualCost cannot exceed held amount");
160
+ // Lock capture to ensure at-most-once charge per requestId (even if callers use different idempotency keys).
161
+ const lockedHold = await tx.holds.lockCapture(req.userId, req.requestId, req.idempotencyKey, now);
162
+ if (lockedHold.capturedIdempotencyKey && lockedHold.capturedIdempotencyKey !== req.idempotencyKey) {
163
+ const settled = await tx.ledger.getByIdempotencyKey(req.userId, lockedHold.capturedIdempotencyKey);
164
+ if (settled) {
165
+ const base = {
166
+ charged: settled.amount < 0n ? -settled.amount : settled.amount,
167
+ hold: lockedHold,
168
+ account: await tx.accounts.getOrCreate(req.userId),
169
+ ledgerEntry: settled,
170
+ };
171
+ if (tx.quota) {
172
+ const quotaBucket = await this.tryGetBucket(tx, req.userId, now);
173
+ if (quotaBucket)
174
+ return { ...base, quotaBucket };
175
+ }
176
+ return base;
177
+ }
178
+ throw new HoldStateError("Hold capture is locked by another idempotency key");
179
+ }
180
+ const account = await tx.accounts.getOrCreate(req.userId);
181
+ // Convert frozen -> charged.
182
+ if (account.frozen < hold.amount)
183
+ throw new HoldStateError("Account frozen is less than hold amount");
184
+ const newFrozen = account.frozen - hold.amount;
185
+ const newBalance = account.balance - req.actualCost;
186
+ if (newBalance < 0n)
187
+ throw new InsufficientCreditsError("Account balance would go negative");
188
+ let quotaBucket = undefined;
189
+ if (tx.quota) {
190
+ quotaBucket = await this.settleQuota(tx, req.userId, now, hold.amount, req.actualCost, 1);
191
+ }
192
+ const updatedAccount = await tx.accounts.update(req.userId, { balance: newBalance, frozen: newFrozen });
193
+ const updatedHold = await tx.holds.finalizeCapture(req.userId, req.requestId, req.idempotencyKey);
194
+ const entry = {
195
+ userId: req.userId,
196
+ type: "charge",
197
+ amount: -req.actualCost,
198
+ referenceId: req.requestId,
199
+ idempotencyKey: req.idempotencyKey,
200
+ meta: {
201
+ model: req.usage.model,
202
+ tokens: req.usage.tokens,
203
+ tools: req.usage.tools ?? [],
204
+ ...(req.usage.meta ?? {}),
205
+ },
206
+ createdAt: now,
207
+ };
208
+ const ledgerEntry = await tx.ledger.append(entry);
209
+ if (tx.usage) {
210
+ await tx.usage.recordUsage({
211
+ userId: req.userId,
212
+ usage: req.usage,
213
+ estimatedCost: hold.amount,
214
+ actualCost: req.actualCost,
215
+ });
216
+ }
217
+ const base = {
218
+ charged: req.actualCost,
219
+ hold: updatedHold,
220
+ account: updatedAccount,
221
+ ledgerEntry,
222
+ };
223
+ if (quotaBucket)
224
+ return { ...base, quotaBucket };
225
+ return base;
226
+ });
227
+ }
228
+ async release(req) {
229
+ return this.persistence.withTransaction(async (tx) => {
230
+ const now = this.persistence.now();
231
+ const hold = await tx.holds.getByRequestId(req.userId, req.requestId);
232
+ if (!hold)
233
+ throw new HoldNotFoundError();
234
+ if (hold.status === "captured")
235
+ throw new HoldStateError("Cannot release a captured hold");
236
+ if (hold.status === "released" || hold.status === "expired") {
237
+ const base = {
238
+ hold,
239
+ account: await tx.accounts.getOrCreate(req.userId),
240
+ };
241
+ if (tx.quota) {
242
+ const quotaBucket = await this.tryGetBucket(tx, req.userId, now);
243
+ if (quotaBucket)
244
+ return { ...base, quotaBucket };
245
+ }
246
+ return base;
247
+ }
248
+ const account = await tx.accounts.getOrCreate(req.userId);
249
+ const updatedAccount = await tx.accounts.update(req.userId, { frozen: account.frozen - hold.amount });
250
+ let quotaBucket = undefined;
251
+ if (tx.quota) {
252
+ quotaBucket = await this.unreserveQuota(tx, req.userId, now, hold.amount, 1);
253
+ }
254
+ const updatedHold = await tx.holds.update(req.userId, req.requestId, {
255
+ status: hold.expiresAt.getTime() <= now.getTime() ? "expired" : "released",
256
+ });
257
+ if (tx.usage) {
258
+ await tx.usage.recordUsage({
259
+ userId: req.userId,
260
+ usage: {
261
+ requestId: req.requestId,
262
+ model: "unknown",
263
+ tokens: { inputTokens: 0, outputTokens: 0 },
264
+ meta: { reason: req.reason },
265
+ },
266
+ estimatedCost: hold.amount,
267
+ actualCost: 0n,
268
+ });
269
+ }
270
+ const base = { hold: updatedHold, account: updatedAccount };
271
+ if (quotaBucket)
272
+ return { ...base, quotaBucket };
273
+ return base;
274
+ });
275
+ }
276
+ // --- Quota helpers (optional store)
277
+ async tryGetBucket(tx, userId, now) {
278
+ const policy = await tx.quota.getActivePolicy(userId);
279
+ if (!policy)
280
+ return undefined;
281
+ return tx.quota.getOrCreateBucket(userId, policy, now);
282
+ }
283
+ async reserveQuota(tx, userId, now, creditsAmount, requests) {
284
+ const policy = await tx.quota.getActivePolicy(userId);
285
+ if (!policy)
286
+ return undefined;
287
+ const bucket = await tx.quota.getOrCreateBucket(userId, policy, now);
288
+ const remainingCredits = policy.limitCredits - bucket.usedCredits - bucket.reservedCredits;
289
+ const remainingReqs = policy.limitRequests == null ? Number.POSITIVE_INFINITY : policy.limitRequests - bucket.usedRequests - bucket.reservedRequests;
290
+ if (remainingCredits < creditsAmount || remainingReqs < requests) {
291
+ throw new QuotaExceededError("Quota remaining is insufficient for this hold");
292
+ }
293
+ return tx.quota.updateBucket(userId, policy.policyId, bucket.periodStart, {
294
+ reservedCredits: bucket.reservedCredits + creditsAmount,
295
+ reservedRequests: bucket.reservedRequests + requests,
296
+ });
297
+ }
298
+ async unreserveQuota(tx, userId, now, creditsAmount, requests) {
299
+ const policy = await tx.quota.getActivePolicy(userId);
300
+ if (!policy)
301
+ return undefined;
302
+ const bucket = await tx.quota.getOrCreateBucket(userId, policy, now);
303
+ return tx.quota.updateBucket(userId, policy.policyId, bucket.periodStart, {
304
+ reservedCredits: bucket.reservedCredits - creditsAmount,
305
+ reservedRequests: bucket.reservedRequests - requests,
306
+ });
307
+ }
308
+ async settleQuota(tx, userId, now, reservedCredits, usedCredits, requests) {
309
+ const policy = await tx.quota.getActivePolicy(userId);
310
+ if (!policy)
311
+ return undefined;
312
+ const bucket = await tx.quota.getOrCreateBucket(userId, policy, now);
313
+ return tx.quota.updateBucket(userId, policy.policyId, bucket.periodStart, {
314
+ reservedCredits: bucket.reservedCredits - reservedCredits,
315
+ reservedRequests: bucket.reservedRequests - requests,
316
+ usedCredits: bucket.usedCredits + usedCredits,
317
+ usedRequests: bucket.usedRequests + requests,
318
+ });
319
+ }
320
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "usage-meter-test",
3
+ "version": "0.1.0",
4
+ "description": "Usage metering core (credits + quota + holds + ledger) for AI and other compute costs.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.6.3"
24
+ }
25
+ }