@dropout-ai/runtime 0.3.2 → 0.3.4

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +197 -114
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Invisible Node.js runtime for capturing AI interactions.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -9,28 +9,15 @@ const https = require('https');
9
9
  const http = require('http');
10
10
  const crypto = require('crypto');
11
11
 
12
- // --- CONFIGURATION ---
12
+ // --- DEFAULT CONFIGURATION ---
13
+ const SUPABASE_FUNCTION_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
14
+
15
+ // Known AI Domains for Auto-Detection
13
16
  const KNOWN_AI_DOMAINS = [
14
- 'api.openai.com', // OpenAI
15
- 'api.anthropic.com', // Claude
16
- 'generativelanguage.googleapis.com', // Gemini
17
- 'aiplatform.googleapis.com', // Vertex AI (Genkit often uses this)
18
- 'api.groq.com', // Groq
19
- 'api.mistral.ai', // Mistral
20
- 'api.cohere.ai' // Cohere
17
+ 'api.openai.com', 'api.anthropic.com', 'generativelanguage.googleapis.com',
18
+ 'aiplatform.googleapis.com', 'api.groq.com', 'api.mistral.ai', 'api.cohere.ai'
21
19
  ];
22
20
 
23
- const DROPOUT_INGEST_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
24
-
25
- // --- UTILS ---
26
- function generateSessionId() {
27
- try {
28
- return crypto.randomUUID();
29
- } catch (e) {
30
- return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
31
- }
32
- }
33
-
34
21
  class Dropout {
35
22
  constructor(config = {}) {
36
23
  // 1. Validation
@@ -43,10 +30,11 @@ class Dropout {
43
30
  this.config = config;
44
31
  this.projectId = config.projectId;
45
32
  this.apiKey = config.apiKey;
46
- this.debug = config.debug || false; // Toggle this to see logs
33
+ this.debug = config.debug || false;
47
34
  this.privacy = config.privacy || 'full';
35
+ this.captureEndpoint = SUPABASE_FUNCTION_URL;
48
36
 
49
- // 3. Singleton Guard (Prevent double-patching)
37
+ // 3. Singleton Guard
50
38
  if (global.__dropout_initialized__) {
51
39
  if (this.debug) console.log("[Dropout] ℹ️ Already initialized. Skipping patch.");
52
40
  return;
@@ -55,118 +43,239 @@ class Dropout {
55
43
 
56
44
  // 4. Initialize Identity
57
45
  if (!global.__dropout_session_id__) {
58
- global.__dropout_session_id__ = generateSessionId();
46
+ global.__dropout_session_id__ = this.generateSessionId();
59
47
  }
60
48
 
61
49
  // 5. Start the Wiretap
62
50
  if (this.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${this.projectId}`);
51
+
52
+ // Bind methods to 'this' to avoid scope loss
53
+ this.log = this.log.bind(this);
54
+ this.isAiRequest = this.isAiRequest.bind(this);
63
55
  this.patchNetwork();
64
56
  }
65
57
 
58
+ // --- UTILS ---
66
59
  log(msg, ...args) {
67
60
  if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
68
61
  }
69
62
 
70
- isAiRequest(url) {
63
+ generateSessionId() {
64
+ try {
65
+ return crypto.randomUUID();
66
+ } catch (e) {
67
+ return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
68
+ }
69
+ }
70
+
71
+ hash(text) {
72
+ if (!text) return null;
73
+ try {
74
+ return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
75
+ } catch (e) { return 'hash_err'; }
76
+ }
77
+
78
+ normalize(url, body) {
79
+ let provider = 'custom';
80
+ let model = 'unknown';
81
+
82
+ if (url) {
83
+ const u = url.toLowerCase();
84
+ if (u.includes('openai')) provider = 'openai';
85
+ else if (u.includes('anthropic')) provider = 'anthropic';
86
+ else if (u.includes('google') || u.includes('gemini') || u.includes('aiplatform')) provider = 'google';
87
+ else if (u.includes('groq')) provider = 'groq';
88
+ else if (u.includes('mistral')) provider = 'mistral';
89
+ else if (u.includes('cohere')) provider = 'cohere';
90
+ else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
91
+ }
92
+
93
+ if (body) {
94
+ try {
95
+ const parsed = typeof body === 'string' ? JSON.parse(body) : body;
96
+ if (parsed.model) model = parsed.model;
97
+ } catch (e) { }
98
+ }
99
+ return { provider, model };
100
+ }
101
+
102
+ isAiRequest(url, bodyString) {
71
103
  if (!url) return false;
72
- // Guard: Infinite Loop Prevention (Don't capture our own calls)
73
- if (url.includes(DROPOUT_INGEST_URL)) return false;
104
+ // Guard: Infinite Loop Prevention
105
+ if (url.includes(this.captureEndpoint)) return false;
106
+
107
+ // 1. Check Known Domains
108
+ if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
109
+
110
+ // 2. Heuristic Check (for custom endpoints)
111
+ if (bodyString) {
112
+ return (bodyString.includes('"model"') || bodyString.includes('"messages"')) &&
113
+ (bodyString.includes('"user"') || bodyString.includes('"prompt"'));
114
+ }
115
+ return false;
116
+ }
117
+
118
+ // --- EMITTER ---
119
+ emit(payload) {
120
+ // 1. Guard
121
+ if (!this.apiKey || !this.projectId) return;
122
+
123
+ // 2. Construct Payload
124
+ const finalPayload = {
125
+ ...payload,
126
+ project_id: this.projectId,
127
+ content_blob: payload.content, // Map for new schema
128
+ content: payload.content, // Map for old schema (fallback)
129
+ received_at: new Date().toISOString()
130
+ };
131
+
132
+ this.log(`🚀 Sending Capture (${payload.turn_role})`);
133
+
134
+ // 3. Fire and Forget using HTTPS (Bypassing our own patches via pure request)
135
+ // We use a clean request to avoid triggering our own hooks if we used global.fetch
136
+ const req = https.request(this.captureEndpoint, {
137
+ method: 'POST',
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ 'x-dropout-key': this.apiKey
141
+ }
142
+ });
74
143
 
75
- // Check against known AI domains
76
- return KNOWN_AI_DOMAINS.some(domain => url.includes(domain));
144
+ req.on('error', (e) => this.log("❌ Upload Failed", e.message));
145
+ req.write(JSON.stringify(finalPayload));
146
+ req.end();
77
147
  }
78
148
 
79
- // --- THE CORE: UNIVERSAL PATCHING ---
149
+ // --- CORE PATCHING ---
80
150
  patchNetwork() {
81
- const _this = this;
151
+ const _this = this; // Capture instance
82
152
 
83
- // A. Patch Global Fetch (Used by OpenAI v4+, Vercel AI SDK, Next.js)
153
+ // --- A. PATCH FETCH ---
84
154
  if (global.fetch) {
85
155
  const originalFetch = global.fetch;
86
156
  global.fetch = async function (input, init) {
87
157
  const url = typeof input === 'string' ? input : input?.url;
88
158
 
89
- // PASSTHROUGH: If not AI, ignore
90
- if (!_this.isAiRequest(url)) {
91
- return originalFetch.apply(this, arguments);
159
+ // Safe Body Check
160
+ let bodyStr = "";
161
+ if (init && init.body) {
162
+ try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
92
163
  }
93
164
 
94
- _this.log(`⚡ [FETCH] Intercepting request to: ${url}`);
165
+ // Guard
166
+ if (!_this.isAiRequest(url, bodyStr)) {
167
+ return originalFetch.apply(this, arguments);
168
+ }
95
169
 
96
- // Capture Request Body
97
- let reqBody = "";
98
- try { if (init && init.body) reqBody = init.body; } catch (e) { }
170
+ _this.log(`⚡ [FETCH] Intercepting: ${url}`);
171
+ const start = Date.now();
172
+
173
+ // Emit Request
174
+ const { provider, model } = _this.normalize(url, bodyStr);
175
+ _this.emit({
176
+ session_id: global.__dropout_session_id__,
177
+ turn_index: 0,
178
+ turn_role: 'user',
179
+ provider,
180
+ model,
181
+ content: _this.privacy === 'full' ? bodyStr : null,
182
+ metadata_flags: { method: 'FETCH', url: url }
183
+ });
99
184
 
100
- const startTime = Date.now();
185
+ // Execute
101
186
  let response;
102
-
103
- // Execute Original Call
104
187
  try {
105
188
  response = await originalFetch.apply(this, arguments);
106
- } catch (e) { throw e; }
189
+ } catch (err) { throw err; }
107
190
 
108
- // Capture Response (Clone response stream)
191
+ const latency = Date.now() - start;
192
+
193
+ // Emit Response
109
194
  try {
110
195
  const cloned = response.clone();
111
- const resBody = await cloned.text();
112
-
196
+ const oText = await cloned.text();
113
197
  _this.emit({
114
- url,
115
- method: 'FETCH',
116
- request: reqBody,
117
- response: resBody,
118
- latency: Date.now() - startTime,
119
- status: response.status
198
+ session_id: global.__dropout_session_id__,
199
+ turn_index: 0,
200
+ turn_role: 'assistant',
201
+ latency_ms: latency,
202
+ provider,
203
+ model,
204
+ content: _this.privacy === 'full' ? oText : null,
205
+ metadata_flags: { status: response.status }
120
206
  });
121
- } catch (e) { _this.log("⚠️ Error reading response body", e); }
207
+ } catch (e) { _this.log("⚠️ Failed to read response body"); }
122
208
 
123
209
  return response;
124
210
  };
125
211
  this.log("✅ Patch Applied: global.fetch");
126
212
  }
127
213
 
128
- // B. Patch Node HTTPS/HTTP (Used by Axios, Google Vertex SDK, Legacy LangChain)
214
+ // --- B. PATCH NODE HTTP/HTTPS ---
129
215
  const patchNodeRequest = (module, moduleName) => {
130
216
  const originalRequest = module.request;
131
217
  module.request = function (...args) {
132
- // Resolve URL from varied arguments
133
218
  let url;
134
- let options;
219
+ // Argument Resolution (url, options, callback) or (options, callback)
135
220
  if (typeof args[0] === 'string') {
136
221
  url = args[0];
137
- options = args[1] || {};
138
222
  } else {
139
- options = args[0] || {};
140
- const protocol = options.protocol || 'https:';
141
- const host = options.hostname || options.host || 'localhost';
142
- const path = options.path || '/';
223
+ const opts = args[0] || {};
224
+ const protocol = opts.protocol || (moduleName === 'https' ? 'https:' : 'http:');
225
+ const host = opts.hostname || opts.host || 'localhost';
226
+ const path = opts.path || '/';
143
227
  url = `${protocol}//${host}${path}`;
144
228
  }
145
229
 
146
- // PASSTHROUGH: If not AI, ignore
147
- if (!_this.isAiRequest(url)) {
230
+ if (!_this.isAiRequest(url, null)) {
148
231
  return originalRequest.apply(this, args);
149
232
  }
150
233
 
151
- _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting request to: ${url}`);
234
+ _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
235
+ const start = Date.now();
152
236
 
153
- const startTime = Date.now();
154
237
  const clientRequest = originalRequest.apply(this, args);
155
238
 
156
- // Capture Request Body (Chunks)
157
- const chunks = [];
239
+ // Capture Buffers
240
+ const reqChunks = [];
158
241
  const originalWrite = clientRequest.write;
159
242
  const originalEnd = clientRequest.end;
160
243
 
244
+ // SAFE WRITE PATCH
161
245
  clientRequest.write = function (...writeArgs) {
162
- if (writeArgs[0]) chunks.push(Buffer.from(writeArgs[0]));
246
+ const chunk = writeArgs[0];
247
+ // Only buffer if it's data (String or Buffer), ignore objects/callbacks
248
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
249
+ reqChunks.push(Buffer.from(chunk));
250
+ }
163
251
  return originalWrite.apply(this, writeArgs);
164
252
  };
165
253
 
254
+ // SAFE END PATCH
166
255
  clientRequest.end = function (...endArgs) {
167
- if (endArgs[0]) chunks.push(Buffer.from(endArgs[0]));
168
- // Store full body on the request object for later
169
- clientRequest._fullBody = Buffer.concat(chunks).toString('utf8');
256
+ const chunk = endArgs[0];
257
+ // Only buffer if it's data
258
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
259
+ reqChunks.push(Buffer.from(chunk));
260
+ }
261
+
262
+ // Emit Request
263
+ const reqBody = Buffer.concat(reqChunks).toString('utf8');
264
+ const { provider, model } = _this.normalize(url, reqBody);
265
+
266
+ // Attach state to request for response handler
267
+ clientRequest._dropout_meta = { provider, model };
268
+
269
+ _this.emit({
270
+ session_id: global.__dropout_session_id__,
271
+ turn_index: 0,
272
+ turn_role: 'user',
273
+ provider,
274
+ model,
275
+ content: _this.privacy === 'full' ? reqBody : null,
276
+ metadata_flags: { method: moduleName.toUpperCase(), url: url }
277
+ });
278
+
170
279
  return originalEnd.apply(this, endArgs);
171
280
  };
172
281
 
@@ -176,13 +285,18 @@ class Dropout {
176
285
  res.on('data', (chunk) => resChunks.push(chunk));
177
286
  res.on('end', () => {
178
287
  const resBody = Buffer.concat(resChunks).toString('utf8');
288
+ const latency = Date.now() - start;
289
+ const meta = clientRequest._dropout_meta || {};
290
+
179
291
  _this.emit({
180
- url,
181
- method: moduleName.toUpperCase(),
182
- request: clientRequest._fullBody,
183
- response: resBody,
184
- latency: Date.now() - startTime,
185
- status: res.statusCode
292
+ session_id: global.__dropout_session_id__,
293
+ turn_index: 0,
294
+ turn_role: 'assistant',
295
+ latency_ms: latency,
296
+ provider: meta.provider,
297
+ model: meta.model,
298
+ content: _this.privacy === 'full' ? resBody : null,
299
+ metadata_flags: { status: res.statusCode }
186
300
  });
187
301
  });
188
302
  });
@@ -195,46 +309,15 @@ class Dropout {
195
309
  patchNodeRequest(http, 'http');
196
310
  this.log("✅ Patch Applied: http/https");
197
311
  }
312
+ }
198
313
 
199
- // --- EMITTER ---
200
- emit(data) {
201
- // 1. Construct Payload
202
- // We combine Request + Response into a single content blob for simplicity in this universal mode
203
- const content = `--- REQUEST ---\n${data.request}\n\n--- RESPONSE ---\n${data.response}`;
204
-
205
- const payload = {
206
- project_id: this.projectId,
207
- session_id: global.__dropout_session_id__,
208
- turn_role: 'assistant', // Default to assistant for system-captured logs
209
- turn_index: 0, // In universal mode, we capture raw streams
210
- //content_blob: this.privacy === 'full' ? content : null,
211
- content: this.privacy === 'full' ? content : null,
212
- metadata_flags: {
213
- latency: data.latency,
214
- url: data.url,
215
- method: data.method,
216
- status: data.status,
217
- captured_via: 'universal_interceptor'
218
- },
219
- received_at: new Date().toISOString()
220
- };
221
-
222
- this.log(`🚀 Sending Capture to Supabase (${data.latency}ms)`);
223
-
224
- // 2. Fire and Forget (Using unpatched fetch mechanism)
225
- // We use a clean http request to avoid triggering our own hooks if we used global.fetch
226
- const req = https.request(DROPOUT_INGEST_URL, {
227
- method: 'POST',
228
- headers: {
229
- 'Content-Type': 'application/json',
230
- 'x-dropout-key': this.apiKey
231
- }
232
- });
233
-
234
- req.on('error', (e) => this.log("❌ Upload Failed", e.message));
235
- req.write(JSON.stringify(payload));
236
- req.end();
237
- }
314
+ // Auto-Start (Node Preload)
315
+ if (process.env.DROPOUT_PROJECT_ID && process.env.DROPOUT_API_KEY) {
316
+ new Dropout({
317
+ projectId: process.env.DROPOUT_PROJECT_ID,
318
+ apiKey: process.env.DROPOUT_API_KEY,
319
+ debug: process.env.DROPOUT_DEBUG === 'true'
320
+ });
238
321
  }
239
322
 
240
323
  module.exports = Dropout;