trickle-observe 0.2.118 → 0.2.120
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/fetch-observer.js +13 -2
- package/dist/llm-observer.d.ts +1 -0
- package/dist/llm-observer.js +224 -0
- package/dist/observe-register.js +9 -0
- package/package.json +1 -1
- package/src/fetch-observer.ts +14 -2
- package/src/llm-observer.ts +217 -0
- package/src/observe-register.ts +9 -0
package/dist/fetch-observer.js
CHANGED
|
@@ -115,6 +115,18 @@ function patchFetch(environment, debugMode) {
|
|
|
115
115
|
// Mark as patched
|
|
116
116
|
globalThis.fetch.__trickle_patched = true;
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Replace literal IDs in URL paths with placeholders to avoid cardinality explosion.
|
|
120
|
+
* "/users/abc123/tasks/456" → "/users/:id/tasks/:id"
|
|
121
|
+
* "/items/550e8400-e29b-41d4-a716-446655440000" → "/items/:uuid"
|
|
122
|
+
*/
|
|
123
|
+
function normalizePath(pathname) {
|
|
124
|
+
return pathname
|
|
125
|
+
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:uuid')
|
|
126
|
+
.replace(/\/[0-9a-f]{24}(?=\/|$)/gi, '/:id')
|
|
127
|
+
.replace(/\/[0-9a-f]{8,}(?=\/|$)/gi, '/:id')
|
|
128
|
+
.replace(/\/\d+(?=\/|$)/g, '/:id');
|
|
129
|
+
}
|
|
118
130
|
/**
|
|
119
131
|
* Parse a URL into a clean function name and module name.
|
|
120
132
|
* "https://api.example.com/v1/users?limit=10"
|
|
@@ -123,14 +135,13 @@ function patchFetch(environment, debugMode) {
|
|
|
123
135
|
function parseUrl(method, rawUrl) {
|
|
124
136
|
try {
|
|
125
137
|
const parsed = new URL(rawUrl);
|
|
126
|
-
const pathname = parsed.pathname || '/';
|
|
138
|
+
const pathname = normalizePath(parsed.pathname || '/');
|
|
127
139
|
return {
|
|
128
140
|
functionName: `${method} ${pathname}`,
|
|
129
141
|
module: parsed.hostname || 'http',
|
|
130
142
|
};
|
|
131
143
|
}
|
|
132
144
|
catch {
|
|
133
|
-
// Relative URL or invalid — use as-is
|
|
134
145
|
return {
|
|
135
146
|
functionName: `${method} ${rawUrl}`,
|
|
136
147
|
module: 'http',
|
package/dist/llm-observer.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function patchOpenAI(openaiModule: any, debug: boolean): void;
|
|
13
13
|
export declare function patchAnthropic(anthropicModule: any, debug: boolean): void;
|
|
14
|
+
export declare function patchGemini(geminiModule: any, debug: boolean): void;
|
|
14
15
|
/**
|
|
15
16
|
* Initialize the LLM observer — clears previous data file.
|
|
16
17
|
*/
|
package/dist/llm-observer.js
CHANGED
|
@@ -46,6 +46,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
46
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
47
|
exports.patchOpenAI = patchOpenAI;
|
|
48
48
|
exports.patchAnthropic = patchAnthropic;
|
|
49
|
+
exports.patchGemini = patchGemini;
|
|
49
50
|
exports.initLlmObserver = initLlmObserver;
|
|
50
51
|
const fs = __importStar(require("fs"));
|
|
51
52
|
const path = __importStar(require("path"));
|
|
@@ -65,6 +66,12 @@ const PRICING = {
|
|
|
65
66
|
'claude-3-5-sonnet-20241022': { input: 3, output: 15 },
|
|
66
67
|
'claude-3-5-haiku-20241022': { input: 0.8, output: 4 },
|
|
67
68
|
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
|
|
69
|
+
'gemini-2.5-flash-lite': { input: 0.1, output: 0.4 },
|
|
70
|
+
'gemini-2.5-flash': { input: 0.3, output: 2.5 },
|
|
71
|
+
'gemini-2.5-pro': { input: 1.25, output: 10 },
|
|
72
|
+
'gemini-2.0-flash': { input: 0.1, output: 0.4 },
|
|
73
|
+
'gemini-1.5-flash': { input: 0.075, output: 0.3 },
|
|
74
|
+
'gemini-1.5-pro': { input: 1.25, output: 5 },
|
|
68
75
|
};
|
|
69
76
|
function getLlmFile() {
|
|
70
77
|
if (llmFile)
|
|
@@ -489,6 +496,223 @@ function captureAnthropicError(params, err, startTime, debug) {
|
|
|
489
496
|
});
|
|
490
497
|
}
|
|
491
498
|
// ────────────────────────────────────────────────────
|
|
499
|
+
// Google Gemini SDK instrumentation (@google/genai)
|
|
500
|
+
// ────────────────────────────────────────────────────
|
|
501
|
+
function patchGemini(geminiModule, debug) {
|
|
502
|
+
if (!geminiModule || getattr(geminiModule, '_trickle_llm_patched'))
|
|
503
|
+
return;
|
|
504
|
+
setattr(geminiModule, '_trickle_llm_patched', true);
|
|
505
|
+
// @google/genai exports GoogleGenAI class
|
|
506
|
+
// Usage: const ai = new GoogleGenAI({ apiKey }); ai.models.generateContent({...})
|
|
507
|
+
const GoogleGenAI = geminiModule.GoogleGenAI || geminiModule.default?.GoogleGenAI;
|
|
508
|
+
if (typeof GoogleGenAI !== 'function') {
|
|
509
|
+
if (debug)
|
|
510
|
+
console.log('[trickle/llm] Gemini: GoogleGenAI class not found');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
// GoogleGenAI creates models as own property in the constructor.
|
|
515
|
+
// Patch the GoogleGenAI constructor to wrap generateContent after creation.
|
|
516
|
+
const origGoogleGenAIInit = GoogleGenAI.prototype.constructor;
|
|
517
|
+
// Use a post-construction hook: after new GoogleGenAI() creates the instance
|
|
518
|
+
// with models.generateContent as an own property, wrap that method.
|
|
519
|
+
const tmpClient = new GoogleGenAI({ apiKey: 'trickle-probe' });
|
|
520
|
+
const ModelsClass = Object.getPrototypeOf(tmpClient.models)?.constructor;
|
|
521
|
+
if (ModelsClass) {
|
|
522
|
+
const origModelsInit = ModelsClass;
|
|
523
|
+
// Patch the Models constructor to wrap generateContent after instance creation
|
|
524
|
+
const origConstruct = ModelsClass.prototype.constructor;
|
|
525
|
+
// We can't replace the ES6 class constructor, so instead we use a
|
|
526
|
+
// post-construction approach: hook into GoogleGenAI's prototype to
|
|
527
|
+
// patch models on each new client instance.
|
|
528
|
+
const origGAIProto = GoogleGenAI.prototype;
|
|
529
|
+
const origInitDescriptors = Object.getOwnPropertyDescriptors(origGAIProto);
|
|
530
|
+
// Define a lazy wrapper: first time models.generateContent is called,
|
|
531
|
+
// install the instrumentation wrapper
|
|
532
|
+
function wrapModelsInstance(models) {
|
|
533
|
+
if (!models || models.__trickle_patched)
|
|
534
|
+
return;
|
|
535
|
+
models.__trickle_patched = true;
|
|
536
|
+
if (typeof models.generateContent === 'function') {
|
|
537
|
+
const origGenerate = models.generateContent.bind(models);
|
|
538
|
+
models.generateContent = function patchedGenerateContent(...args) {
|
|
539
|
+
const params = args[0] || {};
|
|
540
|
+
const startTime = performance.now();
|
|
541
|
+
const result = origGenerate(...args);
|
|
542
|
+
if (result && typeof result.then === 'function') {
|
|
543
|
+
return result.then((response) => {
|
|
544
|
+
captureGeminiResponse(params, response, startTime, false, debug);
|
|
545
|
+
return response;
|
|
546
|
+
}).catch((err) => {
|
|
547
|
+
captureGeminiError(params, err, startTime, debug);
|
|
548
|
+
throw err;
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
return result;
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (typeof models.generateContentStream === 'function') {
|
|
555
|
+
const origStream = models.generateContentStream.bind(models);
|
|
556
|
+
models.generateContentStream = function patchedStream(...args) {
|
|
557
|
+
const params = args[0] || {};
|
|
558
|
+
const startTime = performance.now();
|
|
559
|
+
const result = origStream(...args);
|
|
560
|
+
if (result && typeof result.then === 'function') {
|
|
561
|
+
return result.then((stream) => handleGeminiStream(stream, params, startTime, debug));
|
|
562
|
+
}
|
|
563
|
+
return result;
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Intercept the GoogleGenAI constructor to patch each instance's models
|
|
568
|
+
// Since we can't replace ES6 class constructors, we use a Proxy
|
|
569
|
+
const proxyHandler = {
|
|
570
|
+
construct(target, args, newTarget) {
|
|
571
|
+
const instance = Reflect.construct(target, args, newTarget);
|
|
572
|
+
if (instance.models)
|
|
573
|
+
wrapModelsInstance(instance.models);
|
|
574
|
+
return instance;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
const ProxiedGoogleGenAI = new Proxy(GoogleGenAI, proxyHandler);
|
|
578
|
+
// Replace on the module (try both export styles)
|
|
579
|
+
try {
|
|
580
|
+
geminiModule.GoogleGenAI = ProxiedGoogleGenAI;
|
|
581
|
+
}
|
|
582
|
+
catch { }
|
|
583
|
+
try {
|
|
584
|
+
if (geminiModule.default?.GoogleGenAI)
|
|
585
|
+
geminiModule.default.GoogleGenAI = ProxiedGoogleGenAI;
|
|
586
|
+
}
|
|
587
|
+
catch { }
|
|
588
|
+
// Also patch the already-created probe instance (in case someone imported before us)
|
|
589
|
+
// This is a no-op since the probe is discarded.
|
|
590
|
+
if (debug)
|
|
591
|
+
console.log('[trickle/llm] Patched Gemini SDK');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch (e) {
|
|
595
|
+
if (debug)
|
|
596
|
+
console.log('[trickle/llm] Gemini patch probe failed:', e.message);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function captureGeminiResponse(params, response, startTime, isStream, debug) {
|
|
600
|
+
const usage = response.usageMetadata || {};
|
|
601
|
+
const model = params.model || 'gemini-unknown';
|
|
602
|
+
const inputTokens = usage.promptTokenCount || 0;
|
|
603
|
+
const outputTokens = usage.candidatesTokenCount || 0;
|
|
604
|
+
const totalTokens = usage.totalTokenCount || inputTokens + outputTokens;
|
|
605
|
+
let outputText = '';
|
|
606
|
+
let finishReason = 'unknown';
|
|
607
|
+
try {
|
|
608
|
+
outputText = response.text || '';
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
const candidates = response.candidates || [];
|
|
612
|
+
if (candidates[0]?.content?.parts?.[0]?.text) {
|
|
613
|
+
outputText = candidates[0].content.parts[0].text;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const candidates = response.candidates || [];
|
|
617
|
+
if (candidates[0]?.finishReason)
|
|
618
|
+
finishReason = candidates[0].finishReason;
|
|
619
|
+
// Extract input preview from contents
|
|
620
|
+
let inputPreview = '';
|
|
621
|
+
const contents = params.contents;
|
|
622
|
+
if (typeof contents === 'string') {
|
|
623
|
+
inputPreview = truncate(contents);
|
|
624
|
+
}
|
|
625
|
+
else if (Array.isArray(contents)) {
|
|
626
|
+
const last = contents[contents.length - 1];
|
|
627
|
+
if (typeof last === 'string')
|
|
628
|
+
inputPreview = truncate(last);
|
|
629
|
+
else if (last?.parts?.[0]?.text)
|
|
630
|
+
inputPreview = truncate(last.parts[0].text);
|
|
631
|
+
}
|
|
632
|
+
const event = {
|
|
633
|
+
kind: 'llm_call', provider: 'gemini', model,
|
|
634
|
+
durationMs: round(performance.now() - startTime),
|
|
635
|
+
inputTokens, outputTokens, totalTokens,
|
|
636
|
+
estimatedCostUsd: estimateCost(model, inputTokens, outputTokens),
|
|
637
|
+
stream: isStream, finishReason,
|
|
638
|
+
temperature: params.config?.temperature,
|
|
639
|
+
maxTokens: params.config?.maxOutputTokens,
|
|
640
|
+
systemPrompt: typeof params.config?.systemInstruction === 'string'
|
|
641
|
+
? truncate(params.config.systemInstruction, 200) : undefined,
|
|
642
|
+
inputPreview, outputPreview: truncate(outputText),
|
|
643
|
+
messageCount: Array.isArray(contents) ? contents.length : (contents ? 1 : 0),
|
|
644
|
+
toolUse: !!(params.config?.tools?.length || params.tools?.length),
|
|
645
|
+
timestamp: Date.now(),
|
|
646
|
+
};
|
|
647
|
+
writeLlmEvent(event);
|
|
648
|
+
if (debug)
|
|
649
|
+
console.log(`[trickle/llm] Gemini: ${model} (${totalTokens} tokens, ${event.durationMs}ms)`);
|
|
650
|
+
}
|
|
651
|
+
function captureGeminiError(params, err, startTime, debug) {
|
|
652
|
+
const model = params.model || 'gemini-unknown';
|
|
653
|
+
writeLlmEvent({
|
|
654
|
+
kind: 'llm_call', provider: 'gemini', model,
|
|
655
|
+
durationMs: round(performance.now() - startTime),
|
|
656
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCostUsd: 0,
|
|
657
|
+
stream: false, finishReason: 'error',
|
|
658
|
+
temperature: params.config?.temperature,
|
|
659
|
+
maxTokens: params.config?.maxOutputTokens,
|
|
660
|
+
inputPreview: typeof params.contents === 'string' ? truncate(params.contents) : '',
|
|
661
|
+
outputPreview: '', messageCount: 0,
|
|
662
|
+
toolUse: false, timestamp: Date.now(),
|
|
663
|
+
error: truncate(err?.message || String(err), 200),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
async function handleGeminiStream(stream, params, startTime, debug) {
|
|
667
|
+
if (!stream || !stream[Symbol.asyncIterator])
|
|
668
|
+
return stream;
|
|
669
|
+
const chunks = [];
|
|
670
|
+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
|
|
671
|
+
let lastUsage = null;
|
|
672
|
+
stream[Symbol.asyncIterator] = function () {
|
|
673
|
+
const iter = origIterator();
|
|
674
|
+
return {
|
|
675
|
+
async next() {
|
|
676
|
+
const result = await iter.next();
|
|
677
|
+
if (!result.done) {
|
|
678
|
+
const chunk = result.value;
|
|
679
|
+
try {
|
|
680
|
+
if (chunk.text)
|
|
681
|
+
chunks.push(chunk.text);
|
|
682
|
+
}
|
|
683
|
+
catch { }
|
|
684
|
+
if (chunk.usageMetadata)
|
|
685
|
+
lastUsage = chunk.usageMetadata;
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// Stream finished
|
|
689
|
+
const model = params.model || 'gemini-unknown';
|
|
690
|
+
const inputTokens = lastUsage?.promptTokenCount || 0;
|
|
691
|
+
const outputTokens = lastUsage?.candidatesTokenCount || 0;
|
|
692
|
+
writeLlmEvent({
|
|
693
|
+
kind: 'llm_call', provider: 'gemini', model,
|
|
694
|
+
durationMs: round(performance.now() - startTime),
|
|
695
|
+
inputTokens, outputTokens, totalTokens: inputTokens + outputTokens,
|
|
696
|
+
estimatedCostUsd: estimateCost(model, inputTokens, outputTokens),
|
|
697
|
+
stream: true, finishReason: 'stop',
|
|
698
|
+
temperature: params.config?.temperature,
|
|
699
|
+
maxTokens: params.config?.maxOutputTokens,
|
|
700
|
+
inputPreview: typeof params.contents === 'string' ? truncate(params.contents) : '',
|
|
701
|
+
outputPreview: truncate(chunks.join('')),
|
|
702
|
+
messageCount: 0, toolUse: false, timestamp: Date.now(),
|
|
703
|
+
});
|
|
704
|
+
if (debug)
|
|
705
|
+
console.log(`[trickle/llm] Gemini stream: ${model} (${outputTokens} tokens)`);
|
|
706
|
+
}
|
|
707
|
+
return result;
|
|
708
|
+
},
|
|
709
|
+
return: iter.return?.bind(iter),
|
|
710
|
+
throw: iter.throw?.bind(iter),
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
return stream;
|
|
714
|
+
}
|
|
715
|
+
// ────────────────────────────────────────────────────
|
|
492
716
|
// Helpers
|
|
493
717
|
// ────────────────────────────────────────────────────
|
|
494
718
|
function round(n) {
|
package/dist/observe-register.js
CHANGED
|
@@ -1552,6 +1552,15 @@ if (enabled) {
|
|
|
1552
1552
|
}
|
|
1553
1553
|
catch { /* not critical */ }
|
|
1554
1554
|
}
|
|
1555
|
+
// Google Gemini SDK
|
|
1556
|
+
if (request === '@google/genai' && !expressPatched.has('@google/genai')) {
|
|
1557
|
+
expressPatched.add('@google/genai');
|
|
1558
|
+
try {
|
|
1559
|
+
const { patchGemini } = require(path_1.default.join(__dirname, 'llm-observer.js'));
|
|
1560
|
+
patchGemini(exports, debug);
|
|
1561
|
+
}
|
|
1562
|
+
catch { /* not critical */ }
|
|
1563
|
+
}
|
|
1555
1564
|
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
1556
1565
|
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
1557
1566
|
// with './' or '/'. We need the resolved path to decide if it's user code.
|
package/package.json
CHANGED
package/src/fetch-observer.ts
CHANGED
|
@@ -122,6 +122,19 @@ export function patchFetch(environment: string, debugMode: boolean): void {
|
|
|
122
122
|
(globalThis.fetch as any).__trickle_patched = true;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Replace literal IDs in URL paths with placeholders to avoid cardinality explosion.
|
|
127
|
+
* "/users/abc123/tasks/456" → "/users/:id/tasks/:id"
|
|
128
|
+
* "/items/550e8400-e29b-41d4-a716-446655440000" → "/items/:uuid"
|
|
129
|
+
*/
|
|
130
|
+
function normalizePath(pathname: string): string {
|
|
131
|
+
return pathname
|
|
132
|
+
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:uuid')
|
|
133
|
+
.replace(/\/[0-9a-f]{24}(?=\/|$)/gi, '/:id')
|
|
134
|
+
.replace(/\/[0-9a-f]{8,}(?=\/|$)/gi, '/:id')
|
|
135
|
+
.replace(/\/\d+(?=\/|$)/g, '/:id');
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
/**
|
|
126
139
|
* Parse a URL into a clean function name and module name.
|
|
127
140
|
* "https://api.example.com/v1/users?limit=10"
|
|
@@ -130,13 +143,12 @@ export function patchFetch(environment: string, debugMode: boolean): void {
|
|
|
130
143
|
function parseUrl(method: string, rawUrl: string): { functionName: string; module: string } {
|
|
131
144
|
try {
|
|
132
145
|
const parsed = new URL(rawUrl);
|
|
133
|
-
const pathname = parsed.pathname || '/';
|
|
146
|
+
const pathname = normalizePath(parsed.pathname || '/');
|
|
134
147
|
return {
|
|
135
148
|
functionName: `${method} ${pathname}`,
|
|
136
149
|
module: parsed.hostname || 'http',
|
|
137
150
|
};
|
|
138
151
|
} catch {
|
|
139
|
-
// Relative URL or invalid — use as-is
|
|
140
152
|
return {
|
|
141
153
|
functionName: `${method} ${rawUrl}`,
|
|
142
154
|
module: 'http',
|
package/src/llm-observer.ts
CHANGED
|
@@ -30,6 +30,12 @@ const PRICING: Record<string, { input: number; output: number }> = {
|
|
|
30
30
|
'claude-3-5-sonnet-20241022': { input: 3, output: 15 },
|
|
31
31
|
'claude-3-5-haiku-20241022': { input: 0.8, output: 4 },
|
|
32
32
|
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
|
|
33
|
+
'gemini-2.5-flash-lite': { input: 0.1, output: 0.4 },
|
|
34
|
+
'gemini-2.5-flash': { input: 0.3, output: 2.5 },
|
|
35
|
+
'gemini-2.5-pro': { input: 1.25, output: 10 },
|
|
36
|
+
'gemini-2.0-flash': { input: 0.1, output: 0.4 },
|
|
37
|
+
'gemini-1.5-flash': { input: 0.075, output: 0.3 },
|
|
38
|
+
'gemini-1.5-pro': { input: 1.25, output: 5 },
|
|
33
39
|
};
|
|
34
40
|
|
|
35
41
|
function getLlmFile(): string {
|
|
@@ -480,6 +486,217 @@ function captureAnthropicError(params: any, err: any, startTime: number, debug:
|
|
|
480
486
|
});
|
|
481
487
|
}
|
|
482
488
|
|
|
489
|
+
// ────────────────────────────────────────────────────
|
|
490
|
+
// Google Gemini SDK instrumentation (@google/genai)
|
|
491
|
+
// ────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
export function patchGemini(geminiModule: any, debug: boolean): void {
|
|
494
|
+
if (!geminiModule || getattr(geminiModule, '_trickle_llm_patched')) return;
|
|
495
|
+
setattr(geminiModule, '_trickle_llm_patched', true);
|
|
496
|
+
|
|
497
|
+
// @google/genai exports GoogleGenAI class
|
|
498
|
+
// Usage: const ai = new GoogleGenAI({ apiKey }); ai.models.generateContent({...})
|
|
499
|
+
const GoogleGenAI = geminiModule.GoogleGenAI || geminiModule.default?.GoogleGenAI;
|
|
500
|
+
if (typeof GoogleGenAI !== 'function') {
|
|
501
|
+
if (debug) console.log('[trickle/llm] Gemini: GoogleGenAI class not found');
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
// GoogleGenAI creates models as own property in the constructor.
|
|
507
|
+
// Patch the GoogleGenAI constructor to wrap generateContent after creation.
|
|
508
|
+
const origGoogleGenAIInit = GoogleGenAI.prototype.constructor;
|
|
509
|
+
|
|
510
|
+
// Use a post-construction hook: after new GoogleGenAI() creates the instance
|
|
511
|
+
// with models.generateContent as an own property, wrap that method.
|
|
512
|
+
const tmpClient = new GoogleGenAI({ apiKey: 'trickle-probe' });
|
|
513
|
+
const ModelsClass = Object.getPrototypeOf(tmpClient.models)?.constructor;
|
|
514
|
+
|
|
515
|
+
if (ModelsClass) {
|
|
516
|
+
const origModelsInit = ModelsClass;
|
|
517
|
+
// Patch the Models constructor to wrap generateContent after instance creation
|
|
518
|
+
const origConstruct = ModelsClass.prototype.constructor;
|
|
519
|
+
|
|
520
|
+
// We can't replace the ES6 class constructor, so instead we use a
|
|
521
|
+
// post-construction approach: hook into GoogleGenAI's prototype to
|
|
522
|
+
// patch models on each new client instance.
|
|
523
|
+
const origGAIProto = GoogleGenAI.prototype;
|
|
524
|
+
const origInitDescriptors = Object.getOwnPropertyDescriptors(origGAIProto);
|
|
525
|
+
|
|
526
|
+
// Define a lazy wrapper: first time models.generateContent is called,
|
|
527
|
+
// install the instrumentation wrapper
|
|
528
|
+
function wrapModelsInstance(models: any): void {
|
|
529
|
+
if (!models || models.__trickle_patched) return;
|
|
530
|
+
models.__trickle_patched = true;
|
|
531
|
+
|
|
532
|
+
if (typeof models.generateContent === 'function') {
|
|
533
|
+
const origGenerate = models.generateContent.bind(models);
|
|
534
|
+
models.generateContent = function patchedGenerateContent(...args: any[]) {
|
|
535
|
+
const params = args[0] || {};
|
|
536
|
+
const startTime = performance.now();
|
|
537
|
+
const result = origGenerate(...args);
|
|
538
|
+
if (result && typeof result.then === 'function') {
|
|
539
|
+
return result.then((response: any) => {
|
|
540
|
+
captureGeminiResponse(params, response, startTime, false, debug);
|
|
541
|
+
return response;
|
|
542
|
+
}).catch((err: any) => {
|
|
543
|
+
captureGeminiError(params, err, startTime, debug);
|
|
544
|
+
throw err;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return result;
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (typeof models.generateContentStream === 'function') {
|
|
552
|
+
const origStream = models.generateContentStream.bind(models);
|
|
553
|
+
models.generateContentStream = function patchedStream(...args: any[]) {
|
|
554
|
+
const params = args[0] || {};
|
|
555
|
+
const startTime = performance.now();
|
|
556
|
+
const result = origStream(...args);
|
|
557
|
+
if (result && typeof result.then === 'function') {
|
|
558
|
+
return result.then((stream: any) => handleGeminiStream(stream, params, startTime, debug));
|
|
559
|
+
}
|
|
560
|
+
return result;
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Intercept the GoogleGenAI constructor to patch each instance's models
|
|
566
|
+
// Since we can't replace ES6 class constructors, we use a Proxy
|
|
567
|
+
const proxyHandler: ProxyHandler<any> = {
|
|
568
|
+
construct(target: any, args: any[], newTarget: any): object {
|
|
569
|
+
const instance = Reflect.construct(target, args, newTarget) as any;
|
|
570
|
+
if (instance.models) wrapModelsInstance(instance.models);
|
|
571
|
+
return instance as object;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
const ProxiedGoogleGenAI = new Proxy(GoogleGenAI, proxyHandler);
|
|
575
|
+
|
|
576
|
+
// Replace on the module (try both export styles)
|
|
577
|
+
try { geminiModule.GoogleGenAI = ProxiedGoogleGenAI; } catch {}
|
|
578
|
+
try { if (geminiModule.default?.GoogleGenAI) geminiModule.default.GoogleGenAI = ProxiedGoogleGenAI; } catch {}
|
|
579
|
+
|
|
580
|
+
// Also patch the already-created probe instance (in case someone imported before us)
|
|
581
|
+
// This is a no-op since the probe is discarded.
|
|
582
|
+
|
|
583
|
+
if (debug) console.log('[trickle/llm] Patched Gemini SDK');
|
|
584
|
+
}
|
|
585
|
+
} catch (e: any) {
|
|
586
|
+
if (debug) console.log('[trickle/llm] Gemini patch probe failed:', e.message);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function captureGeminiResponse(params: any, response: any, startTime: number, isStream: boolean, debug: boolean): void {
|
|
591
|
+
const usage = response.usageMetadata || {};
|
|
592
|
+
const model = params.model || 'gemini-unknown';
|
|
593
|
+
const inputTokens = usage.promptTokenCount || 0;
|
|
594
|
+
const outputTokens = usage.candidatesTokenCount || 0;
|
|
595
|
+
const totalTokens = usage.totalTokenCount || inputTokens + outputTokens;
|
|
596
|
+
|
|
597
|
+
let outputText = '';
|
|
598
|
+
let finishReason = 'unknown';
|
|
599
|
+
try {
|
|
600
|
+
outputText = response.text || '';
|
|
601
|
+
} catch {
|
|
602
|
+
const candidates = response.candidates || [];
|
|
603
|
+
if (candidates[0]?.content?.parts?.[0]?.text) {
|
|
604
|
+
outputText = candidates[0].content.parts[0].text;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const candidates = response.candidates || [];
|
|
608
|
+
if (candidates[0]?.finishReason) finishReason = candidates[0].finishReason;
|
|
609
|
+
|
|
610
|
+
// Extract input preview from contents
|
|
611
|
+
let inputPreview = '';
|
|
612
|
+
const contents = params.contents;
|
|
613
|
+
if (typeof contents === 'string') {
|
|
614
|
+
inputPreview = truncate(contents);
|
|
615
|
+
} else if (Array.isArray(contents)) {
|
|
616
|
+
const last = contents[contents.length - 1];
|
|
617
|
+
if (typeof last === 'string') inputPreview = truncate(last);
|
|
618
|
+
else if (last?.parts?.[0]?.text) inputPreview = truncate(last.parts[0].text);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const event: LlmEvent = {
|
|
622
|
+
kind: 'llm_call', provider: 'gemini', model,
|
|
623
|
+
durationMs: round(performance.now() - startTime),
|
|
624
|
+
inputTokens, outputTokens, totalTokens,
|
|
625
|
+
estimatedCostUsd: estimateCost(model, inputTokens, outputTokens),
|
|
626
|
+
stream: isStream, finishReason,
|
|
627
|
+
temperature: params.config?.temperature,
|
|
628
|
+
maxTokens: params.config?.maxOutputTokens,
|
|
629
|
+
systemPrompt: typeof params.config?.systemInstruction === 'string'
|
|
630
|
+
? truncate(params.config.systemInstruction, 200) : undefined,
|
|
631
|
+
inputPreview, outputPreview: truncate(outputText),
|
|
632
|
+
messageCount: Array.isArray(contents) ? contents.length : (contents ? 1 : 0),
|
|
633
|
+
toolUse: !!(params.config?.tools?.length || params.tools?.length),
|
|
634
|
+
timestamp: Date.now(),
|
|
635
|
+
};
|
|
636
|
+
writeLlmEvent(event);
|
|
637
|
+
if (debug) console.log(`[trickle/llm] Gemini: ${model} (${totalTokens} tokens, ${event.durationMs}ms)`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function captureGeminiError(params: any, err: any, startTime: number, debug: boolean): void {
|
|
641
|
+
const model = params.model || 'gemini-unknown';
|
|
642
|
+
writeLlmEvent({
|
|
643
|
+
kind: 'llm_call', provider: 'gemini', model,
|
|
644
|
+
durationMs: round(performance.now() - startTime),
|
|
645
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCostUsd: 0,
|
|
646
|
+
stream: false, finishReason: 'error',
|
|
647
|
+
temperature: params.config?.temperature,
|
|
648
|
+
maxTokens: params.config?.maxOutputTokens,
|
|
649
|
+
inputPreview: typeof params.contents === 'string' ? truncate(params.contents) : '',
|
|
650
|
+
outputPreview: '', messageCount: 0,
|
|
651
|
+
toolUse: false, timestamp: Date.now(),
|
|
652
|
+
error: truncate(err?.message || String(err), 200),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function handleGeminiStream(stream: any, params: any, startTime: number, debug: boolean): Promise<any> {
|
|
657
|
+
if (!stream || !stream[Symbol.asyncIterator]) return stream;
|
|
658
|
+
|
|
659
|
+
const chunks: string[] = [];
|
|
660
|
+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
|
|
661
|
+
let lastUsage: any = null;
|
|
662
|
+
|
|
663
|
+
stream[Symbol.asyncIterator] = function () {
|
|
664
|
+
const iter = origIterator();
|
|
665
|
+
return {
|
|
666
|
+
async next() {
|
|
667
|
+
const result = await iter.next();
|
|
668
|
+
if (!result.done) {
|
|
669
|
+
const chunk = result.value;
|
|
670
|
+
try { if (chunk.text) chunks.push(chunk.text); } catch {}
|
|
671
|
+
if (chunk.usageMetadata) lastUsage = chunk.usageMetadata;
|
|
672
|
+
} else {
|
|
673
|
+
// Stream finished
|
|
674
|
+
const model = params.model || 'gemini-unknown';
|
|
675
|
+
const inputTokens = lastUsage?.promptTokenCount || 0;
|
|
676
|
+
const outputTokens = lastUsage?.candidatesTokenCount || 0;
|
|
677
|
+
writeLlmEvent({
|
|
678
|
+
kind: 'llm_call', provider: 'gemini', model,
|
|
679
|
+
durationMs: round(performance.now() - startTime),
|
|
680
|
+
inputTokens, outputTokens, totalTokens: inputTokens + outputTokens,
|
|
681
|
+
estimatedCostUsd: estimateCost(model, inputTokens, outputTokens),
|
|
682
|
+
stream: true, finishReason: 'stop',
|
|
683
|
+
temperature: params.config?.temperature,
|
|
684
|
+
maxTokens: params.config?.maxOutputTokens,
|
|
685
|
+
inputPreview: typeof params.contents === 'string' ? truncate(params.contents) : '',
|
|
686
|
+
outputPreview: truncate(chunks.join('')),
|
|
687
|
+
messageCount: 0, toolUse: false, timestamp: Date.now(),
|
|
688
|
+
});
|
|
689
|
+
if (debug) console.log(`[trickle/llm] Gemini stream: ${model} (${outputTokens} tokens)`);
|
|
690
|
+
}
|
|
691
|
+
return result;
|
|
692
|
+
},
|
|
693
|
+
return: iter.return?.bind(iter),
|
|
694
|
+
throw: iter.throw?.bind(iter),
|
|
695
|
+
};
|
|
696
|
+
};
|
|
697
|
+
return stream;
|
|
698
|
+
}
|
|
699
|
+
|
|
483
700
|
// ────────────────────────────────────────────────────
|
|
484
701
|
// Helpers
|
|
485
702
|
// ────────────────────────────────────────────────────
|
package/src/observe-register.ts
CHANGED
|
@@ -1537,6 +1537,15 @@ if (enabled) {
|
|
|
1537
1537
|
} catch { /* not critical */ }
|
|
1538
1538
|
}
|
|
1539
1539
|
|
|
1540
|
+
// Google Gemini SDK
|
|
1541
|
+
if (request === '@google/genai' && !expressPatched.has('@google/genai')) {
|
|
1542
|
+
expressPatched.add('@google/genai');
|
|
1543
|
+
try {
|
|
1544
|
+
const { patchGemini } = require(path.join(__dirname, 'llm-observer.js'));
|
|
1545
|
+
patchGemini(exports, debug);
|
|
1546
|
+
} catch { /* not critical */ }
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1540
1549
|
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
1541
1550
|
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
1542
1551
|
// with './' or '/'. We need the resolved path to decide if it's user code.
|