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,430 @@
1
+ """
2
+ Anthropic library patching for Scope Analytics
3
+ Supports multiple Anthropic versions and usage patterns
4
+ """
5
+
6
+ import time
7
+ import functools
8
+ from typing import Optional, Any
9
+
10
+ from ..context import ScopeContext
11
+
12
+
13
+ class AnthropicPatcher:
14
+ """
15
+ Patches Anthropic library to automatically capture LLM calls
16
+
17
+ Supports:
18
+ - Anthropic SDK (client.messages.create)
19
+ - Sync and async calls
20
+ - Streaming and non-streaming responses
21
+ """
22
+
23
+ def __init__(self, scope_sdk):
24
+ """
25
+ Initialize Anthropic patcher
26
+
27
+ Args:
28
+ scope_sdk: Reference to main ScopeAnalytics instance
29
+ """
30
+ self.scope_sdk = scope_sdk
31
+ self.config = scope_sdk.config
32
+ self.event_formatter = scope_sdk.event_formatter
33
+ self.queue = scope_sdk.queue
34
+
35
+ self.original_methods = {}
36
+ self.is_patched = False
37
+ self.anthropic_version = None
38
+
39
+ def patch(self) -> bool:
40
+ """
41
+ Apply Anthropic patches
42
+
43
+ Returns:
44
+ True if patching succeeded, False otherwise
45
+ """
46
+ try:
47
+ import anthropic
48
+
49
+ # Detect Anthropic version
50
+ self.anthropic_version = self._detect_version(anthropic)
51
+ self.config.log(f"Detected Anthropic version: {self.anthropic_version}")
52
+
53
+ # Patch sync client
54
+ if hasattr(anthropic, 'Anthropic'):
55
+ self._patch_client_class(anthropic.Anthropic, 'Anthropic')
56
+
57
+ # Patch async client
58
+ if hasattr(anthropic, 'AsyncAnthropic'):
59
+ self._patch_async_client_class(anthropic.AsyncAnthropic, 'AsyncAnthropic')
60
+
61
+ self.is_patched = True
62
+ self.config.log("✅ Anthropic patching successful")
63
+ return True
64
+
65
+ except ImportError:
66
+ self.config.log("Anthropic library not installed - skipping patch")
67
+ return False
68
+ except Exception as e:
69
+ self.config.log(f"⚠️ Failed to patch Anthropic: {e}")
70
+ return False
71
+
72
+ def unpatch(self):
73
+ """Remove Anthropic patches"""
74
+ if not self.is_patched:
75
+ return
76
+
77
+ try:
78
+ import anthropic
79
+
80
+ # Restore original methods
81
+ for key, original in self.original_methods.items():
82
+ parts = key.split('.')
83
+ obj = anthropic
84
+ for part in parts[:-1]:
85
+ obj = getattr(obj, part)
86
+ setattr(obj, parts[-1], original)
87
+
88
+ self.is_patched = False
89
+ self.config.log("Anthropic patches removed")
90
+
91
+ except Exception as e:
92
+ self.config.log(f"⚠️ Failed to unpatch Anthropic: {e}")
93
+
94
+ def _detect_version(self, anthropic) -> str:
95
+ """Detect Anthropic library version"""
96
+ if hasattr(anthropic, '__version__'):
97
+ return anthropic.__version__
98
+ else:
99
+ return "unknown"
100
+
101
+ def _patch_client_class(self, client_class, class_name: str):
102
+ """Patch Anthropic sync client class"""
103
+ original_init = client_class.__init__
104
+
105
+ def wrapped_init(instance, *args, **kwargs):
106
+ # Call original init
107
+ original_init(instance, *args, **kwargs)
108
+
109
+ # Patch messages.create on this instance
110
+ if hasattr(instance, 'messages') and hasattr(instance.messages, 'create'):
111
+ original_create = instance.messages.create
112
+ instance.messages.create = self._wrap_create(original_create)
113
+
114
+ # Also patch stream if available
115
+ if hasattr(instance.messages, 'stream'):
116
+ original_stream = instance.messages.stream
117
+ instance.messages.stream = self._wrap_stream(original_stream)
118
+
119
+ client_class.__init__ = wrapped_init
120
+ self.original_methods[f'{class_name}.__init__'] = original_init
121
+
122
+ def _patch_async_client_class(self, client_class, class_name: str):
123
+ """Patch Anthropic async client class"""
124
+ original_init = client_class.__init__
125
+
126
+ def wrapped_init(instance, *args, **kwargs):
127
+ # Call original init
128
+ original_init(instance, *args, **kwargs)
129
+
130
+ # Patch async messages.create
131
+ if hasattr(instance, 'messages') and hasattr(instance.messages, 'create'):
132
+ original_create = instance.messages.create
133
+ instance.messages.create = self._wrap_async_create(original_create)
134
+
135
+ # Also patch async stream if available
136
+ if hasattr(instance.messages, 'stream'):
137
+ original_stream = instance.messages.stream
138
+ instance.messages.stream = self._wrap_async_stream(original_stream)
139
+
140
+ client_class.__init__ = wrapped_init
141
+ self.original_methods[f'{class_name}.__init__'] = original_init
142
+
143
+ def _wrap_create(self, original_func):
144
+ """Wrap sync messages.create method"""
145
+ @functools.wraps(original_func)
146
+ def wrapper(*args, **kwargs):
147
+ # RECURSION GUARD: Skip capture if inside Scope internal code
148
+ if ScopeContext.is_in_scope_context():
149
+ return original_func(*args, **kwargs)
150
+
151
+ start_time = time.time()
152
+
153
+ try:
154
+ # Check for streaming
155
+ if kwargs.get('stream', False):
156
+ # Handle streaming response
157
+ response = original_func(*args, **kwargs)
158
+ return self._wrap_streaming_response(response, start_time, kwargs)
159
+ else:
160
+ # Normal response
161
+ response = original_func(*args, **kwargs)
162
+ latency_ms = (time.time() - start_time) * 1000
163
+ self._capture_response(response, kwargs, latency_ms)
164
+ return response
165
+
166
+ except Exception as e:
167
+ latency_ms = (time.time() - start_time) * 1000
168
+ self._capture_error(kwargs, str(e), latency_ms)
169
+ raise
170
+
171
+ return wrapper
172
+
173
+ def _wrap_async_create(self, original_func):
174
+ """Wrap async messages.create method"""
175
+ @functools.wraps(original_func)
176
+ async def wrapper(*args, **kwargs):
177
+ # RECURSION GUARD: Skip capture if inside Scope internal code
178
+ if ScopeContext.is_in_scope_context():
179
+ return await original_func(*args, **kwargs)
180
+
181
+ start_time = time.time()
182
+
183
+ try:
184
+ if kwargs.get('stream', False):
185
+ # Handle async streaming
186
+ response = await original_func(*args, **kwargs)
187
+ return self._wrap_async_streaming_response(response, start_time, kwargs)
188
+ else:
189
+ # Normal async response
190
+ response = await original_func(*args, **kwargs)
191
+ latency_ms = (time.time() - start_time) * 1000
192
+ self._capture_response(response, kwargs, latency_ms)
193
+ return response
194
+
195
+ except Exception as e:
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_stream(self, original_func):
203
+ """Wrap sync stream method (context manager)"""
204
+ patcher = self
205
+
206
+ @functools.wraps(original_func)
207
+ def wrapper(*args, **kwargs):
208
+ start_time = time.time()
209
+
210
+ # Get the stream context manager
211
+ stream_cm = original_func(*args, **kwargs)
212
+
213
+ # Wrap the context manager
214
+ class WrappedStream:
215
+ def __init__(self, cm):
216
+ self._cm = cm
217
+ self._chunks = []
218
+ self._response = None
219
+
220
+ def __enter__(self):
221
+ self._stream = self._cm.__enter__()
222
+ return self
223
+
224
+ def __exit__(self, *args):
225
+ result = self._cm.__exit__(*args)
226
+ # Capture after streaming completes
227
+ latency_ms = (time.time() - start_time) * 1000
228
+ patcher._capture_streaming_response(self._chunks, kwargs, latency_ms, self._response)
229
+ return result
230
+
231
+ def __iter__(self):
232
+ return self
233
+
234
+ def __next__(self):
235
+ try:
236
+ event = next(self._stream)
237
+ self._chunks.append(event)
238
+ return event
239
+ except StopIteration:
240
+ raise
241
+
242
+ def get_final_message(self):
243
+ if hasattr(self._stream, 'get_final_message'):
244
+ self._response = self._stream.get_final_message()
245
+ return self._response
246
+ return None
247
+
248
+ return WrappedStream(stream_cm)
249
+
250
+ return wrapper
251
+
252
+ def _wrap_async_stream(self, original_func):
253
+ """Wrap async stream method"""
254
+ patcher = self
255
+
256
+ @functools.wraps(original_func)
257
+ def wrapper(*args, **kwargs):
258
+ start_time = time.time()
259
+ stream_cm = original_func(*args, **kwargs)
260
+
261
+ class WrappedAsyncStream:
262
+ def __init__(self, cm):
263
+ self._cm = cm
264
+ self._chunks = []
265
+ self._response = None
266
+
267
+ async def __aenter__(self):
268
+ self._stream = await self._cm.__aenter__()
269
+ return self
270
+
271
+ async def __aexit__(self, *args):
272
+ result = await self._cm.__aexit__(*args)
273
+ latency_ms = (time.time() - start_time) * 1000
274
+ patcher._capture_streaming_response(self._chunks, kwargs, latency_ms, self._response)
275
+ return result
276
+
277
+ def __aiter__(self):
278
+ return self
279
+
280
+ async def __anext__(self):
281
+ try:
282
+ event = await self._stream.__anext__()
283
+ self._chunks.append(event)
284
+ return event
285
+ except StopAsyncIteration:
286
+ raise
287
+
288
+ async def get_final_message(self):
289
+ if hasattr(self._stream, 'get_final_message'):
290
+ self._response = await self._stream.get_final_message()
291
+ return self._response
292
+ return None
293
+
294
+ return WrappedAsyncStream(stream_cm)
295
+
296
+ return wrapper
297
+
298
+ def _wrap_streaming_response(self, response, start_time, kwargs):
299
+ """Wrap sync streaming response"""
300
+ chunks = []
301
+
302
+ for chunk in response:
303
+ chunks.append(chunk)
304
+ yield chunk
305
+
306
+ # After streaming completes, capture event
307
+ latency_ms = (time.time() - start_time) * 1000
308
+ self._capture_streaming_response(chunks, kwargs, latency_ms, None)
309
+
310
+ async def _wrap_async_streaming_response(self, response, start_time, kwargs):
311
+ """Wrap async streaming response"""
312
+ chunks = []
313
+
314
+ async for chunk in response:
315
+ chunks.append(chunk)
316
+ yield chunk
317
+
318
+ # After streaming completes, capture event
319
+ latency_ms = (time.time() - start_time) * 1000
320
+ self._capture_streaming_response(chunks, kwargs, latency_ms, None)
321
+
322
+ def _capture_response(self, response, kwargs, latency_ms):
323
+ """Capture non-streaming Anthropic response"""
324
+ try:
325
+ # Extract model
326
+ model = kwargs.get('model', getattr(response, 'model', 'unknown'))
327
+ messages = kwargs.get('messages', [])
328
+
329
+ # Extract response content
330
+ response_text = ""
331
+ if hasattr(response, 'content') and len(response.content) > 0:
332
+ for block in response.content:
333
+ if hasattr(block, 'text'):
334
+ response_text += block.text
335
+
336
+ # Extract token usage
337
+ tokens = {}
338
+ if hasattr(response, 'usage'):
339
+ tokens = {
340
+ 'prompt_tokens': getattr(response.usage, 'input_tokens', 0),
341
+ 'completion_tokens': getattr(response.usage, 'output_tokens', 0),
342
+ 'total_tokens': getattr(response.usage, 'input_tokens', 0) + getattr(response.usage, 'output_tokens', 0),
343
+ }
344
+
345
+ # Format and queue event
346
+ event = self.event_formatter.format_llm_call(
347
+ provider='anthropic',
348
+ model=model,
349
+ messages=messages,
350
+ response=response_text,
351
+ tokens=tokens,
352
+ latency_ms=latency_ms,
353
+ error=None,
354
+ )
355
+
356
+ self.queue.enqueue(event)
357
+ self.config.log(f"Captured Anthropic call: {model}")
358
+
359
+ except Exception as e:
360
+ self.config.log(f"⚠️ Failed to capture Anthropic response: {e}")
361
+
362
+ def _capture_streaming_response(self, chunks, kwargs, latency_ms, final_response):
363
+ """Capture streaming Anthropic response"""
364
+ try:
365
+ model = kwargs.get('model', 'unknown')
366
+ messages = kwargs.get('messages', [])
367
+
368
+ # Try to extract from final response first
369
+ response_text = ""
370
+ tokens = {}
371
+
372
+ if final_response:
373
+ if hasattr(final_response, 'content'):
374
+ for block in final_response.content:
375
+ if hasattr(block, 'text'):
376
+ response_text += block.text
377
+
378
+ if hasattr(final_response, 'usage'):
379
+ tokens = {
380
+ 'prompt_tokens': getattr(final_response.usage, 'input_tokens', 0),
381
+ 'completion_tokens': getattr(final_response.usage, 'output_tokens', 0),
382
+ 'total_tokens': getattr(final_response.usage, 'input_tokens', 0) + getattr(final_response.usage, 'output_tokens', 0),
383
+ }
384
+ else:
385
+ # Extract from chunks
386
+ for chunk in chunks:
387
+ if hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'):
388
+ response_text += chunk.delta.text
389
+ elif hasattr(chunk, 'type') and chunk.type == 'content_block_delta':
390
+ if hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'):
391
+ response_text += chunk.delta.text
392
+
393
+ # Format and queue event
394
+ event = self.event_formatter.format_llm_call(
395
+ provider='anthropic',
396
+ model=model,
397
+ messages=messages,
398
+ response=response_text,
399
+ tokens=tokens,
400
+ latency_ms=latency_ms,
401
+ error=None,
402
+ )
403
+
404
+ self.queue.enqueue(event)
405
+ self.config.log(f"Captured Anthropic streaming call: {model}")
406
+
407
+ except Exception as e:
408
+ self.config.log(f"⚠️ Failed to capture Anthropic streaming response: {e}")
409
+
410
+ def _capture_error(self, kwargs, error_message, latency_ms):
411
+ """Capture failed LLM call"""
412
+ try:
413
+ model = kwargs.get('model', 'unknown')
414
+ messages = kwargs.get('messages', [])
415
+
416
+ event = self.event_formatter.format_llm_call(
417
+ provider='anthropic',
418
+ model=model,
419
+ messages=messages,
420
+ response="",
421
+ tokens={},
422
+ latency_ms=latency_ms,
423
+ error=error_message,
424
+ )
425
+
426
+ self.queue.enqueue(event)
427
+ self.config.log(f"Captured Anthropic error: {error_message}")
428
+
429
+ except Exception as e:
430
+ self.config.log(f"⚠️ Failed to capture Anthropic error: {e}")