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.
- revenium_middleware/__init__.py +184 -0
- revenium_middleware/_core/__init__.py +65 -0
- revenium_middleware/_core/config.py +165 -0
- revenium_middleware/_core/context.py +109 -0
- revenium_middleware/_core/decorators.py +202 -0
- revenium_middleware/_core/metering.py +207 -0
- revenium_middleware/_core/prompt_extraction.py +55 -0
- revenium_middleware/_core/subscriber.py +51 -0
- revenium_middleware/_core/trace_fields.py +265 -0
- revenium_middleware/anthropic/__init__.py +108 -0
- revenium_middleware/anthropic/bedrock_adapter.py +753 -0
- revenium_middleware/anthropic/config.py +29 -0
- revenium_middleware/anthropic/middleware.py +1070 -0
- revenium_middleware/anthropic/prompt_extractor.py +178 -0
- revenium_middleware/anthropic/provider.py +141 -0
- revenium_middleware/anthropic/summary_printer.py +286 -0
- revenium_middleware/anthropic/trace_fields.py +158 -0
- revenium_middleware/google/__init__.py +114 -0
- revenium_middleware/google/common/__init__.py +127 -0
- revenium_middleware/google/common/exceptions.py +137 -0
- revenium_middleware/google/common/protocols.py +192 -0
- revenium_middleware/google/common/summary_printer.py +271 -0
- revenium_middleware/google/common/trace_fields.py +205 -0
- revenium_middleware/google/common/types.py +208 -0
- revenium_middleware/google/common/utils.py +1111 -0
- revenium_middleware/google/config.py +64 -0
- revenium_middleware/google/google_ai/__init__.py +53 -0
- revenium_middleware/google/google_ai/middleware.py +667 -0
- revenium_middleware/google/google_ai/provider.py +135 -0
- revenium_middleware/google/prompt_extractor.py +396 -0
- revenium_middleware/google/vertex_ai/__init__.py +56 -0
- revenium_middleware/google/vertex_ai/middleware.py +1162 -0
- revenium_middleware/google/vertex_ai/provider.py +99 -0
- revenium_middleware/litellm/__init__.py +25 -0
- revenium_middleware/litellm/client/__init__.py +81 -0
- revenium_middleware/litellm/client/config.py +53 -0
- revenium_middleware/litellm/client/context.py +198 -0
- revenium_middleware/litellm/client/decorators.py +912 -0
- revenium_middleware/litellm/client/hooks.py +192 -0
- revenium_middleware/litellm/client/integrations/__init__.py +26 -0
- revenium_middleware/litellm/client/integrations/crewai.py +446 -0
- revenium_middleware/litellm/client/middleware.py +321 -0
- revenium_middleware/litellm/client/summary_printer.py +314 -0
- revenium_middleware/litellm/client/trace_fields.py +51 -0
- revenium_middleware/litellm/client/validation.py +207 -0
- revenium_middleware/litellm/proxy/__init__.py +25 -0
- revenium_middleware/litellm/proxy/middleware.py +217 -0
- revenium_middleware/ollama/__init__.py +28 -0
- revenium_middleware/ollama/middleware.py +569 -0
- revenium_middleware/ollama/trace_fields.py +63 -0
- revenium_middleware/openai/__init__.py +23 -0
- revenium_middleware/openai/azure_config.py +169 -0
- revenium_middleware/openai/azure_model_resolver.py +219 -0
- revenium_middleware/openai/config.py +45 -0
- revenium_middleware/openai/exceptions.py +115 -0
- revenium_middleware/openai/langchain/__init__.py +114 -0
- revenium_middleware/openai/langchain/_utils.py +129 -0
- revenium_middleware/openai/langchain/unified_handler.py +526 -0
- revenium_middleware/openai/middleware.py +1451 -0
- revenium_middleware/openai/prompt_extractor.py +173 -0
- revenium_middleware/openai/provider.py +170 -0
- revenium_middleware/openai/summary_printer.py +292 -0
- revenium_middleware/openai/trace_fields.py +98 -0
- revenium_middleware/perplexity/__init__.py +97 -0
- revenium_middleware/perplexity/middleware.py +379 -0
- revenium_middleware/perplexity/perplexity_sdk.py +256 -0
- revenium_middleware/perplexity/provider.py +84 -0
- revenium_middleware/perplexity/trace_fields.py +25 -0
- revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
- revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
- revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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}'")
|