trickle-observe 0.2.117 → 0.2.118
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/llm-observer.d.ts +17 -0
- package/dist/llm-observer.js +526 -0
- package/dist/observe-register.js +21 -0
- package/dist/vite-plugin.test.d.ts +1 -0
- package/dist/vite-plugin.test.js +160 -0
- package/package.json +1 -1
- package/src/llm-observer.ts +508 -0
- package/src/observe-register.ts +22 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM call observer — auto-instruments OpenAI, Anthropic, and other LLM SDKs
|
|
3
|
+
* to capture prompts, completions, token counts, latency, cost, and model metadata.
|
|
4
|
+
*
|
|
5
|
+
* Writes to .trickle/llm.jsonl as:
|
|
6
|
+
* { "kind": "llm_call", "provider": "openai", "model": "gpt-4",
|
|
7
|
+
* "inputTokens": 100, "outputTokens": 50, "durationMs": 1234.5, ... }
|
|
8
|
+
*
|
|
9
|
+
* Supports both streaming and non-streaming calls.
|
|
10
|
+
* Zero code changes needed — intercepted via Module._load hook.
|
|
11
|
+
*/
|
|
12
|
+
export declare function patchOpenAI(openaiModule: any, debug: boolean): void;
|
|
13
|
+
export declare function patchAnthropic(anthropicModule: any, debug: boolean): void;
|
|
14
|
+
/**
|
|
15
|
+
* Initialize the LLM observer — clears previous data file.
|
|
16
|
+
*/
|
|
17
|
+
export declare function initLlmObserver(): void;
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LLM call observer — auto-instruments OpenAI, Anthropic, and other LLM SDKs
|
|
4
|
+
* to capture prompts, completions, token counts, latency, cost, and model metadata.
|
|
5
|
+
*
|
|
6
|
+
* Writes to .trickle/llm.jsonl as:
|
|
7
|
+
* { "kind": "llm_call", "provider": "openai", "model": "gpt-4",
|
|
8
|
+
* "inputTokens": 100, "outputTokens": 50, "durationMs": 1234.5, ... }
|
|
9
|
+
*
|
|
10
|
+
* Supports both streaming and non-streaming calls.
|
|
11
|
+
* Zero code changes needed — intercepted via Module._load hook.
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.patchOpenAI = patchOpenAI;
|
|
48
|
+
exports.patchAnthropic = patchAnthropic;
|
|
49
|
+
exports.initLlmObserver = initLlmObserver;
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const path = __importStar(require("path"));
|
|
52
|
+
let llmFile = null;
|
|
53
|
+
let eventCount = 0;
|
|
54
|
+
const MAX_LLM_EVENTS = 500;
|
|
55
|
+
const TRUNCATE_LEN = 500;
|
|
56
|
+
// Approximate pricing per 1M tokens (USD) — used for cost estimation
|
|
57
|
+
const PRICING = {
|
|
58
|
+
'gpt-4o': { input: 2.5, output: 10 },
|
|
59
|
+
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
|
60
|
+
'gpt-4-turbo': { input: 10, output: 30 },
|
|
61
|
+
'gpt-4': { input: 30, output: 60 },
|
|
62
|
+
'gpt-3.5-turbo': { input: 0.5, output: 1.5 },
|
|
63
|
+
'claude-opus-4-20250514': { input: 15, output: 75 },
|
|
64
|
+
'claude-sonnet-4-20250514': { input: 3, output: 15 },
|
|
65
|
+
'claude-3-5-sonnet-20241022': { input: 3, output: 15 },
|
|
66
|
+
'claude-3-5-haiku-20241022': { input: 0.8, output: 4 },
|
|
67
|
+
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
|
|
68
|
+
};
|
|
69
|
+
function getLlmFile() {
|
|
70
|
+
if (llmFile)
|
|
71
|
+
return llmFile;
|
|
72
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
llmFile = path.join(dir, 'llm.jsonl');
|
|
78
|
+
return llmFile;
|
|
79
|
+
}
|
|
80
|
+
function writeLlmEvent(event) {
|
|
81
|
+
if (eventCount >= MAX_LLM_EVENTS)
|
|
82
|
+
return;
|
|
83
|
+
eventCount++;
|
|
84
|
+
try {
|
|
85
|
+
fs.appendFileSync(getLlmFile(), JSON.stringify(event) + '\n');
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
function truncate(s, len = TRUNCATE_LEN) {
|
|
90
|
+
if (!s)
|
|
91
|
+
return '';
|
|
92
|
+
return s.length > len ? s.substring(0, len) + '...' : s;
|
|
93
|
+
}
|
|
94
|
+
function estimateCost(model, inputTokens, outputTokens) {
|
|
95
|
+
// Find best matching pricing key
|
|
96
|
+
const key = Object.keys(PRICING).find(k => model.includes(k)) || '';
|
|
97
|
+
if (!key)
|
|
98
|
+
return 0;
|
|
99
|
+
const p = PRICING[key];
|
|
100
|
+
return Math.round(((inputTokens * p.input + outputTokens * p.output) / 1_000_000) * 1_000_000) / 1_000_000;
|
|
101
|
+
}
|
|
102
|
+
function extractInputPreview(messages) {
|
|
103
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
104
|
+
return '';
|
|
105
|
+
const last = messages[messages.length - 1];
|
|
106
|
+
if (typeof last?.content === 'string')
|
|
107
|
+
return truncate(last.content);
|
|
108
|
+
if (Array.isArray(last?.content)) {
|
|
109
|
+
const textPart = last.content.find((p) => p.type === 'text');
|
|
110
|
+
if (textPart?.text)
|
|
111
|
+
return truncate(textPart.text);
|
|
112
|
+
}
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
function extractSystemPrompt(messages) {
|
|
116
|
+
if (!Array.isArray(messages))
|
|
117
|
+
return undefined;
|
|
118
|
+
const sys = messages.find((m) => m.role === 'system');
|
|
119
|
+
if (sys?.content && typeof sys.content === 'string')
|
|
120
|
+
return truncate(sys.content, 200);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function hasToolUse(params) {
|
|
124
|
+
return !!(params.tools && Array.isArray(params.tools) && params.tools.length > 0);
|
|
125
|
+
}
|
|
126
|
+
// ────────────────────────────────────────────────────
|
|
127
|
+
// OpenAI SDK v4+ instrumentation
|
|
128
|
+
// ────────────────────────────────────────────────────
|
|
129
|
+
function patchOpenAI(openaiModule, debug) {
|
|
130
|
+
if (!openaiModule || getattr(openaiModule, '_trickle_llm_patched'))
|
|
131
|
+
return;
|
|
132
|
+
setattr(openaiModule, '_trickle_llm_patched', true);
|
|
133
|
+
const OpenAIClass = openaiModule.OpenAI || openaiModule.default;
|
|
134
|
+
if (typeof OpenAIClass !== 'function')
|
|
135
|
+
return;
|
|
136
|
+
// OpenAI SDK v4+ creates resource instances (chat, completions) in the constructor
|
|
137
|
+
// as own properties. The Completions class is not directly exported, but we can
|
|
138
|
+
// access it by creating a temporary client and getting the prototype of chat.completions.
|
|
139
|
+
try {
|
|
140
|
+
// Create a temporary client to discover the Completions class
|
|
141
|
+
// (ES6 classes require `new`, can't use .call())
|
|
142
|
+
const tmpClient = new OpenAIClass({ apiKey: 'trickle-probe' });
|
|
143
|
+
const CompletionsClass = Object.getPrototypeOf(tmpClient.chat?.completions)?.constructor;
|
|
144
|
+
if (CompletionsClass && CompletionsClass.prototype.create && !CompletionsClass.prototype.create.__trickle_patched) {
|
|
145
|
+
const origCreate = CompletionsClass.prototype.create;
|
|
146
|
+
CompletionsClass.prototype.create = function patchedCreate(...args) {
|
|
147
|
+
const params = args[0] || {};
|
|
148
|
+
const startTime = performance.now();
|
|
149
|
+
const isStream = !!params.stream;
|
|
150
|
+
const result = origCreate.apply(this, args);
|
|
151
|
+
if (isStream)
|
|
152
|
+
return handleOpenAIStream(result, params, startTime, debug);
|
|
153
|
+
if (result && typeof result.then === 'function') {
|
|
154
|
+
return result.then((response) => {
|
|
155
|
+
captureOpenAIResponse(params, response, startTime, debug);
|
|
156
|
+
return response;
|
|
157
|
+
}).catch((err) => {
|
|
158
|
+
captureOpenAIError(params, err, startTime, debug);
|
|
159
|
+
throw err;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
};
|
|
164
|
+
CompletionsClass.prototype.create.__trickle_patched = true;
|
|
165
|
+
if (debug)
|
|
166
|
+
console.log('[trickle/llm] Patched OpenAI SDK');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
if (debug)
|
|
172
|
+
console.log('[trickle/llm] OpenAI patch probe failed:', e.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function patchOpenAIClient(client, debug) {
|
|
176
|
+
// Patch chat.completions.create
|
|
177
|
+
if (client.chat?.completions?.create && !client.chat.completions.create.__trickle_patched) {
|
|
178
|
+
const origCreate = client.chat.completions.create.bind(client.chat.completions);
|
|
179
|
+
client.chat.completions.create = function patchedCreate(...args) {
|
|
180
|
+
const params = args[0] || {};
|
|
181
|
+
const startTime = performance.now();
|
|
182
|
+
const isStream = !!params.stream;
|
|
183
|
+
const result = origCreate(...args);
|
|
184
|
+
if (isStream) {
|
|
185
|
+
return handleOpenAIStream(result, params, startTime, debug);
|
|
186
|
+
}
|
|
187
|
+
// Non-streaming: hook the promise
|
|
188
|
+
if (result && typeof result.then === 'function') {
|
|
189
|
+
return result.then((response) => {
|
|
190
|
+
captureOpenAIResponse(params, response, startTime, debug);
|
|
191
|
+
return response;
|
|
192
|
+
}).catch((err) => {
|
|
193
|
+
captureOpenAIError(params, err, startTime, debug);
|
|
194
|
+
throw err;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
};
|
|
199
|
+
client.chat.completions.create.__trickle_patched = true;
|
|
200
|
+
}
|
|
201
|
+
// Patch completions.create (legacy)
|
|
202
|
+
if (client.completions?.create && !client.completions.create.__trickle_patched) {
|
|
203
|
+
const origCreate = client.completions.create.bind(client.completions);
|
|
204
|
+
client.completions.create = function patchedCreate(...args) {
|
|
205
|
+
const params = args[0] || {};
|
|
206
|
+
const startTime = performance.now();
|
|
207
|
+
const result = origCreate(...args);
|
|
208
|
+
if (result && typeof result.then === 'function') {
|
|
209
|
+
return result.then((response) => {
|
|
210
|
+
const usage = response.usage || {};
|
|
211
|
+
const text = response.choices?.[0]?.text || '';
|
|
212
|
+
writeLlmEvent({
|
|
213
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
214
|
+
durationMs: round(performance.now() - startTime),
|
|
215
|
+
inputTokens: usage.prompt_tokens || 0, outputTokens: usage.completion_tokens || 0,
|
|
216
|
+
totalTokens: usage.total_tokens || 0,
|
|
217
|
+
estimatedCostUsd: estimateCost(params.model || '', usage.prompt_tokens || 0, usage.completion_tokens || 0),
|
|
218
|
+
stream: false, finishReason: response.choices?.[0]?.finish_reason || 'unknown',
|
|
219
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
220
|
+
inputPreview: truncate(params.prompt || ''), outputPreview: truncate(text),
|
|
221
|
+
messageCount: 0, toolUse: false, timestamp: Date.now(),
|
|
222
|
+
});
|
|
223
|
+
return response;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
};
|
|
228
|
+
client.completions.create.__trickle_patched = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function handleOpenAIStream(resultPromise, params, startTime, debug) {
|
|
232
|
+
const stream = await resultPromise;
|
|
233
|
+
const chunks = [];
|
|
234
|
+
let finishReason = 'unknown';
|
|
235
|
+
let totalInputTokens = 0;
|
|
236
|
+
let totalOutputTokens = 0;
|
|
237
|
+
// Wrap the async iterator
|
|
238
|
+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
|
|
239
|
+
stream[Symbol.asyncIterator] = function () {
|
|
240
|
+
const iter = origIterator();
|
|
241
|
+
return {
|
|
242
|
+
async next() {
|
|
243
|
+
const result = await iter.next();
|
|
244
|
+
if (!result.done) {
|
|
245
|
+
const chunk = result.value;
|
|
246
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
247
|
+
if (delta?.content)
|
|
248
|
+
chunks.push(delta.content);
|
|
249
|
+
if (chunk.choices?.[0]?.finish_reason)
|
|
250
|
+
finishReason = chunk.choices[0].finish_reason;
|
|
251
|
+
if (chunk.usage) {
|
|
252
|
+
totalInputTokens = chunk.usage.prompt_tokens || totalInputTokens;
|
|
253
|
+
totalOutputTokens = chunk.usage.completion_tokens || totalOutputTokens;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
// Stream finished — capture
|
|
258
|
+
const outputText = chunks.join('');
|
|
259
|
+
writeLlmEvent({
|
|
260
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
261
|
+
durationMs: round(performance.now() - startTime),
|
|
262
|
+
inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
263
|
+
totalTokens: totalInputTokens + totalOutputTokens,
|
|
264
|
+
estimatedCostUsd: estimateCost(params.model || '', totalInputTokens, totalOutputTokens),
|
|
265
|
+
stream: true, finishReason,
|
|
266
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
267
|
+
systemPrompt: extractSystemPrompt(params.messages),
|
|
268
|
+
inputPreview: extractInputPreview(params.messages),
|
|
269
|
+
outputPreview: truncate(outputText),
|
|
270
|
+
messageCount: params.messages?.length || 0,
|
|
271
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
272
|
+
});
|
|
273
|
+
if (debug)
|
|
274
|
+
console.log(`[trickle/llm] OpenAI stream: ${params.model} (${totalOutputTokens} tokens)`);
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
},
|
|
278
|
+
return: iter.return?.bind(iter),
|
|
279
|
+
throw: iter.throw?.bind(iter),
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
return stream;
|
|
283
|
+
}
|
|
284
|
+
function captureOpenAIResponse(params, response, startTime, debug) {
|
|
285
|
+
const usage = response.usage || {};
|
|
286
|
+
const outputText = response.choices?.[0]?.message?.content || '';
|
|
287
|
+
const event = {
|
|
288
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
289
|
+
durationMs: round(performance.now() - startTime),
|
|
290
|
+
inputTokens: usage.prompt_tokens || 0, outputTokens: usage.completion_tokens || 0,
|
|
291
|
+
totalTokens: usage.total_tokens || 0,
|
|
292
|
+
estimatedCostUsd: estimateCost(params.model || '', usage.prompt_tokens || 0, usage.completion_tokens || 0),
|
|
293
|
+
stream: false, finishReason: response.choices?.[0]?.finish_reason || 'unknown',
|
|
294
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
295
|
+
systemPrompt: extractSystemPrompt(params.messages),
|
|
296
|
+
inputPreview: extractInputPreview(params.messages),
|
|
297
|
+
outputPreview: truncate(outputText),
|
|
298
|
+
messageCount: params.messages?.length || 0,
|
|
299
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
300
|
+
};
|
|
301
|
+
writeLlmEvent(event);
|
|
302
|
+
if (debug)
|
|
303
|
+
console.log(`[trickle/llm] OpenAI: ${params.model} (${usage.total_tokens || 0} tokens, ${event.durationMs}ms)`);
|
|
304
|
+
}
|
|
305
|
+
function captureOpenAIError(params, err, startTime, debug) {
|
|
306
|
+
writeLlmEvent({
|
|
307
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
308
|
+
durationMs: round(performance.now() - startTime),
|
|
309
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCostUsd: 0,
|
|
310
|
+
stream: !!params.stream, finishReason: 'error',
|
|
311
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
312
|
+
systemPrompt: extractSystemPrompt(params.messages),
|
|
313
|
+
inputPreview: extractInputPreview(params.messages),
|
|
314
|
+
outputPreview: '', messageCount: params.messages?.length || 0,
|
|
315
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
316
|
+
error: truncate(err?.message || String(err), 200),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// ────────────────────────────────────────────────────
|
|
320
|
+
// Anthropic SDK instrumentation
|
|
321
|
+
// ────────────────────────────────────────────────────
|
|
322
|
+
function patchAnthropic(anthropicModule, debug) {
|
|
323
|
+
if (!anthropicModule || getattr(anthropicModule, '_trickle_llm_patched'))
|
|
324
|
+
return;
|
|
325
|
+
setattr(anthropicModule, '_trickle_llm_patched', true);
|
|
326
|
+
const AnthropicClass = anthropicModule.Anthropic || anthropicModule.default;
|
|
327
|
+
if (typeof AnthropicClass !== 'function')
|
|
328
|
+
return;
|
|
329
|
+
try {
|
|
330
|
+
const tmpClient = new AnthropicClass({ apiKey: 'trickle-probe' });
|
|
331
|
+
const MessagesClass = Object.getPrototypeOf(tmpClient.messages)?.constructor;
|
|
332
|
+
if (MessagesClass && MessagesClass.prototype.create && !MessagesClass.prototype.create.__trickle_patched) {
|
|
333
|
+
const origCreate = MessagesClass.prototype.create;
|
|
334
|
+
MessagesClass.prototype.create = function patchedCreate(...args) {
|
|
335
|
+
const params = args[0] || {};
|
|
336
|
+
const startTime = performance.now();
|
|
337
|
+
const isStream = !!params.stream;
|
|
338
|
+
const result = origCreate.apply(this, args);
|
|
339
|
+
if (result && typeof result.then === 'function') {
|
|
340
|
+
return result.then((response) => {
|
|
341
|
+
if (isStream)
|
|
342
|
+
return handleAnthropicStream(response, params, startTime, debug);
|
|
343
|
+
captureAnthropicResponse(params, response, startTime, debug);
|
|
344
|
+
return response;
|
|
345
|
+
}).catch((err) => {
|
|
346
|
+
captureAnthropicError(params, err, startTime, debug);
|
|
347
|
+
throw err;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
};
|
|
352
|
+
MessagesClass.prototype.create.__trickle_patched = true;
|
|
353
|
+
if (debug)
|
|
354
|
+
console.log('[trickle/llm] Patched Anthropic SDK');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
if (debug)
|
|
360
|
+
console.log('[trickle/llm] Anthropic patch probe failed:', e.message);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function patchAnthropicClient(client, debug) {
|
|
364
|
+
// Patch messages.create
|
|
365
|
+
if (client.messages?.create && !client.messages.create.__trickle_patched) {
|
|
366
|
+
const origCreate = client.messages.create.bind(client.messages);
|
|
367
|
+
client.messages.create = function patchedCreate(...args) {
|
|
368
|
+
const params = args[0] || {};
|
|
369
|
+
const startTime = performance.now();
|
|
370
|
+
const isStream = !!params.stream;
|
|
371
|
+
const result = origCreate(...args);
|
|
372
|
+
if (result && typeof result.then === 'function') {
|
|
373
|
+
return result.then((response) => {
|
|
374
|
+
if (isStream) {
|
|
375
|
+
return handleAnthropicStream(response, params, startTime, debug);
|
|
376
|
+
}
|
|
377
|
+
captureAnthropicResponse(params, response, startTime, debug);
|
|
378
|
+
return response;
|
|
379
|
+
}).catch((err) => {
|
|
380
|
+
captureAnthropicError(params, err, startTime, debug);
|
|
381
|
+
throw err;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
385
|
+
};
|
|
386
|
+
client.messages.create.__trickle_patched = true;
|
|
387
|
+
}
|
|
388
|
+
// Patch messages.stream (if it exists)
|
|
389
|
+
if (client.messages?.stream && !client.messages.stream.__trickle_patched) {
|
|
390
|
+
const origStream = client.messages.stream.bind(client.messages);
|
|
391
|
+
client.messages.stream = function patchedStream(...args) {
|
|
392
|
+
const params = args[0] || {};
|
|
393
|
+
const startTime = performance.now();
|
|
394
|
+
const result = origStream(...args);
|
|
395
|
+
if (result && typeof result.then === 'function') {
|
|
396
|
+
return result.then((stream) => handleAnthropicStream(stream, params, startTime, debug));
|
|
397
|
+
}
|
|
398
|
+
return handleAnthropicStream(result, params, startTime, debug);
|
|
399
|
+
};
|
|
400
|
+
client.messages.stream.__trickle_patched = true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function handleAnthropicStream(stream, params, startTime, debug) {
|
|
404
|
+
// Anthropic streams have a finalMessage() or on('message') pattern
|
|
405
|
+
// Hook into the stream events to capture the final result
|
|
406
|
+
if (stream && typeof stream.on === 'function') {
|
|
407
|
+
stream.on('finalMessage', (message) => {
|
|
408
|
+
captureAnthropicResponse(params, message, startTime, debug);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
// Also support the async iterator pattern
|
|
412
|
+
if (stream && stream[Symbol.asyncIterator]) {
|
|
413
|
+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
|
|
414
|
+
const chunks = [];
|
|
415
|
+
stream[Symbol.asyncIterator] = function () {
|
|
416
|
+
const iter = origIterator();
|
|
417
|
+
return {
|
|
418
|
+
async next() {
|
|
419
|
+
const result = await iter.next();
|
|
420
|
+
if (!result.done) {
|
|
421
|
+
const event = result.value;
|
|
422
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
423
|
+
chunks.push(event.delta.text);
|
|
424
|
+
}
|
|
425
|
+
if (event.type === 'message_stop' || event.type === 'message_delta') {
|
|
426
|
+
if (event.usage) {
|
|
427
|
+
const outputText = chunks.join('');
|
|
428
|
+
writeLlmEvent({
|
|
429
|
+
kind: 'llm_call', provider: 'anthropic', model: params.model || 'unknown',
|
|
430
|
+
durationMs: round(performance.now() - startTime),
|
|
431
|
+
inputTokens: event.usage.input_tokens || 0,
|
|
432
|
+
outputTokens: event.usage.output_tokens || 0,
|
|
433
|
+
totalTokens: (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0),
|
|
434
|
+
estimatedCostUsd: estimateCost(params.model || '', event.usage.input_tokens || 0, event.usage.output_tokens || 0),
|
|
435
|
+
stream: true, finishReason: 'end_turn',
|
|
436
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
437
|
+
systemPrompt: typeof params.system === 'string' ? truncate(params.system, 200) : undefined,
|
|
438
|
+
inputPreview: extractInputPreview(params.messages),
|
|
439
|
+
outputPreview: truncate(outputText),
|
|
440
|
+
messageCount: params.messages?.length || 0,
|
|
441
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
},
|
|
448
|
+
return: iter.return?.bind(iter),
|
|
449
|
+
throw: iter.throw?.bind(iter),
|
|
450
|
+
};
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return stream;
|
|
454
|
+
}
|
|
455
|
+
function captureAnthropicResponse(params, response, startTime, debug) {
|
|
456
|
+
const usage = response.usage || {};
|
|
457
|
+
const outputText = response.content?.map((c) => c.text || '').join('') || '';
|
|
458
|
+
const event = {
|
|
459
|
+
kind: 'llm_call', provider: 'anthropic', model: response.model || params.model || 'unknown',
|
|
460
|
+
durationMs: round(performance.now() - startTime),
|
|
461
|
+
inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
|
|
462
|
+
totalTokens: (usage.input_tokens || 0) + (usage.output_tokens || 0),
|
|
463
|
+
estimatedCostUsd: estimateCost(response.model || params.model || '', usage.input_tokens || 0, usage.output_tokens || 0),
|
|
464
|
+
stream: false, finishReason: response.stop_reason || 'unknown',
|
|
465
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
466
|
+
systemPrompt: typeof params.system === 'string' ? truncate(params.system, 200) : undefined,
|
|
467
|
+
inputPreview: extractInputPreview(params.messages),
|
|
468
|
+
outputPreview: truncate(outputText),
|
|
469
|
+
messageCount: params.messages?.length || 0,
|
|
470
|
+
toolUse: hasToolUse(params) || response.content?.some((c) => c.type === 'tool_use'),
|
|
471
|
+
timestamp: Date.now(),
|
|
472
|
+
};
|
|
473
|
+
writeLlmEvent(event);
|
|
474
|
+
if (debug)
|
|
475
|
+
console.log(`[trickle/llm] Anthropic: ${event.model} (${event.totalTokens} tokens, ${event.durationMs}ms)`);
|
|
476
|
+
}
|
|
477
|
+
function captureAnthropicError(params, err, startTime, debug) {
|
|
478
|
+
writeLlmEvent({
|
|
479
|
+
kind: 'llm_call', provider: 'anthropic', model: params.model || 'unknown',
|
|
480
|
+
durationMs: round(performance.now() - startTime),
|
|
481
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCostUsd: 0,
|
|
482
|
+
stream: !!params.stream, finishReason: 'error',
|
|
483
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
484
|
+
systemPrompt: typeof params.system === 'string' ? truncate(params.system, 200) : undefined,
|
|
485
|
+
inputPreview: extractInputPreview(params.messages),
|
|
486
|
+
outputPreview: '', messageCount: params.messages?.length || 0,
|
|
487
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
488
|
+
error: truncate(err?.message || String(err), 200),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
// ────────────────────────────────────────────────────
|
|
492
|
+
// Helpers
|
|
493
|
+
// ────────────────────────────────────────────────────
|
|
494
|
+
function round(n) {
|
|
495
|
+
return Math.round(n * 100) / 100;
|
|
496
|
+
}
|
|
497
|
+
function getattr(obj, key) {
|
|
498
|
+
try {
|
|
499
|
+
return !!obj[key];
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function setattr(obj, key, val) {
|
|
506
|
+
try {
|
|
507
|
+
obj[key] = val;
|
|
508
|
+
}
|
|
509
|
+
catch { }
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Initialize the LLM observer — clears previous data file.
|
|
513
|
+
*/
|
|
514
|
+
function initLlmObserver() {
|
|
515
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
516
|
+
try {
|
|
517
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
518
|
+
}
|
|
519
|
+
catch { }
|
|
520
|
+
llmFile = path.join(dir, 'llm.jsonl');
|
|
521
|
+
try {
|
|
522
|
+
fs.writeFileSync(llmFile, '');
|
|
523
|
+
}
|
|
524
|
+
catch { }
|
|
525
|
+
eventCount = 0;
|
|
526
|
+
}
|
package/dist/observe-register.js
CHANGED
|
@@ -41,6 +41,7 @@ const fetch_observer_1 = require("./fetch-observer");
|
|
|
41
41
|
const express_1 = require("./express");
|
|
42
42
|
const trace_var_1 = require("./trace-var");
|
|
43
43
|
const call_trace_1 = require("./call-trace");
|
|
44
|
+
const llm_observer_1 = require("./llm-observer");
|
|
44
45
|
const vite_plugin_1 = require("./vite-plugin");
|
|
45
46
|
// ── Source map support ──
|
|
46
47
|
// Lightweight VLQ decoder for mapping compiled JS lines back to original TS lines
|
|
@@ -1239,6 +1240,8 @@ if (enabled) {
|
|
|
1239
1240
|
}
|
|
1240
1241
|
// ── Hook 0b2: Initialize call trace ──
|
|
1241
1242
|
(0, call_trace_1.initCallTrace)();
|
|
1243
|
+
// ── Hook 0b3: Initialize LLM observer ──
|
|
1244
|
+
(0, llm_observer_1.initLlmObserver)();
|
|
1242
1245
|
// ── Hook 0c: Capture environment snapshot ──
|
|
1243
1246
|
try {
|
|
1244
1247
|
const envDir = process.env.TRICKLE_LOCAL_DIR || path_1.default.join(process.cwd(), '.trickle');
|
|
@@ -1531,6 +1534,24 @@ if (enabled) {
|
|
|
1531
1534
|
}
|
|
1532
1535
|
catch { /* not critical */ }
|
|
1533
1536
|
}
|
|
1537
|
+
// OpenAI SDK
|
|
1538
|
+
if (request === 'openai' && !expressPatched.has('openai')) {
|
|
1539
|
+
expressPatched.add('openai');
|
|
1540
|
+
try {
|
|
1541
|
+
const { patchOpenAI } = require(path_1.default.join(__dirname, 'llm-observer.js'));
|
|
1542
|
+
patchOpenAI(exports, debug);
|
|
1543
|
+
}
|
|
1544
|
+
catch { /* not critical */ }
|
|
1545
|
+
}
|
|
1546
|
+
// Anthropic SDK
|
|
1547
|
+
if ((request === '@anthropic-ai/sdk' || request === 'anthropic') && !expressPatched.has('anthropic')) {
|
|
1548
|
+
expressPatched.add('anthropic');
|
|
1549
|
+
try {
|
|
1550
|
+
const { patchAnthropic } = require(path_1.default.join(__dirname, 'llm-observer.js'));
|
|
1551
|
+
patchAnthropic(exports, debug);
|
|
1552
|
+
}
|
|
1553
|
+
catch { /* not critical */ }
|
|
1554
|
+
}
|
|
1534
1555
|
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
1535
1556
|
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
1536
1557
|
// with './' or '/'. We need the resolved path to decide if it's user code.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
/**
|
|
7
|
+
* Unit tests for the Vite plugin transform (React component tracking).
|
|
8
|
+
*
|
|
9
|
+
* Run with: node --experimental-strip-types --test src/vite-plugin.test.ts
|
|
10
|
+
* Or after build: node --test dist/vite-plugin.test.js
|
|
11
|
+
*/
|
|
12
|
+
const node_test_1 = require("node:test");
|
|
13
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
14
|
+
const vite_plugin_js_1 = require("../dist/vite-plugin.js");
|
|
15
|
+
// Helper: transform code as if it came from a .tsx file
|
|
16
|
+
function transformTsx(code) {
|
|
17
|
+
const plugin = (0, vite_plugin_js_1.tricklePlugin)({ debug: false, traceVars: false });
|
|
18
|
+
const result = plugin.transform(code, '/test/App.tsx');
|
|
19
|
+
return result ? result.code : null;
|
|
20
|
+
}
|
|
21
|
+
function transformTs(code) {
|
|
22
|
+
const plugin = (0, vite_plugin_js_1.tricklePlugin)({ debug: false, traceVars: false });
|
|
23
|
+
const result = plugin.transform(code, '/test/util.ts');
|
|
24
|
+
return result ? result.code : null;
|
|
25
|
+
}
|
|
26
|
+
// ── React file detection ─────────────────────────────────────────────────────
|
|
27
|
+
(0, node_test_1.describe)('React file detection', () => {
|
|
28
|
+
(0, node_test_1.it)('tracks uppercase components in .tsx files', () => {
|
|
29
|
+
const code = `function UserCard(props) { return null; }`;
|
|
30
|
+
const out = transformTsx(code);
|
|
31
|
+
strict_1.default.ok(out, 'should transform');
|
|
32
|
+
strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
33
|
+
});
|
|
34
|
+
(0, node_test_1.it)('does not inject render tracker for .ts files', () => {
|
|
35
|
+
const code = `function UserCard(props) { return null; }`;
|
|
36
|
+
const out = transformTs(code);
|
|
37
|
+
// May still transform for function wrapping, but not for render tracking
|
|
38
|
+
if (out) {
|
|
39
|
+
strict_1.default.ok(!out.includes('__trickle_rc'), 'should NOT inject render tracker in .ts files');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
(0, node_test_1.it)('does not track lowercase functions as components', () => {
|
|
43
|
+
const code = `function helper(x) { return x + 1; }`;
|
|
44
|
+
const out = transformTsx(code);
|
|
45
|
+
if (out) {
|
|
46
|
+
strict_1.default.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
// ── Props capture: function declarations ─────────────────────────────────────
|
|
51
|
+
(0, node_test_1.describe)('Props capture — function declarations', () => {
|
|
52
|
+
(0, node_test_1.it)('uses arguments[0] for simple param: function Component(props)', () => {
|
|
53
|
+
const code = `function MyComponent(props) { return null; }`;
|
|
54
|
+
const out = transformTsx(code);
|
|
55
|
+
strict_1.default.ok(out, 'should transform');
|
|
56
|
+
strict_1.default.ok(out.includes('arguments[0]'), 'should pass arguments[0] as props');
|
|
57
|
+
});
|
|
58
|
+
(0, node_test_1.it)('uses arguments[0] for destructured param: function Component({ name })', () => {
|
|
59
|
+
const code = `function UserCard({ name, age }) { return null; }`;
|
|
60
|
+
const out = transformTsx(code);
|
|
61
|
+
strict_1.default.ok(out, 'should transform');
|
|
62
|
+
strict_1.default.ok(out.includes('arguments[0]'), 'should pass arguments[0] for destructured params');
|
|
63
|
+
});
|
|
64
|
+
(0, node_test_1.it)('injects __trickle_rc call at start of function body', () => {
|
|
65
|
+
const code = `function MyComponent(props) {\n const x = 1;\n return null;\n}`;
|
|
66
|
+
const out = transformTsx(code);
|
|
67
|
+
strict_1.default.ok(out, 'should transform');
|
|
68
|
+
// __trickle_rc should appear before body statements
|
|
69
|
+
const rcIdx = out.indexOf('__trickle_rc');
|
|
70
|
+
const bodyIdx = out.indexOf('const x = 1');
|
|
71
|
+
strict_1.default.ok(rcIdx !== -1, '__trickle_rc should be present');
|
|
72
|
+
strict_1.default.ok(bodyIdx !== -1, 'body code should be present');
|
|
73
|
+
strict_1.default.ok(rcIdx < bodyIdx, '__trickle_rc should come before body statements');
|
|
74
|
+
});
|
|
75
|
+
(0, node_test_1.it)('includes correct component name and line in __trickle_rc call', () => {
|
|
76
|
+
const code = `function UserCard(props) { return null; }`;
|
|
77
|
+
const out = transformTsx(code);
|
|
78
|
+
strict_1.default.ok(out, 'should transform');
|
|
79
|
+
strict_1.default.ok(out.includes('"UserCard"'), 'should include component name');
|
|
80
|
+
strict_1.default.ok(out.includes('__trickle_rc("UserCard"'), 'should call with component name');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// ── Props capture: arrow function components ──────────────────────────────────
|
|
84
|
+
(0, node_test_1.describe)('Props capture — arrow function components', () => {
|
|
85
|
+
(0, node_test_1.it)('uses single param name for simple arrow: const C = (props) => {}', () => {
|
|
86
|
+
const code = `const Dashboard = (props) => { return null; };`;
|
|
87
|
+
const out = transformTsx(code);
|
|
88
|
+
strict_1.default.ok(out, 'should transform');
|
|
89
|
+
strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
90
|
+
// props should be the param variable, not arguments[0]
|
|
91
|
+
strict_1.default.ok(out.includes('__trickle_rc("Dashboard"'), 'should use component name');
|
|
92
|
+
// should NOT use arguments[0] for arrow functions
|
|
93
|
+
const rcCall = out.match(/__trickle_rc\("Dashboard",[^)]+\)/);
|
|
94
|
+
strict_1.default.ok(rcCall, 'should have __trickle_rc call');
|
|
95
|
+
strict_1.default.ok(!rcCall[0].includes('arguments[0]'), 'arrow functions should not use arguments[0]');
|
|
96
|
+
});
|
|
97
|
+
(0, node_test_1.it)('reconstructs object for destructured arrow: const C = ({ a, b }) => {}', () => {
|
|
98
|
+
const code = `const Counter = ({ count, label }) => { return null; };`;
|
|
99
|
+
const out = transformTsx(code);
|
|
100
|
+
strict_1.default.ok(out, 'should transform');
|
|
101
|
+
strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
102
|
+
// Should reconstruct { count, label }
|
|
103
|
+
const rcCall = out.match(/__trickle_rc\("Counter",[^,]+,([^)]+)\)/);
|
|
104
|
+
if (rcCall) {
|
|
105
|
+
strict_1.default.ok(rcCall[1].includes('count') && rcCall[1].includes('label'), 'should reconstruct props object from destructured fields');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
(0, node_test_1.it)('passes undefined for no-param arrow: const C = () => {}', () => {
|
|
109
|
+
const code = `const NoProps = () => { return null; };`;
|
|
110
|
+
const out = transformTsx(code);
|
|
111
|
+
if (out && out.includes('__trickle_rc')) {
|
|
112
|
+
strict_1.default.ok(out.includes('undefined'), 'should pass undefined for no-param component');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// ── render count tracking ─────────────────────────────────────────────────────
|
|
117
|
+
(0, node_test_1.describe)('Render count tracking', () => {
|
|
118
|
+
(0, node_test_1.it)('includes react_render kind in emitted record code', () => {
|
|
119
|
+
const code = `function Card(props) { return null; }`;
|
|
120
|
+
const out = transformTsx(code);
|
|
121
|
+
strict_1.default.ok(out, 'should transform');
|
|
122
|
+
strict_1.default.ok(out.includes("'react_render'"), 'emitted record should have kind react_render');
|
|
123
|
+
});
|
|
124
|
+
(0, node_test_1.it)('includes props data in emitted record', () => {
|
|
125
|
+
const code = `function Card(props) { return null; }`;
|
|
126
|
+
const out = transformTsx(code);
|
|
127
|
+
strict_1.default.ok(out, 'should transform');
|
|
128
|
+
strict_1.default.ok(out.includes('rec.props'), 'should capture props onto the record');
|
|
129
|
+
strict_1.default.ok(out.includes('propKeys'), 'should include propKeys');
|
|
130
|
+
});
|
|
131
|
+
(0, node_test_1.it)('tracks multiple components in one file', () => {
|
|
132
|
+
const code = [
|
|
133
|
+
`function Header(props) { return null; }`,
|
|
134
|
+
`function Footer(props) { return null; }`,
|
|
135
|
+
`function helper(x) { return x; }`,
|
|
136
|
+
].join('\n');
|
|
137
|
+
const out = transformTsx(code);
|
|
138
|
+
strict_1.default.ok(out, 'should transform');
|
|
139
|
+
strict_1.default.ok(out.includes('"Header"'), 'should track Header');
|
|
140
|
+
strict_1.default.ok(out.includes('"Footer"'), 'should track Footer');
|
|
141
|
+
// helper should not be tracked as a component
|
|
142
|
+
const rcCalls = out.match(/__trickle_rc\("helper"/g);
|
|
143
|
+
strict_1.default.ok(!rcCalls, 'lowercase helper should not be tracked');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// ── findFunctionBodyBrace — destructured params don't confuse brace finding ───
|
|
147
|
+
(0, node_test_1.describe)('Correct function body brace detection', () => {
|
|
148
|
+
(0, node_test_1.it)('finds body brace even with destructured object params', () => {
|
|
149
|
+
const code = `function Form({ onSubmit, title }) {\n const x = 1;\n return null;\n}`;
|
|
150
|
+
const out = transformTsx(code);
|
|
151
|
+
strict_1.default.ok(out, 'should transform');
|
|
152
|
+
// __trickle_rc should be INSIDE the function body (before 'const x = 1')
|
|
153
|
+
const rcIdx = out.indexOf('__trickle_rc');
|
|
154
|
+
const bodyIdx = out.indexOf('const x = 1');
|
|
155
|
+
strict_1.default.ok(rcIdx < bodyIdx, 'render tracker must be inside the function body, before first statement');
|
|
156
|
+
// The wrap insertion should be AFTER the closing brace of the function
|
|
157
|
+
const wrapIdx = out.indexOf('__trickle_wrap');
|
|
158
|
+
strict_1.default.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
|
|
159
|
+
});
|
|
160
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM call observer — auto-instruments OpenAI, Anthropic, and other LLM SDKs
|
|
3
|
+
* to capture prompts, completions, token counts, latency, cost, and model metadata.
|
|
4
|
+
*
|
|
5
|
+
* Writes to .trickle/llm.jsonl as:
|
|
6
|
+
* { "kind": "llm_call", "provider": "openai", "model": "gpt-4",
|
|
7
|
+
* "inputTokens": 100, "outputTokens": 50, "durationMs": 1234.5, ... }
|
|
8
|
+
*
|
|
9
|
+
* Supports both streaming and non-streaming calls.
|
|
10
|
+
* Zero code changes needed — intercepted via Module._load hook.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
|
|
16
|
+
let llmFile: string | null = null;
|
|
17
|
+
let eventCount = 0;
|
|
18
|
+
const MAX_LLM_EVENTS = 500;
|
|
19
|
+
const TRUNCATE_LEN = 500;
|
|
20
|
+
|
|
21
|
+
// Approximate pricing per 1M tokens (USD) — used for cost estimation
|
|
22
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
23
|
+
'gpt-4o': { input: 2.5, output: 10 },
|
|
24
|
+
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
|
25
|
+
'gpt-4-turbo': { input: 10, output: 30 },
|
|
26
|
+
'gpt-4': { input: 30, output: 60 },
|
|
27
|
+
'gpt-3.5-turbo': { input: 0.5, output: 1.5 },
|
|
28
|
+
'claude-opus-4-20250514': { input: 15, output: 75 },
|
|
29
|
+
'claude-sonnet-4-20250514': { input: 3, output: 15 },
|
|
30
|
+
'claude-3-5-sonnet-20241022': { input: 3, output: 15 },
|
|
31
|
+
'claude-3-5-haiku-20241022': { input: 0.8, output: 4 },
|
|
32
|
+
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function getLlmFile(): string {
|
|
36
|
+
if (llmFile) return llmFile;
|
|
37
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
38
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
39
|
+
llmFile = path.join(dir, 'llm.jsonl');
|
|
40
|
+
return llmFile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LlmEvent {
|
|
44
|
+
kind: 'llm_call';
|
|
45
|
+
provider: string;
|
|
46
|
+
model: string;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
inputTokens: number;
|
|
49
|
+
outputTokens: number;
|
|
50
|
+
totalTokens: number;
|
|
51
|
+
estimatedCostUsd: number;
|
|
52
|
+
stream: boolean;
|
|
53
|
+
finishReason: string;
|
|
54
|
+
temperature?: number;
|
|
55
|
+
maxTokens?: number;
|
|
56
|
+
systemPrompt?: string;
|
|
57
|
+
inputPreview: string;
|
|
58
|
+
outputPreview: string;
|
|
59
|
+
messageCount: number;
|
|
60
|
+
toolUse: boolean;
|
|
61
|
+
timestamp: number;
|
|
62
|
+
error?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeLlmEvent(event: LlmEvent): void {
|
|
66
|
+
if (eventCount >= MAX_LLM_EVENTS) return;
|
|
67
|
+
eventCount++;
|
|
68
|
+
try {
|
|
69
|
+
fs.appendFileSync(getLlmFile(), JSON.stringify(event) + '\n');
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function truncate(s: string, len = TRUNCATE_LEN): string {
|
|
74
|
+
if (!s) return '';
|
|
75
|
+
return s.length > len ? s.substring(0, len) + '...' : s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function estimateCost(model: string, inputTokens: number, outputTokens: number): number {
|
|
79
|
+
// Find best matching pricing key
|
|
80
|
+
const key = Object.keys(PRICING).find(k => model.includes(k)) || '';
|
|
81
|
+
if (!key) return 0;
|
|
82
|
+
const p = PRICING[key];
|
|
83
|
+
return Math.round(((inputTokens * p.input + outputTokens * p.output) / 1_000_000) * 1_000_000) / 1_000_000;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractInputPreview(messages: any[]): string {
|
|
87
|
+
if (!Array.isArray(messages) || messages.length === 0) return '';
|
|
88
|
+
const last = messages[messages.length - 1];
|
|
89
|
+
if (typeof last?.content === 'string') return truncate(last.content);
|
|
90
|
+
if (Array.isArray(last?.content)) {
|
|
91
|
+
const textPart = last.content.find((p: any) => p.type === 'text');
|
|
92
|
+
if (textPart?.text) return truncate(textPart.text);
|
|
93
|
+
}
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractSystemPrompt(messages: any[]): string | undefined {
|
|
98
|
+
if (!Array.isArray(messages)) return undefined;
|
|
99
|
+
const sys = messages.find((m: any) => m.role === 'system');
|
|
100
|
+
if (sys?.content && typeof sys.content === 'string') return truncate(sys.content, 200);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasToolUse(params: any): boolean {
|
|
105
|
+
return !!(params.tools && Array.isArray(params.tools) && params.tools.length > 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ────────────────────────────────────────────────────
|
|
109
|
+
// OpenAI SDK v4+ instrumentation
|
|
110
|
+
// ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function patchOpenAI(openaiModule: any, debug: boolean): void {
|
|
113
|
+
if (!openaiModule || getattr(openaiModule, '_trickle_llm_patched')) return;
|
|
114
|
+
setattr(openaiModule, '_trickle_llm_patched', true);
|
|
115
|
+
|
|
116
|
+
const OpenAIClass = openaiModule.OpenAI || openaiModule.default;
|
|
117
|
+
if (typeof OpenAIClass !== 'function') return;
|
|
118
|
+
|
|
119
|
+
// OpenAI SDK v4+ creates resource instances (chat, completions) in the constructor
|
|
120
|
+
// as own properties. The Completions class is not directly exported, but we can
|
|
121
|
+
// access it by creating a temporary client and getting the prototype of chat.completions.
|
|
122
|
+
try {
|
|
123
|
+
// Create a temporary client to discover the Completions class
|
|
124
|
+
// (ES6 classes require `new`, can't use .call())
|
|
125
|
+
const tmpClient = new OpenAIClass({ apiKey: 'trickle-probe' });
|
|
126
|
+
const CompletionsClass = Object.getPrototypeOf(tmpClient.chat?.completions)?.constructor;
|
|
127
|
+
if (CompletionsClass && CompletionsClass.prototype.create && !CompletionsClass.prototype.create.__trickle_patched) {
|
|
128
|
+
const origCreate = CompletionsClass.prototype.create;
|
|
129
|
+
CompletionsClass.prototype.create = function patchedCreate(this: any, ...args: any[]) {
|
|
130
|
+
const params = args[0] || {};
|
|
131
|
+
const startTime = performance.now();
|
|
132
|
+
const isStream = !!params.stream;
|
|
133
|
+
const result = origCreate.apply(this, args);
|
|
134
|
+
if (isStream) return handleOpenAIStream(result, params, startTime, debug);
|
|
135
|
+
if (result && typeof result.then === 'function') {
|
|
136
|
+
return result.then((response: any) => {
|
|
137
|
+
captureOpenAIResponse(params, response, startTime, debug);
|
|
138
|
+
return response;
|
|
139
|
+
}).catch((err: any) => {
|
|
140
|
+
captureOpenAIError(params, err, startTime, debug);
|
|
141
|
+
throw err;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
};
|
|
146
|
+
(CompletionsClass.prototype.create as any).__trickle_patched = true;
|
|
147
|
+
if (debug) console.log('[trickle/llm] Patched OpenAI SDK');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
if (debug) console.log('[trickle/llm] OpenAI patch probe failed:', e.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function patchOpenAIClient(client: any, debug: boolean): void {
|
|
156
|
+
// Patch chat.completions.create
|
|
157
|
+
if (client.chat?.completions?.create && !client.chat.completions.create.__trickle_patched) {
|
|
158
|
+
const origCreate = client.chat.completions.create.bind(client.chat.completions);
|
|
159
|
+
client.chat.completions.create = function patchedCreate(...args: any[]) {
|
|
160
|
+
const params = args[0] || {};
|
|
161
|
+
const startTime = performance.now();
|
|
162
|
+
const isStream = !!params.stream;
|
|
163
|
+
|
|
164
|
+
const result = origCreate(...args);
|
|
165
|
+
|
|
166
|
+
if (isStream) {
|
|
167
|
+
return handleOpenAIStream(result, params, startTime, debug);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Non-streaming: hook the promise
|
|
171
|
+
if (result && typeof result.then === 'function') {
|
|
172
|
+
return result.then((response: any) => {
|
|
173
|
+
captureOpenAIResponse(params, response, startTime, debug);
|
|
174
|
+
return response;
|
|
175
|
+
}).catch((err: any) => {
|
|
176
|
+
captureOpenAIError(params, err, startTime, debug);
|
|
177
|
+
throw err;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
client.chat.completions.create.__trickle_patched = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Patch completions.create (legacy)
|
|
187
|
+
if (client.completions?.create && !client.completions.create.__trickle_patched) {
|
|
188
|
+
const origCreate = client.completions.create.bind(client.completions);
|
|
189
|
+
client.completions.create = function patchedCreate(...args: any[]) {
|
|
190
|
+
const params = args[0] || {};
|
|
191
|
+
const startTime = performance.now();
|
|
192
|
+
|
|
193
|
+
const result = origCreate(...args);
|
|
194
|
+
if (result && typeof result.then === 'function') {
|
|
195
|
+
return result.then((response: any) => {
|
|
196
|
+
const usage = response.usage || {};
|
|
197
|
+
const text = response.choices?.[0]?.text || '';
|
|
198
|
+
writeLlmEvent({
|
|
199
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
200
|
+
durationMs: round(performance.now() - startTime),
|
|
201
|
+
inputTokens: usage.prompt_tokens || 0, outputTokens: usage.completion_tokens || 0,
|
|
202
|
+
totalTokens: usage.total_tokens || 0,
|
|
203
|
+
estimatedCostUsd: estimateCost(params.model || '', usage.prompt_tokens || 0, usage.completion_tokens || 0),
|
|
204
|
+
stream: false, finishReason: response.choices?.[0]?.finish_reason || 'unknown',
|
|
205
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
206
|
+
inputPreview: truncate(params.prompt || ''), outputPreview: truncate(text),
|
|
207
|
+
messageCount: 0, toolUse: false, timestamp: Date.now(),
|
|
208
|
+
});
|
|
209
|
+
return response;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
};
|
|
214
|
+
client.completions.create.__trickle_patched = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleOpenAIStream(resultPromise: any, params: any, startTime: number, debug: boolean): Promise<any> {
|
|
219
|
+
const stream = await resultPromise;
|
|
220
|
+
const chunks: string[] = [];
|
|
221
|
+
let finishReason = 'unknown';
|
|
222
|
+
let totalInputTokens = 0;
|
|
223
|
+
let totalOutputTokens = 0;
|
|
224
|
+
|
|
225
|
+
// Wrap the async iterator
|
|
226
|
+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
|
|
227
|
+
stream[Symbol.asyncIterator] = function () {
|
|
228
|
+
const iter = origIterator();
|
|
229
|
+
return {
|
|
230
|
+
async next() {
|
|
231
|
+
const result = await iter.next();
|
|
232
|
+
if (!result.done) {
|
|
233
|
+
const chunk = result.value;
|
|
234
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
235
|
+
if (delta?.content) chunks.push(delta.content);
|
|
236
|
+
if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
|
|
237
|
+
if (chunk.usage) {
|
|
238
|
+
totalInputTokens = chunk.usage.prompt_tokens || totalInputTokens;
|
|
239
|
+
totalOutputTokens = chunk.usage.completion_tokens || totalOutputTokens;
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// Stream finished — capture
|
|
243
|
+
const outputText = chunks.join('');
|
|
244
|
+
writeLlmEvent({
|
|
245
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
246
|
+
durationMs: round(performance.now() - startTime),
|
|
247
|
+
inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
248
|
+
totalTokens: totalInputTokens + totalOutputTokens,
|
|
249
|
+
estimatedCostUsd: estimateCost(params.model || '', totalInputTokens, totalOutputTokens),
|
|
250
|
+
stream: true, finishReason,
|
|
251
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
252
|
+
systemPrompt: extractSystemPrompt(params.messages),
|
|
253
|
+
inputPreview: extractInputPreview(params.messages),
|
|
254
|
+
outputPreview: truncate(outputText),
|
|
255
|
+
messageCount: params.messages?.length || 0,
|
|
256
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
257
|
+
});
|
|
258
|
+
if (debug) console.log(`[trickle/llm] OpenAI stream: ${params.model} (${totalOutputTokens} tokens)`);
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
},
|
|
262
|
+
return: iter.return?.bind(iter),
|
|
263
|
+
throw: iter.throw?.bind(iter),
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return stream;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function captureOpenAIResponse(params: any, response: any, startTime: number, debug: boolean): void {
|
|
271
|
+
const usage = response.usage || {};
|
|
272
|
+
const outputText = response.choices?.[0]?.message?.content || '';
|
|
273
|
+
const event: LlmEvent = {
|
|
274
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
275
|
+
durationMs: round(performance.now() - startTime),
|
|
276
|
+
inputTokens: usage.prompt_tokens || 0, outputTokens: usage.completion_tokens || 0,
|
|
277
|
+
totalTokens: usage.total_tokens || 0,
|
|
278
|
+
estimatedCostUsd: estimateCost(params.model || '', usage.prompt_tokens || 0, usage.completion_tokens || 0),
|
|
279
|
+
stream: false, finishReason: response.choices?.[0]?.finish_reason || 'unknown',
|
|
280
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
281
|
+
systemPrompt: extractSystemPrompt(params.messages),
|
|
282
|
+
inputPreview: extractInputPreview(params.messages),
|
|
283
|
+
outputPreview: truncate(outputText),
|
|
284
|
+
messageCount: params.messages?.length || 0,
|
|
285
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
286
|
+
};
|
|
287
|
+
writeLlmEvent(event);
|
|
288
|
+
if (debug) console.log(`[trickle/llm] OpenAI: ${params.model} (${usage.total_tokens || 0} tokens, ${event.durationMs}ms)`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function captureOpenAIError(params: any, err: any, startTime: number, debug: boolean): void {
|
|
292
|
+
writeLlmEvent({
|
|
293
|
+
kind: 'llm_call', provider: 'openai', model: params.model || 'unknown',
|
|
294
|
+
durationMs: round(performance.now() - startTime),
|
|
295
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCostUsd: 0,
|
|
296
|
+
stream: !!params.stream, finishReason: 'error',
|
|
297
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
298
|
+
systemPrompt: extractSystemPrompt(params.messages),
|
|
299
|
+
inputPreview: extractInputPreview(params.messages),
|
|
300
|
+
outputPreview: '', messageCount: params.messages?.length || 0,
|
|
301
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
302
|
+
error: truncate(err?.message || String(err), 200),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ────────────────────────────────────────────────────
|
|
307
|
+
// Anthropic SDK instrumentation
|
|
308
|
+
// ────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export function patchAnthropic(anthropicModule: any, debug: boolean): void {
|
|
311
|
+
if (!anthropicModule || getattr(anthropicModule, '_trickle_llm_patched')) return;
|
|
312
|
+
setattr(anthropicModule, '_trickle_llm_patched', true);
|
|
313
|
+
|
|
314
|
+
const AnthropicClass = anthropicModule.Anthropic || anthropicModule.default;
|
|
315
|
+
if (typeof AnthropicClass !== 'function') return;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const tmpClient = new AnthropicClass({ apiKey: 'trickle-probe' });
|
|
319
|
+
const MessagesClass = Object.getPrototypeOf(tmpClient.messages)?.constructor;
|
|
320
|
+
if (MessagesClass && MessagesClass.prototype.create && !MessagesClass.prototype.create.__trickle_patched) {
|
|
321
|
+
const origCreate = MessagesClass.prototype.create;
|
|
322
|
+
MessagesClass.prototype.create = function patchedCreate(this: any, ...args: any[]) {
|
|
323
|
+
const params = args[0] || {};
|
|
324
|
+
const startTime = performance.now();
|
|
325
|
+
const isStream = !!params.stream;
|
|
326
|
+
const result = origCreate.apply(this, args);
|
|
327
|
+
if (result && typeof result.then === 'function') {
|
|
328
|
+
return result.then((response: any) => {
|
|
329
|
+
if (isStream) return handleAnthropicStream(response, params, startTime, debug);
|
|
330
|
+
captureAnthropicResponse(params, response, startTime, debug);
|
|
331
|
+
return response;
|
|
332
|
+
}).catch((err: any) => {
|
|
333
|
+
captureAnthropicError(params, err, startTime, debug);
|
|
334
|
+
throw err;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return result;
|
|
338
|
+
};
|
|
339
|
+
(MessagesClass.prototype.create as any).__trickle_patched = true;
|
|
340
|
+
if (debug) console.log('[trickle/llm] Patched Anthropic SDK');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
} catch (e: any) {
|
|
344
|
+
if (debug) console.log('[trickle/llm] Anthropic patch probe failed:', e.message);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function patchAnthropicClient(client: any, debug: boolean): void {
|
|
349
|
+
// Patch messages.create
|
|
350
|
+
if (client.messages?.create && !client.messages.create.__trickle_patched) {
|
|
351
|
+
const origCreate = client.messages.create.bind(client.messages);
|
|
352
|
+
client.messages.create = function patchedCreate(...args: any[]) {
|
|
353
|
+
const params = args[0] || {};
|
|
354
|
+
const startTime = performance.now();
|
|
355
|
+
const isStream = !!params.stream;
|
|
356
|
+
|
|
357
|
+
const result = origCreate(...args);
|
|
358
|
+
|
|
359
|
+
if (result && typeof result.then === 'function') {
|
|
360
|
+
return result.then((response: any) => {
|
|
361
|
+
if (isStream) {
|
|
362
|
+
return handleAnthropicStream(response, params, startTime, debug);
|
|
363
|
+
}
|
|
364
|
+
captureAnthropicResponse(params, response, startTime, debug);
|
|
365
|
+
return response;
|
|
366
|
+
}).catch((err: any) => {
|
|
367
|
+
captureAnthropicError(params, err, startTime, debug);
|
|
368
|
+
throw err;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return result;
|
|
372
|
+
};
|
|
373
|
+
client.messages.create.__trickle_patched = true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Patch messages.stream (if it exists)
|
|
377
|
+
if (client.messages?.stream && !client.messages.stream.__trickle_patched) {
|
|
378
|
+
const origStream = client.messages.stream.bind(client.messages);
|
|
379
|
+
client.messages.stream = function patchedStream(...args: any[]) {
|
|
380
|
+
const params = args[0] || {};
|
|
381
|
+
const startTime = performance.now();
|
|
382
|
+
const result = origStream(...args);
|
|
383
|
+
|
|
384
|
+
if (result && typeof result.then === 'function') {
|
|
385
|
+
return result.then((stream: any) => handleAnthropicStream(stream, params, startTime, debug));
|
|
386
|
+
}
|
|
387
|
+
return handleAnthropicStream(result, params, startTime, debug);
|
|
388
|
+
};
|
|
389
|
+
client.messages.stream.__trickle_patched = true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function handleAnthropicStream(stream: any, params: any, startTime: number, debug: boolean): any {
|
|
394
|
+
// Anthropic streams have a finalMessage() or on('message') pattern
|
|
395
|
+
// Hook into the stream events to capture the final result
|
|
396
|
+
if (stream && typeof stream.on === 'function') {
|
|
397
|
+
stream.on('finalMessage', (message: any) => {
|
|
398
|
+
captureAnthropicResponse(params, message, startTime, debug);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// Also support the async iterator pattern
|
|
402
|
+
if (stream && stream[Symbol.asyncIterator]) {
|
|
403
|
+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
|
|
404
|
+
const chunks: string[] = [];
|
|
405
|
+
stream[Symbol.asyncIterator] = function () {
|
|
406
|
+
const iter = origIterator();
|
|
407
|
+
return {
|
|
408
|
+
async next() {
|
|
409
|
+
const result = await iter.next();
|
|
410
|
+
if (!result.done) {
|
|
411
|
+
const event = result.value;
|
|
412
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
413
|
+
chunks.push(event.delta.text);
|
|
414
|
+
}
|
|
415
|
+
if (event.type === 'message_stop' || event.type === 'message_delta') {
|
|
416
|
+
if (event.usage) {
|
|
417
|
+
const outputText = chunks.join('');
|
|
418
|
+
writeLlmEvent({
|
|
419
|
+
kind: 'llm_call', provider: 'anthropic', model: params.model || 'unknown',
|
|
420
|
+
durationMs: round(performance.now() - startTime),
|
|
421
|
+
inputTokens: event.usage.input_tokens || 0,
|
|
422
|
+
outputTokens: event.usage.output_tokens || 0,
|
|
423
|
+
totalTokens: (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0),
|
|
424
|
+
estimatedCostUsd: estimateCost(params.model || '', event.usage.input_tokens || 0, event.usage.output_tokens || 0),
|
|
425
|
+
stream: true, finishReason: 'end_turn',
|
|
426
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
427
|
+
systemPrompt: typeof params.system === 'string' ? truncate(params.system, 200) : undefined,
|
|
428
|
+
inputPreview: extractInputPreview(params.messages),
|
|
429
|
+
outputPreview: truncate(outputText),
|
|
430
|
+
messageCount: params.messages?.length || 0,
|
|
431
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return result;
|
|
437
|
+
},
|
|
438
|
+
return: iter.return?.bind(iter),
|
|
439
|
+
throw: iter.throw?.bind(iter),
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return stream;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function captureAnthropicResponse(params: any, response: any, startTime: number, debug: boolean): void {
|
|
447
|
+
const usage = response.usage || {};
|
|
448
|
+
const outputText = response.content?.map((c: any) => c.text || '').join('') || '';
|
|
449
|
+
const event: LlmEvent = {
|
|
450
|
+
kind: 'llm_call', provider: 'anthropic', model: response.model || params.model || 'unknown',
|
|
451
|
+
durationMs: round(performance.now() - startTime),
|
|
452
|
+
inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
|
|
453
|
+
totalTokens: (usage.input_tokens || 0) + (usage.output_tokens || 0),
|
|
454
|
+
estimatedCostUsd: estimateCost(response.model || params.model || '', usage.input_tokens || 0, usage.output_tokens || 0),
|
|
455
|
+
stream: false, finishReason: response.stop_reason || 'unknown',
|
|
456
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
457
|
+
systemPrompt: typeof params.system === 'string' ? truncate(params.system, 200) : undefined,
|
|
458
|
+
inputPreview: extractInputPreview(params.messages),
|
|
459
|
+
outputPreview: truncate(outputText),
|
|
460
|
+
messageCount: params.messages?.length || 0,
|
|
461
|
+
toolUse: hasToolUse(params) || response.content?.some((c: any) => c.type === 'tool_use'),
|
|
462
|
+
timestamp: Date.now(),
|
|
463
|
+
};
|
|
464
|
+
writeLlmEvent(event);
|
|
465
|
+
if (debug) console.log(`[trickle/llm] Anthropic: ${event.model} (${event.totalTokens} tokens, ${event.durationMs}ms)`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function captureAnthropicError(params: any, err: any, startTime: number, debug: boolean): void {
|
|
469
|
+
writeLlmEvent({
|
|
470
|
+
kind: 'llm_call', provider: 'anthropic', model: params.model || 'unknown',
|
|
471
|
+
durationMs: round(performance.now() - startTime),
|
|
472
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCostUsd: 0,
|
|
473
|
+
stream: !!params.stream, finishReason: 'error',
|
|
474
|
+
temperature: params.temperature, maxTokens: params.max_tokens,
|
|
475
|
+
systemPrompt: typeof params.system === 'string' ? truncate(params.system, 200) : undefined,
|
|
476
|
+
inputPreview: extractInputPreview(params.messages),
|
|
477
|
+
outputPreview: '', messageCount: params.messages?.length || 0,
|
|
478
|
+
toolUse: hasToolUse(params), timestamp: Date.now(),
|
|
479
|
+
error: truncate(err?.message || String(err), 200),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ────────────────────────────────────────────────────
|
|
484
|
+
// Helpers
|
|
485
|
+
// ────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
function round(n: number): number {
|
|
488
|
+
return Math.round(n * 100) / 100;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function getattr(obj: any, key: string): boolean {
|
|
492
|
+
try { return !!obj[key]; } catch { return false; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function setattr(obj: any, key: string, val: any): void {
|
|
496
|
+
try { obj[key] = val; } catch {}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Initialize the LLM observer — clears previous data file.
|
|
501
|
+
*/
|
|
502
|
+
export function initLlmObserver(): void {
|
|
503
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
504
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
505
|
+
llmFile = path.join(dir, 'llm.jsonl');
|
|
506
|
+
try { fs.writeFileSync(llmFile, ''); } catch {}
|
|
507
|
+
eventCount = 0;
|
|
508
|
+
}
|
package/src/observe-register.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { patchFetch } from './fetch-observer';
|
|
|
38
38
|
import { instrumentExpress, trickleMiddleware } from './express';
|
|
39
39
|
import { initVarTracer, traceVar } from './trace-var';
|
|
40
40
|
import { initCallTrace } from './call-trace';
|
|
41
|
+
import { initLlmObserver } from './llm-observer';
|
|
41
42
|
import {
|
|
42
43
|
findReassignments,
|
|
43
44
|
findForLoopVars,
|
|
@@ -1227,6 +1228,9 @@ if (enabled) {
|
|
|
1227
1228
|
// ── Hook 0b2: Initialize call trace ──
|
|
1228
1229
|
initCallTrace();
|
|
1229
1230
|
|
|
1231
|
+
// ── Hook 0b3: Initialize LLM observer ──
|
|
1232
|
+
initLlmObserver();
|
|
1233
|
+
|
|
1230
1234
|
// ── Hook 0c: Capture environment snapshot ──
|
|
1231
1235
|
try {
|
|
1232
1236
|
const envDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
@@ -1515,6 +1519,24 @@ if (enabled) {
|
|
|
1515
1519
|
} catch { /* not critical */ }
|
|
1516
1520
|
}
|
|
1517
1521
|
|
|
1522
|
+
// OpenAI SDK
|
|
1523
|
+
if (request === 'openai' && !expressPatched.has('openai')) {
|
|
1524
|
+
expressPatched.add('openai');
|
|
1525
|
+
try {
|
|
1526
|
+
const { patchOpenAI } = require(path.join(__dirname, 'llm-observer.js'));
|
|
1527
|
+
patchOpenAI(exports, debug);
|
|
1528
|
+
} catch { /* not critical */ }
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Anthropic SDK
|
|
1532
|
+
if ((request === '@anthropic-ai/sdk' || request === 'anthropic') && !expressPatched.has('anthropic')) {
|
|
1533
|
+
expressPatched.add('anthropic');
|
|
1534
|
+
try {
|
|
1535
|
+
const { patchAnthropic } = require(path.join(__dirname, 'llm-observer.js'));
|
|
1536
|
+
patchAnthropic(exports, debug);
|
|
1537
|
+
} catch { /* not critical */ }
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1518
1540
|
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
1519
1541
|
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
1520
1542
|
// with './' or '/'. We need the resolved path to decide if it's user code.
|