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