@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.d.ts 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 };
package/dist/ai.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  debit
3
- } from "./chunk-H735KDYS.js";
3
+ } from "./chunk-RW2FJVIV.js";
4
4
  import {
5
5
  circuit
6
6
  } from "./chunk-6HH36OK6.js";
@@ -19,7 +19,7 @@ var models = {
19
19
  image: "gemini-2.5-flash-image",
20
20
  text: "gemini-2.5-flash"
21
21
  };
22
- var pricing = {
22
+ var cost = {
23
23
  "gemini-2.5-flash": {
24
24
  cached: 3,
25
25
  input: 30,
@@ -31,18 +31,33 @@ var pricing = {
31
31
  output: 250
32
32
  }
33
33
  };
34
+ var MARKUP = 1.3;
35
+ var pricing = Object.fromEntries(
36
+ Object.entries(cost).map(([model, rates]) => [
37
+ model,
38
+ {
39
+ cached: Math.round(rates.cached * MARKUP),
40
+ input: Math.round(rates.input * MARKUP),
41
+ output: Math.round(rates.output * MARKUP)
42
+ }
43
+ ])
44
+ );
45
+ var sumCents = (rates, tokens) => Math.ceil(
46
+ (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
47
+ );
34
48
  var priceForRequest = (model, usage) => {
35
49
  const rates = pricing[model];
36
50
  if (!rates) {
37
51
  throw new Error(`Unknown model for pricing: ${model}`);
38
52
  }
39
- const tokens = {
40
- cached: Number(usage == null ? void 0 : usage.cached) || 0,
41
- input: Number(usage == null ? void 0 : usage.input) || 0,
42
- output: Number(usage == null ? void 0 : usage.output) || 0
43
- };
44
- const sum = tokens.cached * rates.cached + tokens.input * rates.input + tokens.output * rates.output;
45
- return Math.ceil(sum / 1e6);
53
+ return sumCents(rates, usage);
54
+ };
55
+ var costForRequest = (model, usage) => {
56
+ const rates = cost[model];
57
+ if (!rates) {
58
+ throw new Error(`Unknown model for cost: ${model}`);
59
+ }
60
+ return sumCents(rates, usage);
46
61
  };
47
62
  var tokensFromMetadata = (metadata) => ({
48
63
  cached: Number(metadata == null ? void 0 : metadata.cachedContentTokenCount) || 0,
@@ -51,24 +66,32 @@ var tokensFromMetadata = (metadata) => ({
51
66
  });
52
67
  var billRequest = async ({
53
68
  db,
54
- authenticated,
55
- userId,
69
+ user,
56
70
  model,
57
71
  usage
58
72
  }) => {
59
- const amount = priceForRequest(model, usage);
73
+ const gross = priceForRequest(model, usage);
74
+ const cogs = costForRequest(model, usage);
60
75
  await debit({
61
76
  db,
62
- authenticated,
63
- userId,
64
- amount,
77
+ user,
78
+ amount: gross,
79
+ type: "ai",
65
80
  category: "usage",
66
- tags: ["google", model],
67
- provider: {
81
+ source: "user",
82
+ ai: {
68
83
  name: "google",
69
84
  model,
70
85
  tokens: usage,
71
- snapshot: pricing[model]
86
+ rates: {
87
+ wholesale: cost[model],
88
+ retail: pricing[model]
89
+ },
90
+ totals: {
91
+ cost: cogs,
92
+ net: gross - cogs,
93
+ gross
94
+ }
72
95
  }
73
96
  });
74
97
  };
@@ -79,8 +102,7 @@ var google = {
79
102
  reference = null
80
103
  }, {
81
104
  db,
82
- authenticated,
83
- userId
105
+ user
84
106
  }) => {
85
107
  var _a, _b, _c, _d, _e, _f;
86
108
  const model = models.image;
@@ -88,71 +110,63 @@ var google = {
88
110
  ...reference ? [{ inlineData: { data: reference.data, mimeType: reference.mimeType } }] : [],
89
111
  { text: prompt.join("\n") }
90
112
  ];
91
- try {
92
- const response = await googleCircuit(() => google_client.models.generateContent({
93
- model,
94
- contents: { parts },
95
- config: {
96
- ...config,
97
- responseModalities: ["Image"]
98
- }
99
- }));
100
- 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;
101
- if (!content) {
102
- throw new Error("Could not generate from prompt. Please try again with a different prompt.");
103
- }
104
- if (userId) {
105
- await billRequest({
106
- db,
107
- authenticated,
108
- userId,
109
- model,
110
- usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
111
- });
113
+ const response = await googleCircuit(() => google_client.models.generateContent({
114
+ model,
115
+ contents: { parts },
116
+ config: {
117
+ ...config,
118
+ responseModalities: ["Image"]
112
119
  }
113
- return { content };
114
- } catch (error) {
115
- throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
120
+ }));
121
+ 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;
122
+ if (!content) {
123
+ throw new Error("Could not generate from prompt. Please try again with a different prompt.");
124
+ }
125
+ if (user == null ? void 0 : user.id) {
126
+ await billRequest({
127
+ db,
128
+ user,
129
+ model,
130
+ usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
131
+ });
116
132
  }
133
+ return { content };
117
134
  },
118
135
  text: async ({
119
136
  config = {},
120
137
  prompt = []
121
138
  }, {
122
139
  db,
123
- authenticated,
124
- userId
140
+ user
125
141
  }) => {
126
142
  const model = models.text;
127
- try {
128
- const response = await googleCircuit(() => google_client.models.generateContent({
129
- config: {
130
- ...config,
131
- responseModalities: ["Text"]
132
- },
133
- contents: prompt.join("\n"),
134
- model
135
- }));
136
- const content = response == null ? void 0 : response.text;
137
- if (!content) {
138
- throw new Error("Could not generate from prompt. Please try again with a different prompt.");
139
- }
140
- if (userId) {
141
- await billRequest({
142
- db,
143
- authenticated,
144
- userId,
145
- model,
146
- usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
147
- });
148
- }
149
- return { content };
150
- } catch (error) {
151
- throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
143
+ const response = await googleCircuit(() => google_client.models.generateContent({
144
+ config: {
145
+ ...config,
146
+ responseModalities: ["Text"]
147
+ },
148
+ contents: prompt.join("\n"),
149
+ model
150
+ }));
151
+ const content = response == null ? void 0 : response.text;
152
+ if (!content) {
153
+ throw new Error("Could not generate from prompt. Please try again with a different prompt.");
154
+ }
155
+ if (user == null ? void 0 : user.id) {
156
+ await billRequest({
157
+ db,
158
+ user,
159
+ model,
160
+ usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
161
+ });
152
162
  }
163
+ return { content };
153
164
  }
154
165
  };
155
166
  export {
167
+ MARKUP,
168
+ cost,
169
+ costForRequest,
156
170
  google,
157
171
  priceForRequest,
158
172
  pricing
@@ -0,0 +1,110 @@
1
+ // transactions.js
2
+ var insertTransaction = async ({
3
+ db,
4
+ user,
5
+ type,
6
+ category,
7
+ source,
8
+ amount,
9
+ _id,
10
+ stripeInvoiceId,
11
+ stripeEventId,
12
+ session,
13
+ ...rest
14
+ }) => {
15
+ var _a;
16
+ const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
17
+ const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
18
+ await db.create({
19
+ authenticated: user,
20
+ collection: "transaction",
21
+ data: {
22
+ ..._id && { _id },
23
+ userId: user == null ? void 0 : user.id,
24
+ type,
25
+ category,
26
+ source,
27
+ amount,
28
+ ...balance && { balance },
29
+ ...rest,
30
+ ...stripeInvoiceId && { stripeInvoiceId },
31
+ ...stripeEventId && { stripeEventId }
32
+ },
33
+ ...session && { options: { session } }
34
+ });
35
+ };
36
+ var credit = async ({
37
+ db,
38
+ user,
39
+ amount,
40
+ type,
41
+ category,
42
+ source,
43
+ _id,
44
+ stripeInvoiceId,
45
+ stripeEventId,
46
+ session,
47
+ ...rest
48
+ }) => {
49
+ if (!Number.isInteger(amount) || amount <= 0) {
50
+ throw new Error(`credit() requires a positive integer amount, got ${amount}`);
51
+ }
52
+ if (!type) {
53
+ throw new Error('credit() requires a type (e.g., "ai")');
54
+ }
55
+ if (!source) {
56
+ throw new Error('credit() requires a source ("system" | "admin" | "user")');
57
+ }
58
+ await insertTransaction({
59
+ db,
60
+ user,
61
+ type,
62
+ category,
63
+ source,
64
+ amount,
65
+ _id,
66
+ stripeInvoiceId,
67
+ stripeEventId,
68
+ session,
69
+ ...rest
70
+ });
71
+ };
72
+ var debit = async ({
73
+ db,
74
+ user,
75
+ amount,
76
+ type,
77
+ category,
78
+ source,
79
+ stripeInvoiceId,
80
+ stripeEventId,
81
+ session,
82
+ ...rest
83
+ }) => {
84
+ if (!Number.isInteger(amount) || amount <= 0) {
85
+ throw new Error(`debit() requires a positive integer amount, got ${amount}`);
86
+ }
87
+ if (!type) {
88
+ throw new Error('debit() requires a type (e.g., "ai")');
89
+ }
90
+ if (!source) {
91
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
92
+ }
93
+ await insertTransaction({
94
+ db,
95
+ user,
96
+ type,
97
+ category,
98
+ source,
99
+ amount: -amount,
100
+ stripeInvoiceId,
101
+ stripeEventId,
102
+ session,
103
+ ...rest
104
+ });
105
+ };
106
+
107
+ export {
108
+ credit,
109
+ debit
110
+ };