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,1451 @@
1
+ import datetime
2
+ import logging
3
+ import os
4
+ import uuid
5
+ from typing import Dict, Any, Optional, Tuple
6
+ from enum import Enum
7
+
8
+ import wrapt
9
+ from revenium_middleware import client, run_async_in_thread, shutdown_event, merge_metadata
10
+ from revenium_middleware._core.subscriber import extract_subscriber_from_metadata
11
+
12
+ # Azure OpenAI support imports
13
+ from .provider import Provider, detect_provider, get_provider_metadata, is_azure_provider
14
+ from .azure_model_resolver import resolve_azure_model_name
15
+ from .azure_config import get_azure_config
16
+ from .config import Config, SecurityConfig
17
+ from .exceptions import (
18
+ ReveniumMiddlewareError, ValidationError, MeteringError,
19
+ NetworkError, AuthenticationError, categorize_exception, handle_exception_safely
20
+ )
21
+ from .summary_printer import print_usage_summary
22
+
23
+ # LangChain integration utilities
24
+ from .langchain._utils import is_langchain_available
25
+
26
+ # Prompt capture utilities
27
+ from .prompt_extractor import (
28
+ extract_prompts_from_request,
29
+ extract_response_content,
30
+ extract_streaming_response_content
31
+ )
32
+
33
+ logger = logging.getLogger("revenium_middleware.extension")
34
+
35
+
36
+ # Use centralized security configuration
37
+ SENSITIVE_FIELDS = SecurityConfig.SENSITIVE_FIELDS
38
+
39
+
40
+ def extract_prompt_data_if_enabled(
41
+ request_body: Optional[Dict[str, Any]],
42
+ response: Any = None,
43
+ accumulated_content: str = None
44
+ ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[bool]]:
45
+ """
46
+ Extract prompt data if capture is enabled.
47
+
48
+ Args:
49
+ request_body: Request body containing messages
50
+ response: API response object (for non-streaming)
51
+ accumulated_content: Accumulated streaming content (for streaming)
52
+
53
+ Returns:
54
+ Tuple of (system_prompt, input_messages, output_response, prompts_truncated)
55
+ """
56
+ if not Config.CAPTURE_PROMPTS or not request_body:
57
+ return None, None, None, None
58
+
59
+ # Extract prompts from request
60
+ prompt_data = extract_prompts_from_request(request_body)
61
+ system_prompt = prompt_data.get('systemPrompt')
62
+ input_messages = prompt_data.get('inputMessages')
63
+ prompts_truncated = prompt_data.get('promptsTruncated', False)
64
+
65
+ # Extract response content
66
+ if accumulated_content is not None:
67
+ # Streaming response
68
+ response_data = extract_streaming_response_content(
69
+ accumulated_content, prompts_truncated
70
+ )
71
+ elif response is not None:
72
+ # Non-streaming response
73
+ response_data = extract_response_content(response, prompts_truncated)
74
+ else:
75
+ response_data = {'outputResponse': None, 'promptsTruncated': prompts_truncated}
76
+
77
+ output_response = response_data.get('outputResponse')
78
+ prompts_truncated = response_data.get('promptsTruncated', prompts_truncated)
79
+
80
+ logger.debug(
81
+ f"Prompt capture - system_prompt: {bool(system_prompt)}, "
82
+ f"input_messages: {bool(input_messages)}, "
83
+ f"output_response: {bool(output_response)}, "
84
+ f"truncated: {prompts_truncated}"
85
+ )
86
+
87
+ return system_prompt, input_messages, output_response, prompts_truncated
88
+
89
+
90
+ def sanitize_for_logging(data: Any, max_depth: int = Config.MAX_SANITIZATION_DEPTH) -> Any:
91
+ """
92
+ Sanitize data for secure logging by redacting sensitive fields.
93
+
94
+ Args:
95
+ data: Data to sanitize (dict, list, or primitive)
96
+ max_depth: Maximum recursion depth to prevent infinite loops
97
+
98
+ Returns:
99
+ Sanitized data safe for logging
100
+ """
101
+ if max_depth <= 0:
102
+ return "[MAX_DEPTH_REACHED]"
103
+
104
+ if isinstance(data, dict):
105
+ sanitized = {}
106
+ for key, value in data.items():
107
+ key_lower = str(key).lower()
108
+ if any(sensitive in key_lower for sensitive in SENSITIVE_FIELDS):
109
+ sanitized[key] = "[REDACTED]"
110
+ else:
111
+ sanitized[key] = sanitize_for_logging(value, max_depth - 1)
112
+ return sanitized
113
+ elif isinstance(data, (list, tuple)):
114
+ return [sanitize_for_logging(item, max_depth - 1) for item in data]
115
+ elif isinstance(data, str) and len(data) > Config.MAX_LOG_STRING_LENGTH:
116
+ # Truncate very long strings that might contain sensitive data
117
+ return data[:Config.MAX_LOG_STRING_LENGTH] + "...[TRUNCATED]"
118
+ else:
119
+ return data
120
+
121
+
122
+ class OperationType(str, Enum):
123
+ """Operation types for AI API calls."""
124
+ # Spec-compliant values matching revenium_metering API
125
+ CHAT = "CHAT"
126
+ GENERATE = "GENERATE"
127
+ EMBED = "EMBED"
128
+ CLASSIFY = "CLASSIFY"
129
+ SUMMARIZE = "SUMMARIZE"
130
+ TRANSLATE = "TRANSLATE"
131
+ TOOL_CALL = "TOOL_CALL"
132
+ RERANK = "RERANK"
133
+ SEARCH = "SEARCH"
134
+ MODERATION = "MODERATION"
135
+ VISION = "VISION"
136
+ TRANSFORM = "TRANSFORM"
137
+ GUARDRAIL = "GUARDRAIL"
138
+ OTHER = "OTHER"
139
+
140
+
141
+ def map_operation_type_to_sdk(operation_type: OperationType) -> str:
142
+ """
143
+ Map middleware OperationType to SDK-expected operation_type values.
144
+
145
+ SDK expects: "CHAT", "GENERATE", "EMBED", "CLASSIFY", "SUMMARIZE", "TRANSLATE",
146
+ "TOOL_CALL", "RERANK", "SEARCH", "MODERATION", "VISION", "TRANSFORM",
147
+ "GUARDRAIL", "OTHER"
148
+
149
+ Since the enum values now match the SDK values, we can return the value directly.
150
+ """
151
+ return operation_type.value
152
+
153
+
154
+ # Utility functions for token usage tracking
155
+ def get_stop_reason(openai_finish_reason: Optional[str]) -> str:
156
+ """
157
+ Map OpenAI/Azure OpenAI finish reasons to Revenium stop reasons.
158
+
159
+ Supports both standard OpenAI and Azure-specific finish reasons.
160
+ All unmapped reasons default to "END" to ensure compatibility.
161
+ """
162
+ finish_reason_map = {
163
+ # Standard OpenAI finish reasons
164
+ "stop": "END",
165
+ "function_call": "END_SEQUENCE",
166
+ "timeout": "TIMEOUT",
167
+ "length": "TOKEN_LIMIT",
168
+ "content_filter": "ERROR",
169
+
170
+ # Azure OpenAI specific finish reasons
171
+ "tool_calls": "END_SEQUENCE", # Modern function calling in Azure
172
+ }
173
+
174
+ mapped_reason = finish_reason_map.get(openai_finish_reason or "", "END")
175
+
176
+ # Log unmapped finish reasons for monitoring
177
+ if openai_finish_reason and openai_finish_reason not in finish_reason_map:
178
+ logger.warning(f"Unmapped finish reason '{openai_finish_reason}' defaulting to 'END'. "
179
+ f"Consider adding mapping to ensure accurate stop reason tracking.")
180
+
181
+ return mapped_reason
182
+
183
+
184
+ def _validate_extract_usage_inputs(response: Any, operation_type: OperationType,
185
+ request_time: str, response_time: str,
186
+ request_duration: float) -> None:
187
+ """
188
+ Validate inputs for extract_usage_data function.
189
+
190
+ Args:
191
+ response: OpenAI API response object
192
+ operation_type: OperationType enum value
193
+ request_time: ISO formatted request timestamp
194
+ response_time: ISO formatted response timestamp
195
+ request_duration: Request duration in milliseconds
196
+
197
+ Raises:
198
+ ValidationError: If any input is invalid
199
+ """
200
+ if response is None:
201
+ raise ValidationError("Response object cannot be None")
202
+
203
+ if not isinstance(operation_type, OperationType):
204
+ raise ValidationError(f"operation_type must be OperationType, got {type(operation_type)}")
205
+
206
+ if not isinstance(request_time, str) or not request_time.strip():
207
+ raise ValidationError("request_time must be a non-empty string")
208
+
209
+ if not isinstance(response_time, str) or not response_time.strip():
210
+ raise ValidationError("response_time must be a non-empty string")
211
+
212
+ if not isinstance(request_duration, (int, float)) or request_duration < 0:
213
+ raise ValidationError(f"request_duration must be a non-negative number, got {request_duration}")
214
+
215
+ # Validate response has required attributes
216
+ if not hasattr(response, 'model'):
217
+ raise ValidationError("Response object must have 'model' attribute")
218
+
219
+ if not hasattr(response, 'usage'):
220
+ raise ValidationError("Response object must have 'usage' attribute")
221
+
222
+
223
+ def extract_usage_data(response, operation_type: OperationType, request_time: str, response_time: str, request_duration: float,
224
+ client_instance: Optional[Any] = None) -> Tuple[Dict[str, Any], str]:
225
+ """
226
+ Extract usage data from OpenAI/Azure OpenAI API responses.
227
+ Unified function that handles both chat and embeddings responses with provider detection.
228
+
229
+ Args:
230
+ response: OpenAI API response object (ChatCompletion or CreateEmbeddingResponse) or LegacyAPIResponse
231
+ operation_type: OperationType.CHAT or OperationType.EMBED
232
+ request_time: ISO formatted request timestamp
233
+ response_time: ISO formatted response timestamp
234
+ request_duration: Request duration in milliseconds
235
+ client_instance: OpenAI client instance for provider detection
236
+
237
+ Returns:
238
+ Tuple of (usage_data dict, transaction_id string)
239
+
240
+ Raises:
241
+ ValidationError: If inputs are invalid
242
+ """
243
+ # Handle LegacyAPIResponse from with_raw_response (used by langchain-openai)
244
+ if hasattr(response, 'parse') and callable(response.parse):
245
+ response = response.parse()
246
+
247
+ # Validate all inputs before processing
248
+ _validate_extract_usage_inputs(response, operation_type, request_time, response_time, request_duration)
249
+ # Generate transaction ID - embeddings don't have response.id, chats do
250
+ transaction_id = getattr(response, 'id', str(uuid.uuid4()))
251
+
252
+ # Detect provider for this request
253
+ provider = detect_provider(client_instance, getattr(client_instance, 'base_url', None) if client_instance else None)
254
+ provider_metadata = get_provider_metadata(provider)
255
+
256
+ # Extract raw model name from response
257
+ raw_model_name = response.model
258
+
259
+ # Resolve model name for Azure deployments
260
+ if is_azure_provider(provider) and raw_model_name:
261
+ # For Azure, response.model contains deployment name, resolve to LiteLLM model name
262
+ base_url = getattr(client_instance, 'base_url', None) if client_instance else None
263
+ headers = {} # Headers would need to be passed from wrapper context
264
+ resolved_model_name = resolve_azure_model_name(raw_model_name, base_url, headers)
265
+ logger.debug(f"Azure model resolution: {raw_model_name} -> {resolved_model_name}")
266
+ else:
267
+ resolved_model_name = raw_model_name
268
+
269
+ # Extract tokens based on operation type
270
+ if operation_type == OperationType.EMBED:
271
+ input_tokens = response.usage.prompt_tokens
272
+ output_tokens = 0 # Embeddings don't produce output tokens
273
+ total_tokens = response.usage.total_tokens
274
+ stop_reason = "END" # Embeddings always complete successfully
275
+ else: # CHAT (includes Responses API which is mapped to CHAT for backend compatibility)
276
+ # Handle both Chat Completions and Responses API formats
277
+ # Responses API uses input_tokens/output_tokens, Chat uses prompt_tokens/completion_tokens
278
+ if hasattr(response.usage, 'input_tokens'):
279
+ # Responses API format
280
+ input_tokens = response.usage.input_tokens
281
+ output_tokens = response.usage.output_tokens
282
+ else:
283
+ # Chat Completions format
284
+ input_tokens = response.usage.prompt_tokens
285
+ output_tokens = response.usage.completion_tokens
286
+
287
+ total_tokens = response.usage.total_tokens
288
+
289
+ # Get stop reason - Responses API may not have choices
290
+ if hasattr(response, 'choices') and response.choices:
291
+ openai_finish_reason = response.choices[0].finish_reason
292
+ stop_reason = get_stop_reason(openai_finish_reason)
293
+ else:
294
+ # Responses API doesn't have choices, use default
295
+ stop_reason = "END"
296
+
297
+ # Extract cached tokens (only available for chat completions)
298
+ cached_tokens = 0
299
+ if operation_type == OperationType.CHAT and hasattr(response.usage, 'prompt_tokens_details'):
300
+ cached_tokens = getattr(response.usage.prompt_tokens_details, 'cached_tokens', 0)
301
+
302
+ # Build unified usage data structure
303
+ usage_data = {
304
+ "input_token_count": input_tokens,
305
+ "output_token_count": output_tokens,
306
+ "total_token_count": total_tokens,
307
+ "operation_type": operation_type.value, # Convert enum to string
308
+ "stop_reason": stop_reason,
309
+ "transaction_id": transaction_id,
310
+ "model": resolved_model_name, # Use resolved model name for accurate pricing
311
+ "provider": provider_metadata["provider"],
312
+ "model_source": provider_metadata["model_source"],
313
+ "is_streamed": False, # Will be overridden for streaming
314
+ "time_to_first_token": 0, # Will be set by caller if applicable
315
+ "cache_creation_token_count": cached_tokens,
316
+ "cache_read_token_count": 0,
317
+ "reasoning_token_count": 0,
318
+ "request_time": request_time,
319
+ "response_time": response_time,
320
+ "completion_start_time": response_time,
321
+ "request_duration": int(request_duration),
322
+ "cost_type": "AI",
323
+ "input_token_cost": None, # Let backend calculate
324
+ "output_token_cost": None, # Let backend calculate
325
+ "total_cost": None, # Let backend calculate
326
+ }
327
+
328
+ # Debug logging for provider detection and model resolution
329
+ logger.debug(f"Provider detected: {provider}, metadata: {provider_metadata}")
330
+ logger.debug(f"Model resolution: {raw_model_name} -> {resolved_model_name}")
331
+
332
+ logger.debug(
333
+ "Extracted %s usage data - input: %d, output: %d, total: %d, transaction_id: %s",
334
+ operation_type.lower(), input_tokens, output_tokens, total_tokens, transaction_id
335
+ )
336
+
337
+ return usage_data, transaction_id
338
+
339
+
340
+ async def log_token_usage(
341
+ response_id: str,
342
+ model: str,
343
+ prompt_tokens: int,
344
+ completion_tokens: int,
345
+ total_tokens: int,
346
+ cached_tokens: int,
347
+ stop_reason: str,
348
+ request_time: str,
349
+ response_time: str,
350
+ request_duration: int,
351
+ usage_metadata: Dict[str, Any],
352
+ provider: str = "OPENAI",
353
+ model_source: str = "OPENAI",
354
+ system_fingerprint: Optional[str] = None,
355
+ is_streamed: bool = False,
356
+ time_to_first_token: int = 0,
357
+ operation_type: OperationType = OperationType.CHAT,
358
+ # New trace visualization fields
359
+ environment: Optional[str] = None,
360
+ operation_subtype: Optional[str] = None,
361
+ retry_number: int = 0,
362
+ parent_transaction_id: Optional[str] = None,
363
+ transaction_name: Optional[str] = None,
364
+ region: Optional[str] = None,
365
+ credential_alias: Optional[str] = None,
366
+ trace_type: Optional[str] = None,
367
+ trace_name: Optional[str] = None,
368
+ # Prompt capture fields
369
+ system_prompt: Optional[str] = None,
370
+ input_messages: Optional[str] = None,
371
+ output_response: Optional[str] = None,
372
+ prompts_truncated: Optional[bool] = None,
373
+ ) -> None:
374
+ """Log token usage to Revenium."""
375
+ if shutdown_event.is_set():
376
+ logger.warning("Skipping metering call during shutdown")
377
+ return
378
+
379
+ logger.debug("Metering call to Revenium for %s operation %s", operation_type.lower(), response_id)
380
+
381
+ # Determine provider - check for OLLAMA first via system fingerprint, then use passed parameters
382
+ if system_fingerprint == "fp_ollama":
383
+ provider = "OLLAMA"
384
+ model_source = "OLLAMA"
385
+ logger.debug(f"OLLAMA provider detected via system_fingerprint: {system_fingerprint}")
386
+ else:
387
+ # Use provider information passed as parameters (already correctly detected for Azure/OpenAI)
388
+ logger.debug(f"Using provider: {provider}, model_source: {model_source}")
389
+
390
+ # Create subscriber object from usage metadata
391
+ subscriber = extract_subscriber_from_metadata(usage_metadata)
392
+
393
+ # Prepare arguments for create_completion
394
+ # Build completion args, only including non-None values for optional fields
395
+ completion_args = {
396
+ "cache_creation_token_count": cached_tokens,
397
+ "cache_read_token_count": 0,
398
+ "output_token_count": completion_tokens,
399
+ "cost_type": "AI",
400
+ "model": model,
401
+ "input_token_count": prompt_tokens,
402
+ "provider": provider,
403
+ "model_source": model_source,
404
+ "reasoning_token_count": 0,
405
+ "request_time": request_time,
406
+ "response_time": response_time,
407
+ "completion_start_time": response_time,
408
+ "request_duration": int(request_duration),
409
+ "stop_reason": stop_reason,
410
+ "total_token_count": total_tokens,
411
+ "transaction_id": response_id,
412
+ "is_streamed": is_streamed,
413
+ "operation_type": map_operation_type_to_sdk(operation_type), # Map to SDK-expected values
414
+ "time_to_first_token": time_to_first_token,
415
+ "middleware_source": "PYTHON",
416
+ }
417
+
418
+ # Add optional fields only if they have values
419
+ if usage_metadata.get("trace_id"):
420
+ completion_args["trace_id"] = usage_metadata.get("trace_id")
421
+ if usage_metadata.get("task_type"):
422
+ completion_args["task_type"] = usage_metadata.get("task_type")
423
+ if subscriber:
424
+ completion_args["subscriber"] = subscriber
425
+ # Extract organization name (support both old and new field names)
426
+ # New field names take precedence over deprecated field names
427
+ # Using 'or' logic to handle empty strings and fallback properly
428
+ organization_name = (
429
+ usage_metadata.get("organization_name")
430
+ or usage_metadata.get("organizationName")
431
+ or usage_metadata.get("organization_id")
432
+ or usage_metadata.get("organizationId")
433
+ )
434
+ if organization_name:
435
+ completion_args["organization_name"] = organization_name
436
+ # Legacy alias for backward compatibility (deprecated, will be removed in future version)
437
+ completion_args["organization_id"] = organization_name
438
+
439
+ if usage_metadata.get("subscription_id"):
440
+ completion_args["subscription_id"] = usage_metadata.get("subscription_id")
441
+
442
+ # Extract product name (support both old and new field names)
443
+ # New field names take precedence over deprecated field names
444
+ # Using 'or' logic to handle empty strings and fallback properly
445
+ product_name = (
446
+ usage_metadata.get("product_name")
447
+ or usage_metadata.get("productName")
448
+ or usage_metadata.get("product_id")
449
+ or usage_metadata.get("productId")
450
+ )
451
+ if product_name:
452
+ completion_args["product_name"] = product_name
453
+ # Legacy alias for backward compatibility (deprecated, will be removed in future version)
454
+ completion_args["product_id"] = product_name
455
+ if usage_metadata.get("agent"):
456
+ completion_args["agent"] = usage_metadata.get("agent")
457
+ if usage_metadata.get("response_quality_score"):
458
+ completion_args["response_quality_score"] = usage_metadata.get("response_quality_score")
459
+
460
+ # Add trace visualization fields only if they have values
461
+ if environment:
462
+ completion_args["environment"] = environment
463
+ if operation_subtype:
464
+ completion_args["operation_subtype"] = operation_subtype
465
+ if retry_number is not None: # 0 is a valid value
466
+ completion_args["retry_number"] = retry_number
467
+ if parent_transaction_id:
468
+ completion_args["parent_transaction_id"] = parent_transaction_id
469
+ if transaction_name:
470
+ completion_args["transaction_name"] = transaction_name
471
+ if region:
472
+ completion_args["region"] = region
473
+ if credential_alias:
474
+ completion_args["credential_alias"] = credential_alias
475
+ if trace_type:
476
+ completion_args["trace_type"] = trace_type
477
+ if trace_name:
478
+ completion_args["trace_name"] = trace_name
479
+
480
+ # Add prompt capture fields only if they have values
481
+ if system_prompt is not None:
482
+ completion_args["system_prompt"] = system_prompt
483
+ if input_messages is not None:
484
+ completion_args["input_messages"] = input_messages
485
+ if output_response is not None:
486
+ completion_args["output_response"] = output_response
487
+ if prompts_truncated is not None:
488
+ completion_args["prompts_truncated"] = prompts_truncated
489
+
490
+ # Log the arguments at debug level (redact sensitive prompt data)
491
+ safe_args = {k: v for k, v in completion_args.items()
492
+ if k not in ('system_prompt', 'input_messages', 'output_response')}
493
+ if 'system_prompt' in completion_args:
494
+ safe_args['system_prompt'] = '[REDACTED]'
495
+ if 'input_messages' in completion_args:
496
+ safe_args['input_messages'] = '[REDACTED]'
497
+ if 'output_response' in completion_args:
498
+ safe_args['output_response'] = '[REDACTED]'
499
+ logger.debug("Calling client.ai.create_completion with args: %s", safe_args)
500
+
501
+ # Debug logging for metering call
502
+ logger.debug(f"Metering call for {operation_type.value}: {response_id}, tokens: {prompt_tokens}+{completion_tokens}={total_tokens}")
503
+
504
+ try:
505
+ # The client.ai.create_completion method is not async, so don't use await
506
+ result = client.ai.create_completion(**completion_args)
507
+ logger.debug("Metering call result: %s", result)
508
+ logger.debug(f"✅ REVENIUM SUCCESS: Metering call successful: {result.id}")
509
+
510
+ # Print usage summary if enabled (async, fire-and-forget)
511
+ # Try to get API key from client, fallback to environment variable
512
+ revenium_api_key = None
513
+ if hasattr(client, 'api_key'):
514
+ revenium_api_key = client.api_key
515
+ else:
516
+ revenium_api_key = os.getenv('REVENIUM_METERING_API_KEY')
517
+
518
+ if revenium_api_key:
519
+ # Run summary printing in background thread to avoid blocking
520
+ def _print_summary_async():
521
+ print_usage_summary(
522
+ model=model,
523
+ provider=provider,
524
+ request_duration=request_duration,
525
+ input_token_count=prompt_tokens,
526
+ output_token_count=completion_tokens,
527
+ total_token_count=total_tokens,
528
+ transaction_id=response_id,
529
+ trace_id=usage_metadata.get("trace_id"),
530
+ revenium_api_key=revenium_api_key,
531
+ )
532
+
533
+ run_async_in_thread(_print_summary_async)
534
+ except Exception as e:
535
+ if not shutdown_event.is_set():
536
+ # Categorize the exception for better error handling
537
+ categorized_error = categorize_exception(e)
538
+ logger.error(f"❌ REVENIUM FAILURE: {categorized_error}")
539
+
540
+ # Use sanitized logging to prevent sensitive data exposure
541
+ sanitized_args = sanitize_for_logging(completion_args)
542
+ logger.error(f"❌ REVENIUM FAILURE: Completion args were: {sanitized_args}")
543
+
544
+ # Log the full traceback for better debugging
545
+ import traceback
546
+ logger.error(f"❌ REVENIUM FAILURE: Traceback: {traceback.format_exc()}")
547
+ else:
548
+ logger.debug("Metering call failed during shutdown - this is expected")
549
+
550
+
551
+ def create_metering_call(
552
+ response,
553
+ operation_type: OperationType,
554
+ request_time_dt,
555
+ usage_metadata,
556
+ client_instance: Optional[Any] = None,
557
+ time_to_first_token: int = 0,
558
+ is_streamed: bool = False,
559
+ request_body: Optional[Dict[str, Any]] = None
560
+ ):
561
+ """
562
+ Unified function to create and execute metering calls for any operation
563
+ type. Reduces duplication between chat and embeddings wrappers.
564
+ """
565
+ # Import trace field functions
566
+ from .trace_fields import (
567
+ get_environment, get_region, get_credential_alias,
568
+ get_trace_type, get_trace_name, get_parent_transaction_id,
569
+ get_transaction_name, get_retry_number, detect_operation_type,
570
+ validate_trace_type, validate_trace_name
571
+ )
572
+
573
+ # Record timing
574
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
575
+ response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
576
+ request_time = request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
577
+ request_duration = (
578
+ (response_time_dt - request_time_dt).total_seconds() * 1000
579
+ )
580
+
581
+ # Extract usage data using unified function
582
+ usage_data, transaction_id = extract_usage_data(
583
+ response, operation_type, request_time, response_time,
584
+ request_duration, client_instance
585
+ )
586
+
587
+ # Override streaming and timing info
588
+ usage_data["is_streamed"] = is_streamed
589
+ usage_data["time_to_first_token"] = time_to_first_token
590
+
591
+ # Get system fingerprint if available (chat only)
592
+ system_fingerprint = getattr(response, 'system_fingerprint', None)
593
+
594
+ # Extract trace fields (usage_metadata takes precedence over env vars)
595
+ environment = usage_metadata.get('environment') or get_environment()
596
+ region = usage_metadata.get('region') or get_region()
597
+ credential_alias = (
598
+ usage_metadata.get('credentialAlias') or
599
+ usage_metadata.get('credential_alias') or
600
+ get_credential_alias()
601
+ )
602
+
603
+ # Validate trace_type from usage_metadata to prevent bypass
604
+ trace_type_raw = (
605
+ usage_metadata.get('traceType') or
606
+ usage_metadata.get('trace_type')
607
+ )
608
+ trace_type = validate_trace_type(trace_type_raw) if trace_type_raw else get_trace_type()
609
+
610
+ # Validate trace_name from usage_metadata to prevent bypass
611
+ trace_name_raw = (
612
+ usage_metadata.get('traceName') or
613
+ usage_metadata.get('trace_name')
614
+ )
615
+ trace_name = validate_trace_name(trace_name_raw) if trace_name_raw else get_trace_name()
616
+ parent_transaction_id = (
617
+ usage_metadata.get('parentTransactionId') or
618
+ usage_metadata.get('parent_transaction_id') or
619
+ get_parent_transaction_id()
620
+ )
621
+ transaction_name = (
622
+ usage_metadata.get('transactionName') or
623
+ usage_metadata.get('transaction_name') or
624
+ get_transaction_name(usage_metadata)
625
+ )
626
+ retry_number = usage_metadata.get(
627
+ 'retryNumber',
628
+ usage_metadata.get('retry_number', get_retry_number())
629
+ )
630
+
631
+ # Detect operation type and subtype
632
+ provider = usage_data.get("provider", "OPENAI")
633
+ # Determine endpoint from operation_type
634
+ if operation_type == OperationType.CHAT:
635
+ endpoint = "/chat/completions"
636
+ elif operation_type == OperationType.EMBED:
637
+ endpoint = "/embeddings"
638
+ else:
639
+ endpoint = "/chat/completions" # Default
640
+
641
+ operation_info = detect_operation_type(
642
+ provider, endpoint, request_body or {}
643
+ )
644
+ operation_subtype = operation_info.get('operationSubtype')
645
+
646
+ # Extract prompt data if capture is enabled (only for CHAT operations)
647
+ system_prompt = None
648
+ input_messages = None
649
+ output_response = None
650
+ prompts_truncated = None
651
+
652
+ if operation_type == OperationType.CHAT:
653
+ (system_prompt, input_messages, output_response, prompts_truncated) = (
654
+ extract_prompt_data_if_enabled(request_body, response=response)
655
+ )
656
+
657
+ # Create async metering call
658
+ async def metering_call():
659
+ await log_token_usage(
660
+ response_id=transaction_id,
661
+ model=usage_data["model"],
662
+ prompt_tokens=usage_data["input_token_count"],
663
+ completion_tokens=usage_data["output_token_count"],
664
+ total_tokens=usage_data["total_token_count"],
665
+ cached_tokens=usage_data["cache_creation_token_count"],
666
+ stop_reason=usage_data["stop_reason"],
667
+ request_time=usage_data["request_time"],
668
+ response_time=usage_data["response_time"],
669
+ request_duration=usage_data["request_duration"],
670
+ usage_metadata=usage_metadata,
671
+ provider=usage_data["provider"],
672
+ model_source=usage_data["model_source"],
673
+ system_fingerprint=system_fingerprint,
674
+ is_streamed=is_streamed,
675
+ time_to_first_token=time_to_first_token,
676
+ operation_type=operation_type,
677
+ # New trace visualization fields
678
+ environment=environment,
679
+ operation_subtype=operation_subtype,
680
+ retry_number=retry_number,
681
+ parent_transaction_id=parent_transaction_id,
682
+ transaction_name=transaction_name,
683
+ region=region,
684
+ credential_alias=credential_alias,
685
+ trace_type=trace_type,
686
+ trace_name=trace_name,
687
+ # Prompt capture fields
688
+ system_prompt=system_prompt,
689
+ input_messages=input_messages,
690
+ output_response=output_response,
691
+ prompts_truncated=prompts_truncated,
692
+ )
693
+
694
+ # Start metering thread
695
+ thread = run_async_in_thread(metering_call())
696
+ logger.debug("%s metering thread started: %s", operation_type, thread)
697
+ return thread
698
+
699
+
700
+ def _extract_langchain_usage_metadata():
701
+ """
702
+ Extract usage_metadata from LangChain context variables.
703
+
704
+ LangChain stores context information in thread-local variables that we can access
705
+ to get the usage_metadata that was passed to LangChain methods.
706
+
707
+ This is optional functionality - if LangChain is not installed, this function
708
+ gracefully returns an empty dict without logging errors.
709
+ """
710
+ # Only attempt LangChain integration if LangChain is available
711
+ if not is_langchain_available():
712
+ return {}
713
+
714
+ try:
715
+ # Try to import LangChain context variables
716
+ from langchain_core.globals import get_llm_cache # noqa: F401
717
+ from langchain_core.callbacks.manager import CallbackManagerForLLMRun # noqa: F401
718
+ import contextvars # noqa: F401
719
+
720
+ # Try to get the current context
721
+ # LangChain uses context variables to store run information
722
+ # We need to look for the current callback manager or run context
723
+
724
+ # Check if we're in a LangChain context by looking for context variables
725
+ # This is a best-effort approach since LangChain's internal context handling
726
+ # can vary between versions
727
+
728
+ # Look for common LangChain context patterns
729
+ import inspect
730
+ frame = inspect.currentframe()
731
+
732
+ # Walk up the call stack to find LangChain frames
733
+ while frame:
734
+ frame_locals = frame.f_locals
735
+ frame_globals = frame.f_globals
736
+
737
+ # Look for LangChain-specific variables in the call stack
738
+ # Check for 'config' parameter which often contains metadata
739
+ if 'config' in frame_locals and isinstance(frame_locals['config'], dict):
740
+ config = frame_locals['config']
741
+ if 'metadata' in config and isinstance(config['metadata'], dict):
742
+ metadata = config['metadata']
743
+ if 'usage_metadata' in metadata:
744
+ logger.debug(f"Found usage_metadata in LangChain config: {metadata['usage_metadata']}")
745
+ return metadata['usage_metadata']
746
+
747
+ # Check for 'metadata' parameter directly
748
+ if 'metadata' in frame_locals and isinstance(frame_locals['metadata'], dict):
749
+ metadata = frame_locals['metadata']
750
+ if 'usage_metadata' in metadata:
751
+ logger.debug(f"Found usage_metadata in LangChain metadata: {metadata['usage_metadata']}")
752
+ return metadata['usage_metadata']
753
+
754
+ # Check for callback manager with metadata
755
+ if 'callback_manager' in frame_locals:
756
+ cb_manager = frame_locals['callback_manager']
757
+ if hasattr(cb_manager, 'metadata') and isinstance(cb_manager.metadata, dict):
758
+ if 'usage_metadata' in cb_manager.metadata:
759
+ logger.debug(f"Found usage_metadata in callback manager: {cb_manager.metadata['usage_metadata']}")
760
+ return cb_manager.metadata['usage_metadata']
761
+
762
+ frame = frame.f_back
763
+
764
+ logger.debug("No usage_metadata found in LangChain context")
765
+ return {}
766
+
767
+ except Exception as e:
768
+ # Only log at debug level for unexpected errors during metadata extraction
769
+ # This is optional functionality, so we don't want to spam logs
770
+ logger.debug(f"Error extracting LangChain usage_metadata: {e}")
771
+ return {}
772
+
773
+
774
+ @wrapt.patch_function_wrapper('openai.resources.embeddings', 'Embeddings.create')
775
+ def embeddings_create_wrapper(wrapped, instance, args, kwargs):
776
+ """Wraps the openai.embeddings.create method to log token usage."""
777
+ logger.debug("OpenAI/Azure OpenAI embeddings.create wrapper called")
778
+
779
+ # Capture request body before modifications (for operation detection)
780
+ request_body = kwargs.copy()
781
+
782
+ # Extract API-level metadata from kwargs
783
+ api_metadata = kwargs.pop("usage_metadata", {}) if "usage_metadata" in kwargs else {}
784
+
785
+ # Try to extract usage_metadata from LangChain context if not found in kwargs
786
+ if not api_metadata:
787
+ api_metadata = _extract_langchain_usage_metadata()
788
+
789
+ # Merge with decorator metadata (API-level takes precedence)
790
+ usage_metadata = merge_metadata(api_metadata)
791
+
792
+ # Detect provider and validate Azure config if needed
793
+ client_instance = getattr(instance, '_client', None)
794
+ provider = detect_provider(client=client_instance)
795
+
796
+ # Validate Azure configuration if Azure provider detected
797
+ if is_azure_provider(provider):
798
+ azure_config = get_azure_config()
799
+ if not azure_config.is_valid():
800
+ logger.warning(
801
+ "Azure OpenAI detected but configuration is incomplete. "
802
+ "Set AZURE_OPENAI_ENDPOINT for proper Azure support."
803
+ )
804
+ else:
805
+ logger.debug(
806
+ f"Azure OpenAI configuration validated: "
807
+ f"{azure_config.to_dict()}"
808
+ )
809
+ azure_config.validate_deployment()
810
+
811
+ # Record request time
812
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
813
+ logger.debug(
814
+ f"Calling wrapped embeddings function with args: {args}, "
815
+ f"kwargs: {kwargs}"
816
+ )
817
+
818
+ # Call the original OpenAI function
819
+ response = wrapped(*args, **kwargs)
820
+
821
+ logger.debug("Handling embeddings response: %s", response)
822
+
823
+ # Create metering call using unified function
824
+ create_metering_call(
825
+ response,
826
+ OperationType.EMBED,
827
+ request_time_dt,
828
+ usage_metadata,
829
+ client_instance=getattr(instance, '_client', None),
830
+ request_body=request_body
831
+ )
832
+
833
+ return response
834
+
835
+
836
+ @wrapt.patch_function_wrapper('openai.resources.chat.completions', 'Completions.create')
837
+ def create_wrapper(wrapped, instance, args, kwargs):
838
+ """
839
+ Wraps the openai.ChatCompletion.create method to log token usage.
840
+ Handles both streaming and non-streaming responses for OpenAI and
841
+ Azure OpenAI.
842
+ """
843
+ logger.debug("OpenAI/Azure OpenAI chat.completions.create wrapper called")
844
+
845
+ # Capture request body before modifications (for operation detection)
846
+ request_body = kwargs.copy()
847
+
848
+ # Extract API-level metadata from kwargs
849
+ api_metadata = kwargs.pop("usage_metadata", {}) if "usage_metadata" in kwargs else {}
850
+
851
+ # Try to extract usage_metadata from LangChain context if not found in kwargs
852
+ if not api_metadata:
853
+ api_metadata = _extract_langchain_usage_metadata()
854
+
855
+ # Merge with decorator metadata (API-level takes precedence)
856
+ usage_metadata = merge_metadata(api_metadata)
857
+
858
+ # Check if this is a streaming request
859
+ stream = kwargs.get('stream', False)
860
+
861
+ # If streaming, add stream_options to include usage information
862
+ if stream:
863
+ # Initialize stream_options if it doesn't exist
864
+ if 'stream_options' not in kwargs:
865
+ kwargs['stream_options'] = {}
866
+ # Add include_usage flag to get token counts in the response
867
+ kwargs['stream_options']['include_usage'] = True
868
+ logger.debug(
869
+ "Added include_usage to stream_options for accurate token "
870
+ "counting in streaming response"
871
+ )
872
+
873
+ # Detect provider and validate Azure config if needed
874
+ client_instance = getattr(instance, '_client', None)
875
+ provider = detect_provider(client=client_instance)
876
+
877
+ # Validate Azure configuration if Azure provider detected
878
+ if is_azure_provider(provider):
879
+ azure_config = get_azure_config()
880
+ if not azure_config.is_valid():
881
+ logger.warning(
882
+ "Azure OpenAI detected but configuration is incomplete. "
883
+ "Set AZURE_OPENAI_ENDPOINT for proper Azure support."
884
+ )
885
+ else:
886
+ logger.debug(
887
+ f"Azure OpenAI configuration validated: "
888
+ f"{azure_config.to_dict()}"
889
+ )
890
+ azure_config.validate_deployment()
891
+
892
+ # Record request time
893
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
894
+ logger.debug(
895
+ f"Calling wrapped function with args: {args}, kwargs: {kwargs}"
896
+ )
897
+
898
+ # Call the original OpenAI function
899
+ response = wrapped(*args, **kwargs)
900
+
901
+ # Record time to first token (for non-streaming, same as full response)
902
+ first_token_time_dt = datetime.datetime.now(datetime.timezone.utc)
903
+ time_to_first_token = int(
904
+ (first_token_time_dt - request_time_dt).total_seconds() * 1000
905
+ )
906
+
907
+ # Handle based on response type
908
+ if stream:
909
+ # For streaming responses (openai.Stream)
910
+ logger.debug("Handling streaming response")
911
+ return handle_streaming_response(
912
+ response,
913
+ request_time_dt,
914
+ usage_metadata,
915
+ client_instance=getattr(instance, '_client', None),
916
+ request_body=request_body
917
+ )
918
+ else:
919
+ # For non-streaming responses (ChatCompletion)
920
+ logger.debug("Handling non-streaming response: %s", response)
921
+
922
+ # Create metering call using unified function
923
+ create_metering_call(
924
+ response,
925
+ OperationType.CHAT,
926
+ request_time_dt,
927
+ usage_metadata,
928
+ client_instance=getattr(instance, '_client', None),
929
+ time_to_first_token=time_to_first_token,
930
+ request_body=request_body
931
+ )
932
+
933
+ return response
934
+
935
+
936
+ def handle_streaming_response(
937
+ stream,
938
+ request_time_dt,
939
+ usage_metadata,
940
+ client_instance: Optional[Any] = None,
941
+ request_body: Optional[Dict[str, Any]] = None
942
+ ):
943
+ """
944
+ Handle streaming responses from OpenAI/Azure OpenAI.
945
+ Wraps the stream to collect metrics and log them after completion.
946
+ Similar to the approach used in the Ollama middleware.
947
+ """
948
+
949
+ # Create a wrapper for the streaming response with proper resource
950
+ # management
951
+ class StreamWrapper:
952
+ def __init__(self, stream):
953
+ self.stream = stream
954
+ self.chunks = []
955
+ self.response_id = None
956
+ self.model = None
957
+ self.finish_reason = None
958
+ self.system_fingerprint = None
959
+ self.request_time_dt = request_time_dt
960
+ self.usage_metadata = usage_metadata
961
+ self.final_usage = None
962
+ self.completion_text = ""
963
+ self.first_token_time = None
964
+ # Store for Azure provider detection
965
+ self.client_instance = client_instance
966
+ self.request_body = request_body
967
+ self._closed = False
968
+ self._usage_logged = False
969
+
970
+ def __iter__(self):
971
+ return self
972
+
973
+ def __next__(self):
974
+ if self._closed:
975
+ raise StopIteration("Stream has been closed")
976
+
977
+ try:
978
+ chunk = next(self.stream)
979
+ self._process_chunk(chunk)
980
+ return chunk
981
+ except StopIteration:
982
+ self._finalize()
983
+ raise
984
+ except Exception as e:
985
+ # Ensure cleanup on any error
986
+ self._finalize()
987
+ logger.error(f"Error in streaming response: {e}")
988
+ raise
989
+
990
+ def __enter__(self):
991
+ """Context manager entry."""
992
+ return self
993
+
994
+ def __exit__(self, exc_type, exc_val, exc_tb):
995
+ """Context manager exit with cleanup."""
996
+ self._finalize()
997
+
998
+ def _finalize(self):
999
+ """Finalize the stream and log usage if not already done."""
1000
+ if not self._usage_logged:
1001
+ self._log_usage()
1002
+ self._usage_logged = True
1003
+ self._close_stream()
1004
+
1005
+ def _close_stream(self):
1006
+ """Close the underlying stream if possible."""
1007
+ if not self._closed:
1008
+ try:
1009
+ if hasattr(self.stream, 'close'):
1010
+ self.stream.close()
1011
+ except Exception as e:
1012
+ logger.debug(f"Error closing stream: {e}")
1013
+ finally:
1014
+ self._closed = True
1015
+
1016
+ def _process_chunk(self, chunk):
1017
+ # Extract response ID and model from the chunk if available
1018
+ if self.response_id is None and hasattr(chunk, 'id'):
1019
+ self.response_id = chunk.id
1020
+ if self.model is None and hasattr(chunk, 'model'):
1021
+ self.model = chunk.model
1022
+ if self.system_fingerprint is None and hasattr(chunk, 'system_fingerprint'):
1023
+ self.system_fingerprint = chunk.system_fingerprint
1024
+ logger.debug(f"Captured system_fingerprint from stream chunk: {self.system_fingerprint}")
1025
+ else:
1026
+ logger.debug(f"System fingerprint already set: {self.system_fingerprint}")
1027
+
1028
+
1029
+ # Check for finish reason in the chunk
1030
+ if chunk.choices and chunk.choices[0].finish_reason:
1031
+ self.finish_reason = chunk.choices[0].finish_reason
1032
+
1033
+ # Check if this chunk has usage data (can be in final chunk with or without choices)
1034
+ if hasattr(chunk, 'usage') and chunk.usage:
1035
+ logger.debug(f"Found usage data in chunk: {chunk.usage}")
1036
+ self.final_usage = chunk.usage
1037
+ # Don't return yet - we still need to process the chunk for finish_reason etc.
1038
+
1039
+ # Collect content for token estimation if needed
1040
+ if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content') and \
1041
+ chunk.choices[0].delta.content:
1042
+ # Record time of first token if not already set
1043
+ if self.first_token_time is None:
1044
+ self.first_token_time = datetime.datetime.now(datetime.timezone.utc)
1045
+ self.completion_text += chunk.choices[0].delta.content
1046
+
1047
+ # Store the chunk for later analysis
1048
+ self.chunks.append(chunk)
1049
+
1050
+ def _log_usage(self):
1051
+ # Only return if we have neither chunks nor final usage data
1052
+ if not self.chunks and not self.final_usage:
1053
+ return
1054
+
1055
+ # Record response time and calculate duration
1056
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
1057
+ response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
1058
+ request_duration = (response_time_dt - self.request_time_dt).total_seconds() * 1000
1059
+
1060
+ # Get token usage information
1061
+ prompt_tokens = 0
1062
+ completion_tokens = 0
1063
+ total_tokens = 0
1064
+ cached_tokens = 0
1065
+
1066
+ # First check if we have the final usage data from the special chunk
1067
+ if self.final_usage:
1068
+ prompt_tokens = self.final_usage.prompt_tokens
1069
+ completion_tokens = self.final_usage.completion_tokens
1070
+ total_tokens = self.final_usage.total_tokens
1071
+ # Check if we have cached tokens info
1072
+ if hasattr(self.final_usage, 'prompt_tokens_details') and hasattr(
1073
+ self.final_usage.prompt_tokens_details, 'cached_tokens'):
1074
+ cached_tokens = self.final_usage.prompt_tokens_details.cached_tokens
1075
+ logger.debug(
1076
+ f"Using token usage from final chunk: prompt={prompt_tokens}, completion={completion_tokens}, total={total_tokens}")
1077
+ else:
1078
+ # If we don't have usage data, estimate from content
1079
+ logger.warning("No usage data found in streaming response!")
1080
+
1081
+ stop_reason = get_stop_reason(self.finish_reason)
1082
+
1083
+ # Log the token usage
1084
+ if self.response_id:
1085
+ logger.debug(
1086
+ "Streaming token usage - response_id: %s, prompt: %d, completion: %d, total: %d",
1087
+ self.response_id, prompt_tokens, completion_tokens, total_tokens
1088
+ )
1089
+
1090
+ # Detect provider and resolve model name for Azure
1091
+ provider = detect_provider(self.client_instance,
1092
+ getattr(self.client_instance, 'base_url', None) if self.client_instance else None)
1093
+ provider_metadata = get_provider_metadata(provider)
1094
+
1095
+ # Resolve model name for Azure deployments
1096
+ raw_model_name = self.model or "unknown"
1097
+ if is_azure_provider(provider) and raw_model_name != "unknown":
1098
+ base_url = getattr(self.client_instance, 'base_url', None) if self.client_instance else None
1099
+ headers = {} # Headers would need to be passed from wrapper context
1100
+ resolved_model_name = resolve_azure_model_name(raw_model_name, base_url, headers)
1101
+ logger.debug(f"Azure streaming model resolution: {raw_model_name} -> {resolved_model_name}")
1102
+ else:
1103
+ resolved_model_name = raw_model_name
1104
+
1105
+ # Calculate time to first token if available
1106
+ time_to_first_token = 0
1107
+ if self.first_token_time:
1108
+ time_to_first_token = int(
1109
+ (self.first_token_time - self.request_time_dt)
1110
+ .total_seconds() * 1000
1111
+ )
1112
+ logger.debug(f"Time to first token: {time_to_first_token}ms")
1113
+
1114
+ # Extract trace fields for streaming response
1115
+ from .trace_fields import (
1116
+ get_environment, get_region, get_credential_alias,
1117
+ get_trace_type, get_trace_name,
1118
+ get_parent_transaction_id,
1119
+ get_transaction_name, get_retry_number,
1120
+ detect_operation_type,
1121
+ validate_trace_type, validate_trace_name
1122
+ )
1123
+
1124
+ # Get trace fields (usage_metadata takes precedence)
1125
+ environment = (
1126
+ self.usage_metadata.get('environment') or
1127
+ get_environment()
1128
+ )
1129
+ region = (
1130
+ self.usage_metadata.get('region') or
1131
+ get_region()
1132
+ )
1133
+ credential_alias = (
1134
+ self.usage_metadata.get('credentialAlias') or
1135
+ self.usage_metadata.get('credential_alias') or
1136
+ get_credential_alias()
1137
+ )
1138
+
1139
+ # Validate trace_type from usage_metadata to prevent bypass
1140
+ trace_type_raw = (
1141
+ self.usage_metadata.get('traceType') or
1142
+ self.usage_metadata.get('trace_type')
1143
+ )
1144
+ trace_type = validate_trace_type(trace_type_raw) if trace_type_raw else get_trace_type()
1145
+
1146
+ # Validate trace_name from usage_metadata to prevent bypass
1147
+ trace_name_raw = (
1148
+ self.usage_metadata.get('traceName') or
1149
+ self.usage_metadata.get('trace_name')
1150
+ )
1151
+ trace_name = validate_trace_name(trace_name_raw) if trace_name_raw else get_trace_name()
1152
+ parent_transaction_id = (
1153
+ self.usage_metadata.get('parentTransactionId') or
1154
+ self.usage_metadata.get('parent_transaction_id') or
1155
+ get_parent_transaction_id()
1156
+ )
1157
+ transaction_name = (
1158
+ self.usage_metadata.get('transactionName') or
1159
+ self.usage_metadata.get('transaction_name') or
1160
+ get_transaction_name(self.usage_metadata)
1161
+ )
1162
+ retry_number = self.usage_metadata.get(
1163
+ 'retryNumber',
1164
+ self.usage_metadata.get('retry_number', get_retry_number())
1165
+ )
1166
+
1167
+ # Detect operation type and subtype
1168
+ operation_info = detect_operation_type(
1169
+ provider, "/chat/completions", self.request_body or {}
1170
+ )
1171
+ operation_subtype = operation_info.get('operationSubtype')
1172
+
1173
+ # Extract prompt data if capture is enabled
1174
+ (system_prompt, input_messages, output_response, prompts_truncated) = (
1175
+ extract_prompt_data_if_enabled(
1176
+ self.request_body,
1177
+ accumulated_content=self.completion_text
1178
+ )
1179
+ )
1180
+
1181
+ async def metering_call():
1182
+ await log_token_usage(
1183
+ response_id=self.response_id,
1184
+ model=resolved_model_name,
1185
+ prompt_tokens=prompt_tokens,
1186
+ completion_tokens=completion_tokens,
1187
+ total_tokens=total_tokens,
1188
+ cached_tokens=cached_tokens,
1189
+ stop_reason=stop_reason,
1190
+ request_time=self.request_time_dt.strftime(
1191
+ "%Y-%m-%dT%H:%M:%SZ"
1192
+ ),
1193
+ response_time=response_time,
1194
+ request_duration=int(request_duration),
1195
+ usage_metadata=self.usage_metadata,
1196
+ provider=provider_metadata["provider"],
1197
+ model_source=provider_metadata["model_source"],
1198
+ system_fingerprint=self.system_fingerprint,
1199
+ is_streamed=True,
1200
+ time_to_first_token=time_to_first_token,
1201
+ operation_type=OperationType.CHAT,
1202
+ # New trace visualization fields
1203
+ environment=environment,
1204
+ operation_subtype=operation_subtype,
1205
+ retry_number=retry_number,
1206
+ parent_transaction_id=parent_transaction_id,
1207
+ transaction_name=transaction_name,
1208
+ region=region,
1209
+ credential_alias=credential_alias,
1210
+ trace_type=trace_type,
1211
+ trace_name=trace_name,
1212
+ # Prompt capture fields
1213
+ system_prompt=system_prompt,
1214
+ input_messages=input_messages,
1215
+ output_response=output_response,
1216
+ prompts_truncated=prompts_truncated,
1217
+ )
1218
+
1219
+ thread = run_async_in_thread(metering_call())
1220
+ logger.debug("Streaming metering thread started: %s", thread)
1221
+
1222
+ # Return the wrapped stream
1223
+ return StreamWrapper(iter(stream))
1224
+
1225
+
1226
+ @wrapt.patch_function_wrapper('openai.resources.responses', 'Responses.create')
1227
+ def responses_create_wrapper(wrapped, instance, args, kwargs):
1228
+ """
1229
+ Wraps the openai.responses.create method to log token usage.
1230
+ Handles both streaming and non-streaming responses for OpenAI Responses API.
1231
+
1232
+ Note: The Responses API automatically includes usage data in streaming responses
1233
+ without requiring stream_options configuration (unlike Chat Completions API).
1234
+ """
1235
+ logger.debug("OpenAI Responses API create wrapper called")
1236
+
1237
+ # Extract usage metadata and store it for later use
1238
+ usage_metadata = kwargs.pop("usage_metadata", {})
1239
+
1240
+ # Try to extract usage_metadata from LangChain context if not found in kwargs
1241
+ if not usage_metadata:
1242
+ usage_metadata = _extract_langchain_usage_metadata()
1243
+
1244
+ # Check if this is a streaming request
1245
+ stream = kwargs.get('stream', False)
1246
+
1247
+ # Record request time
1248
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
1249
+ logger.debug(f"Calling wrapped responses function with args: {args}, kwargs: {kwargs}")
1250
+
1251
+ # Call the original OpenAI function
1252
+ response = wrapped(*args, **kwargs)
1253
+
1254
+ # Record time to first token (for non-streaming, this is the same as the full response time)
1255
+ first_token_time_dt = datetime.datetime.now(datetime.timezone.utc)
1256
+ time_to_first_token = int((first_token_time_dt - request_time_dt).total_seconds() * 1000)
1257
+
1258
+ # Handle based on response type
1259
+ if stream:
1260
+ # For streaming responses
1261
+ logger.debug("Handling streaming Responses API response")
1262
+ return handle_streaming_responses(
1263
+ response,
1264
+ request_time_dt,
1265
+ usage_metadata,
1266
+ client_instance=getattr(instance, '_client', None)
1267
+ )
1268
+ else:
1269
+ # For non-streaming responses
1270
+ logger.debug("Handling non-streaming Responses API response: %s", response)
1271
+
1272
+ # Create metering call using unified function - pass client instance for provider detection
1273
+ # Map Responses API to CHAT operation type for Revenium backend compatibility
1274
+ # The backend does not yet support a separate RESPONSES operation type
1275
+ create_metering_call(response, OperationType.CHAT, request_time_dt, usage_metadata,
1276
+ client_instance=getattr(instance, '_client', None),
1277
+ time_to_first_token=time_to_first_token)
1278
+
1279
+ return response
1280
+
1281
+
1282
+ def handle_streaming_responses(stream, request_time_dt, usage_metadata,
1283
+ client_instance: Optional[Any] = None):
1284
+ """
1285
+ Handle streaming responses from OpenAI Responses API.
1286
+ Wraps the stream to collect metrics and log them after completion.
1287
+ """
1288
+
1289
+ # Create a wrapper for the streaming response with proper resource management
1290
+ class StreamResponseWrapper:
1291
+ def __init__(self, stream):
1292
+ self.stream = stream
1293
+ self.chunks = []
1294
+ self.response_id = None
1295
+ self.model = None
1296
+ self.request_time_dt = request_time_dt
1297
+ self.usage_metadata = usage_metadata
1298
+ self.final_usage = None
1299
+ self.client_instance = client_instance # Store for provider detection
1300
+ self._closed = False
1301
+ self._usage_logged = False
1302
+ self.last_chunk = None # Store the last chunk to extract usage data
1303
+
1304
+ def __iter__(self):
1305
+ return self
1306
+
1307
+ def __next__(self):
1308
+ if self._closed:
1309
+ raise StopIteration("Stream has been closed")
1310
+
1311
+ try:
1312
+ chunk = next(self.stream)
1313
+ self._process_chunk(chunk)
1314
+ self.last_chunk = chunk # Store the last chunk
1315
+ return chunk
1316
+ except StopIteration:
1317
+ self._finalize()
1318
+ raise
1319
+ except Exception as e:
1320
+ # Ensure cleanup on any error
1321
+ self._finalize()
1322
+ logger.error(f"Error in streaming Responses API response: {e}")
1323
+ raise
1324
+
1325
+ def __enter__(self):
1326
+ """Context manager entry."""
1327
+ return self
1328
+
1329
+ def __exit__(self, exc_type, exc_val, exc_tb):
1330
+ """Context manager exit with cleanup."""
1331
+ self._finalize()
1332
+
1333
+ def _finalize(self):
1334
+ """Finalize the stream and log usage if not already done."""
1335
+ if not self._usage_logged:
1336
+ self._log_usage()
1337
+ self._usage_logged = True
1338
+ self._close_stream()
1339
+
1340
+ def _close_stream(self):
1341
+ """Close the underlying stream if possible."""
1342
+ if not self._closed:
1343
+ try:
1344
+ if hasattr(self.stream, 'close'):
1345
+ self.stream.close()
1346
+ except Exception as e:
1347
+ logger.debug(f"Error closing stream: {e}")
1348
+ finally:
1349
+ self._closed = True
1350
+
1351
+ def _process_chunk(self, chunk):
1352
+ # Extract response ID and model from the chunk if available
1353
+ if self.response_id is None and hasattr(chunk, 'id'):
1354
+ self.response_id = chunk.id
1355
+ if self.model is None and hasattr(chunk, 'model'):
1356
+ self.model = chunk.model
1357
+
1358
+ # Check if this is the final chunk with usage data
1359
+ if hasattr(chunk, 'usage') and chunk.usage:
1360
+ logger.debug(f"Found usage data in Responses API stream: {chunk.usage}")
1361
+ self.final_usage = chunk.usage
1362
+ return
1363
+
1364
+ # Store the chunk for later analysis
1365
+ self.chunks.append(chunk)
1366
+
1367
+ def _log_usage(self):
1368
+ if not self.chunks and not self.final_usage and not self.last_chunk:
1369
+ return
1370
+
1371
+ # Record response time and calculate duration
1372
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
1373
+ response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
1374
+ request_duration = (response_time_dt - self.request_time_dt).total_seconds() * 1000
1375
+
1376
+ # Get token usage information
1377
+ input_tokens = 0
1378
+ output_tokens = 0
1379
+ total_tokens = 0
1380
+
1381
+ # Get usage data from the final chunk or last chunk
1382
+ if self.final_usage:
1383
+ input_tokens = self.final_usage.input_tokens
1384
+ output_tokens = self.final_usage.output_tokens
1385
+ total_tokens = self.final_usage.total_tokens
1386
+ logger.debug(
1387
+ f"Using token usage from Responses API stream final chunk: input={input_tokens}, "
1388
+ f"output={output_tokens}, total={total_tokens}")
1389
+ elif self.last_chunk and hasattr(self.last_chunk, 'usage') and self.last_chunk.usage:
1390
+ # Try to extract usage from the last chunk
1391
+ input_tokens = self.last_chunk.usage.input_tokens
1392
+ output_tokens = self.last_chunk.usage.output_tokens
1393
+ total_tokens = self.last_chunk.usage.total_tokens
1394
+ logger.debug(
1395
+ f"Using token usage from Responses API last chunk: input={input_tokens}, "
1396
+ f"output={output_tokens}, total={total_tokens}")
1397
+ else:
1398
+ # If we don't have usage data, log warning
1399
+ logger.warning("No usage data found in streaming Responses API response!")
1400
+
1401
+ # Log the token usage
1402
+ if self.response_id:
1403
+ logger.debug(
1404
+ "Streaming Responses API token usage - response_id: %s, input: %d, output: %d, total: %d",
1405
+ self.response_id, input_tokens, output_tokens, total_tokens
1406
+ )
1407
+
1408
+ # Detect provider and resolve model name for Azure
1409
+ provider = detect_provider(self.client_instance,
1410
+ getattr(self.client_instance, 'base_url', None)
1411
+ if self.client_instance else None)
1412
+ provider_metadata = get_provider_metadata(provider)
1413
+
1414
+ # Resolve model name for Azure deployments
1415
+ raw_model_name = self.model or "unknown"
1416
+ if is_azure_provider(provider) and raw_model_name != "unknown":
1417
+ base_url = getattr(self.client_instance, 'base_url', None) if self.client_instance else None
1418
+ headers = {} # Headers would need to be passed from wrapper context
1419
+ resolved_model_name = resolve_azure_model_name(raw_model_name, base_url, headers)
1420
+ logger.debug(f"Azure Responses API streaming model resolution: {raw_model_name} -> "
1421
+ f"{resolved_model_name}")
1422
+ else:
1423
+ resolved_model_name = raw_model_name
1424
+
1425
+ async def metering_call():
1426
+ await log_token_usage(
1427
+ response_id=self.response_id,
1428
+ model=resolved_model_name,
1429
+ prompt_tokens=input_tokens,
1430
+ completion_tokens=output_tokens,
1431
+ total_tokens=total_tokens,
1432
+ cached_tokens=0,
1433
+ stop_reason="END",
1434
+ request_time=self.request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
1435
+ response_time=response_time,
1436
+ request_duration=int(request_duration),
1437
+ usage_metadata=self.usage_metadata,
1438
+ provider=provider_metadata["provider"],
1439
+ model_source=provider_metadata["model_source"],
1440
+ system_fingerprint=None,
1441
+ is_streamed=True,
1442
+ time_to_first_token=0,
1443
+ # Map Responses API to CHAT operation type for Revenium backend compatibility
1444
+ operation_type=OperationType.CHAT,
1445
+ )
1446
+
1447
+ thread = run_async_in_thread(metering_call())
1448
+ logger.debug("Streaming Responses API metering thread started: %s", thread)
1449
+
1450
+ # Return the wrapped stream
1451
+ return StreamResponseWrapper(iter(stream))