@drawbridge/drawbridge-utils 0.0.29 → 0.0.32

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 CHANGED
@@ -19,6 +19,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // ai.js
20
20
  var ai_exports = {};
21
21
  __export(ai_exports, {
22
+ MARKUP: () => MARKUP,
23
+ cost: () => cost,
24
+ costForRequest: () => costForRequest,
22
25
  google: () => google,
23
26
  priceForRequest: () => priceForRequest,
24
27
  pricing: () => pricing
@@ -78,105 +81,73 @@ var circuit = ({
78
81
  };
79
82
 
80
83
  // transactions.js
81
- var recordTransaction = async ({
84
+ var insertTransaction = async ({
82
85
  db,
83
- authenticated,
84
- userId,
86
+ user,
87
+ type,
85
88
  category,
86
- tags,
89
+ source,
87
90
  amount,
88
- balance,
89
- provider,
91
+ _id,
90
92
  stripeInvoiceId,
91
93
  stripeEventId,
92
- session
94
+ session,
95
+ ...rest
93
96
  }) => {
97
+ var _a;
98
+ const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
99
+ const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
94
100
  await db.create({
95
- authenticated,
101
+ authenticated: user,
96
102
  collection: "transaction",
97
103
  data: {
98
- userId,
99
- billable: {
100
- item: "ai"
101
- },
104
+ ..._id && { _id },
105
+ userId: user == null ? void 0 : user.id,
106
+ type,
102
107
  category,
103
- tags,
108
+ source,
104
109
  amount,
105
- balance,
106
- ...provider && { provider },
110
+ ...balance && { balance },
111
+ ...rest,
107
112
  ...stripeInvoiceId && { stripeInvoiceId },
108
113
  ...stripeEventId && { stripeEventId }
109
114
  },
110
115
  ...session && { options: { session } }
111
116
  });
112
117
  };
113
- var InsufficientCreditsError = class extends Error {
114
- constructor(message = "Insufficient AI credits") {
115
- super(message);
116
- this.name = "InsufficientCreditsError";
117
- this.status = 402;
118
- }
119
- };
120
118
  var debit = async ({
121
119
  db,
122
- authenticated,
123
- userId,
120
+ user,
124
121
  amount,
122
+ type,
125
123
  category,
126
- tags = [],
127
- provider,
124
+ source,
128
125
  stripeInvoiceId,
129
126
  stripeEventId,
130
- session
127
+ session,
128
+ ...rest
131
129
  }) => {
132
- var _a, _b, _c;
133
130
  if (!Number.isInteger(amount) || amount <= 0) {
134
131
  throw new Error(`debit() requires a positive integer amount, got ${amount}`);
135
132
  }
136
- const result = await db.update({
137
- authenticated,
138
- collection: "user",
139
- query: {
140
- id: userId,
141
- "balance.ai": {
142
- $gte: amount
143
- }
144
- },
145
- data: {
146
- $inc: {
147
- "balance.ai": -amount
148
- }
149
- },
150
- ...session && { options: { session } }
151
- });
152
- const after = ((_b = (_a = result == null ? void 0 : result.value) == null ? void 0 : _a.balance) == null ? void 0 : _b.ai) ?? ((_c = result == null ? void 0 : result.balance) == null ? void 0 : _c.ai);
153
- if (typeof after !== "number") {
154
- throw new InsufficientCreditsError();
133
+ if (!type) {
134
+ throw new Error('debit() requires a type (e.g., "ai")');
135
+ }
136
+ if (!source) {
137
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
155
138
  }
156
- const before = after + amount;
157
- await recordTransaction({
139
+ await insertTransaction({
158
140
  db,
159
- authenticated,
160
- userId,
141
+ user,
142
+ type,
161
143
  category,
162
- tags,
144
+ source,
163
145
  amount: -amount,
164
- balance: {
165
- before,
166
- after
167
- },
168
- provider,
169
146
  stripeInvoiceId,
170
147
  stripeEventId,
171
- session
148
+ session,
149
+ ...rest
172
150
  });
173
- return {
174
- amount: -amount,
175
- balance: {
176
- before,
177
- after
178
- }
179
- };
180
151
  };
181
152
 
182
153
  // ai.js
@@ -192,7 +163,7 @@ var models = {
192
163
  image: "gemini-2.5-flash-image",
193
164
  text: "gemini-2.5-flash"
194
165
  };
195
- var pricing = {
166
+ var cost = {
196
167
  "gemini-2.5-flash": {
197
168
  cached: 3,
198
169
  input: 30,
@@ -204,18 +175,33 @@ var pricing = {
204
175
  output: 250
205
176
  }
206
177
  };
178
+ var MARKUP = 1.3;
179
+ var pricing = Object.fromEntries(
180
+ Object.entries(cost).map(([model, rates]) => [
181
+ model,
182
+ {
183
+ cached: Math.round(rates.cached * MARKUP),
184
+ input: Math.round(rates.input * MARKUP),
185
+ output: Math.round(rates.output * MARKUP)
186
+ }
187
+ ])
188
+ );
189
+ var sumCents = (rates, tokens) => Math.ceil(
190
+ (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) * rates.output) / 1e6
191
+ );
207
192
  var priceForRequest = (model, usage) => {
208
193
  const rates = pricing[model];
209
194
  if (!rates) {
210
195
  throw new Error(`Unknown model for pricing: ${model}`);
211
196
  }
212
- const tokens = {
213
- cached: Number(usage == null ? void 0 : usage.cached) || 0,
214
- input: Number(usage == null ? void 0 : usage.input) || 0,
215
- output: Number(usage == null ? void 0 : usage.output) || 0
216
- };
217
- const sum = tokens.cached * rates.cached + tokens.input * rates.input + tokens.output * rates.output;
218
- return Math.ceil(sum / 1e6);
197
+ return sumCents(rates, usage);
198
+ };
199
+ var costForRequest = (model, usage) => {
200
+ const rates = cost[model];
201
+ if (!rates) {
202
+ throw new Error(`Unknown model for cost: ${model}`);
203
+ }
204
+ return sumCents(rates, usage);
219
205
  };
220
206
  var tokensFromMetadata = (metadata) => ({
221
207
  cached: Number(metadata == null ? void 0 : metadata.cachedContentTokenCount) || 0,
@@ -224,24 +210,32 @@ var tokensFromMetadata = (metadata) => ({
224
210
  });
225
211
  var billRequest = async ({
226
212
  db,
227
- authenticated,
228
- userId,
213
+ user,
229
214
  model,
230
215
  usage
231
216
  }) => {
232
- const amount = priceForRequest(model, usage);
217
+ const gross = priceForRequest(model, usage);
218
+ const cogs = costForRequest(model, usage);
233
219
  await debit({
234
220
  db,
235
- authenticated,
236
- userId,
237
- amount,
221
+ user,
222
+ amount: gross,
223
+ type: "ai",
238
224
  category: "usage",
239
- tags: ["google", model],
240
- provider: {
225
+ source: "user",
226
+ ai: {
241
227
  name: "google",
242
228
  model,
243
229
  tokens: usage,
244
- snapshot: pricing[model]
230
+ rates: {
231
+ wholesale: cost[model],
232
+ retail: pricing[model]
233
+ },
234
+ totals: {
235
+ cost: cogs,
236
+ net: gross - cogs,
237
+ gross
238
+ }
245
239
  }
246
240
  });
247
241
  };
@@ -252,8 +246,7 @@ var google = {
252
246
  reference = null
253
247
  }, {
254
248
  db,
255
- authenticated,
256
- userId
249
+ user
257
250
  }) => {
258
251
  var _a, _b, _c, _d, _e, _f;
259
252
  const model = models.image;
@@ -261,72 +254,64 @@ var google = {
261
254
  ...reference ? [{ inlineData: { data: reference.data, mimeType: reference.mimeType } }] : [],
262
255
  { text: prompt.join("\n") }
263
256
  ];
264
- try {
265
- const response = await googleCircuit(() => google_client.models.generateContent({
266
- model,
267
- contents: { parts },
268
- config: {
269
- ...config,
270
- responseModalities: ["Image"]
271
- }
272
- }));
273
- const content = (_f = (_e = (_d = (_c = (_b = (_a = response == null ? void 0 : response.candidates) == null ? void 0 : _a[0]) == null ? void 0 : _b.content) == null ? void 0 : _c.parts) == null ? void 0 : _d[0]) == null ? void 0 : _e.inlineData) == null ? void 0 : _f.data;
274
- if (!content) {
275
- throw new Error("Could not generate from prompt. Please try again with a different prompt.");
276
- }
277
- if (userId) {
278
- await billRequest({
279
- db,
280
- authenticated,
281
- userId,
282
- model,
283
- usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
284
- });
257
+ const response = await googleCircuit(() => google_client.models.generateContent({
258
+ model,
259
+ contents: { parts },
260
+ config: {
261
+ ...config,
262
+ responseModalities: ["Image"]
285
263
  }
286
- return { content };
287
- } catch (error) {
288
- throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
264
+ }));
265
+ const content = (_f = (_e = (_d = (_c = (_b = (_a = response == null ? void 0 : response.candidates) == null ? void 0 : _a[0]) == null ? void 0 : _b.content) == null ? void 0 : _c.parts) == null ? void 0 : _d[0]) == null ? void 0 : _e.inlineData) == null ? void 0 : _f.data;
266
+ if (!content) {
267
+ throw new Error("Could not generate from prompt. Please try again with a different prompt.");
289
268
  }
269
+ if (user == null ? void 0 : user.id) {
270
+ await billRequest({
271
+ db,
272
+ user,
273
+ model,
274
+ usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
275
+ });
276
+ }
277
+ return { content };
290
278
  },
291
279
  text: async ({
292
280
  config = {},
293
281
  prompt = []
294
282
  }, {
295
283
  db,
296
- authenticated,
297
- userId
284
+ user
298
285
  }) => {
299
286
  const model = models.text;
300
- try {
301
- const response = await googleCircuit(() => google_client.models.generateContent({
302
- config: {
303
- ...config,
304
- responseModalities: ["Text"]
305
- },
306
- contents: prompt.join("\n"),
307
- model
308
- }));
309
- const content = response == null ? void 0 : response.text;
310
- if (!content) {
311
- throw new Error("Could not generate from prompt. Please try again with a different prompt.");
312
- }
313
- if (userId) {
314
- await billRequest({
315
- db,
316
- authenticated,
317
- userId,
318
- model,
319
- usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
320
- });
321
- }
322
- return { content };
323
- } catch (error) {
324
- throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
287
+ const response = await googleCircuit(() => google_client.models.generateContent({
288
+ config: {
289
+ ...config,
290
+ responseModalities: ["Text"]
291
+ },
292
+ contents: prompt.join("\n"),
293
+ model
294
+ }));
295
+ const content = response == null ? void 0 : response.text;
296
+ if (!content) {
297
+ throw new Error("Could not generate from prompt. Please try again with a different prompt.");
298
+ }
299
+ if (user == null ? void 0 : user.id) {
300
+ await billRequest({
301
+ db,
302
+ user,
303
+ model,
304
+ usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
305
+ });
325
306
  }
307
+ return { content };
326
308
  }
327
309
  };
328
310
  // Annotate the CommonJS export names for ESM import in node:
329
311
  0 && (module.exports = {
312
+ MARKUP,
313
+ cost,
314
+ costForRequest,
330
315
  google,
331
316
  priceForRequest,
332
317
  pricing
package/dist/ai.d.cts CHANGED
@@ -36,14 +36,12 @@ const models = {
36
36
  text : 'gemini-2.5-flash'
37
37
  };
38
38
 
39
- // Per-model pricing in cents per 1,000,000 tokens. Source of truth for what
40
- // users get charged; reviewed in PRs.
41
- //
42
- // Values mirror LiteLLM's published pricing at the time of writing. Model
43
- // providers update pricing periodically — verify against
39
+ // Raw per-model provider rates in cents per 1,000,000 tokens. This is what
40
+ // Drawbridge pays the provider the wholesale cost. Sync from LiteLLM:
44
41
  // https://github.com/BerriAI/litellm/blob/main/litellm/model_prices_and_context_window_backup.json
45
- // before any change.
46
- const pricing = {
42
+ // Never apply markup here; the MARKUP constant below derives the retail
43
+ // `pricing` table.
44
+ const cost = {
47
45
  'gemini-2.5-flash' : {
48
46
  cached : 3,
49
47
  input : 30,
@@ -56,9 +54,37 @@ const pricing = {
56
54
  }
57
55
  };
58
56
 
59
- // Cost in cents for a single request, rounded up so we never charge fractional
60
- // cents and never under-bill. Throws on an unknown model so a typo doesn't
61
- // silently fall back to a wrong price.
57
+ // Markup multiplier applied to every model's cost to produce the retail
58
+ // price. 1.3 = 30% margin. Change one number, all pricing updates. Mirrors
59
+ // the per-customer markup Stripe rate cards used to provide; keeping it
60
+ // global (rather than per-model or per-plan) for simplicity — easy to
61
+ // refactor later if tier-based pricing becomes a real need.
62
+ const MARKUP = 1.3;
63
+
64
+ // Derived retail pricing in cents per 1,000,000 tokens — what users pay.
65
+ // Rounded so the per-1M rate is a whole-cent value; per-request rounding
66
+ // happens in priceForRequest via Math.ceil so we never under-bill.
67
+ const pricing = Object.fromEntries(
68
+ Object.entries( cost ).map( ( [ model, rates ] ) => [
69
+ model,
70
+ {
71
+ cached : Math.round( rates.cached * MARKUP ),
72
+ input : Math.round( rates.input * MARKUP ),
73
+ output : Math.round( rates.output * MARKUP )
74
+ }
75
+ ] )
76
+ );
77
+
78
+ const sumCents = ( rates, tokens ) => Math.ceil(
79
+ (
80
+ Number( tokens?.cached || 0 ) * rates.cached +
81
+ Number( tokens?.input || 0 ) * rates.input +
82
+ Number( tokens?.output || 0 ) * rates.output
83
+ ) / 1_000_000
84
+ );
85
+
86
+ // What we charge the user, rounded up so we never under-bill. Throws on an
87
+ // unknown model so a typo doesn't silently fall back to a wrong price.
62
88
  const priceForRequest = ( model, usage ) => {
63
89
 
64
90
  const rates = pricing[ model ];
@@ -69,19 +95,23 @@ const priceForRequest = ( model, usage ) => {
69
95
 
70
96
  }
71
97
 
72
- const tokens = {
73
- cached : Number( usage?.cached ) || 0,
74
- input : Number( usage?.input ) || 0,
75
- output : Number( usage?.output ) || 0
76
- };
98
+ return sumCents( rates, usage );
99
+
100
+ };
77
101
 
78
- const sum = (
79
- tokens.cached * rates.cached +
80
- tokens.input * rates.input +
81
- tokens.output * rates.output
82
- );
102
+ // What we pay the provider. Recorded on each transaction row so margin is
103
+ // computable per request without backfilling from git history.
104
+ const costForRequest = ( model, usage ) => {
83
105
 
84
- return Math.ceil( sum / 1_000_000 );
106
+ const rates = cost[ model ];
107
+
108
+ if( !rates ){
109
+
110
+ throw new Error( `Unknown model for cost: ${ model }` );
111
+
112
+ }
113
+
114
+ return sumCents( rates, usage );
85
115
 
86
116
  };
87
117
 
@@ -92,30 +122,41 @@ const tokensFromMetadata = ( metadata ) => ({
92
122
  output : Number( metadata?.candidatesTokenCount ) || 0
93
123
  });
94
124
 
95
- // Compute cost from response usage and atomically debit the user. Shared by
96
- // both image/text handlers so the accounting path is identical for both.
125
+ // Compute gross + cost from response usage and atomically debit the user.
126
+ // Records the full income-statement line on the transaction row's `ai`
127
+ // sub-doc: wholesale + retail per-1M rate snapshots, plus rolled-up cost,
128
+ // net, and gross totals. Margin per request is `ai.net`; margin per query
129
+ // is a single $sum: '$ai.net'.
97
130
  const billRequest = async ({
98
131
  db,
99
- authenticated,
100
- userId,
132
+ user,
101
133
  model,
102
134
  usage
103
135
  }) => {
104
136
 
105
- const amount = priceForRequest( model, usage );
137
+ const gross = priceForRequest( model, usage );
138
+ const cogs = costForRequest( model, usage );
106
139
 
107
140
  await debit({
108
141
  db,
109
- authenticated,
110
- userId,
111
- amount,
142
+ user,
143
+ amount : gross,
144
+ type : 'ai',
112
145
  category : 'usage',
113
- tags : [ 'google', model ],
114
- provider : {
146
+ source : 'user',
147
+ ai : {
115
148
  name : 'google',
116
149
  model,
117
150
  tokens : usage,
118
- snapshot : pricing[ model ]
151
+ rates : {
152
+ wholesale : cost[ model ],
153
+ retail : pricing[ model ]
154
+ },
155
+ totals : {
156
+ cost : cogs,
157
+ net : gross - cogs,
158
+ gross
159
+ }
119
160
  }
120
161
  });
121
162
 
@@ -131,8 +172,7 @@ const google = {
131
172
  },
132
173
  {
133
174
  db,
134
- authenticated,
135
- userId
175
+ user
136
176
  }
137
177
  ) => {
138
178
 
@@ -143,45 +183,36 @@ const google = {
143
183
  { text : prompt.join( '\n' ) }
144
184
  ];
145
185
 
146
- try {
147
-
148
- const response = await googleCircuit( () => google_client.models.generateContent({
149
- model,
150
- contents : { parts },
151
- config : {
152
- ...config,
153
- responseModalities : [ 'Image' ]
154
- }
155
- }) );
156
-
157
- const content = response?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.inlineData?.data;
158
-
159
- if( !content ){
160
-
161
- throw new Error( 'Could not generate from prompt. Please try again with a different prompt.' );
162
-
186
+ const response = await googleCircuit( () => google_client.models.generateContent({
187
+ model,
188
+ contents : { parts },
189
+ config : {
190
+ ...config,
191
+ responseModalities : [ 'Image' ]
163
192
  }
193
+ }) );
164
194
 
165
- if( userId ){
195
+ const content = response?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.inlineData?.data;
166
196
 
167
- await billRequest({
168
- db,
169
- authenticated,
170
- userId,
171
- model,
172
- usage : tokensFromMetadata( response?.usageMetadata )
173
- });
197
+ if( !content ){
174
198
 
175
- }
199
+ throw new Error( 'Could not generate from prompt. Please try again with a different prompt.' );
176
200
 
177
- return { content };
201
+ }
178
202
 
179
- } catch ( error ) {
203
+ if( user?.id ){
180
204
 
181
- throw new Error( error?.message || 'AI error, please try again' );
205
+ await billRequest({
206
+ db,
207
+ user,
208
+ model,
209
+ usage : tokensFromMetadata( response?.usageMetadata )
210
+ });
182
211
 
183
212
  }
184
213
 
214
+ return { content };
215
+
185
216
  },
186
217
 
187
218
  text : async (
@@ -191,54 +222,44 @@ const google = {
191
222
  },
192
223
  {
193
224
  db,
194
- authenticated,
195
- userId
225
+ user
196
226
  }
197
227
  ) => {
198
228
 
199
229
  const model = models.text;
200
230
 
201
- try {
202
-
203
- const response = await googleCircuit( () => google_client.models.generateContent({
204
- config : {
205
- ...config,
206
- responseModalities : [ 'Text' ]
207
- },
208
- contents : prompt.join( '\n' ),
209
- model
210
- }) );
211
-
212
- const content = response?.text;
231
+ const response = await googleCircuit( () => google_client.models.generateContent({
232
+ config : {
233
+ ...config,
234
+ responseModalities : [ 'Text' ]
235
+ },
236
+ contents : prompt.join( '\n' ),
237
+ model
238
+ }) );
213
239
 
214
- if( !content ){
240
+ const content = response?.text;
215
241
 
216
- throw new Error( 'Could not generate from prompt. Please try again with a different prompt.' );
242
+ if( !content ){
217
243
 
218
- }
219
-
220
- if( userId ){
221
-
222
- await billRequest({
223
- db,
224
- authenticated,
225
- userId,
226
- model,
227
- usage : tokensFromMetadata( response?.usageMetadata )
228
- });
229
-
230
- }
244
+ throw new Error( 'Could not generate from prompt. Please try again with a different prompt.' );
231
245
 
232
- return { content };
246
+ }
233
247
 
234
- } catch ( error ) {
248
+ if( user?.id ){
235
249
 
236
- throw new Error( error?.message || 'AI error, please try again' );
250
+ await billRequest({
251
+ db,
252
+ user,
253
+ model,
254
+ usage : tokensFromMetadata( response?.usageMetadata )
255
+ });
237
256
 
238
257
  }
239
258
 
259
+ return { content };
260
+
240
261
  }
241
262
 
242
263
  };
243
264
 
244
- export { google, priceForRequest, pricing };
265
+ export { MARKUP, cost, costForRequest, google, priceForRequest, pricing };