@dropout-ai/runtime 0.3.0 → 0.3.2

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 +204 -196
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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
@@ -1,34 +1,28 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
- * Role: Passive observer.
4
- * Behavior: Fire-and-forget raw JSON to Supabase.
5
- * Authentication: API Key + Project ID.
3
+ * Universal AI Interaction Capture for Node.js & Next.js
4
+ * Patches: fetch, http.request, https.request
5
+ * Capability: Captures Genkit, OpenAI, LangChain, Axios, and standard fetch.
6
6
  */
7
7
 
8
+ const https = require('https');
9
+ const http = require('http');
8
10
  const crypto = require('crypto');
9
11
 
10
- // --- DEFAULT CONFIGURATION ---
11
- const SUPABASE_FUNCTION_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
12
-
13
- // Global State
14
- const GLOBAL_OBJ = typeof window !== 'undefined' ? window : global;
15
- let activeConfig = {
16
- projectId: null,
17
- apiKey: null,
18
- captureEndpoint: SUPABASE_FUNCTION_URL,
19
- maxOutputBytes: 32768,
20
- privacyMode: (typeof process !== 'undefined' && process.env.DROPOUT_PRIVACY_MODE) || 'full'
21
- };
22
-
23
- let isPatched = false;
24
- let turnIndex = 0;
25
- let lastTurnConfirmed = true;
26
- let lastPromptHash = null;
27
- let lastResponseHash = null;
28
- let instance = null;
12
+ // --- CONFIGURATION ---
13
+ 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
21
+ ];
29
22
 
30
- // --- UTILS ---
23
+ const DROPOUT_INGEST_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
31
24
 
25
+ // --- UTILS ---
32
26
  function generateSessionId() {
33
27
  try {
34
28
  return crypto.randomUUID();
@@ -37,195 +31,209 @@ function generateSessionId() {
37
31
  }
38
32
  }
39
33
 
40
- function hash(text) {
41
- if (!text) return null;
42
- try {
43
- return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
44
- } catch (e) { return 'hash_err'; }
45
- }
46
-
47
- function normalize(url, body) {
48
- let provider = 'unknown';
49
- let model = 'unknown';
50
-
51
- if (url) {
52
- const u = url.toLowerCase();
53
- if (u.includes('openai.com')) provider = 'openai';
54
- else if (u.includes('anthropic.com')) provider = 'anthropic';
55
- else if (u.includes('google.com') || u.includes('generative')) provider = 'google';
56
- else if (u.includes('groq.com')) provider = 'groq';
57
- else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
58
- }
59
-
60
- if (body) {
61
- try {
62
- const parsed = typeof body === 'string' ? JSON.parse(body) : body;
63
- model = parsed.model || model;
64
- if (provider === 'unknown' && (parsed.messages || parsed.prompt || parsed.input)) {
65
- provider = 'heuristic';
66
- }
67
- } catch (e) { }
68
- }
69
- return { provider, model };
70
- }
71
-
72
- // --- EMITTER (Authenticated) ---
73
-
74
- function emit(payload) {
75
- // 1. Guard: Do not emit if not initialized
76
- if (!activeConfig.apiKey || !activeConfig.projectId) return;
77
-
78
- const fetchFn = GLOBAL_OBJ.__dropout_original_fetch__ || GLOBAL_OBJ.fetch;
79
- if (typeof fetchFn !== 'function') return;
80
-
81
- // 2. Attach Project ID to payload
82
- const finalPayload = {
83
- ...payload,
84
- project_id: activeConfig.projectId
85
- };
86
-
87
- setTimeout(() => {
88
- fetchFn(activeConfig.captureEndpoint, {
89
- method: 'POST',
90
- headers: {
91
- 'Content-Type': 'application/json',
92
- 'x-dropout-key': activeConfig.apiKey // <--- AUTH HEADER
93
- },
94
- body: JSON.stringify(finalPayload),
95
- keepalive: true
96
- }).catch(() => { /* Silent Fail */ });
97
- }, 0);
98
- }
99
-
100
- // --- MONKEY PATCH ---
101
-
102
- function applyPatch() {
103
- if (isPatched || !GLOBAL_OBJ.fetch) return;
104
-
105
- GLOBAL_OBJ.__dropout_original_fetch__ = GLOBAL_OBJ.fetch;
34
+ class Dropout {
35
+ constructor(config = {}) {
36
+ // 1. Validation
37
+ if (!config.apiKey || !config.projectId) {
38
+ console.warn("[Dropout] ⚠️ Initialization Skipped: Missing apiKey or projectId.");
39
+ return;
40
+ }
106
41
 
107
- GLOBAL_OBJ.fetch = async function (input, init) {
108
- const url = typeof input === 'string' ? input : (input && input.url);
42
+ // 2. Config Setup
43
+ this.config = config;
44
+ this.projectId = config.projectId;
45
+ this.apiKey = config.apiKey;
46
+ this.debug = config.debug || false; // Toggle this to see logs
47
+ this.privacy = config.privacy || 'full';
109
48
 
110
- // Guard: Don't track our own calls
111
- if (url && url.includes(activeConfig.captureEndpoint)) {
112
- return GLOBAL_OBJ.__dropout_original_fetch__(input, init);
49
+ // 3. Singleton Guard (Prevent double-patching)
50
+ if (global.__dropout_initialized__) {
51
+ if (this.debug) console.log("[Dropout] ℹ️ Already initialized. Skipping patch.");
52
+ return;
113
53
  }
54
+ global.__dropout_initialized__ = true;
114
55
 
115
- const isAI = url && (
116
- url.includes('openai.com') ||
117
- url.includes('anthropic.com') ||
118
- url.includes('generative') ||
119
- url.includes('groq.com') ||
120
- (init && init.body && (init.body.includes('"model"') || init.body.includes('"messages"')))
121
- );
122
-
123
- if (!isAI) return GLOBAL_OBJ.__dropout_original_fetch__(input, init);
124
-
125
- const start = Date.now();
126
- let activeTurn;
127
- if (lastTurnConfirmed) {
128
- activeTurn = turnIndex++;
129
- lastTurnConfirmed = false;
130
- } else {
131
- activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
56
+ // 4. Initialize Identity
57
+ if (!global.__dropout_session_id__) {
58
+ global.__dropout_session_id__ = generateSessionId();
132
59
  }
133
60
 
134
- const { provider, model } = normalize(url, init && init.body);
135
-
136
- // Emit Request
137
- let pText = "";
138
- if (init && init.body) {
139
- try { pText = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
140
- }
141
- const pHash = hash(pText);
142
-
143
- emit({
144
- session_id: GLOBAL_OBJ.__dropout_session_id__,
145
- turn_index: activeTurn,
146
- direction: 'user_to_ai',
147
- turn_role: 'user',
148
- provider,
149
- model,
150
- content: activeConfig.privacyMode === 'full' ? pText : null,
151
- content_hash: pHash,
152
- metadata_flags: { retry_like: pHash === lastPromptHash ? 1 : 0 }
153
- });
154
- lastPromptHash = pHash;
155
-
156
- // Actual Call
157
- let response;
158
- let oText = "";
159
- try {
160
- response = await GLOBAL_OBJ.__dropout_original_fetch__(input, init);
161
- } catch (err) { throw err; }
162
-
163
- const latency = Date.now() - start;
164
-
165
- // Emit Response
166
- try {
167
- const cloned = response.clone();
168
- oText = await cloned.text();
169
- if (oText && oText.length > activeConfig.maxOutputBytes) {
170
- oText = oText.slice(0, activeConfig.maxOutputBytes);
171
- }
172
- } catch (e) { }
173
-
174
- const oHash = hash(oText);
175
-
176
- emit({
177
- session_id: GLOBAL_OBJ.__dropout_session_id__,
178
- turn_index: activeTurn,
179
- direction: 'ai_to_user',
180
- turn_role: 'assistant',
181
- latency_ms: latency,
182
- provider,
183
- model,
184
- content: activeConfig.privacyMode === 'full' ? oText : null,
185
- content_hash: oHash,
186
- metadata_flags: {
187
- non_adaptive_response: oHash === lastResponseHash ? 1 : 0,
188
- turn_boundary_confirmed: 1
189
- }
190
- });
61
+ // 5. Start the Wiretap
62
+ if (this.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${this.projectId}`);
63
+ this.patchNetwork();
64
+ }
191
65
 
192
- lastResponseHash = oHash;
193
- lastTurnConfirmed = true;
194
- return response;
195
- };
66
+ log(msg, ...args) {
67
+ if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
68
+ }
196
69
 
197
- GLOBAL_OBJ.fetch.__dropout_patched__ = true;
198
- isPatched = true;
199
- }
70
+ isAiRequest(url) {
71
+ if (!url) return false;
72
+ // Guard: Infinite Loop Prevention (Don't capture our own calls)
73
+ if (url.includes(DROPOUT_INGEST_URL)) return false;
200
74
 
201
- // --- MAIN CLASS EXPORT ---
75
+ // Check against known AI domains
76
+ return KNOWN_AI_DOMAINS.some(domain => url.includes(domain));
77
+ }
202
78
 
203
- class Dropout {
204
- constructor(config = {}) {
205
- if (instance) {
206
- return instance;
79
+ // --- THE CORE: UNIVERSAL PATCHING ---
80
+ patchNetwork() {
81
+ const _this = this;
82
+
83
+ // A. Patch Global Fetch (Used by OpenAI v4+, Vercel AI SDK, Next.js)
84
+ if (global.fetch) {
85
+ const originalFetch = global.fetch;
86
+ global.fetch = async function (input, init) {
87
+ const url = typeof input === 'string' ? input : input?.url;
88
+
89
+ // PASSTHROUGH: If not AI, ignore
90
+ if (!_this.isAiRequest(url)) {
91
+ return originalFetch.apply(this, arguments);
92
+ }
93
+
94
+ _this.log(`⚡ [FETCH] Intercepting request to: ${url}`);
95
+
96
+ // Capture Request Body
97
+ let reqBody = "";
98
+ try { if (init && init.body) reqBody = init.body; } catch (e) { }
99
+
100
+ const startTime = Date.now();
101
+ let response;
102
+
103
+ // Execute Original Call
104
+ try {
105
+ response = await originalFetch.apply(this, arguments);
106
+ } catch (e) { throw e; }
107
+
108
+ // Capture Response (Clone response stream)
109
+ try {
110
+ const cloned = response.clone();
111
+ const resBody = await cloned.text();
112
+
113
+ _this.emit({
114
+ url,
115
+ method: 'FETCH',
116
+ request: reqBody,
117
+ response: resBody,
118
+ latency: Date.now() - startTime,
119
+ status: response.status
120
+ });
121
+ } catch (e) { _this.log("⚠️ Error reading response body", e); }
122
+
123
+ return response;
124
+ };
125
+ this.log("✅ Patch Applied: global.fetch");
207
126
  }
208
127
 
209
- if (!config.apiKey || !config.projectId) {
210
- console.warn("[Dropout] Missing apiKey or projectId. Tracking disabled.");
211
- return;
212
- }
128
+ // B. Patch Node HTTPS/HTTP (Used by Axios, Google Vertex SDK, Legacy LangChain)
129
+ const patchNodeRequest = (module, moduleName) => {
130
+ const originalRequest = module.request;
131
+ module.request = function (...args) {
132
+ // Resolve URL from varied arguments
133
+ let url;
134
+ let options;
135
+ if (typeof args[0] === 'string') {
136
+ url = args[0];
137
+ options = args[1] || {};
138
+ } else {
139
+ options = args[0] || {};
140
+ const protocol = options.protocol || 'https:';
141
+ const host = options.hostname || options.host || 'localhost';
142
+ const path = options.path || '/';
143
+ url = `${protocol}//${host}${path}`;
144
+ }
145
+
146
+ // PASSTHROUGH: If not AI, ignore
147
+ if (!_this.isAiRequest(url)) {
148
+ return originalRequest.apply(this, args);
149
+ }
150
+
151
+ _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting request to: ${url}`);
152
+
153
+ const startTime = Date.now();
154
+ const clientRequest = originalRequest.apply(this, args);
155
+
156
+ // Capture Request Body (Chunks)
157
+ const chunks = [];
158
+ const originalWrite = clientRequest.write;
159
+ const originalEnd = clientRequest.end;
160
+
161
+ clientRequest.write = function (...writeArgs) {
162
+ if (writeArgs[0]) chunks.push(Buffer.from(writeArgs[0]));
163
+ return originalWrite.apply(this, writeArgs);
164
+ };
165
+
166
+ 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');
170
+ return originalEnd.apply(this, endArgs);
171
+ };
172
+
173
+ // Capture Response
174
+ clientRequest.on('response', (res) => {
175
+ const resChunks = [];
176
+ res.on('data', (chunk) => resChunks.push(chunk));
177
+ res.on('end', () => {
178
+ const resBody = Buffer.concat(resChunks).toString('utf8');
179
+ _this.emit({
180
+ url,
181
+ method: moduleName.toUpperCase(),
182
+ request: clientRequest._fullBody,
183
+ response: resBody,
184
+ latency: Date.now() - startTime,
185
+ status: res.statusCode
186
+ });
187
+ });
188
+ });
189
+
190
+ return clientRequest;
191
+ };
192
+ };
193
+
194
+ patchNodeRequest(https, 'https');
195
+ patchNodeRequest(http, 'http');
196
+ this.log("✅ Patch Applied: http/https");
197
+ }
213
198
 
214
- // Update Global Configuration
215
- activeConfig.apiKey = config.apiKey;
216
- activeConfig.projectId = config.projectId;
217
- if (config.privacyMode) activeConfig.privacyMode = config.privacyMode;
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
+ };
218
221
 
219
- // Initialize Identity
220
- if (!GLOBAL_OBJ.__dropout_session_id__) {
221
- GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
222
- }
222
+ this.log(`🚀 Sending Capture to Supabase (${data.latency}ms)`);
223
223
 
224
- // Apply the patch
225
- applyPatch();
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
+ });
226
233
 
227
- console.log("[Dropout] Initialized for project:", config.projectId);
228
- instance = this;
234
+ req.on('error', (e) => this.log(" Upload Failed", e.message));
235
+ req.write(JSON.stringify(payload));
236
+ req.end();
229
237
  }
230
238
  }
231
239