@dropout-ai/runtime 0.3.0 → 0.3.1
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/package.json +1 -1
- package/src/index.js +203 -196
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,34 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @dropout-ai/runtime
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
// ---
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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,208 @@ function generateSessionId() {
|
|
|
37
31
|
}
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
//
|
|
111
|
-
if (
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
};
|
|
66
|
+
log(msg, ...args) {
|
|
67
|
+
if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
|
|
68
|
+
}
|
|
196
69
|
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
//
|
|
75
|
+
// Check against known AI domains
|
|
76
|
+
return KNOWN_AI_DOMAINS.some(domain => url.includes(domain));
|
|
77
|
+
}
|
|
202
78
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
metadata_flags: {
|
|
212
|
+
latency: data.latency,
|
|
213
|
+
url: data.url,
|
|
214
|
+
method: data.method,
|
|
215
|
+
status: data.status,
|
|
216
|
+
captured_via: 'universal_interceptor'
|
|
217
|
+
},
|
|
218
|
+
received_at: new Date().toISOString()
|
|
219
|
+
};
|
|
218
220
|
|
|
219
|
-
|
|
220
|
-
if (!GLOBAL_OBJ.__dropout_session_id__) {
|
|
221
|
-
GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
|
|
222
|
-
}
|
|
221
|
+
this.log(`🚀 Sending Capture to Supabase (${data.latency}ms)`);
|
|
223
222
|
|
|
224
|
-
//
|
|
225
|
-
|
|
223
|
+
// 2. Fire and Forget (Using unpatched fetch mechanism)
|
|
224
|
+
// We use a clean http request to avoid triggering our own hooks if we used global.fetch
|
|
225
|
+
const req = https.request(DROPOUT_INGEST_URL, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
'Content-Type': 'application/json',
|
|
229
|
+
'x-dropout-key': this.apiKey
|
|
230
|
+
}
|
|
231
|
+
});
|
|
226
232
|
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
req.on('error', (e) => this.log("❌ Upload Failed", e.message));
|
|
234
|
+
req.write(JSON.stringify(payload));
|
|
235
|
+
req.end();
|
|
229
236
|
}
|
|
230
237
|
}
|
|
231
238
|
|