scope-analytics 0.1.0__py3-none-any.whl
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.
- scope_analytics/__init__.py +244 -0
- scope_analytics/auto.py +335 -0
- scope_analytics/cli.py +240 -0
- scope_analytics/client.py +121 -0
- scope_analytics/config.py +101 -0
- scope_analytics/context.py +173 -0
- scope_analytics/events.py +275 -0
- scope_analytics/middleware.py +669 -0
- scope_analytics/patches/__init__.py +9 -0
- scope_analytics/patches/anthropic_patch.py +430 -0
- scope_analytics/patches/gemini_patch.py +422 -0
- scope_analytics/patches/openai_patch.py +483 -0
- scope_analytics/queue.py +158 -0
- scope_analytics-0.1.0.dist-info/METADATA +253 -0
- scope_analytics-0.1.0.dist-info/RECORD +19 -0
- scope_analytics-0.1.0.dist-info/WHEEL +5 -0
- scope_analytics-0.1.0.dist-info/entry_points.txt +2 -0
- scope_analytics-0.1.0.dist-info/licenses/LICENSE +21 -0
- scope_analytics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Gemini (Generative AI) library patching for Scope Analytics
|
|
3
|
+
Supports google-generativeai library
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import functools
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
|
|
10
|
+
from ..context import ScopeContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeminiPatcher:
|
|
14
|
+
"""
|
|
15
|
+
Patches Google Generative AI library to automatically capture LLM calls
|
|
16
|
+
|
|
17
|
+
Supports:
|
|
18
|
+
- GenerativeModel.generate_content()
|
|
19
|
+
- ChatSession.send_message()
|
|
20
|
+
- Sync and async calls
|
|
21
|
+
- Streaming responses
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, scope_sdk):
|
|
25
|
+
"""
|
|
26
|
+
Initialize Gemini patcher
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
scope_sdk: Reference to main ScopeAnalytics instance
|
|
30
|
+
"""
|
|
31
|
+
self.scope_sdk = scope_sdk
|
|
32
|
+
self.config = scope_sdk.config
|
|
33
|
+
self.event_formatter = scope_sdk.event_formatter
|
|
34
|
+
self.queue = scope_sdk.queue
|
|
35
|
+
|
|
36
|
+
self.original_methods = {}
|
|
37
|
+
self.is_patched = False
|
|
38
|
+
self.genai_version = None
|
|
39
|
+
|
|
40
|
+
def patch(self) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Apply Gemini patches
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if patching succeeded, False otherwise
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
import google.generativeai as genai
|
|
49
|
+
|
|
50
|
+
# Detect version
|
|
51
|
+
self.genai_version = self._detect_version(genai)
|
|
52
|
+
self.config.log(f"Detected Google Generative AI version: {self.genai_version}")
|
|
53
|
+
|
|
54
|
+
# Patch GenerativeModel
|
|
55
|
+
if hasattr(genai, 'GenerativeModel'):
|
|
56
|
+
self._patch_generative_model(genai.GenerativeModel)
|
|
57
|
+
|
|
58
|
+
self.is_patched = True
|
|
59
|
+
self.config.log("✅ Gemini patching successful")
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
except ImportError:
|
|
63
|
+
self.config.log("Google Generative AI library not installed - skipping patch")
|
|
64
|
+
return False
|
|
65
|
+
except Exception as e:
|
|
66
|
+
self.config.log(f"⚠️ Failed to patch Gemini: {e}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def unpatch(self):
|
|
70
|
+
"""Remove Gemini patches"""
|
|
71
|
+
if not self.is_patched:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
import google.generativeai as genai
|
|
76
|
+
|
|
77
|
+
# Restore original methods
|
|
78
|
+
for key, original in self.original_methods.items():
|
|
79
|
+
if key == 'GenerativeModel.generate_content':
|
|
80
|
+
genai.GenerativeModel.generate_content = original
|
|
81
|
+
elif key == 'GenerativeModel.generate_content_async':
|
|
82
|
+
genai.GenerativeModel.generate_content_async = original
|
|
83
|
+
|
|
84
|
+
self.is_patched = False
|
|
85
|
+
self.config.log("Gemini patches removed")
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
self.config.log(f"⚠️ Failed to unpatch Gemini: {e}")
|
|
89
|
+
|
|
90
|
+
def _detect_version(self, genai) -> str:
|
|
91
|
+
"""Detect Google Generative AI library version"""
|
|
92
|
+
try:
|
|
93
|
+
import google.generativeai as genai_module
|
|
94
|
+
if hasattr(genai_module, '__version__'):
|
|
95
|
+
return genai_module.__version__
|
|
96
|
+
except:
|
|
97
|
+
pass
|
|
98
|
+
return "unknown"
|
|
99
|
+
|
|
100
|
+
def _patch_generative_model(self, model_class):
|
|
101
|
+
"""Patch GenerativeModel class"""
|
|
102
|
+
|
|
103
|
+
# Patch generate_content (sync)
|
|
104
|
+
if hasattr(model_class, 'generate_content'):
|
|
105
|
+
original = model_class.generate_content
|
|
106
|
+
self.original_methods['GenerativeModel.generate_content'] = original
|
|
107
|
+
model_class.generate_content = self._wrap_generate_content(original)
|
|
108
|
+
|
|
109
|
+
# Patch generate_content_async (async)
|
|
110
|
+
if hasattr(model_class, 'generate_content_async'):
|
|
111
|
+
original = model_class.generate_content_async
|
|
112
|
+
self.original_methods['GenerativeModel.generate_content_async'] = original
|
|
113
|
+
model_class.generate_content_async = self._wrap_generate_content_async(original)
|
|
114
|
+
|
|
115
|
+
# Patch start_chat to wrap ChatSession
|
|
116
|
+
if hasattr(model_class, 'start_chat'):
|
|
117
|
+
original_start_chat = model_class.start_chat
|
|
118
|
+
self.original_methods['GenerativeModel.start_chat'] = original_start_chat
|
|
119
|
+
model_class.start_chat = self._wrap_start_chat(original_start_chat)
|
|
120
|
+
|
|
121
|
+
def _wrap_generate_content(self, original_func):
|
|
122
|
+
"""Wrap sync generate_content method"""
|
|
123
|
+
patcher = self
|
|
124
|
+
|
|
125
|
+
@functools.wraps(original_func)
|
|
126
|
+
def wrapper(self_model, *args, **kwargs):
|
|
127
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
128
|
+
if ScopeContext.is_in_scope_context():
|
|
129
|
+
return original_func(self_model, *args, **kwargs)
|
|
130
|
+
|
|
131
|
+
start_time = time.time()
|
|
132
|
+
|
|
133
|
+
# Extract prompt from args or kwargs
|
|
134
|
+
prompt = args[0] if args else kwargs.get('contents', '')
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Check for streaming
|
|
138
|
+
if kwargs.get('stream', False):
|
|
139
|
+
response = original_func(self_model, *args, **kwargs)
|
|
140
|
+
return patcher._wrap_streaming_response(response, start_time, self_model, prompt)
|
|
141
|
+
else:
|
|
142
|
+
response = original_func(self_model, *args, **kwargs)
|
|
143
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
144
|
+
patcher._capture_response(response, self_model, prompt, latency_ms)
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
149
|
+
patcher._capture_error(self_model, prompt, str(e), latency_ms)
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
return wrapper
|
|
153
|
+
|
|
154
|
+
def _wrap_generate_content_async(self, original_func):
|
|
155
|
+
"""Wrap async generate_content method"""
|
|
156
|
+
patcher = self
|
|
157
|
+
|
|
158
|
+
@functools.wraps(original_func)
|
|
159
|
+
async def wrapper(self_model, *args, **kwargs):
|
|
160
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
161
|
+
if ScopeContext.is_in_scope_context():
|
|
162
|
+
return await original_func(self_model, *args, **kwargs)
|
|
163
|
+
|
|
164
|
+
start_time = time.time()
|
|
165
|
+
|
|
166
|
+
prompt = args[0] if args else kwargs.get('contents', '')
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
if kwargs.get('stream', False):
|
|
170
|
+
response = await original_func(self_model, *args, **kwargs)
|
|
171
|
+
return patcher._wrap_async_streaming_response(response, start_time, self_model, prompt)
|
|
172
|
+
else:
|
|
173
|
+
response = await original_func(self_model, *args, **kwargs)
|
|
174
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
175
|
+
patcher._capture_response(response, self_model, prompt, latency_ms)
|
|
176
|
+
return response
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
180
|
+
patcher._capture_error(self_model, prompt, str(e), latency_ms)
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
return wrapper
|
|
184
|
+
|
|
185
|
+
def _wrap_start_chat(self, original_func):
|
|
186
|
+
"""Wrap start_chat to return wrapped ChatSession"""
|
|
187
|
+
patcher = self
|
|
188
|
+
|
|
189
|
+
@functools.wraps(original_func)
|
|
190
|
+
def wrapper(self_model, *args, **kwargs):
|
|
191
|
+
chat = original_func(self_model, *args, **kwargs)
|
|
192
|
+
|
|
193
|
+
# Wrap send_message
|
|
194
|
+
if hasattr(chat, 'send_message'):
|
|
195
|
+
original_send = chat.send_message
|
|
196
|
+
chat.send_message = patcher._wrap_send_message(original_send, self_model)
|
|
197
|
+
|
|
198
|
+
# Wrap send_message_async
|
|
199
|
+
if hasattr(chat, 'send_message_async'):
|
|
200
|
+
original_send_async = chat.send_message_async
|
|
201
|
+
chat.send_message_async = patcher._wrap_send_message_async(original_send_async, self_model)
|
|
202
|
+
|
|
203
|
+
return chat
|
|
204
|
+
|
|
205
|
+
return wrapper
|
|
206
|
+
|
|
207
|
+
def _wrap_send_message(self, original_func, model):
|
|
208
|
+
"""Wrap ChatSession.send_message"""
|
|
209
|
+
patcher = self
|
|
210
|
+
|
|
211
|
+
@functools.wraps(original_func)
|
|
212
|
+
def wrapper(*args, **kwargs):
|
|
213
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
214
|
+
if ScopeContext.is_in_scope_context():
|
|
215
|
+
return original_func(*args, **kwargs)
|
|
216
|
+
|
|
217
|
+
start_time = time.time()
|
|
218
|
+
|
|
219
|
+
prompt = args[0] if args else kwargs.get('content', '')
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
if kwargs.get('stream', False):
|
|
223
|
+
response = original_func(*args, **kwargs)
|
|
224
|
+
return patcher._wrap_streaming_response(response, start_time, model, prompt)
|
|
225
|
+
else:
|
|
226
|
+
response = original_func(*args, **kwargs)
|
|
227
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
228
|
+
patcher._capture_response(response, model, prompt, latency_ms)
|
|
229
|
+
return response
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
233
|
+
patcher._capture_error(model, prompt, str(e), latency_ms)
|
|
234
|
+
raise
|
|
235
|
+
|
|
236
|
+
return wrapper
|
|
237
|
+
|
|
238
|
+
def _wrap_send_message_async(self, original_func, model):
|
|
239
|
+
"""Wrap ChatSession.send_message_async"""
|
|
240
|
+
patcher = self
|
|
241
|
+
|
|
242
|
+
@functools.wraps(original_func)
|
|
243
|
+
async def wrapper(*args, **kwargs):
|
|
244
|
+
# RECURSION GUARD: Skip capture if inside Scope internal code
|
|
245
|
+
if ScopeContext.is_in_scope_context():
|
|
246
|
+
return await original_func(*args, **kwargs)
|
|
247
|
+
|
|
248
|
+
start_time = time.time()
|
|
249
|
+
|
|
250
|
+
prompt = args[0] if args else kwargs.get('content', '')
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
if kwargs.get('stream', False):
|
|
254
|
+
response = await original_func(*args, **kwargs)
|
|
255
|
+
return patcher._wrap_async_streaming_response(response, start_time, model, prompt)
|
|
256
|
+
else:
|
|
257
|
+
response = await original_func(*args, **kwargs)
|
|
258
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
259
|
+
patcher._capture_response(response, model, prompt, latency_ms)
|
|
260
|
+
return response
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
264
|
+
patcher._capture_error(model, prompt, str(e), latency_ms)
|
|
265
|
+
raise
|
|
266
|
+
|
|
267
|
+
return wrapper
|
|
268
|
+
|
|
269
|
+
def _wrap_streaming_response(self, response, start_time, model, prompt):
|
|
270
|
+
"""Wrap sync streaming response"""
|
|
271
|
+
chunks = []
|
|
272
|
+
|
|
273
|
+
for chunk in response:
|
|
274
|
+
chunks.append(chunk)
|
|
275
|
+
yield chunk
|
|
276
|
+
|
|
277
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
278
|
+
self._capture_streaming_response(chunks, model, prompt, latency_ms)
|
|
279
|
+
|
|
280
|
+
async def _wrap_async_streaming_response(self, response, start_time, model, prompt):
|
|
281
|
+
"""Wrap async streaming response"""
|
|
282
|
+
chunks = []
|
|
283
|
+
|
|
284
|
+
async for chunk in response:
|
|
285
|
+
chunks.append(chunk)
|
|
286
|
+
yield chunk
|
|
287
|
+
|
|
288
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
289
|
+
self._capture_streaming_response(chunks, model, prompt, latency_ms)
|
|
290
|
+
|
|
291
|
+
def _capture_response(self, response, model, prompt, latency_ms):
|
|
292
|
+
"""Capture non-streaming Gemini response"""
|
|
293
|
+
try:
|
|
294
|
+
# Extract model name
|
|
295
|
+
model_name = getattr(model, 'model_name', 'gemini')
|
|
296
|
+
if model_name.startswith('models/'):
|
|
297
|
+
model_name = model_name[7:] # Remove 'models/' prefix
|
|
298
|
+
|
|
299
|
+
# Format prompt as messages
|
|
300
|
+
messages = self._format_prompt_as_messages(prompt)
|
|
301
|
+
|
|
302
|
+
# Extract response text
|
|
303
|
+
response_text = ""
|
|
304
|
+
if hasattr(response, 'text'):
|
|
305
|
+
response_text = response.text
|
|
306
|
+
elif hasattr(response, 'candidates') and len(response.candidates) > 0:
|
|
307
|
+
candidate = response.candidates[0]
|
|
308
|
+
if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
|
|
309
|
+
for part in candidate.content.parts:
|
|
310
|
+
if hasattr(part, 'text'):
|
|
311
|
+
response_text += part.text
|
|
312
|
+
|
|
313
|
+
# Extract token usage
|
|
314
|
+
tokens = {}
|
|
315
|
+
if hasattr(response, 'usage_metadata'):
|
|
316
|
+
usage = response.usage_metadata
|
|
317
|
+
tokens = {
|
|
318
|
+
'prompt_tokens': getattr(usage, 'prompt_token_count', 0),
|
|
319
|
+
'completion_tokens': getattr(usage, 'candidates_token_count', 0),
|
|
320
|
+
'total_tokens': getattr(usage, 'total_token_count', 0),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# Format and queue event
|
|
324
|
+
event = self.event_formatter.format_llm_call(
|
|
325
|
+
provider='google',
|
|
326
|
+
model=model_name,
|
|
327
|
+
messages=messages,
|
|
328
|
+
response=response_text,
|
|
329
|
+
tokens=tokens,
|
|
330
|
+
latency_ms=latency_ms,
|
|
331
|
+
error=None,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
self.queue.enqueue(event)
|
|
335
|
+
self.config.log(f"Captured Gemini call: {model_name}")
|
|
336
|
+
|
|
337
|
+
except Exception as e:
|
|
338
|
+
self.config.log(f"⚠️ Failed to capture Gemini response: {e}")
|
|
339
|
+
|
|
340
|
+
def _capture_streaming_response(self, chunks, model, prompt, latency_ms):
|
|
341
|
+
"""Capture streaming Gemini response"""
|
|
342
|
+
try:
|
|
343
|
+
model_name = getattr(model, 'model_name', 'gemini')
|
|
344
|
+
if model_name.startswith('models/'):
|
|
345
|
+
model_name = model_name[7:]
|
|
346
|
+
|
|
347
|
+
messages = self._format_prompt_as_messages(prompt)
|
|
348
|
+
|
|
349
|
+
# Combine chunks
|
|
350
|
+
response_text = ""
|
|
351
|
+
for chunk in chunks:
|
|
352
|
+
if hasattr(chunk, 'text'):
|
|
353
|
+
response_text += chunk.text
|
|
354
|
+
elif hasattr(chunk, 'candidates') and len(chunk.candidates) > 0:
|
|
355
|
+
candidate = chunk.candidates[0]
|
|
356
|
+
if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
|
|
357
|
+
for part in candidate.content.parts:
|
|
358
|
+
if hasattr(part, 'text'):
|
|
359
|
+
response_text += part.text
|
|
360
|
+
|
|
361
|
+
# Format and queue event
|
|
362
|
+
event = self.event_formatter.format_llm_call(
|
|
363
|
+
provider='google',
|
|
364
|
+
model=model_name,
|
|
365
|
+
messages=messages,
|
|
366
|
+
response=response_text,
|
|
367
|
+
tokens={},
|
|
368
|
+
latency_ms=latency_ms,
|
|
369
|
+
error=None,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
self.queue.enqueue(event)
|
|
373
|
+
self.config.log(f"Captured Gemini streaming call: {model_name}")
|
|
374
|
+
|
|
375
|
+
except Exception as e:
|
|
376
|
+
self.config.log(f"⚠️ Failed to capture Gemini streaming response: {e}")
|
|
377
|
+
|
|
378
|
+
def _capture_error(self, model, prompt, error_message, latency_ms):
|
|
379
|
+
"""Capture failed LLM call"""
|
|
380
|
+
try:
|
|
381
|
+
model_name = getattr(model, 'model_name', 'gemini')
|
|
382
|
+
if model_name.startswith('models/'):
|
|
383
|
+
model_name = model_name[7:]
|
|
384
|
+
|
|
385
|
+
messages = self._format_prompt_as_messages(prompt)
|
|
386
|
+
|
|
387
|
+
event = self.event_formatter.format_llm_call(
|
|
388
|
+
provider='google',
|
|
389
|
+
model=model_name,
|
|
390
|
+
messages=messages,
|
|
391
|
+
response="",
|
|
392
|
+
tokens={},
|
|
393
|
+
latency_ms=latency_ms,
|
|
394
|
+
error=error_message,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
self.queue.enqueue(event)
|
|
398
|
+
self.config.log(f"Captured Gemini error: {error_message}")
|
|
399
|
+
|
|
400
|
+
except Exception as e:
|
|
401
|
+
self.config.log(f"⚠️ Failed to capture Gemini error: {e}")
|
|
402
|
+
|
|
403
|
+
def _format_prompt_as_messages(self, prompt) -> list:
|
|
404
|
+
"""Format prompt into messages format for consistency"""
|
|
405
|
+
if isinstance(prompt, str):
|
|
406
|
+
return [{"role": "user", "content": prompt}]
|
|
407
|
+
elif isinstance(prompt, list):
|
|
408
|
+
# Handle list of content parts
|
|
409
|
+
messages = []
|
|
410
|
+
for item in prompt:
|
|
411
|
+
if isinstance(item, str):
|
|
412
|
+
messages.append({"role": "user", "content": item})
|
|
413
|
+
elif hasattr(item, 'text'):
|
|
414
|
+
messages.append({"role": "user", "content": item.text})
|
|
415
|
+
elif isinstance(item, dict):
|
|
416
|
+
messages.append(item)
|
|
417
|
+
return messages
|
|
418
|
+
else:
|
|
419
|
+
# Try to extract text
|
|
420
|
+
if hasattr(prompt, 'text'):
|
|
421
|
+
return [{"role": "user", "content": prompt.text}]
|
|
422
|
+
return [{"role": "user", "content": str(prompt)}]
|