revenium-python-sdk 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.
Files changed (73) hide show
  1. revenium_middleware/__init__.py +184 -0
  2. revenium_middleware/_core/__init__.py +65 -0
  3. revenium_middleware/_core/config.py +165 -0
  4. revenium_middleware/_core/context.py +109 -0
  5. revenium_middleware/_core/decorators.py +202 -0
  6. revenium_middleware/_core/metering.py +207 -0
  7. revenium_middleware/_core/prompt_extraction.py +55 -0
  8. revenium_middleware/_core/subscriber.py +51 -0
  9. revenium_middleware/_core/trace_fields.py +265 -0
  10. revenium_middleware/anthropic/__init__.py +108 -0
  11. revenium_middleware/anthropic/bedrock_adapter.py +753 -0
  12. revenium_middleware/anthropic/config.py +29 -0
  13. revenium_middleware/anthropic/middleware.py +1070 -0
  14. revenium_middleware/anthropic/prompt_extractor.py +178 -0
  15. revenium_middleware/anthropic/provider.py +141 -0
  16. revenium_middleware/anthropic/summary_printer.py +286 -0
  17. revenium_middleware/anthropic/trace_fields.py +158 -0
  18. revenium_middleware/google/__init__.py +114 -0
  19. revenium_middleware/google/common/__init__.py +127 -0
  20. revenium_middleware/google/common/exceptions.py +137 -0
  21. revenium_middleware/google/common/protocols.py +192 -0
  22. revenium_middleware/google/common/summary_printer.py +271 -0
  23. revenium_middleware/google/common/trace_fields.py +205 -0
  24. revenium_middleware/google/common/types.py +208 -0
  25. revenium_middleware/google/common/utils.py +1111 -0
  26. revenium_middleware/google/config.py +64 -0
  27. revenium_middleware/google/google_ai/__init__.py +53 -0
  28. revenium_middleware/google/google_ai/middleware.py +667 -0
  29. revenium_middleware/google/google_ai/provider.py +135 -0
  30. revenium_middleware/google/prompt_extractor.py +396 -0
  31. revenium_middleware/google/vertex_ai/__init__.py +56 -0
  32. revenium_middleware/google/vertex_ai/middleware.py +1162 -0
  33. revenium_middleware/google/vertex_ai/provider.py +99 -0
  34. revenium_middleware/litellm/__init__.py +25 -0
  35. revenium_middleware/litellm/client/__init__.py +81 -0
  36. revenium_middleware/litellm/client/config.py +53 -0
  37. revenium_middleware/litellm/client/context.py +198 -0
  38. revenium_middleware/litellm/client/decorators.py +912 -0
  39. revenium_middleware/litellm/client/hooks.py +192 -0
  40. revenium_middleware/litellm/client/integrations/__init__.py +26 -0
  41. revenium_middleware/litellm/client/integrations/crewai.py +446 -0
  42. revenium_middleware/litellm/client/middleware.py +321 -0
  43. revenium_middleware/litellm/client/summary_printer.py +314 -0
  44. revenium_middleware/litellm/client/trace_fields.py +51 -0
  45. revenium_middleware/litellm/client/validation.py +207 -0
  46. revenium_middleware/litellm/proxy/__init__.py +25 -0
  47. revenium_middleware/litellm/proxy/middleware.py +217 -0
  48. revenium_middleware/ollama/__init__.py +28 -0
  49. revenium_middleware/ollama/middleware.py +569 -0
  50. revenium_middleware/ollama/trace_fields.py +63 -0
  51. revenium_middleware/openai/__init__.py +23 -0
  52. revenium_middleware/openai/azure_config.py +169 -0
  53. revenium_middleware/openai/azure_model_resolver.py +219 -0
  54. revenium_middleware/openai/config.py +45 -0
  55. revenium_middleware/openai/exceptions.py +115 -0
  56. revenium_middleware/openai/langchain/__init__.py +114 -0
  57. revenium_middleware/openai/langchain/_utils.py +129 -0
  58. revenium_middleware/openai/langchain/unified_handler.py +526 -0
  59. revenium_middleware/openai/middleware.py +1451 -0
  60. revenium_middleware/openai/prompt_extractor.py +173 -0
  61. revenium_middleware/openai/provider.py +170 -0
  62. revenium_middleware/openai/summary_printer.py +292 -0
  63. revenium_middleware/openai/trace_fields.py +98 -0
  64. revenium_middleware/perplexity/__init__.py +97 -0
  65. revenium_middleware/perplexity/middleware.py +379 -0
  66. revenium_middleware/perplexity/perplexity_sdk.py +256 -0
  67. revenium_middleware/perplexity/provider.py +84 -0
  68. revenium_middleware/perplexity/trace_fields.py +25 -0
  69. revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
  70. revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
  71. revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
  72. revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  73. revenium_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1070 @@
1
+ import logging
2
+ import datetime
3
+ import wrapt
4
+ import time
5
+ import contextvars
6
+ import os
7
+ import threading
8
+ import queue
9
+ from typing import Optional, Callable, Any, Dict, Tuple
10
+
11
+ # Import our provider detection and Bedrock adapter
12
+ from .provider import Provider, detect_provider, get_provider_metadata
13
+ from .bedrock_adapter import (
14
+ bedrock_invoke, create_bedrock_payload, create_anthropic_response, BedrockStreamWrapper,
15
+ BedrockError, BedrockValidationError, BedrockInvokeError, BedrockStreamError
16
+ )
17
+
18
+ # Import decorator support and metering client from core package
19
+ from revenium_middleware import client, run_async_in_thread, shutdown_event, merge_metadata
20
+ from revenium_middleware._core.subscriber import extract_subscriber_from_metadata
21
+
22
+ # Import trace visualization functions
23
+ from .trace_fields import (
24
+ get_environment, get_region, get_credential_alias,
25
+ get_trace_type, get_trace_name, get_parent_transaction_id,
26
+ get_transaction_name, get_retry_number, detect_operation_type,
27
+ detect_vision_content
28
+ )
29
+
30
+ # Import configuration and prompt capture utilities
31
+ from .config import Config
32
+ from .prompt_extractor import (
33
+ extract_prompts_from_request,
34
+ extract_response_content,
35
+ extract_streaming_response_content
36
+ )
37
+
38
+ # Import summary printer
39
+ from .summary_printer import print_usage_summary
40
+
41
+ logger = logging.getLogger("revenium_middleware.extension")
42
+
43
+ # Define usage context for thread-safe metadata storage
44
+ usage_context = contextvars.ContextVar('usage_metadata', default={})
45
+
46
+
47
+ def _emit_usage_summary(
48
+ model: str,
49
+ provider: str,
50
+ request_duration: float,
51
+ prompt_tokens: int,
52
+ completion_tokens: int,
53
+ response_id: str,
54
+ usage_metadata: Dict[str, Any],
55
+ ) -> None:
56
+ """
57
+ Helper function to emit usage summary asynchronously.
58
+
59
+ Runs summary printing in a background thread to avoid blocking API responses.
60
+ This is a fire-and-forget operation that never fails the main request.
61
+
62
+ Args:
63
+ model: The model name
64
+ provider: The provider name
65
+ request_duration: Request duration in milliseconds
66
+ prompt_tokens: Number of input tokens
67
+ completion_tokens: Number of output tokens
68
+ response_id: The transaction ID
69
+ usage_metadata: Metadata dictionary containing trace_id and other info
70
+ """
71
+ revenium_api_key = os.getenv(Config.ENV_REVENIUM_API_KEY)
72
+ if revenium_api_key:
73
+ # Run summary printing in background thread to avoid blocking
74
+ def _print_summary_async():
75
+ print_usage_summary(
76
+ model=model,
77
+ provider=provider,
78
+ request_duration=request_duration,
79
+ input_token_count=prompt_tokens,
80
+ output_token_count=completion_tokens,
81
+ total_token_count=prompt_tokens + completion_tokens,
82
+ transaction_id=response_id,
83
+ trace_id=usage_metadata.get("trace_id"),
84
+ revenium_api_key=revenium_api_key,
85
+ )
86
+
87
+ run_async_in_thread(_print_summary_async)
88
+
89
+ # Ensure debug logging is enabled when REVENIUM_DEBUG is set
90
+ if os.getenv("REVENIUM_DEBUG", "").lower() in ("true", "1", "yes"):
91
+ logger.setLevel(logging.DEBUG)
92
+ # Also ensure the handler is configured
93
+ if not logger.handlers:
94
+ handler = logging.StreamHandler()
95
+ handler.setLevel(logging.DEBUG)
96
+ formatter = logging.Formatter('DEBUG - %(name)s - %(message)s')
97
+ handler.setFormatter(formatter)
98
+ logger.addHandler(handler)
99
+
100
+ # Thread-safe metering infrastructure
101
+ _metering_lock = threading.RLock()
102
+ _metering_queue = queue.Queue(maxsize=1000) # Prevent memory issues
103
+ _client_cache = {}
104
+ _client_cache_lock = threading.RLock()
105
+
106
+
107
+ def _get_thread_safe_client():
108
+ """Get a thread-safe Revenium client instance."""
109
+ thread_id = threading.get_ident()
110
+
111
+ with _client_cache_lock:
112
+ if thread_id in _client_cache:
113
+ return _client_cache[thread_id]
114
+
115
+ # Import here to avoid circular imports and ensure proper initialization
116
+ try:
117
+ # Import the client module to get a fresh instance per thread
118
+ import revenium_middleware
119
+
120
+ # Use the pre-instantiated client instance from revenium_middleware
121
+ thread_client = revenium_middleware.client
122
+ _client_cache[thread_id] = thread_client
123
+ logger.debug(f"Created thread-safe client for thread {thread_id}")
124
+ return thread_client
125
+ except Exception as e:
126
+ logger.warning(f"Failed to create thread-safe client: {e}")
127
+ return None
128
+
129
+
130
+ def _safe_run_async_in_thread(coro_func: Callable, *args, **kwargs):
131
+ """Thread-safe wrapper for async operations with proper error handling."""
132
+ try:
133
+ from revenium_middleware import run_async_in_thread, shutdown_event
134
+
135
+ if shutdown_event.is_set():
136
+ logger.warning("Skipping async operation during shutdown")
137
+ return None
138
+
139
+ # Use a lock to prevent concurrent async operations from interfering
140
+ with _metering_lock:
141
+ thread = run_async_in_thread(coro_func(*args, **kwargs))
142
+ logger.debug(f"Started thread-safe async operation: {thread}")
143
+ return thread
144
+ except Exception as e:
145
+ logger.warning(f"Error in thread-safe async operation: {e}")
146
+ return None
147
+
148
+
149
+ def extract_prompt_data_if_enabled(
150
+ request_body: Optional[Dict[str, Any]],
151
+ response: Any = None,
152
+ accumulated_content: str = None
153
+ ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[bool]]:
154
+ """
155
+ Extract prompt data if capture is enabled.
156
+
157
+ Args:
158
+ request_body: Request body containing messages and system prompt
159
+ response: API response object (for non-streaming)
160
+ accumulated_content: Accumulated streaming content (for streaming)
161
+
162
+ Returns:
163
+ Tuple of (system_prompt, input_messages, output_response, prompts_truncated)
164
+ """
165
+ if not Config.CAPTURE_PROMPTS or not request_body:
166
+ return None, None, None, None
167
+
168
+ # Extract prompts from request
169
+ prompt_data = extract_prompts_from_request(request_body)
170
+ system_prompt = prompt_data.get('systemPrompt')
171
+ input_messages = prompt_data.get('inputMessages')
172
+ prompts_truncated = prompt_data.get('promptsTruncated', False)
173
+
174
+ # Extract response content
175
+ if accumulated_content is not None:
176
+ # Streaming response
177
+ response_data = extract_streaming_response_content(
178
+ accumulated_content, prompts_truncated
179
+ )
180
+ elif response is not None:
181
+ # Non-streaming response
182
+ response_data = extract_response_content(response, prompts_truncated)
183
+ else:
184
+ response_data = {'outputResponse': None, 'promptsTruncated': prompts_truncated}
185
+
186
+ output_response = response_data.get('outputResponse')
187
+ prompts_truncated = response_data.get('promptsTruncated', prompts_truncated)
188
+
189
+ logger.debug(
190
+ f"Prompt capture - system_prompt: {bool(system_prompt)}, "
191
+ f"input_messages: {bool(input_messages)}, "
192
+ f"output_response: {bool(output_response)}, "
193
+ f"truncated: {prompts_truncated}"
194
+ )
195
+
196
+ return system_prompt, input_messages, output_response, prompts_truncated
197
+
198
+
199
+ def _handle_bedrock_request(args, kwargs, usage_metadata, request_time_dt, request_time): # pylint: disable=unused-argument
200
+ """
201
+ Handle a Bedrock request by converting parameters and invoking the Bedrock adapter.
202
+
203
+ Returns:
204
+ Anthropic-compatible response object
205
+ """
206
+ logger.debug("Handling Bedrock request")
207
+
208
+ # Extract parameters from kwargs
209
+ model = kwargs.get("model", "claude-3-sonnet-20240229")
210
+ messages = kwargs.get("messages", [])
211
+
212
+ # Create Bedrock payload - exclude 'messages' from kwargs to avoid conflict
213
+ bedrock_kwargs = {k: v for k, v in kwargs.items() if k != "messages"}
214
+ payload = create_bedrock_payload(messages, **bedrock_kwargs)
215
+
216
+ # Invoke Bedrock
217
+ text, input_tokens, output_tokens = bedrock_invoke(model, payload)
218
+
219
+ # Create Anthropic-compatible response
220
+ response = create_anthropic_response(
221
+ text=text,
222
+ input_tokens=input_tokens,
223
+ output_tokens=output_tokens,
224
+ model=model
225
+ )
226
+
227
+ # Calculate timing
228
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
229
+ response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
230
+ request_duration = (response_time_dt - request_time_dt).total_seconds() * 1000
231
+
232
+ # Create metering call for Bedrock (pass kwargs for vision detection)
233
+ _create_bedrock_metering_call(
234
+ response, usage_metadata, request_time, response_time, request_duration, kwargs
235
+ )
236
+
237
+ return response
238
+
239
+
240
+ def _handle_bedrock_stream_request(args, kwargs, usage_metadata, request_time_dt, request_time): # pylint: disable=unused-argument
241
+ """
242
+ Handle a Bedrock streaming request by creating a BedrockStreamWrapper.
243
+
244
+ Returns:
245
+ BedrockStreamWrapper: Stream wrapper compatible with Anthropic's interface
246
+ """
247
+ logger.debug("Handling Bedrock streaming request")
248
+
249
+ # Extract parameters from kwargs
250
+ model = kwargs.get("model", "claude-3-sonnet-20240229")
251
+ messages = kwargs.get("messages", [])
252
+
253
+ # Create Bedrock payload - exclude 'messages' from kwargs to avoid conflict
254
+ bedrock_kwargs = {k: v for k, v in kwargs.items() if k != "messages"}
255
+ payload = create_bedrock_payload(messages, **bedrock_kwargs)
256
+
257
+ # Create and return BedrockStreamWrapper
258
+ return BedrockStreamWrapper(
259
+ model=model,
260
+ payload=payload,
261
+ messages=messages,
262
+ region=kwargs.get("region"),
263
+ usage_metadata=usage_metadata,
264
+ request_time_dt=request_time_dt,
265
+ request_time=request_time
266
+ )
267
+
268
+
269
+ def _extract_trace_fields(usage_metadata, request_body=None):
270
+ """
271
+ Extract trace visualization fields from usage_metadata and environment variables.
272
+
273
+ Args:
274
+ usage_metadata: Dictionary containing usage metadata
275
+ request_body: Optional request body for operation type detection
276
+
277
+ Returns:
278
+ Dictionary with trace visualization fields
279
+ """
280
+ # Get trace fields (usage_metadata takes precedence over environment variables)
281
+ environment = usage_metadata.get('environment') or get_environment()
282
+ region = usage_metadata.get('region') or get_region()
283
+ credential_alias = (
284
+ usage_metadata.get('credentialAlias') or
285
+ usage_metadata.get('credential_alias') or
286
+ get_credential_alias()
287
+ )
288
+ trace_type = (
289
+ usage_metadata.get('traceType') or
290
+ usage_metadata.get('trace_type') or
291
+ get_trace_type()
292
+ )
293
+ trace_name = (
294
+ usage_metadata.get('traceName') or
295
+ usage_metadata.get('trace_name') or
296
+ get_trace_name()
297
+ )
298
+ parent_transaction_id = (
299
+ usage_metadata.get('parentTransactionId') or
300
+ usage_metadata.get('parent_transaction_id') or
301
+ get_parent_transaction_id()
302
+ )
303
+ transaction_name = (
304
+ usage_metadata.get('transactionName') or
305
+ usage_metadata.get('transaction_name') or
306
+ get_transaction_name(usage_metadata)
307
+ )
308
+ retry_number = usage_metadata.get(
309
+ 'retryNumber',
310
+ usage_metadata.get('retry_number', get_retry_number())
311
+ )
312
+
313
+ # Detect operation type and subtype
314
+ request_body = request_body or {}
315
+ operation_info = detect_operation_type(
316
+ 'anthropic', '/messages', request_body
317
+ )
318
+ operation_type = operation_info.get('operationType')
319
+ operation_subtype = operation_info.get('operationSubtype')
320
+
321
+ # Detect vision content in messages
322
+ messages = request_body.get('messages', [])
323
+ has_vision_content = detect_vision_content(messages)
324
+
325
+ return {
326
+ 'environment': environment,
327
+ 'region': region,
328
+ 'credential_alias': credential_alias,
329
+ 'trace_type': trace_type,
330
+ 'trace_name': trace_name,
331
+ 'parent_transaction_id': parent_transaction_id,
332
+ 'transaction_name': transaction_name,
333
+ 'retry_number': retry_number,
334
+ 'operation_type': operation_type,
335
+ 'operation_subtype': operation_subtype,
336
+ 'has_vision_content': has_vision_content,
337
+ }
338
+
339
+
340
+ def _create_bedrock_metering_call(response, usage_metadata, request_time, response_time, request_duration, request_kwargs=None):
341
+ """Create a metering call for Bedrock usage."""
342
+
343
+ # Get provider metadata
344
+ provider_metadata = get_provider_metadata(Provider.BEDROCK)
345
+
346
+ async def metering_call():
347
+ try:
348
+ from revenium_middleware import shutdown_event
349
+
350
+ if shutdown_event.is_set():
351
+ logger.warning("Skipping metering call during shutdown")
352
+ return
353
+
354
+ logger.debug("Metering call to Revenium for Bedrock completion %s", response.id)
355
+
356
+ # Get thread-safe client
357
+ client = _get_thread_safe_client()
358
+ if not client:
359
+ logger.warning("No thread-safe client available for Bedrock metering")
360
+ return
361
+
362
+ # Build subscriber object like Anthropic calls
363
+ subscriber = {}
364
+ if usage_metadata.get("subscriber_id"):
365
+ subscriber["id"] = usage_metadata.get("subscriber_id")
366
+ if usage_metadata.get("subscriber_email"):
367
+ subscriber["email"] = usage_metadata.get("subscriber_email")
368
+ if usage_metadata.get("subscriber_credential_name"):
369
+ subscriber["credential"] = {
370
+ "name": usage_metadata.get("subscriber_credential_name"),
371
+ "value": usage_metadata.get("subscriber_credential")
372
+ }
373
+
374
+ # Extract trace visualization fields (pass request kwargs for vision detection)
375
+ trace_fields = _extract_trace_fields(usage_metadata, request_kwargs)
376
+
377
+ # Build extra_body for additional fields not in SDK
378
+ extra_body = {}
379
+ if trace_fields.get('has_vision_content'):
380
+ extra_body['hasVisionContent'] = True
381
+
382
+ # Extract organization and product names with backward compatibility
383
+ organization_name, product_name = _extract_organization_and_product_names(usage_metadata)
384
+
385
+ result = client.ai.create_completion(
386
+ cache_creation_token_count=0, # Bedrock doesn't provide cache info yet
387
+ cache_read_token_count=0,
388
+ input_token_cost=None, # Backend calculates pricing
389
+ output_token_cost=None,
390
+ total_cost=None,
391
+ output_token_count=response.usage.output_tokens,
392
+ cost_type="AI",
393
+ model=response.model,
394
+ input_token_count=response.usage.input_tokens,
395
+ provider=provider_metadata["provider"],
396
+ model_source=provider_metadata["model_source"],
397
+ reasoning_token_count=0,
398
+ request_time=request_time,
399
+ response_time=response_time,
400
+ completion_start_time=response_time,
401
+ request_duration=int(request_duration),
402
+ time_to_first_token=int(request_duration), # For non-streaming
403
+ stop_reason="END", # Simplified for MVP
404
+ total_token_count=response.usage.total_tokens,
405
+ transaction_id=response.id,
406
+ trace_id=usage_metadata.get("trace_id"),
407
+ task_type=usage_metadata.get("task_type"),
408
+ subscriber=subscriber if subscriber else None,
409
+ organization_name=organization_name,
410
+ subscription_id=usage_metadata.get("subscription_id"),
411
+ product_name=product_name,
412
+ agent=usage_metadata.get("agent"),
413
+ response_quality_score=usage_metadata.get("response_quality_score"),
414
+ is_streamed=False,
415
+ operation_type=trace_fields.get('operation_type', 'CHAT'),
416
+ # Trace visualization fields
417
+ environment=trace_fields.get('environment'),
418
+ region=trace_fields.get('region'),
419
+ credential_alias=trace_fields.get('credential_alias'),
420
+ trace_type=trace_fields.get('trace_type'),
421
+ trace_name=trace_fields.get('trace_name'),
422
+ parent_transaction_id=trace_fields.get('parent_transaction_id'),
423
+ transaction_name=trace_fields.get('transaction_name'),
424
+ retry_number=trace_fields.get('retry_number'),
425
+ operation_subtype=trace_fields.get('operation_subtype'),
426
+ # Additional fields via extra_body
427
+ extra_body=extra_body if extra_body else None,
428
+ )
429
+ logger.debug("Bedrock metering call result: %s", result)
430
+ except Exception as e:
431
+ from revenium_middleware import shutdown_event
432
+ if not shutdown_event.is_set():
433
+ logger.warning(f"Error in Bedrock metering call: {str(e)}")
434
+ import traceback
435
+ logger.warning(f"Traceback: {traceback.format_exc()}")
436
+
437
+ thread = _safe_run_async_in_thread(metering_call)
438
+ logger.debug("Bedrock metering thread started: %s", thread)
439
+
440
+
441
+ def extract_usage_metadata_and_timing(kwargs: dict, operation_name: str = "operation"):
442
+ """
443
+ Extract usage metadata from kwargs.
444
+ Provides robust error handling for malformed metadata structures.
445
+
446
+ Args:
447
+ kwargs: The kwargs dict to extract from (will be modified)
448
+ operation_name: Name of operation for logging (e.g., "create", "stream")
449
+
450
+ Returns:
451
+ tuple: (usage_metadata, request_time, request_time_dt)
452
+ """
453
+ # Extract API-level metadata from kwargs
454
+ api_metadata = kwargs.pop("usage_metadata", {})
455
+
456
+ # Validate and sanitize API-level metadata
457
+ if not isinstance(api_metadata, dict):
458
+ logger.warning(f"usage_metadata for {operation_name} should be a dict, got {type(api_metadata)}. Using empty dict.")
459
+ api_metadata = {}
460
+
461
+ # Merge with decorator metadata (API-level takes precedence)
462
+ usage_metadata = merge_metadata(api_metadata)
463
+ logger.debug(f"Merged decorator metadata for {operation_name}: {usage_metadata}")
464
+
465
+ # Sanitize metadata structure (defensive programming)
466
+ usage_metadata = _sanitize_metadata(usage_metadata, operation_name)
467
+
468
+ # Create request timestamp
469
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
470
+ request_time = request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
471
+
472
+ # Debug logging
473
+ logger.debug(f"Usage metadata for {operation_name}: %s", usage_metadata)
474
+
475
+ return usage_metadata, request_time, request_time_dt
476
+
477
+
478
+ def _sanitize_metadata(metadata: dict, operation_name: str, max_depth: int = 5, current_depth: int = 0) -> dict:
479
+ """
480
+ Sanitize metadata structure to prevent issues with deeply nested objects
481
+ or problematic data types that could break metering calls.
482
+
483
+ Args:
484
+ metadata: The metadata dict to sanitize
485
+ operation_name: Operation name for logging
486
+ max_depth: Maximum allowed nesting depth
487
+ current_depth: Current recursion depth
488
+
489
+ Returns:
490
+ dict: Sanitized metadata
491
+ """
492
+ if current_depth > max_depth:
493
+ logger.warning(f"Metadata for {operation_name} exceeds maximum depth {max_depth}. Truncating.")
494
+ return {}
495
+
496
+ if not isinstance(metadata, dict):
497
+ return {}
498
+
499
+ sanitized = {}
500
+ for key, value in metadata.items():
501
+ # Ensure key is a string
502
+ if not isinstance(key, str):
503
+ key = str(key)
504
+
505
+ # Sanitize value based on type
506
+ if isinstance(value, dict):
507
+ sanitized[key] = _sanitize_metadata(value, operation_name, max_depth, current_depth + 1)
508
+ elif isinstance(value, (list, tuple)):
509
+ # Convert lists/tuples to strings to avoid complex nested structures
510
+ sanitized[key] = str(value)
511
+ elif isinstance(value, (str, int, float, bool)):
512
+ sanitized[key] = value
513
+ elif value is None:
514
+ sanitized[key] = None
515
+ else:
516
+ # Convert other types to string
517
+ sanitized[key] = str(value)
518
+
519
+ return sanitized
520
+
521
+
522
+ def _extract_organization_and_product_names(usage_metadata: dict) -> tuple:
523
+ """
524
+ Extract organization name and product name from usage metadata.
525
+
526
+ Supports both new field names (organizationName, productName) and
527
+ deprecated field names (organizationId, productId) for backward compatibility.
528
+ New field names take precedence over deprecated ones.
529
+
530
+ Precedence order (highest to lowest):
531
+ 1. organizationName / productName (camelCase - API-level, documented)
532
+ 2. organization_name / product_name (snake_case - alternative)
533
+ 3. organizationId / productId (camelCase - deprecated)
534
+ 4. organization_id / product_id (snake_case - deprecated)
535
+
536
+ Args:
537
+ usage_metadata: Dictionary containing usage metadata
538
+
539
+ Returns:
540
+ tuple: (organization_name, product_name)
541
+ """
542
+ # Extract organization name (support both new and deprecated field names)
543
+ # Using 'or' logic to handle empty strings and fallback properly
544
+ # Check camelCase first (API-level, documented format), then snake_case, then deprecated
545
+ organization_name = (
546
+ usage_metadata.get("organizationName")
547
+ or usage_metadata.get("organization_name")
548
+ or usage_metadata.get("organizationId")
549
+ or usage_metadata.get("organization_id")
550
+ )
551
+
552
+ # Emit deprecation warning if using old field names
553
+ if not (usage_metadata.get("organizationName") or usage_metadata.get("organization_name")):
554
+ if usage_metadata.get("organizationId") or usage_metadata.get("organization_id"):
555
+ logger.warning(
556
+ "Fields 'organizationId' and 'organization_id' are deprecated. "
557
+ "Use 'organizationName' instead. "
558
+ "The old fields will be removed in a future version."
559
+ )
560
+
561
+ # Extract product name (support both new and deprecated field names)
562
+ # Using 'or' logic to handle empty strings and fallback properly
563
+ # Check camelCase first (API-level, documented format), then snake_case, then deprecated
564
+ product_name = (
565
+ usage_metadata.get("productName")
566
+ or usage_metadata.get("product_name")
567
+ or usage_metadata.get("productId")
568
+ or usage_metadata.get("product_id")
569
+ )
570
+
571
+ # Emit deprecation warning if using old field names
572
+ if not (usage_metadata.get("productName") or usage_metadata.get("product_name")):
573
+ if usage_metadata.get("productId") or usage_metadata.get("product_id"):
574
+ logger.warning(
575
+ "Fields 'productId' and 'product_id' are deprecated. "
576
+ "Use 'productName' instead. "
577
+ "The old fields will be removed in a future version."
578
+ )
579
+
580
+ return organization_name, product_name
581
+
582
+
583
+ @wrapt.patch_function_wrapper('anthropic.resources.messages.messages', 'Messages.create')
584
+ def create_wrapper(wrapped, instance, args, kwargs):
585
+ """
586
+ Wraps the anthropic.ChatCompletion.create method to log token usage.
587
+ Now supports both direct Anthropic API and AWS Bedrock routing.
588
+ """
589
+ logger.debug("Anthropic client.messages.create wrapper called: %s: %s", wrapped, args)
590
+
591
+ # Extract usage metadata and timing using shared handler
592
+ usage_metadata, request_time, request_time_dt = extract_usage_metadata_and_timing(kwargs, "create")
593
+
594
+ # Check if Bedrock is disabled via environment variable
595
+ if os.getenv("REVENIUM_BEDROCK_DISABLE") == "1":
596
+ logger.debug("Bedrock support disabled via REVENIUM_BEDROCK_DISABLE")
597
+ provider = Provider.ANTHROPIC
598
+ else:
599
+ # Detect provider based on client and parameters
600
+ client_instance = getattr(instance, '_client', None) if instance else None
601
+ base_url = kwargs.get('base_url', None)
602
+ provider = detect_provider(client=client_instance, base_url=base_url)
603
+
604
+ logger.debug(f"Detected provider: {provider}")
605
+
606
+ # Route to appropriate handler
607
+ if provider == Provider.BEDROCK:
608
+ try:
609
+ logger.debug("Routing to Bedrock handler")
610
+ return _handle_bedrock_request(args, kwargs, usage_metadata, request_time_dt, request_time)
611
+ except (BedrockValidationError, BedrockInvokeError) as e:
612
+ logger.error(f"Bedrock request failed: {e}. Falling back to direct Anthropic API.")
613
+ # Fall back to direct Anthropic API on Bedrock-specific errors
614
+ provider = Provider.ANTHROPIC
615
+ except ImportError as e:
616
+ logger.error(f"Bedrock dependencies not available: {e}. Falling back to direct Anthropic API.")
617
+ # Fall back to direct Anthropic API if boto3 not installed
618
+ provider = Provider.ANTHROPIC
619
+ except Exception as e:
620
+ logger.error(f"Unexpected error in Bedrock handler: {e}. Falling back to direct Anthropic API.")
621
+ # Fall back to direct Anthropic API on unexpected errors
622
+ provider = Provider.ANTHROPIC
623
+
624
+ # Handle direct Anthropic API (original logic)
625
+ logger.debug("REVENIUM MIDDLEWARE: Calling client.messages.create with args: %s, kwargs: %s", args, kwargs)
626
+
627
+ # Capture request kwargs for vision detection before calling wrapped
628
+ # (need to preserve messages for detecting vision content in metering call)
629
+ request_kwargs = dict(kwargs)
630
+
631
+ response = wrapped(*args, **kwargs)
632
+ logger.debug("REVENIUM MIDDLEWARE: Received response from client.messages.create: %s", response.id)
633
+ logger.debug(
634
+ "Anthropic client.messages.create response: %s",
635
+ response)
636
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
637
+ response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
638
+ request_duration = (response_time_dt - request_time_dt).total_seconds() * 1000
639
+ response_id = response.id
640
+
641
+ prompt_tokens = response.usage.input_tokens
642
+ completion_tokens = response.usage.output_tokens
643
+ cache_creation_input_tokens = response.usage.cache_creation_input_tokens
644
+ cache_read_input_tokens = response.usage.cache_read_input_tokens
645
+
646
+ logger.debug(
647
+ "Anthropic client.ai.create_completion token usage - prompt: %d, completion: %d, "
648
+ "cache_creation_input_tokens: %d,cache_read_input_tokens: %d",
649
+ prompt_tokens, completion_tokens, cache_creation_input_tokens, cache_read_input_tokens
650
+ )
651
+
652
+ anthropic_finish_reason = None
653
+ if response.stop_reason:
654
+ anthropic_finish_reason = response.stop_reason
655
+
656
+ finish_reason_map = {
657
+ "end_turn": "END",
658
+ "tool_use": "END_SEQUENCE",
659
+ "max_tokens": "TOKEN_LIMIT",
660
+ "content_filter": "ERROR"
661
+ }
662
+ stop_reason = finish_reason_map.get(anthropic_finish_reason, "end_turn") # type: ignore
663
+
664
+ # Extract prompt data if capture is enabled
665
+ (system_prompt, input_messages, output_response, prompts_truncated) = (
666
+ extract_prompt_data_if_enabled(kwargs, response=response)
667
+ )
668
+
669
+ # Get provider metadata for metering
670
+ provider_metadata = get_provider_metadata(provider)
671
+
672
+ async def metering_call():
673
+ try:
674
+ from revenium_middleware import shutdown_event
675
+
676
+ if shutdown_event.is_set():
677
+ logger.warning("Skipping metering call during shutdown")
678
+ return
679
+ logger.debug("Metering call to Revenium for completion %s with usage_metadata: %s", response_id,
680
+ usage_metadata)
681
+
682
+ # Get thread-safe client
683
+ client = _get_thread_safe_client()
684
+ if not client:
685
+ logger.warning("No thread-safe client available for metering")
686
+ return
687
+
688
+ # Create subscriber object from usage metadata
689
+ subscriber = extract_subscriber_from_metadata(usage_metadata)
690
+
691
+ # Extract trace visualization fields (pass request kwargs for vision detection)
692
+ trace_fields = _extract_trace_fields(usage_metadata, request_kwargs)
693
+
694
+ # Build extra_body for additional fields not in SDK
695
+ extra_body = {}
696
+ if trace_fields.get('has_vision_content'):
697
+ extra_body['hasVisionContent'] = True
698
+
699
+ # Extract organization and product names with backward compatibility
700
+ organization_name, product_name = _extract_organization_and_product_names(usage_metadata)
701
+
702
+ result = client.ai.create_completion(
703
+ cache_creation_token_count=cache_creation_input_tokens,
704
+ cache_read_token_count=cache_read_input_tokens,
705
+ input_token_cost=None,
706
+ output_token_cost=None,
707
+ total_cost=None,
708
+ output_token_count=completion_tokens,
709
+ cost_type="AI",
710
+ model=response.model,
711
+ input_token_count=prompt_tokens,
712
+ provider=provider_metadata["provider"],
713
+ model_source=provider_metadata["model_source"],
714
+ reasoning_token_count=0,
715
+ request_time=request_time,
716
+ response_time=response_time,
717
+ completion_start_time=response_time,
718
+ request_duration=int(request_duration),
719
+ time_to_first_token=int(request_duration), # For non-streaming, use the full request duration
720
+ stop_reason=stop_reason,
721
+ total_token_count=prompt_tokens + completion_tokens,
722
+ transaction_id=response_id,
723
+ trace_id=usage_metadata.get("trace_id"),
724
+ task_type=usage_metadata.get("task_type"),
725
+ subscriber=subscriber if subscriber else None,
726
+ organization_name=organization_name,
727
+ subscription_id=usage_metadata.get("subscription_id"),
728
+ product_name=product_name,
729
+ agent=usage_metadata.get("agent"),
730
+ response_quality_score=usage_metadata.get("response_quality_score"),
731
+ is_streamed=False,
732
+ operation_type=trace_fields.get('operation_type', 'CHAT'),
733
+ middleware_source="PYTHON",
734
+ # Trace visualization fields
735
+ environment=trace_fields.get('environment'),
736
+ region=trace_fields.get('region'),
737
+ credential_alias=trace_fields.get('credential_alias'),
738
+ trace_type=trace_fields.get('trace_type'),
739
+ trace_name=trace_fields.get('trace_name'),
740
+ parent_transaction_id=trace_fields.get('parent_transaction_id'),
741
+ transaction_name=trace_fields.get('transaction_name'),
742
+ retry_number=trace_fields.get('retry_number'),
743
+ operation_subtype=trace_fields.get('operation_subtype'),
744
+ # Prompt capture fields
745
+ system_prompt=system_prompt,
746
+ input_messages=input_messages,
747
+ output_response=output_response,
748
+ prompts_truncated=prompts_truncated,
749
+ # Additional fields via extra_body (for vision detection)
750
+ extra_body=extra_body if extra_body else None,
751
+ )
752
+ logger.debug("Metering call result: %s", result)
753
+ # Treat any successful resource response as success; only warn on explicit failure
754
+ success = False
755
+ try:
756
+ if result is None:
757
+ success = False
758
+ elif hasattr(result, 'status_code'):
759
+ status_code = int(getattr(result, 'status_code', 0) or 0)
760
+ success = 200 <= status_code < 300
761
+ elif hasattr(result, 'resource_type') or hasattr(result, 'resourceType') or hasattr(result, 'id'):
762
+ # Revenium SDK returns a resource object on success (e.g., MeteringResponseResource)
763
+ success = True
764
+ else:
765
+ # Unknown shape but non-empty result; assume success
766
+ success = True
767
+ except Exception:
768
+ success = False
769
+
770
+ if success:
771
+ logger.debug("[REVENIUM SUCCESS] Metering call successful for transaction %s", response_id)
772
+ else:
773
+ logger.warning("[REVENIUM ERROR] Metering call did not return success for transaction %s: %s", response_id, result)
774
+
775
+ # Print usage summary if enabled (async, fire-and-forget)
776
+ # Print regardless of metering success - user should see API usage even if metering fails
777
+ _emit_usage_summary(
778
+ model=response.model,
779
+ provider=provider_metadata["provider"],
780
+ request_duration=request_duration,
781
+ prompt_tokens=prompt_tokens,
782
+ completion_tokens=completion_tokens,
783
+ response_id=response_id,
784
+ usage_metadata=usage_metadata,
785
+ )
786
+ except Exception as e:
787
+ from revenium_middleware import shutdown_event
788
+ if not shutdown_event.is_set():
789
+ logger.warning(f"Error in metering call: {str(e)}")
790
+ # Log the full traceback for better debugging
791
+ import traceback
792
+ logger.warning(f"Traceback: {traceback.format_exc()}")
793
+
794
+ thread = _safe_run_async_in_thread(metering_call)
795
+ logger.debug("Metering thread started: %s", thread)
796
+ return response
797
+
798
+
799
+ @wrapt.patch_function_wrapper('anthropic.resources.messages.messages', 'Messages.stream')
800
+ def stream_wrapper(wrapped, instance, args, kwargs):
801
+ """
802
+ Wraps the anthropic.resources.messages.Messages.stream method to log token usage.
803
+ Extracts usage data from the final message of the stream.
804
+
805
+ Note: Bedrock streaming is not yet supported in MVP. Falls back to direct Anthropic API.
806
+ """
807
+ logger.debug("REVENIUM MIDDLEWARE: Intercepted client.messages.stream call - wrapper active")
808
+
809
+ # Extract usage metadata and timing using shared handler
810
+ usage_metadata, request_time, request_time_dt = extract_usage_metadata_and_timing(kwargs, "stream")
811
+
812
+ # Check if this would be a Bedrock request
813
+ if os.getenv("REVENIUM_BEDROCK_DISABLE") != "1":
814
+ client_instance = getattr(instance, '_client', None) if instance else None
815
+ base_url = kwargs.get('base_url', None)
816
+ provider = detect_provider(client=client_instance, base_url=base_url)
817
+
818
+ if provider == Provider.BEDROCK:
819
+ try:
820
+ logger.debug("Routing streaming request to Bedrock handler")
821
+ return _handle_bedrock_stream_request(args, kwargs, usage_metadata, request_time_dt, request_time)
822
+ except (BedrockValidationError, BedrockStreamError) as e:
823
+ logger.error(f"Bedrock streaming request failed: {e}. Falling back to direct Anthropic API.")
824
+ # Fall back to direct Anthropic API on Bedrock-specific errors
825
+ except ImportError as e:
826
+ logger.error(f"Bedrock dependencies not available: {e}. Falling back to direct Anthropic API.")
827
+ # Fall back to direct Anthropic API if boto3 not installed
828
+ except Exception as e:
829
+ logger.error(f"Unexpected error in Bedrock streaming handler: {e}. Falling back to direct Anthropic API.")
830
+ # Fall back to direct Anthropic API on unexpected errors
831
+
832
+ logger.debug("REVENIUM MIDDLEWARE: Calling client.messages.stream with args: %s, kwargs: %s", args, kwargs)
833
+
834
+ # Capture request kwargs for vision detection before calling wrapped
835
+ # (need to preserve messages for detecting vision content in metering call)
836
+ request_kwargs = dict(kwargs)
837
+
838
+ stream = wrapped(*args, **kwargs)
839
+ logger.debug("REVENIUM MIDDLEWARE: Received stream from client.messages.stream")
840
+
841
+ # Create a wrapper for the stream that will capture the final message
842
+ class StreamWrapper:
843
+ def __init__(self, stream):
844
+ self.stream = stream
845
+ self.response_time_dt = None
846
+ self.response_id = None
847
+ self.collected_content = []
848
+ self.final_message = None
849
+ self.first_token_time = None
850
+ self.request_start_time = time.time() * 1000 # Convert to milliseconds
851
+
852
+ def __enter__(self):
853
+ self.stream_context = self.stream.__enter__()
854
+ return self
855
+
856
+ def __exit__(self, exc_type, exc_val, exc_tb):
857
+ result = self.stream.__exit__(exc_type, exc_val, exc_tb)
858
+
859
+ # Get the final message with usage information
860
+ try:
861
+ self.final_message = self.stream_context.get_final_message()
862
+ self.response_time_dt = datetime.datetime.now(datetime.timezone.utc)
863
+ self.response_time = self.response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
864
+ request_duration = (self.response_time_dt - request_time_dt).total_seconds() * 1000
865
+
866
+ self.response_id = self.final_message.id
867
+
868
+ prompt_tokens = self.final_message.usage.input_tokens
869
+ completion_tokens = self.final_message.usage.output_tokens
870
+ cache_creation_input_tokens = self.final_message.usage.cache_creation_input_tokens
871
+ cache_read_input_tokens = self.final_message.usage.cache_read_input_tokens
872
+
873
+ logger.debug(
874
+ "Anthropic client.messages.stream token usage - prompt: %d, completion: %d, "
875
+ "cache_creation_input_tokens: %d, cache_read_input_tokens: %d",
876
+ prompt_tokens, completion_tokens, cache_creation_input_tokens, cache_read_input_tokens
877
+ )
878
+
879
+ anthropic_finish_reason = None
880
+ if self.final_message.stop_reason:
881
+ anthropic_finish_reason = self.final_message.stop_reason
882
+
883
+ finish_reason_map = {
884
+ "end_turn": "END",
885
+ "tool_use": "END_SEQUENCE",
886
+ "max_tokens": "TOKEN_LIMIT",
887
+ "content_filter": "ERROR"
888
+ }
889
+ stop_reason = finish_reason_map.get(anthropic_finish_reason, "end_turn") # type: ignore
890
+
891
+ # Extract prompt data if capture is enabled (for streaming)
892
+ accumulated_content = ''.join(self.collected_content)
893
+ (system_prompt, input_messages, output_response, prompts_truncated) = (
894
+ extract_prompt_data_if_enabled(kwargs, accumulated_content=accumulated_content)
895
+ )
896
+
897
+ # For streaming, we always use Anthropic provider metadata since Bedrock streaming falls back
898
+ provider_metadata = get_provider_metadata(Provider.ANTHROPIC)
899
+
900
+ async def metering_call():
901
+ try:
902
+ from revenium_middleware import shutdown_event
903
+
904
+ if shutdown_event.is_set():
905
+ logger.warning("Skipping metering call during shutdown")
906
+ return
907
+ logger.debug("Metering call to Revenium for stream completion %s", self.response_id)
908
+
909
+ # Get thread-safe client
910
+ client = _get_thread_safe_client()
911
+ if not client:
912
+ logger.warning("No thread-safe client available for stream metering")
913
+ return
914
+
915
+ # Create subscriber object from usage metadata
916
+ subscriber = extract_subscriber_from_metadata(usage_metadata)
917
+
918
+ # Extract trace visualization fields (pass request kwargs for vision detection)
919
+ trace_fields = _extract_trace_fields(usage_metadata, request_kwargs)
920
+
921
+ # Build extra_body for additional fields not in SDK
922
+ extra_body = {}
923
+ if trace_fields.get('has_vision_content'):
924
+ extra_body['hasVisionContent'] = True
925
+
926
+ # Extract organization and product names with backward compatibility
927
+ organization_name, product_name = _extract_organization_and_product_names(usage_metadata)
928
+
929
+ result = client.ai.create_completion(
930
+ cache_creation_token_count=cache_creation_input_tokens,
931
+ cache_read_token_count=cache_read_input_tokens,
932
+ input_token_cost=None,
933
+ output_token_cost=None,
934
+ total_cost=None,
935
+ output_token_count=completion_tokens,
936
+ cost_type="AI",
937
+ model=self.final_message.model,
938
+ input_token_count=prompt_tokens,
939
+ provider=provider_metadata["provider"],
940
+ model_source=provider_metadata["model_source"],
941
+ reasoning_token_count=0,
942
+ request_time=request_time,
943
+ response_time=self.response_time,
944
+ completion_start_time=self.response_time,
945
+ request_duration=int(request_duration),
946
+ time_to_first_token=int(
947
+ self.first_token_time - self.request_start_time) if self.first_token_time else 0,
948
+ stop_reason=stop_reason,
949
+ total_token_count=prompt_tokens + completion_tokens,
950
+ transaction_id=self.response_id,
951
+ trace_id=usage_metadata.get("trace_id"),
952
+ task_type=usage_metadata.get("task_type"),
953
+ subscriber=subscriber if subscriber else None,
954
+ organization_name=organization_name,
955
+ subscription_id=usage_metadata.get("subscription_id"),
956
+ product_name=product_name,
957
+ agent=usage_metadata.get("agent"),
958
+ is_streamed=True,
959
+ operation_type=trace_fields.get('operation_type', 'CHAT'),
960
+ response_quality_score=usage_metadata.get("response_quality_score"),
961
+ middleware_source="PYTHON",
962
+ # Trace visualization fields
963
+ environment=trace_fields.get('environment'),
964
+ region=trace_fields.get('region'),
965
+ credential_alias=trace_fields.get('credential_alias'),
966
+ trace_type=trace_fields.get('trace_type'),
967
+ trace_name=trace_fields.get('trace_name'),
968
+ parent_transaction_id=trace_fields.get('parent_transaction_id'),
969
+ transaction_name=trace_fields.get('transaction_name'),
970
+ retry_number=trace_fields.get('retry_number'),
971
+ operation_subtype=trace_fields.get('operation_subtype'),
972
+ # Prompt capture fields
973
+ system_prompt=system_prompt,
974
+ input_messages=input_messages,
975
+ output_response=output_response,
976
+ prompts_truncated=prompts_truncated,
977
+ # Additional fields via extra_body (for vision detection)
978
+ extra_body=extra_body if extra_body else None,
979
+ )
980
+ logger.debug("Metering call result for stream: %s", result)
981
+ # Treat any successful resource response as success; only warn on explicit failure
982
+ success = False
983
+ try:
984
+ if result is None:
985
+ success = False
986
+ elif hasattr(result, 'status_code'):
987
+ status_code = int(getattr(result, 'status_code', 0) or 0)
988
+ success = 200 <= status_code < 300
989
+ elif hasattr(result, 'resource_type') or hasattr(result, 'resourceType') or hasattr(result, 'id'):
990
+ success = True
991
+ else:
992
+ success = True
993
+ except Exception:
994
+ success = False
995
+
996
+ if success:
997
+ logger.debug("[REVENIUM SUCCESS] Streaming metering call successful for transaction %s", self.response_id)
998
+ else:
999
+ logger.warning(
1000
+ "[REVENIUM ERROR] Streaming metering call did not return success for transaction %s: %s",
1001
+ self.response_id, result
1002
+ )
1003
+
1004
+ # Print usage summary if enabled (async, fire-and-forget)
1005
+ # Print regardless of metering success - user should see API usage even if metering fails
1006
+ _emit_usage_summary(
1007
+ model=self.final_message.model,
1008
+ provider=provider_metadata["provider"],
1009
+ request_duration=request_duration,
1010
+ prompt_tokens=prompt_tokens,
1011
+ completion_tokens=completion_tokens,
1012
+ response_id=self.response_id,
1013
+ usage_metadata=usage_metadata,
1014
+ )
1015
+ except Exception as e:
1016
+ from revenium_middleware import shutdown_event
1017
+ if not shutdown_event.is_set():
1018
+ logger.warning(f"Error in metering call for stream: {str(e)}")
1019
+ # Log the full traceback for better debugging
1020
+ import traceback
1021
+ logger.warning(f"Traceback: {traceback.format_exc()}")
1022
+
1023
+ thread = _safe_run_async_in_thread(metering_call)
1024
+ logger.debug("Metering thread started for stream: %s", thread)
1025
+
1026
+ except Exception as e:
1027
+ logger.warning(f"Error processing final message from stream: {str(e)}")
1028
+ import traceback
1029
+ logger.warning(f"Traceback: {traceback.format_exc()}")
1030
+
1031
+ return result
1032
+
1033
+ @property
1034
+ def text_stream(self):
1035
+ # Create a wrapper for the text_stream that doesn't consume it
1036
+ original_text_stream = self.stream_context.text_stream
1037
+ wrapper_self = self
1038
+
1039
+ class TextStreamWrapper:
1040
+ def __iter__(self):
1041
+ return self
1042
+
1043
+ def __next__(self):
1044
+ try:
1045
+ chunk = next(original_text_stream)
1046
+ # Record the time of the first token
1047
+ if wrapper_self.first_token_time is None and chunk:
1048
+ wrapper_self.first_token_time = time.time() * 1000 # Convert to milliseconds
1049
+ return chunk
1050
+ except StopIteration:
1051
+ raise
1052
+
1053
+ return TextStreamWrapper()
1054
+
1055
+ def get_final_message(self):
1056
+ if self.final_message:
1057
+ return self.final_message
1058
+ return self.stream_context.get_final_message()
1059
+
1060
+ def __iter__(self):
1061
+ return iter(self.stream_context)
1062
+
1063
+ def __getattr__(self, name):
1064
+ return getattr(self.stream_context, name)
1065
+
1066
+ return StreamWrapper(stream)
1067
+
1068
+
1069
+ # Log middleware initialization
1070
+ logger.debug("REVENIUM MIDDLEWARE: Anthropic middleware loaded and wrappers registered")