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,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}")
|