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