@drawbridge/drawbridge-utils 0.0.26 → 0.0.28

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 ADDED
@@ -0,0 +1,244 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import { circuit } from './circuit.js';
3
+ import { debit } from './transactions.js';
4
+
5
+ // AI request entry point. Wraps the Google GenAI client with a per-process
6
+ // circuit breaker, owns the per-model pricing table, and atomically debits
7
+ // the user's balance.ai based on the token usage returned by the provider.
8
+ // Both drawbridge-api routes and drawbridge-sync workers use this so the
9
+ // wrapper, pricing, and accounting all stay in one place.
10
+ //
11
+ // import { google } from '@drawbridge/drawbridge-utils/ai';
12
+ //
13
+ // const { content } = await google.text(
14
+ // { prompt : [ 'Write me a haiku' ] },
15
+ // { db, authenticated, userId }
16
+ // );
17
+ //
18
+ // If the user runs out of credits between the gating middleware and the
19
+ // debit call (a real but rare race), `debit` throws InsufficientCreditsError
20
+ // — the Google call has already happened, accepted trade-off vs. doing a
21
+ // second pre-flight balance read.
22
+
23
+
24
+ const google_client = new GoogleGenAI({
25
+ apiKey : process.env.GOOGLE_API_KEY
26
+ });
27
+
28
+ const googleCircuit = circuit({
29
+ name : 'google-ai',
30
+ threshold : 3,
31
+ timeout : 60000
32
+ });
33
+
34
+ const models = {
35
+ image : 'gemini-2.5-flash-image',
36
+ text : 'gemini-2.5-flash'
37
+ };
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
44
+ // https://github.com/BerriAI/litellm/blob/main/litellm/model_prices_and_context_window_backup.json
45
+ // before any change.
46
+ const pricing = {
47
+ 'gemini-2.5-flash' : {
48
+ cached : 3,
49
+ input : 30,
50
+ output : 250
51
+ },
52
+ 'gemini-2.5-flash-image' : {
53
+ cached : 3,
54
+ input : 30,
55
+ output : 250
56
+ }
57
+ };
58
+
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.
62
+ const priceForRequest = ( model, usage ) => {
63
+
64
+ const rates = pricing[ model ];
65
+
66
+ if( !rates ){
67
+
68
+ throw new Error( `Unknown model for pricing: ${ model }` );
69
+
70
+ }
71
+
72
+ const tokens = {
73
+ cached : Number( usage?.cached ) || 0,
74
+ input : Number( usage?.input ) || 0,
75
+ output : Number( usage?.output ) || 0
76
+ };
77
+
78
+ const sum = (
79
+ tokens.cached * rates.cached +
80
+ tokens.input * rates.input +
81
+ tokens.output * rates.output
82
+ );
83
+
84
+ return Math.ceil( sum / 1_000_000 );
85
+
86
+ };
87
+
88
+ // Map Google's usageMetadata shape into the pricing.tokens shape.
89
+ const tokensFromMetadata = ( metadata ) => ({
90
+ cached : Number( metadata?.cachedContentTokenCount ) || 0,
91
+ input : Number( metadata?.promptTokenCount ) || 0,
92
+ output : Number( metadata?.candidatesTokenCount ) || 0
93
+ });
94
+
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.
97
+ const billRequest = async ({
98
+ db,
99
+ authenticated,
100
+ userId,
101
+ model,
102
+ usage
103
+ }) => {
104
+
105
+ const amount = priceForRequest( model, usage );
106
+
107
+ await debit({
108
+ db,
109
+ authenticated,
110
+ userId,
111
+ amount,
112
+ category : 'usage',
113
+ tags : [ 'google', model ],
114
+ provider : {
115
+ name : 'google',
116
+ model,
117
+ tokens : usage,
118
+ snapshot : pricing[ model ]
119
+ }
120
+ });
121
+
122
+ };
123
+
124
+ const google = {
125
+
126
+ image : async (
127
+ {
128
+ config = {},
129
+ prompt = [],
130
+ reference = null
131
+ },
132
+ {
133
+ db,
134
+ authenticated,
135
+ userId
136
+ }
137
+ ) => {
138
+
139
+ const model = models.image;
140
+
141
+ const parts = [
142
+ ...( reference ? [ { inlineData : { data : reference.data, mimeType : reference.mimeType } } ] : [] ),
143
+ { text : prompt.join( '\n' ) }
144
+ ];
145
+
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
+
163
+ }
164
+
165
+ if( userId ){
166
+
167
+ await billRequest({
168
+ db,
169
+ authenticated,
170
+ userId,
171
+ model,
172
+ usage : tokensFromMetadata( response?.usageMetadata )
173
+ });
174
+
175
+ }
176
+
177
+ return { content };
178
+
179
+ } catch ( error ) {
180
+
181
+ throw new Error( error?.message || 'AI error, please try again' );
182
+
183
+ }
184
+
185
+ },
186
+
187
+ text : async (
188
+ {
189
+ config = {},
190
+ prompt = []
191
+ },
192
+ {
193
+ db,
194
+ authenticated,
195
+ userId
196
+ }
197
+ ) => {
198
+
199
+ const model = models.text;
200
+
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;
213
+
214
+ if( !content ){
215
+
216
+ throw new Error( 'Could not generate from prompt. Please try again with a different prompt.' );
217
+
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
+ }
231
+
232
+ return { content };
233
+
234
+ } catch ( error ) {
235
+
236
+ throw new Error( error?.message || 'AI error, please try again' );
237
+
238
+ }
239
+
240
+ }
241
+
242
+ };
243
+
244
+ export { google, priceForRequest, pricing };
package/dist/ai.js ADDED
@@ -0,0 +1,159 @@
1
+ import {
2
+ debit
3
+ } from "./chunk-RC5E56UB.js";
4
+ import {
5
+ circuit
6
+ } from "./chunk-6HH36OK6.js";
7
+
8
+ // ai.js
9
+ import { GoogleGenAI } from "@google/genai";
10
+ var google_client = new GoogleGenAI({
11
+ apiKey: process.env.GOOGLE_API_KEY
12
+ });
13
+ var googleCircuit = circuit({
14
+ name: "google-ai",
15
+ threshold: 3,
16
+ timeout: 6e4
17
+ });
18
+ var models = {
19
+ image: "gemini-2.5-flash-image",
20
+ text: "gemini-2.5-flash"
21
+ };
22
+ var pricing = {
23
+ "gemini-2.5-flash": {
24
+ cached: 3,
25
+ input: 30,
26
+ output: 250
27
+ },
28
+ "gemini-2.5-flash-image": {
29
+ cached: 3,
30
+ input: 30,
31
+ output: 250
32
+ }
33
+ };
34
+ var priceForRequest = (model, usage) => {
35
+ const rates = pricing[model];
36
+ if (!rates) {
37
+ throw new Error(`Unknown model for pricing: ${model}`);
38
+ }
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);
46
+ };
47
+ var tokensFromMetadata = (metadata) => ({
48
+ cached: Number(metadata == null ? void 0 : metadata.cachedContentTokenCount) || 0,
49
+ input: Number(metadata == null ? void 0 : metadata.promptTokenCount) || 0,
50
+ output: Number(metadata == null ? void 0 : metadata.candidatesTokenCount) || 0
51
+ });
52
+ var billRequest = async ({
53
+ db,
54
+ authenticated,
55
+ userId,
56
+ model,
57
+ usage
58
+ }) => {
59
+ const amount = priceForRequest(model, usage);
60
+ await debit({
61
+ db,
62
+ authenticated,
63
+ userId,
64
+ amount,
65
+ category: "usage",
66
+ tags: ["google", model],
67
+ provider: {
68
+ name: "google",
69
+ model,
70
+ tokens: usage,
71
+ snapshot: pricing[model]
72
+ }
73
+ });
74
+ };
75
+ var google = {
76
+ image: async ({
77
+ config = {},
78
+ prompt = [],
79
+ reference = null
80
+ }, {
81
+ db,
82
+ authenticated,
83
+ userId
84
+ }) => {
85
+ var _a, _b, _c, _d, _e, _f;
86
+ const model = models.image;
87
+ const parts = [
88
+ ...reference ? [{ inlineData: { data: reference.data, mimeType: reference.mimeType } }] : [],
89
+ { text: prompt.join("\n") }
90
+ ];
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
+ });
112
+ }
113
+ return { content };
114
+ } catch (error) {
115
+ throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
116
+ }
117
+ },
118
+ text: async ({
119
+ config = {},
120
+ prompt = []
121
+ }, {
122
+ db,
123
+ authenticated,
124
+ userId
125
+ }) => {
126
+ 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");
152
+ }
153
+ }
154
+ };
155
+ export {
156
+ google,
157
+ priceForRequest,
158
+ pricing
159
+ };
@@ -0,0 +1,54 @@
1
+ // circuit.js
2
+ var CLOSED = "CLOSED";
3
+ var OPEN = "OPEN";
4
+ var HALF_OPEN = "HALF_OPEN";
5
+ var circuit = ({
6
+ name,
7
+ threshold = 5,
8
+ timeout = 3e4
9
+ }) => {
10
+ let state = CLOSED;
11
+ let failures = 0;
12
+ let openedAt = null;
13
+ const trip = () => {
14
+ state = OPEN;
15
+ openedAt = Date.now();
16
+ console.error(`Circuit breaker OPEN: ${name}`);
17
+ };
18
+ const reset = () => {
19
+ state = CLOSED;
20
+ failures = 0;
21
+ openedAt = null;
22
+ console.log(`Circuit breaker CLOSED: ${name}`);
23
+ };
24
+ return async (fn) => {
25
+ if (state === OPEN) {
26
+ if (Date.now() - openedAt >= timeout) {
27
+ state = HALF_OPEN;
28
+ } else {
29
+ const error = new Error(`${name} is temporarily unavailable`);
30
+ error.status = 503;
31
+ throw error;
32
+ }
33
+ }
34
+ try {
35
+ const result = await fn();
36
+ if (state === HALF_OPEN) {
37
+ reset();
38
+ } else {
39
+ failures = 0;
40
+ }
41
+ return result;
42
+ } catch (error) {
43
+ failures++;
44
+ if (state === HALF_OPEN || failures >= threshold) {
45
+ trip();
46
+ }
47
+ throw error;
48
+ }
49
+ };
50
+ };
51
+
52
+ export {
53
+ circuit
54
+ };
@@ -0,0 +1,154 @@
1
+ // transactions.js
2
+ var recordTransaction = async ({
3
+ db,
4
+ authenticated,
5
+ userId,
6
+ category,
7
+ tags,
8
+ amount,
9
+ balance,
10
+ provider,
11
+ stripeInvoiceId,
12
+ stripeEventId
13
+ }) => {
14
+ await db.create({
15
+ authenticated,
16
+ collection: "transactions",
17
+ data: {
18
+ userId,
19
+ billable: {
20
+ item: "ai"
21
+ },
22
+ category,
23
+ tags,
24
+ amount,
25
+ balance,
26
+ ...provider && { provider },
27
+ ...stripeInvoiceId && { stripeInvoiceId },
28
+ ...stripeEventId && { stripeEventId }
29
+ }
30
+ });
31
+ };
32
+ var credit = async ({
33
+ db,
34
+ authenticated,
35
+ userId,
36
+ amount,
37
+ category,
38
+ tags = [],
39
+ stripeInvoiceId,
40
+ stripeEventId
41
+ }) => {
42
+ var _a, _b, _c;
43
+ if (!Number.isInteger(amount) || amount <= 0) {
44
+ throw new Error(`credit() requires a positive integer amount, got ${amount}`);
45
+ }
46
+ const result = await db.update({
47
+ authenticated,
48
+ collection: "users",
49
+ query: {
50
+ id: userId
51
+ },
52
+ data: {
53
+ $inc: {
54
+ "balance.ai": amount
55
+ }
56
+ }
57
+ });
58
+ 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);
59
+ if (typeof after !== "number") {
60
+ throw new Error(`credit() could not read updated balance for user ${userId}`);
61
+ }
62
+ const before = after - amount;
63
+ await recordTransaction({
64
+ db,
65
+ authenticated,
66
+ userId,
67
+ category,
68
+ tags,
69
+ amount,
70
+ balance: {
71
+ before,
72
+ after
73
+ },
74
+ stripeInvoiceId,
75
+ stripeEventId
76
+ });
77
+ return {
78
+ balance: {
79
+ before,
80
+ after
81
+ }
82
+ };
83
+ };
84
+ var InsufficientCreditsError = class extends Error {
85
+ constructor(message = "Insufficient AI credits") {
86
+ super(message);
87
+ this.name = "InsufficientCreditsError";
88
+ this.status = 402;
89
+ }
90
+ };
91
+ var debit = async ({
92
+ db,
93
+ authenticated,
94
+ userId,
95
+ amount,
96
+ category,
97
+ tags = [],
98
+ provider,
99
+ stripeInvoiceId,
100
+ stripeEventId
101
+ }) => {
102
+ var _a, _b, _c;
103
+ if (!Number.isInteger(amount) || amount <= 0) {
104
+ throw new Error(`debit() requires a positive integer amount, got ${amount}`);
105
+ }
106
+ const result = await db.update({
107
+ authenticated,
108
+ collection: "users",
109
+ query: {
110
+ id: userId,
111
+ "balance.ai": {
112
+ $gte: amount
113
+ }
114
+ },
115
+ data: {
116
+ $inc: {
117
+ "balance.ai": -amount
118
+ }
119
+ }
120
+ });
121
+ 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);
122
+ if (typeof after !== "number") {
123
+ throw new InsufficientCreditsError();
124
+ }
125
+ const before = after + amount;
126
+ await recordTransaction({
127
+ db,
128
+ authenticated,
129
+ userId,
130
+ category,
131
+ tags,
132
+ amount: -amount,
133
+ balance: {
134
+ before,
135
+ after
136
+ },
137
+ provider,
138
+ stripeInvoiceId,
139
+ stripeEventId
140
+ });
141
+ return {
142
+ amount: -amount,
143
+ balance: {
144
+ before,
145
+ after
146
+ }
147
+ };
148
+ };
149
+
150
+ export {
151
+ credit,
152
+ InsufficientCreditsError,
153
+ debit
154
+ };
package/dist/circuit.js CHANGED
@@ -1,53 +1,6 @@
1
- // circuit.js
2
- var CLOSED = "CLOSED";
3
- var OPEN = "OPEN";
4
- var HALF_OPEN = "HALF_OPEN";
5
- var circuit = ({
6
- name,
7
- threshold = 5,
8
- timeout = 3e4
9
- }) => {
10
- let state = CLOSED;
11
- let failures = 0;
12
- let openedAt = null;
13
- const trip = () => {
14
- state = OPEN;
15
- openedAt = Date.now();
16
- console.error(`Circuit breaker OPEN: ${name}`);
17
- };
18
- const reset = () => {
19
- state = CLOSED;
20
- failures = 0;
21
- openedAt = null;
22
- console.log(`Circuit breaker CLOSED: ${name}`);
23
- };
24
- return async (fn) => {
25
- if (state === OPEN) {
26
- if (Date.now() - openedAt >= timeout) {
27
- state = HALF_OPEN;
28
- } else {
29
- const error = new Error(`${name} is temporarily unavailable`);
30
- error.status = 503;
31
- throw error;
32
- }
33
- }
34
- try {
35
- const result = await fn();
36
- if (state === HALF_OPEN) {
37
- reset();
38
- } else {
39
- failures = 0;
40
- }
41
- return result;
42
- } catch (error) {
43
- failures++;
44
- if (state === HALF_OPEN || failures >= threshold) {
45
- trip();
46
- }
47
- throw error;
48
- }
49
- };
50
- };
1
+ import {
2
+ circuit
3
+ } from "./chunk-6HH36OK6.js";
51
4
  export {
52
5
  circuit
53
6
  };
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  formatNumber: () => formatNumber,
32
32
  getPlanFeature: () => getPlanFeature,
33
33
  gigabyte: () => gigabyte,
34
+ incrementUsageTotals: () => incrementUsageTotals,
34
35
  infinite: () => infinite,
35
36
  isInfinite: () => isInfinite,
36
37
  megabyte: () => megabyte,
@@ -309,6 +310,25 @@ var getPlanFeature = (plan, key) => {
309
310
  message: granted ? feature : error
310
311
  };
311
312
  };
313
+ var incrementUsageTotals = async ({ controller, usageId, $inc, session }) => {
314
+ if (!usageId || !$inc) return;
315
+ const safeInc = Object.fromEntries(
316
+ Object.entries($inc).filter(([, value]) => {
317
+ const number = Number(value);
318
+ return Number.isFinite(number) && number !== 0;
319
+ })
320
+ );
321
+ if (Object.keys(safeInc).length === 0) return;
322
+ return controller.update({
323
+ collection: "usage",
324
+ data: { $inc: safeInc },
325
+ options: {
326
+ bypassDocumentValidation: true,
327
+ ...session && { session }
328
+ },
329
+ query: { id: usageId }
330
+ });
331
+ };
312
332
  var percentage = (value1, value2, decimals = 1) => ((value1 || 0) / (value2 || 0) * 100 || 0).toFixed(decimals);
313
333
  var reducers = {
314
334
  fields: (data2 = [], callback = () => ({})) => data2.reduce(
@@ -400,6 +420,7 @@ var currency = (val) => (0, import_currency_codes.code)(val);
400
420
  formatNumber,
401
421
  getPlanFeature,
402
422
  gigabyte,
423
+ incrementUsageTotals,
403
424
  infinite,
404
425
  isInfinite,
405
426
  megabyte,