@drawbridge/drawbridge-utils 0.0.65 → 0.0.67
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/dist/ai.cjs +45 -49
- package/dist/ai.d.cts +7 -161
- package/dist/ai.d.ts +7 -161
- package/dist/ai.js +44 -42
- package/dist/billing.cjs +373 -0
- package/dist/billing.d.cts +300 -0
- package/dist/billing.d.ts +300 -0
- package/dist/billing.js +343 -0
- package/dist/index.cjs +22 -61
- package/dist/index.d.cts +2 -122
- package/dist/index.d.ts +2 -122
- package/dist/index.js +22 -60
- package/dist/plans.d.cts +1 -0
- package/dist/plans.d.ts +1 -0
- package/dist/usage.cjs +91 -0
- package/dist/usage.d.cts +129 -0
- package/dist/usage.d.ts +129 -0
- package/dist/usage.js +65 -0
- package/package.json +6 -1
package/dist/billing.cjs
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// lib/billing.js
|
|
20
|
+
var billing_exports = {};
|
|
21
|
+
__export(billing_exports, {
|
|
22
|
+
MARKUP: () => MARKUP,
|
|
23
|
+
action: () => action,
|
|
24
|
+
ai: () => ai,
|
|
25
|
+
grant: () => grant,
|
|
26
|
+
scrape: () => scrape
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(billing_exports);
|
|
29
|
+
|
|
30
|
+
// lib/transactions.js
|
|
31
|
+
var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
|
|
32
|
+
var insertTransaction = async ({
|
|
33
|
+
db,
|
|
34
|
+
user,
|
|
35
|
+
type,
|
|
36
|
+
category,
|
|
37
|
+
source,
|
|
38
|
+
amount,
|
|
39
|
+
_id,
|
|
40
|
+
stripeInvoiceId,
|
|
41
|
+
stripeEventId,
|
|
42
|
+
session,
|
|
43
|
+
...rest
|
|
44
|
+
}) => {
|
|
45
|
+
var _a;
|
|
46
|
+
const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
|
|
47
|
+
const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
|
|
48
|
+
const trace = (0, import_drawbridge_telemetry.currentTraceId)();
|
|
49
|
+
await db.create({
|
|
50
|
+
authenticated: user,
|
|
51
|
+
collection: "transaction",
|
|
52
|
+
data: {
|
|
53
|
+
..._id && { _id },
|
|
54
|
+
user: user == null ? void 0 : user.id,
|
|
55
|
+
type,
|
|
56
|
+
category,
|
|
57
|
+
source,
|
|
58
|
+
amount,
|
|
59
|
+
...balance && { balance },
|
|
60
|
+
...trace && { trace },
|
|
61
|
+
...rest,
|
|
62
|
+
...stripeInvoiceId && { stripeInvoiceId },
|
|
63
|
+
...stripeEventId && { stripeEventId }
|
|
64
|
+
},
|
|
65
|
+
...session && { options: { session } }
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
var credit = async ({
|
|
69
|
+
db,
|
|
70
|
+
user,
|
|
71
|
+
amount,
|
|
72
|
+
type,
|
|
73
|
+
category,
|
|
74
|
+
source,
|
|
75
|
+
_id,
|
|
76
|
+
stripeInvoiceId,
|
|
77
|
+
stripeEventId,
|
|
78
|
+
session,
|
|
79
|
+
...rest
|
|
80
|
+
}) => {
|
|
81
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
82
|
+
throw new Error(`credit() requires a positive integer amount, got ${amount}`);
|
|
83
|
+
}
|
|
84
|
+
if (!type) {
|
|
85
|
+
throw new Error('credit() requires a type (e.g., "ai")');
|
|
86
|
+
}
|
|
87
|
+
if (!source) {
|
|
88
|
+
throw new Error('credit() requires a source ("system" | "admin" | "user")');
|
|
89
|
+
}
|
|
90
|
+
await insertTransaction({
|
|
91
|
+
db,
|
|
92
|
+
user,
|
|
93
|
+
type,
|
|
94
|
+
category,
|
|
95
|
+
source,
|
|
96
|
+
amount,
|
|
97
|
+
_id,
|
|
98
|
+
stripeInvoiceId,
|
|
99
|
+
stripeEventId,
|
|
100
|
+
session,
|
|
101
|
+
...rest
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
var debit = async ({
|
|
105
|
+
db,
|
|
106
|
+
user,
|
|
107
|
+
amount,
|
|
108
|
+
type,
|
|
109
|
+
category,
|
|
110
|
+
source,
|
|
111
|
+
stripeInvoiceId,
|
|
112
|
+
stripeEventId,
|
|
113
|
+
session,
|
|
114
|
+
...rest
|
|
115
|
+
}) => {
|
|
116
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
117
|
+
throw new Error(`debit() requires a positive integer amount, got ${amount}`);
|
|
118
|
+
}
|
|
119
|
+
if (!type) {
|
|
120
|
+
throw new Error('debit() requires a type (e.g., "ai")');
|
|
121
|
+
}
|
|
122
|
+
if (!source) {
|
|
123
|
+
throw new Error('debit() requires a source ("system" | "admin" | "user")');
|
|
124
|
+
}
|
|
125
|
+
await insertTransaction({
|
|
126
|
+
db,
|
|
127
|
+
user,
|
|
128
|
+
type,
|
|
129
|
+
category,
|
|
130
|
+
source,
|
|
131
|
+
amount: -amount,
|
|
132
|
+
stripeInvoiceId,
|
|
133
|
+
stripeEventId,
|
|
134
|
+
session,
|
|
135
|
+
...rest
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// lib/usage.js
|
|
140
|
+
var incrementUsageTotals = async ({ controller, usageId, $inc, session }) => {
|
|
141
|
+
if (!usageId || !$inc) return;
|
|
142
|
+
const safeInc = Object.fromEntries(
|
|
143
|
+
Object.entries($inc).filter(([, value]) => {
|
|
144
|
+
const number = Number(value);
|
|
145
|
+
return Number.isFinite(number) && number !== 0;
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
if (Object.keys(safeInc).length === 0) return;
|
|
149
|
+
return controller.update({
|
|
150
|
+
collection: "usage",
|
|
151
|
+
data: { $inc: safeInc },
|
|
152
|
+
options: {
|
|
153
|
+
bypassDocumentValidation: true,
|
|
154
|
+
...session && { session }
|
|
155
|
+
},
|
|
156
|
+
query: { id: usageId }
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
var isDuplicateKey = (error) => (error == null ? void 0 : error.code) === 11e3 || (error == null ? void 0 : error.code) === "11000" || /E11000|duplicate key/i.test((error == null ? void 0 : error.message) || "");
|
|
160
|
+
var recordAction = async ({ controller, organization, usageId, type, units = 1, key, meta = {}, traceId, session }) => {
|
|
161
|
+
if (!controller || !organization || !usageId || !type || !key) return;
|
|
162
|
+
const amount = Number(units);
|
|
163
|
+
if (!Number.isFinite(amount) || amount === 0) return;
|
|
164
|
+
let inserted = false;
|
|
165
|
+
try {
|
|
166
|
+
const { lastErrorObject } = await controller.update({
|
|
167
|
+
collection: "action",
|
|
168
|
+
data: {
|
|
169
|
+
$setOnInsert: {
|
|
170
|
+
organization,
|
|
171
|
+
usage: usageId,
|
|
172
|
+
type,
|
|
173
|
+
units: amount,
|
|
174
|
+
key,
|
|
175
|
+
meta,
|
|
176
|
+
...traceId && { traceId }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
options: {
|
|
180
|
+
bypassDocumentValidation: true,
|
|
181
|
+
includeResultMetadata: true,
|
|
182
|
+
upsert: true,
|
|
183
|
+
...session && { session }
|
|
184
|
+
},
|
|
185
|
+
query: {
|
|
186
|
+
key,
|
|
187
|
+
usage: usageId
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
inserted = Boolean(lastErrorObject == null ? void 0 : lastErrorObject.upserted);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (isDuplicateKey(error) && !session) return;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (inserted) {
|
|
196
|
+
await incrementUsageTotals({ controller, usageId, $inc: { "totals.actions": amount }, session });
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// lib/billing.js
|
|
201
|
+
var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
|
|
202
|
+
var logger = (0, import_drawbridge_telemetry2.createLogger)();
|
|
203
|
+
var MARKUP = 1.3;
|
|
204
|
+
var cost = {
|
|
205
|
+
"gemini-2.5-flash": {
|
|
206
|
+
cached: 3,
|
|
207
|
+
input: 30,
|
|
208
|
+
output: 250
|
|
209
|
+
},
|
|
210
|
+
"gemini-2.5-flash-image": {
|
|
211
|
+
cached: 3,
|
|
212
|
+
input: 30,
|
|
213
|
+
output: 3e3
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
var toolCost = {
|
|
217
|
+
search: 3.5
|
|
218
|
+
};
|
|
219
|
+
var toolPricing = Object.fromEntries(
|
|
220
|
+
Object.entries(toolCost).map(([tool, value]) => [
|
|
221
|
+
tool,
|
|
222
|
+
Math.ceil(value * MARKUP)
|
|
223
|
+
])
|
|
224
|
+
);
|
|
225
|
+
var pricing = Object.fromEntries(
|
|
226
|
+
Object.entries(cost).map(([model, rates]) => [
|
|
227
|
+
model,
|
|
228
|
+
{
|
|
229
|
+
cached: Math.round(rates.cached * MARKUP),
|
|
230
|
+
input: Math.round(rates.input * MARKUP),
|
|
231
|
+
output: Math.round(rates.output * MARKUP)
|
|
232
|
+
}
|
|
233
|
+
])
|
|
234
|
+
);
|
|
235
|
+
var sumCents = (rates, tokens) => Math.ceil(
|
|
236
|
+
(Number((tokens == null ? void 0 : tokens.cached) || 0) * rates.cached + Number((tokens == null ? void 0 : tokens.input) || 0) * rates.input + (Number((tokens == null ? void 0 : tokens.output) || 0) + Number((tokens == null ? void 0 : tokens.thinking) || 0)) * rates.output) / 1e6
|
|
237
|
+
);
|
|
238
|
+
var sumToolCents = (table, tools) => Object.entries(tools || {}).reduce(
|
|
239
|
+
(total, [name, used]) => total + (used && table[name] ? table[name] : 0),
|
|
240
|
+
0
|
|
241
|
+
);
|
|
242
|
+
var priceForRequest = (model, usage, tools) => {
|
|
243
|
+
const rates = pricing[model];
|
|
244
|
+
if (!rates) {
|
|
245
|
+
throw new Error(`Unknown model for pricing: ${model}`);
|
|
246
|
+
}
|
|
247
|
+
return sumCents(rates, usage) + Math.ceil(sumToolCents(toolPricing, tools));
|
|
248
|
+
};
|
|
249
|
+
var costForRequest = (model, usage, tools) => {
|
|
250
|
+
const rates = cost[model];
|
|
251
|
+
if (!rates) {
|
|
252
|
+
throw new Error(`Unknown model for cost: ${model}`);
|
|
253
|
+
}
|
|
254
|
+
return sumCents(rates, usage) + Math.ceil(sumToolCents(toolCost, tools));
|
|
255
|
+
};
|
|
256
|
+
var billRequest = async ({ db, user, model, usage, tools }) => {
|
|
257
|
+
const gross = priceForRequest(model, usage, tools);
|
|
258
|
+
const cogs = costForRequest(model, usage, tools);
|
|
259
|
+
await debit({
|
|
260
|
+
db,
|
|
261
|
+
user,
|
|
262
|
+
amount: gross,
|
|
263
|
+
type: "ai",
|
|
264
|
+
category: "usage",
|
|
265
|
+
source: "user",
|
|
266
|
+
ai: {
|
|
267
|
+
name: "google",
|
|
268
|
+
model,
|
|
269
|
+
tokens: usage,
|
|
270
|
+
tools,
|
|
271
|
+
rates: {
|
|
272
|
+
wholesale: cost[model],
|
|
273
|
+
retail: pricing[model]
|
|
274
|
+
},
|
|
275
|
+
totals: {
|
|
276
|
+
cost: cogs,
|
|
277
|
+
net: gross - cogs,
|
|
278
|
+
gross
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
var ai = {
|
|
284
|
+
// Retail cents for a request (what the user pays).
|
|
285
|
+
price: priceForRequest,
|
|
286
|
+
// Wholesale cents for a request (what we pay the provider).
|
|
287
|
+
cost: costForRequest,
|
|
288
|
+
// Deferred bill() the caller fires on success, so the debit lands only when
|
|
289
|
+
// the request succeeds and bundles into that request's Sentry trace. No user
|
|
290
|
+
// → a no-op.
|
|
291
|
+
bill: ({ db, user, model, usage, tools }) => (user == null ? void 0 : user.id) ? () => billRequest({ db, user, model, usage, tools }) : () => Promise.resolve()
|
|
292
|
+
};
|
|
293
|
+
var STANDARD_RATE_PER_GB = 8;
|
|
294
|
+
var PREMIUM_RATE_PER_GB = 11;
|
|
295
|
+
var PREMIUM_HOSTS = /* @__PURE__ */ new Set();
|
|
296
|
+
var LOCAL_FLAT_CENTS = 1;
|
|
297
|
+
var REUSE_FLAT_CENTS = 1;
|
|
298
|
+
var ratePerGB = (url) => {
|
|
299
|
+
try {
|
|
300
|
+
return PREMIUM_HOSTS.has(new URL(url).hostname) ? PREMIUM_RATE_PER_GB : STANDARD_RATE_PER_GB;
|
|
301
|
+
} catch {
|
|
302
|
+
return STANDARD_RATE_PER_GB;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
var scrapeFee = ({ provider, bytes, url, cold }) => {
|
|
306
|
+
if (!cold) return REUSE_FLAT_CENTS;
|
|
307
|
+
if (provider === "brightdata") {
|
|
308
|
+
return Math.ceil((bytes || 0) / 1e9 * ratePerGB(url) * 100 * MARKUP);
|
|
309
|
+
}
|
|
310
|
+
return LOCAL_FLAT_CENTS;
|
|
311
|
+
};
|
|
312
|
+
var scrape = {
|
|
313
|
+
// Cents for a scrape given its provider/bytes/cold basis.
|
|
314
|
+
fee: scrapeFee,
|
|
315
|
+
// Deferred bill(scrapeId) the scrape store's resolve() hands the generation
|
|
316
|
+
// flow. Atomically claims the cold (first-time) fee via the `charged` flag so
|
|
317
|
+
// a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
|
|
318
|
+
// flat rate. Best-effort — a billing hiccup never fails generation.
|
|
319
|
+
bill: ({ controller, user, page, url }) => async (scrapeId) => {
|
|
320
|
+
var _a;
|
|
321
|
+
if (!(user == null ? void 0 : user.id) || !scrapeId) return;
|
|
322
|
+
try {
|
|
323
|
+
const claim = await controller.update({
|
|
324
|
+
collection: "scrape",
|
|
325
|
+
data: { $set: { charged: true } },
|
|
326
|
+
options: { bypassDocumentValidation: true },
|
|
327
|
+
query: { id: scrapeId, charged: { $ne: true } }
|
|
328
|
+
});
|
|
329
|
+
const cents = scrapeFee({
|
|
330
|
+
provider: (_a = page == null ? void 0 : page.provider) == null ? void 0 : _a.name,
|
|
331
|
+
bytes: page == null ? void 0 : page.bytes,
|
|
332
|
+
url: (page == null ? void 0 : page.url) || url,
|
|
333
|
+
cold: Boolean(claim == null ? void 0 : claim.value)
|
|
334
|
+
});
|
|
335
|
+
if (cents > 0) await debit({ db: controller, user, amount: cents, type: "ai", category: "usage", source: "user" });
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logger.error(error, { extra: { action: "generation.scrape.bill", scrapeId } });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
var grant = {
|
|
342
|
+
// System-issued credit (signup welcome, migration catch-up). Pass `session`
|
|
343
|
+
// to enroll the credit in the caller's transaction.
|
|
344
|
+
bonus: ({ db, user, amount, session }) => credit({ db, user, amount, type: "ai", category: "promotional", source: "system", ...session && { session } }),
|
|
345
|
+
// Admin-issued credit (manual grant). `user` is the recipient, not the admin.
|
|
346
|
+
admin: ({ db, user, amount }) => credit({ db, user, amount, type: "ai", category: "promotional", source: "admin" })
|
|
347
|
+
};
|
|
348
|
+
var action = {
|
|
349
|
+
// Append a billable action (idempotent on { usage, key }) + bump the
|
|
350
|
+
// usage.totals.actions display cache. The proven recordAction primitive.
|
|
351
|
+
record: recordAction,
|
|
352
|
+
// Authoritative per-period total to bill — sum(units) over the period's ledger
|
|
353
|
+
// rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
|
|
354
|
+
sum: async ({ controller, usageId }) => {
|
|
355
|
+
if (!controller || !usageId) return 0;
|
|
356
|
+
const [row] = await controller.aggregate({
|
|
357
|
+
collection: "action",
|
|
358
|
+
pipeline: [
|
|
359
|
+
{ $match: { usage: usageId } },
|
|
360
|
+
{ $group: { _id: null, units: { $sum: "$units" } } }
|
|
361
|
+
]
|
|
362
|
+
}) || [];
|
|
363
|
+
return (row == null ? void 0 : row.units) || 0;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
367
|
+
0 && (module.exports = {
|
|
368
|
+
MARKUP,
|
|
369
|
+
action,
|
|
370
|
+
ai,
|
|
371
|
+
grant,
|
|
372
|
+
scrape
|
|
373
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { credit, debit } from './transactions.cjs';
|
|
2
|
+
import { recordAction } from './usage.cjs';
|
|
3
|
+
import { createLogger } from '@drawbridge/drawbridge-telemetry';
|
|
4
|
+
|
|
5
|
+
// Billing — the single source of truth for what Drawbridge charges, and the
|
|
6
|
+
// single public surface for moving money. Namespaced by what's billed, each noun
|
|
7
|
+
// exposing the pure fee math and a deferred bill()/credit:
|
|
8
|
+
//
|
|
9
|
+
// billing.ai { price, cost, bill } AI requests → credit rail (debit)
|
|
10
|
+
// billing.scrape { fee, bill } scrape fees → credit rail (debit)
|
|
11
|
+
// billing.grant { bonus, admin } promo credits → credit rail (credit)
|
|
12
|
+
// billing.action { record, sum } usage actions → Stripe meter rail
|
|
13
|
+
//
|
|
14
|
+
// Sits on top of transactions.js (the generic credit/debit money rail) and
|
|
15
|
+
// usage.js (the action-ledger + usage-totals primitives). Shopify order fees are
|
|
16
|
+
// NOT billed here — they meter through @drawbridge/shopify's recordUsage and stay
|
|
17
|
+
// Shopify-only by design. The Stripe meter EMIT for actions also stays in the
|
|
18
|
+
// Stripe layer (utils must not depend on Stripe); billing.action.sum gives that
|
|
19
|
+
// layer the authoritative number to report.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const logger = createLogger();
|
|
23
|
+
|
|
24
|
+
// Markup multiplier applied to provider cost to produce the retail price.
|
|
25
|
+
// 1.3 = 30% margin. Change one number, all pricing updates. Shared across rails
|
|
26
|
+
// (AI tokens + the metered scrape fee).
|
|
27
|
+
const MARKUP = 1.3;
|
|
28
|
+
|
|
29
|
+
// ─── AI (credit rail) ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
// Raw per-model provider rates in cents per 1,000,000 tokens — the wholesale
|
|
32
|
+
// cost. Sync from LiteLLM. Never apply markup here; `pricing` derives retail.
|
|
33
|
+
const cost = {
|
|
34
|
+
'gemini-2.5-flash' : {
|
|
35
|
+
cached : 3,
|
|
36
|
+
input : 30,
|
|
37
|
+
output : 250
|
|
38
|
+
},
|
|
39
|
+
'gemini-2.5-flash-image' : {
|
|
40
|
+
cached : 3,
|
|
41
|
+
input : 30,
|
|
42
|
+
output : 3000
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Flat per-request fees Google charges when specific tools fire. Search
|
|
47
|
+
// grounding is $35/1k requests → 3.5 cents/request, billed only when the tool
|
|
48
|
+
// actually ran. Add more as Google introduces them.
|
|
49
|
+
const toolCost = {
|
|
50
|
+
search : 3.5
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toolPricing = Object.fromEntries(
|
|
54
|
+
Object.entries( toolCost ).map( ( [ tool, value ] ) => [
|
|
55
|
+
tool,
|
|
56
|
+
Math.ceil( value * MARKUP )
|
|
57
|
+
] )
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Derived retail pricing in cents per 1,000,000 tokens — what users pay.
|
|
61
|
+
const pricing = Object.fromEntries(
|
|
62
|
+
Object.entries( cost ).map( ( [ model, rates ] ) => [
|
|
63
|
+
model,
|
|
64
|
+
{
|
|
65
|
+
cached : Math.round( rates.cached * MARKUP ),
|
|
66
|
+
input : Math.round( rates.input * MARKUP ),
|
|
67
|
+
output : Math.round( rates.output * MARKUP )
|
|
68
|
+
}
|
|
69
|
+
] )
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Thinking tokens (Gemini 2.5 internal reasoning) bill at the output rate, so
|
|
73
|
+
// they roll into the same multiplier even though they're tracked separately.
|
|
74
|
+
const sumCents = ( rates, tokens ) => Math.ceil(
|
|
75
|
+
(
|
|
76
|
+
Number( tokens?.cached || 0 ) * rates.cached +
|
|
77
|
+
Number( tokens?.input || 0 ) * rates.input +
|
|
78
|
+
(
|
|
79
|
+
Number( tokens?.output || 0 ) +
|
|
80
|
+
Number( tokens?.thinking || 0 )
|
|
81
|
+
) * rates.output
|
|
82
|
+
) / 1_000_000
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Sum the per-request flat fees for the tools that actually fired.
|
|
86
|
+
const sumToolCents = ( table, tools ) => Object.entries( tools || {} ).reduce(
|
|
87
|
+
( total, [ name, used ] ) => total + ( used && table[ name ] ? table[ name ] : 0 ),
|
|
88
|
+
0
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// What we charge the user, rounded up so we never under-bill. Throws on an
|
|
92
|
+
// unknown model so a typo doesn't silently fall back to a wrong price.
|
|
93
|
+
const priceForRequest = ( model, usage, tools ) => {
|
|
94
|
+
|
|
95
|
+
const rates = pricing[ model ];
|
|
96
|
+
|
|
97
|
+
if( !rates ){
|
|
98
|
+
|
|
99
|
+
throw new Error( `Unknown model for pricing: ${ model }` );
|
|
100
|
+
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return sumCents( rates, usage ) + Math.ceil( sumToolCents( toolPricing, tools ) );
|
|
104
|
+
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// What we pay the provider. Recorded on each transaction row so margin is
|
|
108
|
+
// computable per request without backfilling from git history.
|
|
109
|
+
const costForRequest = ( model, usage, tools ) => {
|
|
110
|
+
|
|
111
|
+
const rates = cost[ model ];
|
|
112
|
+
|
|
113
|
+
if( !rates ){
|
|
114
|
+
|
|
115
|
+
throw new Error( `Unknown model for cost: ${ model }` );
|
|
116
|
+
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return sumCents( rates, usage ) + Math.ceil( sumToolCents( toolCost, tools ) );
|
|
120
|
+
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Compute gross + cost from response usage and atomically debit the user.
|
|
124
|
+
// Records the full income-statement line on the transaction row's `ai` sub-doc:
|
|
125
|
+
// wholesale + retail per-1M rate snapshots, plus rolled-up cost, net, and gross.
|
|
126
|
+
const billRequest = async ({ db, user, model, usage, tools }) => {
|
|
127
|
+
|
|
128
|
+
const gross = priceForRequest( model, usage, tools );
|
|
129
|
+
const cogs = costForRequest( model, usage, tools );
|
|
130
|
+
|
|
131
|
+
await debit({
|
|
132
|
+
db,
|
|
133
|
+
user,
|
|
134
|
+
amount : gross,
|
|
135
|
+
type : 'ai',
|
|
136
|
+
category : 'usage',
|
|
137
|
+
source : 'user',
|
|
138
|
+
ai : {
|
|
139
|
+
name : 'google',
|
|
140
|
+
model,
|
|
141
|
+
tokens : usage,
|
|
142
|
+
tools,
|
|
143
|
+
rates : {
|
|
144
|
+
wholesale : cost[ model ],
|
|
145
|
+
retail : pricing[ model ]
|
|
146
|
+
},
|
|
147
|
+
totals : {
|
|
148
|
+
cost : cogs,
|
|
149
|
+
net : gross - cogs,
|
|
150
|
+
gross
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const ai = {
|
|
158
|
+
|
|
159
|
+
// Retail cents for a request (what the user pays).
|
|
160
|
+
price : priceForRequest,
|
|
161
|
+
|
|
162
|
+
// Wholesale cents for a request (what we pay the provider).
|
|
163
|
+
cost : costForRequest,
|
|
164
|
+
|
|
165
|
+
// Deferred bill() the caller fires on success, so the debit lands only when
|
|
166
|
+
// the request succeeds and bundles into that request's Sentry trace. No user
|
|
167
|
+
// → a no-op.
|
|
168
|
+
bill : ({ db, user, model, usage, tools }) =>
|
|
169
|
+
user?.id
|
|
170
|
+
? () => billRequest({ db, user, model, usage, tools })
|
|
171
|
+
: () => Promise.resolve()
|
|
172
|
+
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ─── Scrape (credit rail) ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
// BrightData (the Scraping Browser fallback) bills by GB, so its fee is METERED
|
|
178
|
+
// to the traffic measured at scrape time; the 30% margin matches MARKUP above. A
|
|
179
|
+
// cold local render and a cache reuse have no external cost, so they're small
|
|
180
|
+
// flat fees (set to 0 to make either free). Tune against real BrightData invoices.
|
|
181
|
+
const STANDARD_RATE_PER_GB = 8;
|
|
182
|
+
const PREMIUM_RATE_PER_GB = 11;
|
|
183
|
+
const PREMIUM_HOSTS = new Set();
|
|
184
|
+
const LOCAL_FLAT_CENTS = 1;
|
|
185
|
+
const REUSE_FLAT_CENTS = 1;
|
|
186
|
+
|
|
187
|
+
const ratePerGB = ( url ) => {
|
|
188
|
+
|
|
189
|
+
try { return PREMIUM_HOSTS.has( new URL( url ).hostname ) ? PREMIUM_RATE_PER_GB : STANDARD_RATE_PER_GB; }
|
|
190
|
+
catch { return STANDARD_RATE_PER_GB; }
|
|
191
|
+
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Cents to charge. `cold` = the first generation off this scrape (charge the real
|
|
195
|
+
// cost: metered for BrightData, a flat for a local render); otherwise it's a reuse
|
|
196
|
+
// (flat). Rounded up so we never under-bill a fractional cent.
|
|
197
|
+
const scrapeFee = ({ provider, bytes, url, cold }) => {
|
|
198
|
+
|
|
199
|
+
if( ! cold ) return REUSE_FLAT_CENTS;
|
|
200
|
+
|
|
201
|
+
if( provider === 'brightdata' ){
|
|
202
|
+
|
|
203
|
+
return Math.ceil( ( ( bytes || 0 ) / 1e9 ) * ratePerGB( url ) * 100 * MARKUP );
|
|
204
|
+
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return LOCAL_FLAT_CENTS;
|
|
208
|
+
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const scrape = {
|
|
212
|
+
|
|
213
|
+
// Cents for a scrape given its provider/bytes/cold basis.
|
|
214
|
+
fee : scrapeFee,
|
|
215
|
+
|
|
216
|
+
// Deferred bill(scrapeId) the scrape store's resolve() hands the generation
|
|
217
|
+
// flow. Atomically claims the cold (first-time) fee via the `charged` flag so
|
|
218
|
+
// a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
|
|
219
|
+
// flat rate. Best-effort — a billing hiccup never fails generation.
|
|
220
|
+
bill : ({ controller, user, page, url }) => async ( scrapeId ) => {
|
|
221
|
+
|
|
222
|
+
if( ! user?.id || ! scrapeId ) return;
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
|
|
226
|
+
const claim = await controller.update({
|
|
227
|
+
collection : 'scrape',
|
|
228
|
+
data : { $set : { charged : true } },
|
|
229
|
+
options : { bypassDocumentValidation : true },
|
|
230
|
+
query : { id : scrapeId, charged : { $ne : true } }
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const cents = scrapeFee({
|
|
234
|
+
provider : page?.provider?.name,
|
|
235
|
+
bytes : page?.bytes,
|
|
236
|
+
url : page?.url || url,
|
|
237
|
+
cold : Boolean( claim?.value )
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if( cents > 0 ) await debit({ db : controller, user, amount : cents, type : 'ai', category : 'usage', source : 'user' });
|
|
241
|
+
|
|
242
|
+
} catch ( error ) {
|
|
243
|
+
|
|
244
|
+
logger.error( error, { extra : { action : 'generation.scrape.bill', scrapeId } } );
|
|
245
|
+
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// ─── Grants (credit rail) ──────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
// Promotional credits to a user's AI balance. `source` captures who acted; the
|
|
255
|
+
// stream listener on the transaction collection does the balance.ai $inc.
|
|
256
|
+
const grant = {
|
|
257
|
+
|
|
258
|
+
// System-issued credit (signup welcome, migration catch-up). Pass `session`
|
|
259
|
+
// to enroll the credit in the caller's transaction.
|
|
260
|
+
bonus : ({ db, user, amount, session }) =>
|
|
261
|
+
credit({ db, user, amount, type : 'ai', category : 'promotional', source : 'system', ...( session && { session }) }),
|
|
262
|
+
|
|
263
|
+
// Admin-issued credit (manual grant). `user` is the recipient, not the admin.
|
|
264
|
+
admin : ({ db, user, amount }) =>
|
|
265
|
+
credit({ db, user, amount, type : 'ai', category : 'promotional', source : 'admin' })
|
|
266
|
+
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ─── Actions (usage meter rail) ────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
// The metered-usage rail (distinct from the AI credit rail above): an append-only
|
|
272
|
+
// `action` ledger keyed on organization/period, summed at changeover and reported
|
|
273
|
+
// to the Stripe meter. See the action-ledger design.
|
|
274
|
+
const action = {
|
|
275
|
+
|
|
276
|
+
// Append a billable action (idempotent on { usage, key }) + bump the
|
|
277
|
+
// usage.totals.actions display cache. The proven recordAction primitive.
|
|
278
|
+
record : recordAction,
|
|
279
|
+
|
|
280
|
+
// Authoritative per-period total to bill — sum(units) over the period's ledger
|
|
281
|
+
// rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
|
|
282
|
+
sum : async ({ controller, usageId }) => {
|
|
283
|
+
|
|
284
|
+
if( ! controller || ! usageId ) return 0;
|
|
285
|
+
|
|
286
|
+
const [ row ] = await controller.aggregate({
|
|
287
|
+
collection : 'action',
|
|
288
|
+
pipeline : [
|
|
289
|
+
{ $match : { usage : usageId } },
|
|
290
|
+
{ $group : { _id : null, units : { $sum : '$units' } } }
|
|
291
|
+
]
|
|
292
|
+
}) || [];
|
|
293
|
+
|
|
294
|
+
return row?.units || 0;
|
|
295
|
+
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export { MARKUP, action, ai, grant, scrape };
|