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.
@@ -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)}]