@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.cjs ADDED
@@ -0,0 +1,328 @@
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
+ // ai.js
20
+ var ai_exports = {};
21
+ __export(ai_exports, {
22
+ google: () => google,
23
+ priceForRequest: () => priceForRequest,
24
+ pricing: () => pricing
25
+ });
26
+ module.exports = __toCommonJS(ai_exports);
27
+ var import_genai = require("@google/genai");
28
+
29
+ // circuit.js
30
+ var CLOSED = "CLOSED";
31
+ var OPEN = "OPEN";
32
+ var HALF_OPEN = "HALF_OPEN";
33
+ var circuit = ({
34
+ name,
35
+ threshold = 5,
36
+ timeout = 3e4
37
+ }) => {
38
+ let state = CLOSED;
39
+ let failures = 0;
40
+ let openedAt = null;
41
+ const trip = () => {
42
+ state = OPEN;
43
+ openedAt = Date.now();
44
+ console.error(`Circuit breaker OPEN: ${name}`);
45
+ };
46
+ const reset = () => {
47
+ state = CLOSED;
48
+ failures = 0;
49
+ openedAt = null;
50
+ console.log(`Circuit breaker CLOSED: ${name}`);
51
+ };
52
+ return async (fn) => {
53
+ if (state === OPEN) {
54
+ if (Date.now() - openedAt >= timeout) {
55
+ state = HALF_OPEN;
56
+ } else {
57
+ const error = new Error(`${name} is temporarily unavailable`);
58
+ error.status = 503;
59
+ throw error;
60
+ }
61
+ }
62
+ try {
63
+ const result = await fn();
64
+ if (state === HALF_OPEN) {
65
+ reset();
66
+ } else {
67
+ failures = 0;
68
+ }
69
+ return result;
70
+ } catch (error) {
71
+ failures++;
72
+ if (state === HALF_OPEN || failures >= threshold) {
73
+ trip();
74
+ }
75
+ throw error;
76
+ }
77
+ };
78
+ };
79
+
80
+ // transactions.js
81
+ var recordTransaction = async ({
82
+ db,
83
+ authenticated,
84
+ userId,
85
+ category,
86
+ tags,
87
+ amount,
88
+ balance,
89
+ provider,
90
+ stripeInvoiceId,
91
+ stripeEventId
92
+ }) => {
93
+ await db.create({
94
+ authenticated,
95
+ collection: "transactions",
96
+ data: {
97
+ userId,
98
+ billable: {
99
+ item: "ai"
100
+ },
101
+ category,
102
+ tags,
103
+ amount,
104
+ balance,
105
+ ...provider && { provider },
106
+ ...stripeInvoiceId && { stripeInvoiceId },
107
+ ...stripeEventId && { stripeEventId }
108
+ }
109
+ });
110
+ };
111
+ var InsufficientCreditsError = class extends Error {
112
+ constructor(message = "Insufficient AI credits") {
113
+ super(message);
114
+ this.name = "InsufficientCreditsError";
115
+ this.status = 402;
116
+ }
117
+ };
118
+ var debit = async ({
119
+ db,
120
+ authenticated,
121
+ userId,
122
+ amount,
123
+ category,
124
+ tags = [],
125
+ provider,
126
+ stripeInvoiceId,
127
+ stripeEventId
128
+ }) => {
129
+ var _a, _b, _c;
130
+ if (!Number.isInteger(amount) || amount <= 0) {
131
+ throw new Error(`debit() requires a positive integer amount, got ${amount}`);
132
+ }
133
+ const result = await db.update({
134
+ authenticated,
135
+ collection: "users",
136
+ query: {
137
+ id: userId,
138
+ "balance.ai": {
139
+ $gte: amount
140
+ }
141
+ },
142
+ data: {
143
+ $inc: {
144
+ "balance.ai": -amount
145
+ }
146
+ }
147
+ });
148
+ 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);
149
+ if (typeof after !== "number") {
150
+ throw new InsufficientCreditsError();
151
+ }
152
+ const before = after + amount;
153
+ await recordTransaction({
154
+ db,
155
+ authenticated,
156
+ userId,
157
+ category,
158
+ tags,
159
+ amount: -amount,
160
+ balance: {
161
+ before,
162
+ after
163
+ },
164
+ provider,
165
+ stripeInvoiceId,
166
+ stripeEventId
167
+ });
168
+ return {
169
+ amount: -amount,
170
+ balance: {
171
+ before,
172
+ after
173
+ }
174
+ };
175
+ };
176
+
177
+ // ai.js
178
+ var google_client = new import_genai.GoogleGenAI({
179
+ apiKey: process.env.GOOGLE_API_KEY
180
+ });
181
+ var googleCircuit = circuit({
182
+ name: "google-ai",
183
+ threshold: 3,
184
+ timeout: 6e4
185
+ });
186
+ var models = {
187
+ image: "gemini-2.5-flash-image",
188
+ text: "gemini-2.5-flash"
189
+ };
190
+ var pricing = {
191
+ "gemini-2.5-flash": {
192
+ cached: 3,
193
+ input: 30,
194
+ output: 250
195
+ },
196
+ "gemini-2.5-flash-image": {
197
+ cached: 3,
198
+ input: 30,
199
+ output: 250
200
+ }
201
+ };
202
+ var priceForRequest = (model, usage) => {
203
+ const rates = pricing[model];
204
+ if (!rates) {
205
+ throw new Error(`Unknown model for pricing: ${model}`);
206
+ }
207
+ const tokens = {
208
+ cached: Number(usage == null ? void 0 : usage.cached) || 0,
209
+ input: Number(usage == null ? void 0 : usage.input) || 0,
210
+ output: Number(usage == null ? void 0 : usage.output) || 0
211
+ };
212
+ const sum = tokens.cached * rates.cached + tokens.input * rates.input + tokens.output * rates.output;
213
+ return Math.ceil(sum / 1e6);
214
+ };
215
+ var tokensFromMetadata = (metadata) => ({
216
+ cached: Number(metadata == null ? void 0 : metadata.cachedContentTokenCount) || 0,
217
+ input: Number(metadata == null ? void 0 : metadata.promptTokenCount) || 0,
218
+ output: Number(metadata == null ? void 0 : metadata.candidatesTokenCount) || 0
219
+ });
220
+ var billRequest = async ({
221
+ db,
222
+ authenticated,
223
+ userId,
224
+ model,
225
+ usage
226
+ }) => {
227
+ const amount = priceForRequest(model, usage);
228
+ await debit({
229
+ db,
230
+ authenticated,
231
+ userId,
232
+ amount,
233
+ category: "usage",
234
+ tags: ["google", model],
235
+ provider: {
236
+ name: "google",
237
+ model,
238
+ tokens: usage,
239
+ snapshot: pricing[model]
240
+ }
241
+ });
242
+ };
243
+ var google = {
244
+ image: async ({
245
+ config = {},
246
+ prompt = [],
247
+ reference = null
248
+ }, {
249
+ db,
250
+ authenticated,
251
+ userId
252
+ }) => {
253
+ var _a, _b, _c, _d, _e, _f;
254
+ const model = models.image;
255
+ const parts = [
256
+ ...reference ? [{ inlineData: { data: reference.data, mimeType: reference.mimeType } }] : [],
257
+ { text: prompt.join("\n") }
258
+ ];
259
+ try {
260
+ const response = await googleCircuit(() => google_client.models.generateContent({
261
+ model,
262
+ contents: { parts },
263
+ config: {
264
+ ...config,
265
+ responseModalities: ["Image"]
266
+ }
267
+ }));
268
+ 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;
269
+ if (!content) {
270
+ throw new Error("Could not generate from prompt. Please try again with a different prompt.");
271
+ }
272
+ if (userId) {
273
+ await billRequest({
274
+ db,
275
+ authenticated,
276
+ userId,
277
+ model,
278
+ usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
279
+ });
280
+ }
281
+ return { content };
282
+ } catch (error) {
283
+ throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
284
+ }
285
+ },
286
+ text: async ({
287
+ config = {},
288
+ prompt = []
289
+ }, {
290
+ db,
291
+ authenticated,
292
+ userId
293
+ }) => {
294
+ const model = models.text;
295
+ try {
296
+ const response = await googleCircuit(() => google_client.models.generateContent({
297
+ config: {
298
+ ...config,
299
+ responseModalities: ["Text"]
300
+ },
301
+ contents: prompt.join("\n"),
302
+ model
303
+ }));
304
+ const content = response == null ? void 0 : response.text;
305
+ if (!content) {
306
+ throw new Error("Could not generate from prompt. Please try again with a different prompt.");
307
+ }
308
+ if (userId) {
309
+ await billRequest({
310
+ db,
311
+ authenticated,
312
+ userId,
313
+ model,
314
+ usage: tokensFromMetadata(response == null ? void 0 : response.usageMetadata)
315
+ });
316
+ }
317
+ return { content };
318
+ } catch (error) {
319
+ throw new Error((error == null ? void 0 : error.message) || "AI error, please try again");
320
+ }
321
+ }
322
+ };
323
+ // Annotate the CommonJS export names for ESM import in node:
324
+ 0 && (module.exports = {
325
+ google,
326
+ priceForRequest,
327
+ pricing
328
+ });
package/dist/ai.d.cts ADDED
@@ -0,0 +1,244 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import { circuit } from './circuit.cjs';
3
+ import { debit } from './transactions.cjs';
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 };