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,753 @@
1
+ # Copyright (c) 2025 Revenium, Inc.
2
+ # Licensed under the MIT License. See LICENSE file in the project root.
3
+
4
+ """
5
+ AWS Bedrock adapter for Anthropic Claude models.
6
+
7
+ This module provides a clean interface for invoking Anthropic Claude models
8
+ through AWS Bedrock while maintaining compatibility with the direct Anthropic API.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import logging
14
+ import time
15
+ import datetime
16
+ import uuid
17
+ import hashlib
18
+ import threading
19
+
20
+ from typing import Dict, Any, Optional, Tuple, Generator, Iterator, Union, List
21
+
22
+ logger = logging.getLogger("revenium_middleware.extension")
23
+
24
+
25
+ # Custom exceptions for better error handling
26
+ class BedrockError(Exception):
27
+ """Base exception for Bedrock-related errors."""
28
+ pass
29
+
30
+
31
+ class BedrockValidationError(BedrockError):
32
+ """Exception raised for input validation errors."""
33
+ pass
34
+
35
+
36
+ class BedrockInvokeError(BedrockError):
37
+ """Exception raised for Bedrock invocation errors."""
38
+ pass
39
+
40
+
41
+ class BedrockStreamError(BedrockError):
42
+ """Exception raised for Bedrock streaming errors."""
43
+ pass
44
+
45
+
46
+ # Input validation utilities
47
+ def _validate_messages(messages: Any) -> List[Dict[str, Any]]:
48
+ """Validate messages parameter."""
49
+ if not isinstance(messages, list):
50
+ raise BedrockValidationError(f"messages must be a list, got {type(messages).__name__}")
51
+
52
+ if not messages:
53
+ raise BedrockValidationError("messages cannot be empty")
54
+
55
+ for i, message in enumerate(messages):
56
+ if not isinstance(message, dict):
57
+ raise BedrockValidationError(f"message at index {i} must be a dict, got {type(message).__name__}")
58
+
59
+ if "role" not in message:
60
+ raise BedrockValidationError(f"message at index {i} missing required 'role' field")
61
+
62
+ if "content" not in message:
63
+ raise BedrockValidationError(f"message at index {i} missing required 'content' field")
64
+
65
+ return messages
66
+
67
+
68
+ def _validate_max_tokens(max_tokens: Any) -> int:
69
+ """Validate max_tokens parameter."""
70
+ if not isinstance(max_tokens, int):
71
+ try:
72
+ max_tokens = int(max_tokens)
73
+ except (ValueError, TypeError):
74
+ raise BedrockValidationError(f"max_tokens must be an integer, got {type(max_tokens).__name__}")
75
+
76
+ if max_tokens <= 0:
77
+ raise BedrockValidationError(f"max_tokens must be positive, got {max_tokens}")
78
+
79
+ if max_tokens > 200000: # Reasonable upper limit
80
+ raise BedrockValidationError(f"max_tokens too large, got {max_tokens} (max: 200000)")
81
+
82
+ return max_tokens
83
+
84
+
85
+ def _validate_model_name(model: Any) -> str:
86
+ """Validate model name parameter."""
87
+ if not isinstance(model, str):
88
+ raise BedrockValidationError(f"model must be a string, got {type(model).__name__}")
89
+
90
+ if not model.strip():
91
+ raise BedrockValidationError("model cannot be empty")
92
+
93
+ return model.strip()
94
+
95
+
96
+ def _generate_safe_id(prefix: str = "msg_bedrock", content: str = "") -> str:
97
+ """Generate a safe, deterministic ID using UUID and content hash."""
98
+ # Create a deterministic hash of the content for reproducibility in tests
99
+ if content:
100
+ content_hash = hashlib.sha256(content.encode('utf-8')).hexdigest()[:8]
101
+ return f"{prefix}_{content_hash}_{uuid.uuid4().hex[:8]}"
102
+ else:
103
+ return f"{prefix}_{uuid.uuid4().hex[:12]}"
104
+
105
+
106
+ # Thread-safe cache configuration
107
+ _CACHE_SIZE = int(os.getenv("REVENIUM_BEDROCK_CACHE_SIZE", "32"))
108
+ _cache_lock = threading.RLock()
109
+
110
+ # Simple model mapping from Anthropic to Bedrock model IDs
111
+ _MODEL_MAP = {
112
+ "claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0",
113
+ "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0",
114
+ "claude-3-haiku-20240307": "us.anthropic.claude-3-5-haiku-20241022-v1:0",
115
+ "claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0",
116
+ "claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0",
117
+ "claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0",
118
+ }
119
+
120
+
121
+ def _import_boto3():
122
+ """Import boto3 with helpful error message if not installed."""
123
+ try:
124
+ import boto3
125
+ return boto3
126
+ except ImportError:
127
+ raise ImportError(
128
+ "boto3 is required for Bedrock support. "
129
+ "Install with: pip install revenium-python-sdk[anthropic]"
130
+ )
131
+
132
+
133
+ # Thread-safe client cache
134
+ _client_cache: Dict[str, Any] = {}
135
+
136
+
137
+ def get_bedrock_client(region: str):
138
+ """Get a cached boto3 bedrock-runtime client for the specified region."""
139
+ if not isinstance(region, str) or not region.strip():
140
+ raise BedrockValidationError(f"region must be a non-empty string, got {type(region).__name__}")
141
+
142
+ region = region.strip()
143
+
144
+ with _cache_lock:
145
+ if region in _client_cache:
146
+ return _client_cache[region]
147
+
148
+ # Limit cache size
149
+ if len(_client_cache) >= _CACHE_SIZE:
150
+ # Remove oldest entry (simple FIFO)
151
+ oldest_key = next(iter(_client_cache))
152
+ del _client_cache[oldest_key]
153
+ logger.debug(f"Removed cached client for region {oldest_key} (cache full)")
154
+
155
+ boto3 = _import_boto3()
156
+ client = boto3.client("bedrock-runtime", region_name=region)
157
+ _client_cache[region] = client
158
+ logger.debug(f"Created new Bedrock client for region {region}")
159
+ return client
160
+
161
+
162
+ def _model_id(model_name: str) -> str:
163
+ """Map Anthropic model name to Bedrock model ID."""
164
+ return _MODEL_MAP.get(model_name, f"anthropic.{model_name}")
165
+
166
+
167
+ def bedrock_invoke(model: str, payload: dict, region: Optional[str] = None) -> Tuple[str, int, int]:
168
+ """
169
+ Invoke Bedrock model with Anthropic-compatible parameters.
170
+
171
+ Args:
172
+ model: Anthropic model name (e.g., "claude-3-sonnet-20240229")
173
+ payload: Request payload in Anthropic format
174
+ region: AWS region (defaults to AWS_REGION env var or us-east-1)
175
+
176
+ Returns:
177
+ Tuple of (text_content, input_tokens, output_tokens)
178
+
179
+ Raises:
180
+ BedrockValidationError: For invalid input parameters
181
+ BedrockInvokeError: For AWS/Bedrock API errors
182
+ ImportError: If boto3 is not installed
183
+ """
184
+ # Validate inputs
185
+ model = _validate_model_name(model)
186
+
187
+ if not isinstance(payload, dict):
188
+ raise BedrockValidationError(f"payload must be a dict, got {type(payload).__name__}")
189
+
190
+ region = region or os.getenv("AWS_REGION", "us-east-1")
191
+ if not isinstance(region, str) or not region.strip():
192
+ raise BedrockValidationError(f"region must be a non-empty string, got {type(region).__name__}")
193
+
194
+ try:
195
+ client = get_bedrock_client(region.strip())
196
+ model_id = _model_id(model)
197
+
198
+ logger.debug(f"Invoking Bedrock model {model_id} in region {region}")
199
+
200
+ # Make the API call
201
+ resp = client.invoke_model(
202
+ modelId=model_id,
203
+ body=json.dumps(payload),
204
+ accept="application/json"
205
+ )
206
+
207
+ # Parse response
208
+ try:
209
+ body = json.loads(resp["body"].read())
210
+ except (json.JSONDecodeError, KeyError) as e:
211
+ raise BedrockInvokeError(f"Failed to parse Bedrock response: {e}")
212
+
213
+ usage = body.get("usage", {})
214
+
215
+ # Extract text content from content array
216
+ content_blocks = body.get("content", [])
217
+ text = "".join(
218
+ c.get("text", "")
219
+ for c in content_blocks
220
+ if c.get("type") == "text"
221
+ )
222
+
223
+ # Try multiple token field formats to find the right one
224
+ input_tokens = usage.get("inputTokens", 0) # camelCase (AWS standard)
225
+ output_tokens = usage.get("outputTokens", 0)
226
+
227
+ # Try snake_case if camelCase returns 0
228
+ if input_tokens == 0 and output_tokens == 0:
229
+ input_tokens = usage.get("input_tokens", 0)
230
+ output_tokens = usage.get("output_tokens", 0)
231
+
232
+ # Try other possible field names
233
+ if input_tokens == 0 and output_tokens == 0:
234
+ input_tokens = usage.get("prompt_tokens", 0)
235
+ output_tokens = usage.get("completion_tokens", 0)
236
+
237
+ logger.debug(f"Bedrock invoke successful: {input_tokens} input tokens, {output_tokens} output tokens")
238
+ return text, input_tokens, output_tokens
239
+
240
+ except BedrockValidationError:
241
+ # Re-raise validation errors as-is
242
+ raise
243
+ except ImportError:
244
+ # Re-raise import errors as-is
245
+ raise
246
+ except Exception as e:
247
+ # Wrap other exceptions in BedrockInvokeError
248
+ error_msg = f"Bedrock invoke failed for model {model} in region {region}: {e}"
249
+ logger.error(error_msg)
250
+ raise BedrockInvokeError(error_msg) from e
251
+
252
+
253
+ class BedrockStreamIterator:
254
+ """
255
+ Iterator for Bedrock streaming responses that tracks token usage.
256
+ """
257
+
258
+ def __init__(self, model: str, payload: dict, region: Optional[str] = None):
259
+ self.model = model
260
+ self.payload = payload
261
+ self.region = region or os.getenv("AWS_REGION", "us-east-1")
262
+ self.accumulated_text = ""
263
+ self.input_tokens = 0
264
+ self.output_tokens = 0
265
+ self._stream = None
266
+ self._started = False
267
+
268
+ def __iter__(self):
269
+ return self
270
+
271
+ def __next__(self):
272
+ if not self._started:
273
+ self._start_stream()
274
+ self._started = True
275
+
276
+ try:
277
+ return next(self._stream)
278
+ except StopIteration:
279
+ raise
280
+
281
+ def _start_stream(self):
282
+ """Initialize the Bedrock streaming connection."""
283
+ try:
284
+ client = get_bedrock_client(self.region)
285
+ model_id = _model_id(self.model)
286
+
287
+ logger.debug(f"Starting Bedrock streaming for model {model_id} in region {self.region}")
288
+
289
+ # Make the streaming API call
290
+ resp = client.invoke_model_with_response_stream(
291
+ modelId=model_id,
292
+ body=json.dumps(self.payload),
293
+ accept="application/json"
294
+ )
295
+
296
+ # Process the streaming response
297
+ stream = resp.get("body")
298
+ if not stream:
299
+ logger.error("No response body in Bedrock streaming response")
300
+ self._stream = iter([])
301
+ return
302
+
303
+ self._stream = self._process_stream(stream)
304
+
305
+ except Exception as e:
306
+ logger.error(f"Bedrock streaming invoke failed for model {self.model} in region {self.region}: {e}")
307
+ raise
308
+
309
+ def _process_stream(self, stream):
310
+ """Process the Bedrock stream and yield text chunks."""
311
+ for event in stream:
312
+ chunk = event.get("chunk")
313
+ if not chunk:
314
+ continue
315
+
316
+ chunk_bytes = chunk.get("bytes")
317
+ if not chunk_bytes:
318
+ continue
319
+
320
+ try:
321
+ chunk_data = json.loads(chunk_bytes.decode("utf-8"))
322
+ logger.debug(f"Bedrock stream chunk: {chunk_data}")
323
+
324
+ chunk_type = chunk_data.get("type")
325
+
326
+ # Handle content block delta (text chunk)
327
+ if chunk_type == "content_block_delta":
328
+ delta = chunk_data.get("delta", {})
329
+ text = delta.get("text", "")
330
+ if text:
331
+ self.accumulated_text += text
332
+ yield text
333
+
334
+ # Handle message stop (final message with usage)
335
+ elif chunk_type == "message_stop":
336
+ # Check for usage in the chunk data
337
+ usage = chunk_data.get("usage", {})
338
+ # Also check for Amazon Bedrock invocation metrics
339
+ metrics = chunk_data.get("amazon-bedrock-invocationMetrics", {})
340
+
341
+ # Try multiple token field formats
342
+ self.input_tokens = (
343
+ usage.get("inputTokens", 0) or
344
+ usage.get("input_tokens", 0) or
345
+ metrics.get("inputTokenCount", 0)
346
+ )
347
+ self.output_tokens = (
348
+ usage.get("outputTokens", 0) or
349
+ usage.get("output_tokens", 0) or
350
+ metrics.get("outputTokenCount", 0)
351
+ )
352
+
353
+ logger.debug(f"Bedrock streaming completed. Input tokens: {self.input_tokens}, Output tokens: {self.output_tokens}")
354
+
355
+ # Handle other chunk types (content_block_start, etc.)
356
+ elif chunk_type in ["content_block_start", "message_start"]:
357
+ logger.debug(f"Bedrock stream: {chunk_type}")
358
+
359
+ except json.JSONDecodeError as e:
360
+ logger.warning(f"Failed to decode Bedrock stream chunk: {e}")
361
+ continue
362
+
363
+ logger.debug(f"Bedrock streaming finished. Total text length: {len(self.accumulated_text)}")
364
+
365
+
366
+ def bedrock_invoke_stream(model: str, payload: dict, region: Optional[str] = None) -> BedrockStreamIterator:
367
+ """
368
+ Invoke Bedrock model with streaming response.
369
+
370
+ Args:
371
+ model: Anthropic model name (e.g., "claude-3-sonnet-20240229")
372
+ payload: Request payload in Anthropic format
373
+ region: AWS region (defaults to AWS_REGION env var or us-east-1)
374
+
375
+ Returns:
376
+ BedrockStreamIterator: Iterator that yields text chunks and tracks token counts
377
+
378
+ Raises:
379
+ ImportError: If boto3 is not installed
380
+ Exception: For any AWS/Bedrock API errors (re-raised with context)
381
+ """
382
+ return BedrockStreamIterator(model, payload, region)
383
+
384
+
385
+ def create_bedrock_payload(messages: Union[list, List[Dict[str, Any]]], **kwargs) -> dict:
386
+ """
387
+ Create a Bedrock-compatible payload from Anthropic parameters.
388
+
389
+ Args:
390
+ messages: List of message objects
391
+ **kwargs: Additional parameters (max_tokens, temperature, etc.)
392
+
393
+ Returns:
394
+ Dictionary formatted for Bedrock API
395
+
396
+ Raises:
397
+ BedrockValidationError: For invalid input parameters
398
+ """
399
+ # Validate inputs
400
+ validated_messages = _validate_messages(messages)
401
+ max_tokens = _validate_max_tokens(kwargs.get("max_tokens", 1000))
402
+
403
+ # Validate optional numeric parameters
404
+ temperature = kwargs.get("temperature")
405
+ if temperature is not None:
406
+ if not isinstance(temperature, (int, float)) or not (0.0 <= temperature <= 1.0):
407
+ raise BedrockValidationError(f"temperature must be a number between 0.0 and 1.0, got {temperature}")
408
+
409
+ top_p = kwargs.get("top_p")
410
+ if top_p is not None:
411
+ if not isinstance(top_p, (int, float)) or not (0.0 <= top_p <= 1.0):
412
+ raise BedrockValidationError(f"top_p must be a number between 0.0 and 1.0, got {top_p}")
413
+
414
+ top_k = kwargs.get("top_k")
415
+ if top_k is not None:
416
+ if not isinstance(top_k, int) or top_k <= 0:
417
+ raise BedrockValidationError(f"top_k must be a positive integer, got {top_k}")
418
+
419
+ payload = {
420
+ "anthropic_version": kwargs.get("anthropic_version", "bedrock-2023-05-31"),
421
+ "messages": validated_messages,
422
+ "max_tokens": max_tokens,
423
+ }
424
+
425
+ # Add optional parameters if provided and valid
426
+ system = kwargs.get("system")
427
+ if system:
428
+ if not isinstance(system, str):
429
+ raise BedrockValidationError(f"system must be a string, got {type(system).__name__}")
430
+ payload["system"] = system
431
+
432
+ if temperature is not None:
433
+ payload["temperature"] = temperature
434
+ if top_p is not None:
435
+ payload["top_p"] = top_p
436
+ if top_k is not None:
437
+ payload["top_k"] = top_k
438
+
439
+ stop_sequences = kwargs.get("stop_sequences")
440
+ if stop_sequences:
441
+ if not isinstance(stop_sequences, list):
442
+ raise BedrockValidationError(f"stop_sequences must be a list, got {type(stop_sequences).__name__}")
443
+ payload["stop_sequences"] = stop_sequences
444
+
445
+ return payload
446
+
447
+
448
+ def create_anthropic_response(text: str, input_tokens: int, output_tokens: int,
449
+ model: str, request_id: Optional[str] = None):
450
+ """
451
+ Create an Anthropic-compatible response object.
452
+
453
+ Args:
454
+ text: Generated text content
455
+ input_tokens: Number of input tokens
456
+ output_tokens: Number of output tokens
457
+ model: Model name
458
+ request_id: Optional request ID
459
+
460
+ Returns:
461
+ Object that mimics Anthropic's Message response structure with both
462
+ attribute and dictionary access support
463
+
464
+ Raises:
465
+ BedrockValidationError: For invalid input parameters
466
+ """
467
+ # Validate inputs
468
+ if not isinstance(text, str):
469
+ raise BedrockValidationError(f"text must be a string, got {type(text).__name__}")
470
+
471
+ if not isinstance(input_tokens, int) or input_tokens < 0:
472
+ raise BedrockValidationError(f"input_tokens must be a non-negative integer, got {input_tokens}")
473
+
474
+ if not isinstance(output_tokens, int) or output_tokens < 0:
475
+ raise BedrockValidationError(f"output_tokens must be a non-negative integer, got {output_tokens}")
476
+
477
+ model = _validate_model_name(model)
478
+
479
+ # Create objects that mimic Anthropic's response structure with hybrid access
480
+ class HybridAccessMixin:
481
+ """Mixin to provide both attribute and dictionary access."""
482
+
483
+ def __getitem__(self, key):
484
+ """Support dictionary-style access."""
485
+ try:
486
+ return getattr(self, key)
487
+ except AttributeError:
488
+ raise KeyError(key)
489
+
490
+ def __setitem__(self, key, value):
491
+ """Support dictionary-style assignment."""
492
+ setattr(self, key, value)
493
+
494
+ def __contains__(self, key):
495
+ """Support 'in' operator."""
496
+ return hasattr(self, key)
497
+
498
+ def get(self, key, default=None):
499
+ """Support dict.get() method."""
500
+ return getattr(self, key, default)
501
+
502
+ class TextBlock(HybridAccessMixin):
503
+ def __init__(self, text):
504
+ self.type = "text"
505
+ self.text = text
506
+
507
+ class Usage(HybridAccessMixin):
508
+ def __init__(self, input_tokens, output_tokens):
509
+ self.input_tokens = input_tokens
510
+ self.output_tokens = output_tokens
511
+ self.total_tokens = input_tokens + output_tokens
512
+ # Add cache token attributes for compatibility
513
+ self.cache_creation_input_tokens = 0
514
+ self.cache_read_input_tokens = 0
515
+
516
+ class Message(HybridAccessMixin):
517
+ def __init__(self, text, input_tokens, output_tokens, model, request_id):
518
+ self.id = request_id or _generate_safe_id("msg_bedrock", text)
519
+ self.type = "message"
520
+ self.role = "assistant"
521
+ self.model = model
522
+ self.content = [TextBlock(text)]
523
+ self.usage = Usage(input_tokens, output_tokens)
524
+ self.stop_reason = "end_turn"
525
+ self.stop_sequence = None
526
+
527
+ return Message(text, input_tokens, output_tokens, model, request_id)
528
+
529
+
530
+ class BedrockStreamWrapper:
531
+ """
532
+ Stream wrapper for Bedrock streaming responses that provides the same interface
533
+ as Anthropic's stream wrapper for compatibility.
534
+ """
535
+
536
+ def __init__(self, model: str, payload: dict, region: Optional[str] = None,
537
+ messages: Optional[list] = None,
538
+ usage_metadata: Optional[dict] = None, request_time_dt: Optional[datetime.datetime] = None,
539
+ request_time: Optional[str] = None):
540
+ self.model = model
541
+ self.payload = payload
542
+ self.messages = messages
543
+ self.region = region
544
+ self.usage_metadata = usage_metadata or {}
545
+ self.request_time_dt = request_time_dt or datetime.datetime.now(datetime.timezone.utc)
546
+ self.request_time = request_time or self.request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
547
+
548
+ # Stream state
549
+ self.stream_iterator = None
550
+ self.response_time_dt = None
551
+ self.response_id = None
552
+ self.final_message = None
553
+ self.first_token_time = None
554
+ self.request_start_time = time.time() * 1000 # Convert to milliseconds
555
+ self.accumulated_text = ""
556
+
557
+ def __enter__(self):
558
+ """Enter the context manager and initialize the stream."""
559
+ self.stream_iterator = bedrock_invoke_stream(self.model, self.payload, self.region)
560
+ return self
561
+
562
+ def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
563
+ """Exit the context manager and handle metering."""
564
+ # Get the final message with usage information
565
+ try:
566
+ self.response_time_dt = datetime.datetime.now(datetime.timezone.utc)
567
+ self.response_time = self.response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
568
+ request_duration = (self.response_time_dt - self.request_time_dt).total_seconds() * 1000
569
+
570
+ # Create final message if not already created
571
+ if not self.final_message:
572
+ self._create_final_message()
573
+
574
+ # Send metering data
575
+ self._send_metering_data(request_duration)
576
+
577
+ except Exception as e:
578
+ logger.warning(f"Error processing final message from Bedrock stream: {str(e)}")
579
+ import traceback
580
+ logger.warning(f"Traceback: {traceback.format_exc()}")
581
+
582
+ return None
583
+
584
+ @property
585
+ def text_stream(self):
586
+ """
587
+ Property that returns an iterator for text chunks.
588
+ Compatible with Anthropic's text_stream interface.
589
+ """
590
+ wrapper_self = self
591
+
592
+ class TextStreamWrapper:
593
+ def __iter__(self):
594
+ return self
595
+
596
+ def __next__(self):
597
+ try:
598
+ chunk = next(wrapper_self.stream_iterator)
599
+ # Record the time of the first token
600
+ if wrapper_self.first_token_time is None and chunk:
601
+ wrapper_self.first_token_time = time.time() * 1000 # Convert to milliseconds
602
+
603
+ # Accumulate text for final message
604
+ wrapper_self.accumulated_text += chunk
605
+ return chunk
606
+ except StopIteration:
607
+ # Stream is complete, create final message
608
+ wrapper_self._create_final_message()
609
+ raise
610
+
611
+ return TextStreamWrapper()
612
+
613
+ def get_final_message(self):
614
+ """
615
+ Get the final message with usage information.
616
+ Compatible with Anthropic's get_final_message interface.
617
+ """
618
+ if self.final_message:
619
+ return self.final_message
620
+
621
+ # If final message not created yet, create it now
622
+ self._create_final_message()
623
+ return self.final_message
624
+
625
+ def _create_final_message(self):
626
+ """Create the final message object with usage information."""
627
+ if self.final_message:
628
+ return
629
+
630
+ # Get token counts from the stream iterator
631
+ input_tokens = getattr(self.stream_iterator, 'input_tokens', 0)
632
+ output_tokens = getattr(self.stream_iterator, 'output_tokens', 0)
633
+
634
+ # Generate a response ID
635
+ self.response_id = _generate_safe_id("msg_bedrock_stream", self.accumulated_text)
636
+
637
+ # Create an Anthropic-compatible message object
638
+ self.final_message = create_anthropic_response(
639
+ text=self.accumulated_text,
640
+ input_tokens=input_tokens,
641
+ output_tokens=output_tokens,
642
+ model=self.model,
643
+ request_id=self.response_id
644
+ )
645
+
646
+ def _send_metering_data(self, request_duration: float):
647
+ """Send thread-safe metering data to Revenium."""
648
+ try:
649
+ # Import here to avoid circular imports
650
+ from revenium_middleware import shutdown_event
651
+ from .provider import Provider, get_provider_metadata
652
+ from .middleware import _get_thread_safe_client, _safe_run_async_in_thread
653
+ from .trace_fields import detect_vision_content
654
+ from revenium_middleware._core.subscriber import extract_subscriber_from_metadata
655
+
656
+ if shutdown_event.is_set():
657
+ logger.warning("Skipping metering call during shutdown")
658
+ return
659
+
660
+ if not self.final_message:
661
+ logger.warning("No final message available for metering")
662
+ return
663
+
664
+ prompt_tokens = self.final_message.usage.input_tokens
665
+ completion_tokens = self.final_message.usage.output_tokens
666
+
667
+ logger.debug(
668
+ "Bedrock streaming token usage - prompt: %d, completion: %d",
669
+ prompt_tokens, completion_tokens
670
+ )
671
+
672
+ # Use Bedrock provider metadata for streaming
673
+ provider_metadata = get_provider_metadata(Provider.BEDROCK)
674
+
675
+ # Detect vision content
676
+ has_vision_content = detect_vision_content(self.messages)
677
+
678
+ # Build extra_body for vision detection
679
+ extra_body = {}
680
+ if has_vision_content:
681
+ extra_body['hasVisionContent'] = True
682
+
683
+ async def metering_call():
684
+ try:
685
+ if shutdown_event.is_set():
686
+ logger.warning("Skipping metering call during shutdown")
687
+ return
688
+ logger.debug("Metering call to Revenium for Bedrock stream completion %s", self.response_id)
689
+
690
+ # Get thread-safe client
691
+ client = _get_thread_safe_client()
692
+ if not client:
693
+ logger.warning("No thread-safe client available for Bedrock stream metering")
694
+ return
695
+
696
+ # Build subscriber object from usage metadata
697
+ subscriber = extract_subscriber_from_metadata(self.usage_metadata)
698
+
699
+ result = client.ai.create_completion(
700
+ cache_creation_token_count=0, # Bedrock doesn't support cache tokens yet
701
+ cache_read_token_count=0,
702
+ input_token_cost=None,
703
+ output_token_cost=None,
704
+ total_cost=None,
705
+ output_token_count=completion_tokens,
706
+ cost_type="AI",
707
+ model=self.final_message.model,
708
+ input_token_count=prompt_tokens,
709
+ provider=provider_metadata["provider"],
710
+ model_source=provider_metadata["model_source"],
711
+ reasoning_token_count=0,
712
+ request_time=self.request_time,
713
+ response_time=self.response_time,
714
+ completion_start_time=self.response_time,
715
+ request_duration=int(request_duration),
716
+ time_to_first_token=int(
717
+ self.first_token_time - self.request_start_time) if self.first_token_time else 0,
718
+ stop_reason="END", # Simplified for Bedrock
719
+ total_token_count=prompt_tokens + completion_tokens,
720
+ transaction_id=self.response_id,
721
+ trace_id=self.usage_metadata.get("trace_id"),
722
+ task_type=self.usage_metadata.get("task_type"),
723
+ subscriber=subscriber if subscriber else None,
724
+ organization_id=self.usage_metadata.get("organization_id"),
725
+ subscription_id=self.usage_metadata.get("subscription_id"),
726
+ product_id=self.usage_metadata.get("product_id"),
727
+ agent=self.usage_metadata.get("agent"),
728
+ is_streamed=True,
729
+ operation_type="CHAT",
730
+ response_quality_score=self.usage_metadata.get("response_quality_score"),
731
+ middleware_source="PYTHON",
732
+ extra_body=extra_body if extra_body else None
733
+ )
734
+ logger.debug("Metering call result for Bedrock stream: %s", result)
735
+ except Exception as e:
736
+ if not shutdown_event.is_set():
737
+ logger.warning(f"Error in metering call for Bedrock stream: {str(e)}")
738
+ import traceback
739
+ logger.warning(f"Traceback: {traceback.format_exc()}")
740
+
741
+ thread = _safe_run_async_in_thread(metering_call)
742
+ logger.debug("Metering thread started for Bedrock stream: %s", thread)
743
+
744
+ except Exception as e:
745
+ logger.warning(f"Error setting up metering for Bedrock stream: {str(e)}")
746
+ import traceback
747
+ logger.warning(f"Traceback: {traceback.format_exc()}")
748
+
749
+ def __getattr__(self, name):
750
+ """Delegate unknown attributes to the stream iterator for compatibility."""
751
+ if self.stream_iterator and hasattr(self.stream_iterator, name):
752
+ return getattr(self.stream_iterator, name)
753
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")