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,483 @@
1
+ """
2
+ OpenAI library patching for Scope Analytics
3
+ Supports multiple OpenAI versions and usage patterns
4
+ """
5
+
6
+ import time
7
+ import functools
8
+ from typing import Optional, Any, Iterator
9
+ import sys
10
+
11
+ from ..context import ScopeContext
12
+
13
+
14
+ class OpenAIPatcher:
15
+ """
16
+ Patches OpenAI library to automatically capture LLM calls
17
+
18
+ Supports:
19
+ - OpenAI v0.x (old API: openai.ChatCompletion.create)
20
+ - OpenAI v1.0+ (new API: client.chat.completions.create)
21
+ - Sync and async calls
22
+ - Streaming and non-streaming responses
23
+ """
24
+
25
+ def __init__(self, scope_sdk):
26
+ """
27
+ Initialize OpenAI patcher
28
+
29
+ Args:
30
+ scope_sdk: Reference to main ScopeAnalytics instance
31
+ """
32
+ self.scope_sdk = scope_sdk
33
+ self.config = scope_sdk.config
34
+ self.event_formatter = scope_sdk.event_formatter
35
+ self.queue = scope_sdk.queue
36
+
37
+ self.original_methods = {}
38
+ self.is_patched = False
39
+ self.openai_version = None
40
+
41
+ def patch(self) -> bool:
42
+ """
43
+ Apply OpenAI patches
44
+
45
+ Returns:
46
+ True if patching succeeded, False otherwise
47
+ """
48
+ try:
49
+ import openai
50
+
51
+ # Detect OpenAI version
52
+ self.openai_version = self._detect_version(openai)
53
+ self.config.log(f"Detected OpenAI version: {self.openai_version}")
54
+
55
+ if self.openai_version.startswith("1."):
56
+ # Patch new API (v1.0+)
57
+ self._patch_v1(openai)
58
+ else:
59
+ # Patch old API (v0.x)
60
+ self._patch_v0(openai)
61
+
62
+ self.is_patched = True
63
+ self.config.log("✅ OpenAI patching successful")
64
+ return True
65
+
66
+ except ImportError:
67
+ self.config.log("OpenAI library not installed - skipping patch")
68
+ return False
69
+ except Exception as e:
70
+ self.config.log(f"⚠️ Failed to patch OpenAI: {e}")
71
+ return False
72
+
73
+ def unpatch(self):
74
+ """Remove OpenAI patches"""
75
+ if not self.is_patched:
76
+ return
77
+
78
+ try:
79
+ import openai
80
+
81
+ # Restore original methods
82
+ for key, original in self.original_methods.items():
83
+ parts = key.split('.')
84
+ obj = openai
85
+ for part in parts[:-1]:
86
+ obj = getattr(obj, part)
87
+ setattr(obj, parts[-1], original)
88
+
89
+ self.is_patched = False
90
+ self.config.log("OpenAI patches removed")
91
+
92
+ except Exception as e:
93
+ self.config.log(f"⚠️ Failed to unpatch OpenAI: {e}")
94
+
95
+ def _detect_version(self, openai) -> str:
96
+ """Detect OpenAI library version"""
97
+ if hasattr(openai, '__version__'):
98
+ return openai.__version__
99
+ elif hasattr(openai, 'version'):
100
+ return openai.version.VERSION
101
+ else:
102
+ # Default to v1 if can't detect
103
+ return "1.0.0"
104
+
105
+ def _patch_v1(self, openai):
106
+ """Patch OpenAI v1.0+ API"""
107
+ self.config.log("Patching OpenAI v1.0+ API...")
108
+
109
+ # Patch sync client
110
+ if hasattr(openai, 'OpenAI'):
111
+ self._patch_client_class(openai.OpenAI, 'OpenAI')
112
+
113
+ # Patch async client
114
+ if hasattr(openai, 'AsyncOpenAI'):
115
+ self._patch_async_client_class(openai.AsyncOpenAI, 'AsyncOpenAI')
116
+
117
+ def _patch_v0(self, openai):
118
+ """Patch OpenAI v0.x API"""
119
+ self.config.log("Patching OpenAI v0.x API...")
120
+
121
+ # Patch ChatCompletion.create
122
+ if hasattr(openai, 'ChatCompletion'):
123
+ original = openai.ChatCompletion.create
124
+ self.original_methods['ChatCompletion.create'] = original
125
+ openai.ChatCompletion.create = self._wrap_v0_create(original)
126
+
127
+ # Patch ChatCompletion.acreate (async)
128
+ if hasattr(openai, 'ChatCompletion') and hasattr(openai.ChatCompletion, 'acreate'):
129
+ original = openai.ChatCompletion.acreate
130
+ self.original_methods['ChatCompletion.acreate'] = original
131
+ openai.ChatCompletion.acreate = self._wrap_v0_async_create(original)
132
+
133
+ def _patch_client_class(self, client_class, class_name: str):
134
+ """Patch OpenAI v1+ client class"""
135
+ # We need to patch the instance method, not the class method
136
+ # This is tricky - we'll patch at the resources level
137
+
138
+ # Store original __init__ and wrap it to patch instance methods
139
+ original_init = client_class.__init__
140
+
141
+ def wrapped_init(instance, *args, **kwargs):
142
+ # Call original init
143
+ original_init(instance, *args, **kwargs)
144
+
145
+ # Patch chat completions on this instance
146
+ if hasattr(instance, 'chat') and hasattr(instance.chat, 'completions'):
147
+ original_create = instance.chat.completions.create
148
+ instance.chat.completions.create = self._wrap_v1_create(original_create)
149
+
150
+ client_class.__init__ = wrapped_init
151
+ self.original_methods[f'{class_name}.__init__'] = original_init
152
+
153
+ def _patch_async_client_class(self, client_class, class_name: str):
154
+ """Patch OpenAI v1+ async client class"""
155
+ original_init = client_class.__init__
156
+
157
+ def wrapped_init(instance, *args, **kwargs):
158
+ # Call original init
159
+ original_init(instance, *args, **kwargs)
160
+
161
+ # Patch async chat completions
162
+ if hasattr(instance, 'chat') and hasattr(instance.chat, 'completions'):
163
+ original_create = instance.chat.completions.create
164
+ instance.chat.completions.create = self._wrap_v1_async_create(original_create)
165
+
166
+ client_class.__init__ = wrapped_init
167
+ self.original_methods[f'{class_name}.__init__'] = original_init
168
+
169
+ def _wrap_v1_create(self, original_func):
170
+ """Wrap OpenAI v1+ sync create method"""
171
+ @functools.wraps(original_func)
172
+ def wrapper(*args, **kwargs):
173
+ # RECURSION GUARD: Skip capture if inside Scope internal code
174
+ # This prevents infinite loops when Scope backend tracks itself
175
+ if ScopeContext.is_in_scope_context():
176
+ return original_func(*args, **kwargs)
177
+
178
+ start_time = time.time()
179
+
180
+ try:
181
+ # Call original function
182
+ response = original_func(*args, **kwargs)
183
+
184
+ # Handle streaming vs non-streaming
185
+ if kwargs.get('stream', False):
186
+ # Return streaming wrapper
187
+ return self._wrap_streaming_response(response, start_time, kwargs)
188
+ else:
189
+ # Capture non-streaming response
190
+ latency_ms = (time.time() - start_time) * 1000
191
+ self._capture_v1_response(response, kwargs, latency_ms)
192
+ return response
193
+
194
+ except Exception as e:
195
+ # Capture error
196
+ latency_ms = (time.time() - start_time) * 1000
197
+ self._capture_error(kwargs, str(e), latency_ms)
198
+ raise
199
+
200
+ return wrapper
201
+
202
+ def _wrap_v1_async_create(self, original_func):
203
+ """Wrap OpenAI v1+ async create method"""
204
+ @functools.wraps(original_func)
205
+ async def wrapper(*args, **kwargs):
206
+ # RECURSION GUARD: Skip capture if inside Scope internal code
207
+ if ScopeContext.is_in_scope_context():
208
+ return await original_func(*args, **kwargs)
209
+
210
+ start_time = time.time()
211
+
212
+ try:
213
+ # Call original async function
214
+ response = await original_func(*args, **kwargs)
215
+
216
+ # Handle streaming vs non-streaming
217
+ if kwargs.get('stream', False):
218
+ # Return async streaming wrapper
219
+ return self._wrap_async_streaming_response(response, start_time, kwargs)
220
+ else:
221
+ # Capture non-streaming response
222
+ latency_ms = (time.time() - start_time) * 1000
223
+ self._capture_v1_response(response, kwargs, latency_ms)
224
+ return response
225
+
226
+ except Exception as e:
227
+ # Capture error
228
+ latency_ms = (time.time() - start_time) * 1000
229
+ self._capture_error(kwargs, str(e), latency_ms)
230
+ raise
231
+
232
+ return wrapper
233
+
234
+ def _wrap_v0_create(self, original_func):
235
+ """Wrap OpenAI v0.x sync create method"""
236
+ @functools.wraps(original_func)
237
+ def wrapper(*args, **kwargs):
238
+ # RECURSION GUARD: Skip capture if inside Scope internal code
239
+ if ScopeContext.is_in_scope_context():
240
+ return original_func(*args, **kwargs)
241
+
242
+ start_time = time.time()
243
+
244
+ try:
245
+ response = original_func(*args, **kwargs)
246
+ latency_ms = (time.time() - start_time) * 1000
247
+
248
+ # Handle streaming
249
+ if kwargs.get('stream', False):
250
+ return self._wrap_v0_streaming_response(response, start_time, kwargs)
251
+ else:
252
+ self._capture_v0_response(response, kwargs, latency_ms)
253
+ return response
254
+
255
+ except Exception as e:
256
+ latency_ms = (time.time() - start_time) * 1000
257
+ self._capture_error(kwargs, str(e), latency_ms)
258
+ raise
259
+
260
+ return wrapper
261
+
262
+ def _wrap_v0_async_create(self, original_func):
263
+ """Wrap OpenAI v0.x async create method"""
264
+ @functools.wraps(original_func)
265
+ async def wrapper(*args, **kwargs):
266
+ # RECURSION GUARD: Skip capture if inside Scope internal code
267
+ if ScopeContext.is_in_scope_context():
268
+ return await original_func(*args, **kwargs)
269
+
270
+ start_time = time.time()
271
+
272
+ try:
273
+ response = await original_func(*args, **kwargs)
274
+ latency_ms = (time.time() - start_time) * 1000
275
+ self._capture_v0_response(response, kwargs, latency_ms)
276
+ return response
277
+
278
+ except Exception as e:
279
+ latency_ms = (time.time() - start_time) * 1000
280
+ self._capture_error(kwargs, str(e), latency_ms)
281
+ raise
282
+
283
+ return wrapper
284
+
285
+ def _wrap_streaming_response(self, response, start_time, kwargs):
286
+ """Wrap v1+ streaming response to capture chunks"""
287
+ chunks = []
288
+
289
+ for chunk in response:
290
+ chunks.append(chunk)
291
+ yield chunk
292
+
293
+ # After streaming completes, capture event
294
+ latency_ms = (time.time() - start_time) * 1000
295
+ self._capture_v1_streaming_response(chunks, kwargs, latency_ms)
296
+
297
+ async def _wrap_async_streaming_response(self, response, start_time, kwargs):
298
+ """Wrap v1+ async streaming response"""
299
+ chunks = []
300
+
301
+ async for chunk in response:
302
+ chunks.append(chunk)
303
+ yield chunk
304
+
305
+ # After streaming completes, capture event
306
+ latency_ms = (time.time() - start_time) * 1000
307
+ self._capture_v1_streaming_response(chunks, kwargs, latency_ms)
308
+
309
+ def _wrap_v0_streaming_response(self, response, start_time, kwargs):
310
+ """Wrap v0.x streaming response"""
311
+ chunks = []
312
+
313
+ for chunk in response:
314
+ chunks.append(chunk)
315
+ yield chunk
316
+
317
+ # After streaming completes, capture event
318
+ latency_ms = (time.time() - start_time) * 1000
319
+ self._capture_v0_streaming_response(chunks, kwargs, latency_ms)
320
+
321
+ def _capture_v1_response(self, response, kwargs, latency_ms):
322
+ """Capture OpenAI v1+ non-streaming response"""
323
+ try:
324
+ # Extract data from response
325
+ model = kwargs.get('model', response.model if hasattr(response, 'model') else 'unknown')
326
+ messages = kwargs.get('messages', [])
327
+
328
+ # Extract response content
329
+ response_text = ""
330
+ if hasattr(response, 'choices') and len(response.choices) > 0:
331
+ choice = response.choices[0]
332
+ if hasattr(choice, 'message') and hasattr(choice.message, 'content'):
333
+ response_text = choice.message.content or ""
334
+
335
+ # Extract token usage
336
+ tokens = {}
337
+ if hasattr(response, 'usage'):
338
+ tokens = {
339
+ 'prompt_tokens': response.usage.prompt_tokens,
340
+ 'completion_tokens': response.usage.completion_tokens,
341
+ 'total_tokens': response.usage.total_tokens,
342
+ }
343
+
344
+ # Format and queue event
345
+ event = self.event_formatter.format_llm_call(
346
+ provider='openai',
347
+ model=model,
348
+ messages=messages,
349
+ response=response_text,
350
+ tokens=tokens,
351
+ latency_ms=latency_ms,
352
+ error=None,
353
+ )
354
+
355
+ self.queue.enqueue(event)
356
+ self.config.log(f"Captured OpenAI call: {model}")
357
+
358
+ except Exception as e:
359
+ self.config.log(f"⚠️ Failed to capture OpenAI response: {e}")
360
+
361
+ def _capture_v1_streaming_response(self, chunks, kwargs, latency_ms):
362
+ """Capture OpenAI v1+ streaming response"""
363
+ try:
364
+ # Reconstruct response from chunks
365
+ model = kwargs.get('model', 'unknown')
366
+ messages = kwargs.get('messages', [])
367
+
368
+ # Combine streaming chunks
369
+ response_text = ""
370
+ for chunk in chunks:
371
+ if hasattr(chunk, 'choices') and len(chunk.choices) > 0:
372
+ delta = chunk.choices[0].delta
373
+ if hasattr(delta, 'content') and delta.content:
374
+ response_text += delta.content
375
+
376
+ # Format and queue event
377
+ event = self.event_formatter.format_llm_call(
378
+ provider='openai',
379
+ model=model,
380
+ messages=messages,
381
+ response=response_text,
382
+ tokens={}, # Streaming usually doesn't include tokens
383
+ latency_ms=latency_ms,
384
+ error=None,
385
+ )
386
+
387
+ self.queue.enqueue(event)
388
+ self.config.log(f"Captured OpenAI streaming call: {model}")
389
+
390
+ except Exception as e:
391
+ self.config.log(f"⚠️ Failed to capture OpenAI streaming response: {e}")
392
+
393
+ def _capture_v0_response(self, response, kwargs, latency_ms):
394
+ """Capture OpenAI v0.x response"""
395
+ try:
396
+ model = kwargs.get('model', response.get('model', 'unknown'))
397
+ messages = kwargs.get('messages', [])
398
+
399
+ # Extract response
400
+ response_text = ""
401
+ if 'choices' in response and len(response['choices']) > 0:
402
+ choice = response['choices'][0]
403
+ if 'message' in choice and 'content' in choice['message']:
404
+ response_text = choice['message']['content'] or ""
405
+
406
+ # Extract tokens
407
+ tokens = {}
408
+ if 'usage' in response:
409
+ tokens = {
410
+ 'prompt_tokens': response['usage'].get('prompt_tokens', 0),
411
+ 'completion_tokens': response['usage'].get('completion_tokens', 0),
412
+ 'total_tokens': response['usage'].get('total_tokens', 0),
413
+ }
414
+
415
+ # Format and queue event
416
+ event = self.event_formatter.format_llm_call(
417
+ provider='openai',
418
+ model=model,
419
+ messages=messages,
420
+ response=response_text,
421
+ tokens=tokens,
422
+ latency_ms=latency_ms,
423
+ error=None,
424
+ )
425
+
426
+ self.queue.enqueue(event)
427
+ self.config.log(f"Captured OpenAI call: {model}")
428
+
429
+ except Exception as e:
430
+ self.config.log(f"⚠️ Failed to capture OpenAI v0 response: {e}")
431
+
432
+ def _capture_v0_streaming_response(self, chunks, kwargs, latency_ms):
433
+ """Capture OpenAI v0.x streaming response"""
434
+ try:
435
+ model = kwargs.get('model', 'unknown')
436
+ messages = kwargs.get('messages', [])
437
+
438
+ # Combine streaming chunks
439
+ response_text = ""
440
+ for chunk in chunks:
441
+ if 'choices' in chunk and len(chunk['choices']) > 0:
442
+ delta = chunk['choices'][0].get('delta', {})
443
+ if 'content' in delta:
444
+ response_text += delta['content']
445
+
446
+ # Format and queue event
447
+ event = self.event_formatter.format_llm_call(
448
+ provider='openai',
449
+ model=model,
450
+ messages=messages,
451
+ response=response_text,
452
+ tokens={},
453
+ latency_ms=latency_ms,
454
+ error=None,
455
+ )
456
+
457
+ self.queue.enqueue(event)
458
+ self.config.log(f"Captured OpenAI v0 streaming call: {model}")
459
+
460
+ except Exception as e:
461
+ self.config.log(f"⚠️ Failed to capture OpenAI v0 streaming response: {e}")
462
+
463
+ def _capture_error(self, kwargs, error_message, latency_ms):
464
+ """Capture failed LLM call"""
465
+ try:
466
+ model = kwargs.get('model', 'unknown')
467
+ messages = kwargs.get('messages', [])
468
+
469
+ event = self.event_formatter.format_llm_call(
470
+ provider='openai',
471
+ model=model,
472
+ messages=messages,
473
+ response="",
474
+ tokens={},
475
+ latency_ms=latency_ms,
476
+ error=error_message,
477
+ )
478
+
479
+ self.queue.enqueue(event)
480
+ self.config.log(f"Captured OpenAI error: {error_message}")
481
+
482
+ except Exception as e:
483
+ self.config.log(f"⚠️ Failed to capture OpenAI error: {e}")
@@ -0,0 +1,158 @@
1
+ """
2
+ Event queue with batch flushing for Scope Analytics
3
+ Manages in-memory event storage and batch processing
4
+ """
5
+
6
+ import threading
7
+ import time
8
+ from collections import deque
9
+ from typing import Dict, Any, Callable, Optional
10
+
11
+
12
+ class EventQueue:
13
+ """
14
+ Thread-safe event queue with automatic batch flushing
15
+
16
+ Features:
17
+ - Batches events by size (batch_size)
18
+ - Flushes automatically after timeout (batch_timeout_seconds)
19
+ - Drops oldest events if max_queue_size exceeded
20
+ - Thread-safe for concurrent access
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ batch_size: int,
26
+ batch_timeout_seconds: int,
27
+ max_queue_size: int,
28
+ flush_callback: Callable[[list], None],
29
+ config,
30
+ ):
31
+ """
32
+ Initialize event queue
33
+
34
+ Args:
35
+ batch_size: Number of events to batch before flushing
36
+ batch_timeout_seconds: Max seconds to wait before flushing partial batch
37
+ max_queue_size: Maximum events to queue (oldest dropped if exceeded)
38
+ flush_callback: Function to call when flushing batch
39
+ config: SDK configuration for logging
40
+ """
41
+ self.batch_size = batch_size
42
+ self.batch_timeout_seconds = batch_timeout_seconds
43
+ self.max_queue_size = max_queue_size
44
+ self.flush_callback = flush_callback
45
+ self.config = config
46
+
47
+ self.queue = deque(maxlen=max_queue_size)
48
+ self.lock = threading.Lock()
49
+ self.last_flush_time = time.time()
50
+
51
+ # Background thread for periodic flushing
52
+ self.running = False
53
+ self.flush_thread: Optional[threading.Thread] = None
54
+
55
+ def start(self):
56
+ """Start background flush thread"""
57
+ if self.running:
58
+ return
59
+
60
+ self.running = True
61
+ self.flush_thread = threading.Thread(
62
+ target=self._flush_loop,
63
+ daemon=True,
64
+ name="ScopeAnalytics-FlushThread"
65
+ )
66
+ self.flush_thread.start()
67
+ self.config.log("Event queue started")
68
+
69
+ def stop(self):
70
+ """Stop background thread and flush remaining events"""
71
+ self.config.log("Stopping event queue...")
72
+ self.running = False
73
+
74
+ if self.flush_thread:
75
+ self.flush_thread.join(timeout=5)
76
+
77
+ # Flush any remaining events
78
+ self.flush()
79
+ self.config.log("Event queue stopped")
80
+
81
+ def enqueue(self, event: Dict[str, Any]):
82
+ """
83
+ Add event to queue
84
+
85
+ Args:
86
+ event: Event dictionary to queue
87
+ """
88
+ with self.lock:
89
+ # Check if queue is full
90
+ if len(self.queue) >= self.max_queue_size:
91
+ self.config.log(
92
+ f"Queue full ({self.max_queue_size}), dropping oldest event"
93
+ )
94
+
95
+ self.queue.append(event)
96
+
97
+ # Check if we should flush due to batch size
98
+ if len(self.queue) >= self.batch_size:
99
+ self.config.log(
100
+ f"Batch size ({self.batch_size}) reached, flushing..."
101
+ )
102
+ self._flush_batch()
103
+
104
+ def flush(self):
105
+ """Manually flush all queued events"""
106
+ with self.lock:
107
+ if len(self.queue) > 0:
108
+ self.config.log(f"Manual flush: {len(self.queue)} events")
109
+ self._flush_batch()
110
+
111
+ def _flush_loop(self):
112
+ """Background thread that periodically flushes events"""
113
+ while self.running:
114
+ time.sleep(1) # Check every second
115
+
116
+ with self.lock:
117
+ time_since_flush = time.time() - self.last_flush_time
118
+
119
+ # Flush if timeout reached and we have events
120
+ if time_since_flush >= self.batch_timeout_seconds and len(self.queue) > 0:
121
+ self.config.log(
122
+ f"Timeout ({self.batch_timeout_seconds}s) reached, flushing {len(self.queue)} events"
123
+ )
124
+ self._flush_batch()
125
+
126
+ def _flush_batch(self):
127
+ """
128
+ Flush current batch of events
129
+ NOTE: Must be called with lock held
130
+ """
131
+ if len(self.queue) == 0:
132
+ return
133
+
134
+ # Copy batch and clear queue while holding lock
135
+ batch = list(self.queue)
136
+ self.queue.clear()
137
+ self.last_flush_time = time.time()
138
+
139
+ # We'll flush outside the lock in a separate thread
140
+ # This prevents blocking the queue during I/O
141
+ import threading
142
+ def flush_in_background():
143
+ try:
144
+ self.flush_callback(batch)
145
+ except Exception as e:
146
+ self.config.log(f"Error flushing batch: {e}")
147
+ # Re-queue events on failure
148
+ with self.lock:
149
+ self.queue.extendleft(reversed(batch))
150
+
151
+ # Start flush in background
152
+ thread = threading.Thread(target=flush_in_background, daemon=True)
153
+ thread.start()
154
+
155
+ def size(self) -> int:
156
+ """Get current queue size"""
157
+ with self.lock:
158
+ return len(self.queue)