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,321 @@
|
|
|
1
|
+
import wrapt
|
|
2
|
+
import logging
|
|
3
|
+
import datetime
|
|
4
|
+
from revenium_middleware import client, run_async_in_thread, shutdown_event
|
|
5
|
+
from revenium_middleware._core.subscriber import extract_subscriber_from_metadata
|
|
6
|
+
from .context import metadata_context
|
|
7
|
+
from .hooks import execute_metadata_hooks
|
|
8
|
+
from . import trace_fields
|
|
9
|
+
from .summary_printer import print_usage_summary
|
|
10
|
+
from .config import get_api_key
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("revenium_middleware.extension")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@wrapt.patch_function_wrapper('litellm', 'completion')
|
|
16
|
+
def completion_wrapper(wrapped, _, args, kwargs):
|
|
17
|
+
"""
|
|
18
|
+
Wraps the litellm.completion method to log token usage.
|
|
19
|
+
Handles both streaming and non-streaming responses.
|
|
20
|
+
|
|
21
|
+
Metadata is collected from two sources:
|
|
22
|
+
1. Context metadata (set via metadata_context API)
|
|
23
|
+
2. Explicit usage_metadata kwarg
|
|
24
|
+
|
|
25
|
+
Explicit kwargs take precedence over context metadata.
|
|
26
|
+
"""
|
|
27
|
+
logger.debug("LiteLLM completion wrapper called")
|
|
28
|
+
|
|
29
|
+
# Get context metadata first
|
|
30
|
+
context_metadata = metadata_context.get()
|
|
31
|
+
|
|
32
|
+
# Get explicit metadata from kwargs (takes precedence)
|
|
33
|
+
explicit_metadata = kwargs.pop("usage_metadata", {}) if "usage_metadata" in kwargs else {}
|
|
34
|
+
|
|
35
|
+
# Merge: context metadata as base, explicit metadata overrides
|
|
36
|
+
usage_metadata = {**context_metadata, **explicit_metadata}
|
|
37
|
+
|
|
38
|
+
# Execute registered hooks to allow modification/enrichment
|
|
39
|
+
try:
|
|
40
|
+
usage_metadata = execute_metadata_hooks(usage_metadata)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Error executing metadata hooks: {e}. Continuing with unmodified metadata.")
|
|
43
|
+
# Continue with the metadata we have - don't let hook errors break the middleware
|
|
44
|
+
|
|
45
|
+
logger.debug("Usage metadata (merged from context and kwargs, after hooks): {}".format(usage_metadata))
|
|
46
|
+
|
|
47
|
+
request_time_dt = datetime.datetime.now(datetime.timezone.utc)
|
|
48
|
+
logger.debug(f"Calling chat function with args: {args}, kwargs: {kwargs}")
|
|
49
|
+
|
|
50
|
+
is_streaming = kwargs.get("stream", False)
|
|
51
|
+
logger.debug(f"is_streaming: {is_streaming}")
|
|
52
|
+
|
|
53
|
+
response = wrapped(*args, **kwargs)
|
|
54
|
+
if is_streaming:
|
|
55
|
+
return handle_streaming_response(response, request_time_dt, usage_metadata)
|
|
56
|
+
else:
|
|
57
|
+
return handle_response(response, request_time_dt, usage_metadata, False)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def handle_streaming_response(generator, request_time_dt, usage_metadata):
|
|
61
|
+
"""
|
|
62
|
+
Handles streaming responses by collecting all chunks and processing the final state.
|
|
63
|
+
Returns a new generator that yields the same chunks.
|
|
64
|
+
"""
|
|
65
|
+
chunks = []
|
|
66
|
+
final_response = None
|
|
67
|
+
|
|
68
|
+
def wrapped_generator():
|
|
69
|
+
nonlocal final_response
|
|
70
|
+
|
|
71
|
+
# Collect all chunks
|
|
72
|
+
for chunk in generator:
|
|
73
|
+
chunks.append(chunk)
|
|
74
|
+
yield chunk
|
|
75
|
+
|
|
76
|
+
# After all chunks are processed, construct the final response
|
|
77
|
+
if chunks:
|
|
78
|
+
# The last chunk should contain the complete response data
|
|
79
|
+
final_response = chunks[-1]
|
|
80
|
+
handle_response(final_response, request_time_dt, usage_metadata, True)
|
|
81
|
+
|
|
82
|
+
return wrapped_generator()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def handle_response(response, request_time_dt, usage_metadata, is_streaming):
|
|
86
|
+
"""
|
|
87
|
+
Process a complete response (either streaming or non-streaming) and send metering data.
|
|
88
|
+
Returns the original response.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
async def metering_call():
|
|
92
|
+
response_time_dt = datetime.datetime.now(datetime.timezone.utc)
|
|
93
|
+
response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
94
|
+
request_duration = (response_time_dt - request_time_dt).total_seconds() * 1000
|
|
95
|
+
request_time = request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
|
|
97
|
+
# Generate a unique ID if not present in response
|
|
98
|
+
response_id = getattr(response, 'id', f"litellm_client-{datetime.datetime.now().timestamp()}")
|
|
99
|
+
|
|
100
|
+
# Extract token counts from LiteLLM response
|
|
101
|
+
prompt_tokens = getattr(response.usage, 'prompt_tokens', 0)
|
|
102
|
+
completion_tokens = getattr(response.usage, 'completion_tokens', 0)
|
|
103
|
+
cached_tokens = getattr(response.usage, 'cached_tokens', 0)
|
|
104
|
+
total_tokens = prompt_tokens + completion_tokens + cached_tokens
|
|
105
|
+
|
|
106
|
+
logger.debug(
|
|
107
|
+
"LiteLLM completion token usage - prompt: %d, completion: %d, cached: %d, total: %d",
|
|
108
|
+
prompt_tokens, completion_tokens, cached_tokens, total_tokens
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
finish_reason = getattr(response, 'finish_reason', None)
|
|
112
|
+
|
|
113
|
+
finish_reason_map = {
|
|
114
|
+
"stop": "END",
|
|
115
|
+
"length": "TOKEN_LIMIT",
|
|
116
|
+
"error": "ERROR",
|
|
117
|
+
"cancelled": "CANCELLED",
|
|
118
|
+
"tool_calls": "END_SEQUENCE",
|
|
119
|
+
"function_calls": "END_SEQUENCE"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stop_reason = finish_reason_map.get(finish_reason, "END") # type: ignore
|
|
123
|
+
try:
|
|
124
|
+
if shutdown_event.is_set():
|
|
125
|
+
logger.warning("Skipping metering call during shutdown")
|
|
126
|
+
return
|
|
127
|
+
logger.debug("Metering call to Revenium for completion %s", response_id)
|
|
128
|
+
|
|
129
|
+
# Create subscriber object from usage metadata
|
|
130
|
+
subscriber = extract_subscriber_from_metadata(usage_metadata)
|
|
131
|
+
|
|
132
|
+
# Prepare arguments for create_completion
|
|
133
|
+
# Extract organization name (support both old and new field names)
|
|
134
|
+
# Priority: organization_name > organizationName > organization_id > organizationId
|
|
135
|
+
organization_name = usage_metadata.get("organization_name")
|
|
136
|
+
if organization_name is None:
|
|
137
|
+
organization_name = usage_metadata.get("organizationName")
|
|
138
|
+
if organization_name is None:
|
|
139
|
+
# Fallback to deprecated field names
|
|
140
|
+
organization_name = usage_metadata.get("organization_id")
|
|
141
|
+
if organization_name is None:
|
|
142
|
+
organization_name = usage_metadata.get("organizationId")
|
|
143
|
+
|
|
144
|
+
# Extract product name (support both old and new field names)
|
|
145
|
+
# Priority: product_name > productName > product_id > productId
|
|
146
|
+
product_name = usage_metadata.get("product_name")
|
|
147
|
+
if product_name is None:
|
|
148
|
+
product_name = usage_metadata.get("productName")
|
|
149
|
+
if product_name is None:
|
|
150
|
+
# Fallback to deprecated field names
|
|
151
|
+
product_name = usage_metadata.get("product_id")
|
|
152
|
+
if product_name is None:
|
|
153
|
+
product_name = usage_metadata.get("productId")
|
|
154
|
+
|
|
155
|
+
# Log deprecation warning if old fields are used
|
|
156
|
+
# Note: We check for old field names only when new field names are NOT present
|
|
157
|
+
# The decorators now set organization_name/product_name, so this warning
|
|
158
|
+
# only fires for users who directly use the deprecated field names
|
|
159
|
+
if usage_metadata.get("organization_id") or usage_metadata.get("organizationId"):
|
|
160
|
+
if not (usage_metadata.get("organization_name") or usage_metadata.get("organizationName")):
|
|
161
|
+
logger.warning(
|
|
162
|
+
"Fields 'organization_id' and 'organizationId' are deprecated. "
|
|
163
|
+
"Use 'organization_name' or 'organizationName' instead. "
|
|
164
|
+
"The old fields will be removed in a future version."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if usage_metadata.get("product_id") or usage_metadata.get("productId"):
|
|
168
|
+
if not (usage_metadata.get("product_name") or usage_metadata.get("productName")):
|
|
169
|
+
logger.warning(
|
|
170
|
+
"Fields 'product_id' and 'productId' are deprecated. "
|
|
171
|
+
"Use 'product_name' or 'productName' instead. "
|
|
172
|
+
"The old fields will be removed in a future version."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
completion_args = {
|
|
176
|
+
"cache_creation_token_count": cached_tokens,
|
|
177
|
+
"cache_read_token_count": 0,
|
|
178
|
+
"input_token_cost": None,
|
|
179
|
+
"output_token_cost": None,
|
|
180
|
+
"total_cost": None,
|
|
181
|
+
"output_token_count": completion_tokens,
|
|
182
|
+
"cost_type": "AI",
|
|
183
|
+
"model": getattr(response, 'model', 'litellm-model'),
|
|
184
|
+
"input_token_count": prompt_tokens,
|
|
185
|
+
"provider": "LITELLM",
|
|
186
|
+
"model_source": "LITELLM",
|
|
187
|
+
"reasoning_token_count": 0,
|
|
188
|
+
"request_time": request_time,
|
|
189
|
+
"response_time": response_time,
|
|
190
|
+
"completion_start_time": response_time,
|
|
191
|
+
"request_duration": int(request_duration),
|
|
192
|
+
"stop_reason": stop_reason,
|
|
193
|
+
"total_token_count": total_tokens,
|
|
194
|
+
"transaction_id": response_id,
|
|
195
|
+
# Support both snake_case and camelCase for trace_id
|
|
196
|
+
"trace_id": usage_metadata.get("trace_id") or usage_metadata.get("traceId"),
|
|
197
|
+
"task_type": usage_metadata.get("task_type"),
|
|
198
|
+
"subscriber": subscriber if subscriber else None,
|
|
199
|
+
"organization_name": organization_name,
|
|
200
|
+
"subscription_id": usage_metadata.get("subscription_id"),
|
|
201
|
+
"product_name": product_name,
|
|
202
|
+
"agent": usage_metadata.get("agent"),
|
|
203
|
+
"response_quality_score": usage_metadata.get("response_quality_score"),
|
|
204
|
+
"is_streamed": is_streaming,
|
|
205
|
+
"operation_type": "CHAT",
|
|
206
|
+
"system_fingerprint": getattr(response, 'system_fingerprint', None),
|
|
207
|
+
"middleware_source": "PYTHON"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Add trace visualization fields (v0.3.0+)
|
|
211
|
+
# These fields support both environment variables and usage_metadata parameters
|
|
212
|
+
# Priority: usage_metadata > environment variable
|
|
213
|
+
|
|
214
|
+
# Environment field
|
|
215
|
+
environment = (
|
|
216
|
+
usage_metadata.get("environment") or
|
|
217
|
+
trace_fields.get_environment()
|
|
218
|
+
)
|
|
219
|
+
if environment:
|
|
220
|
+
completion_args["environment"] = environment
|
|
221
|
+
|
|
222
|
+
# Region field
|
|
223
|
+
region = (
|
|
224
|
+
usage_metadata.get("region") or
|
|
225
|
+
trace_fields.get_region()
|
|
226
|
+
)
|
|
227
|
+
if region:
|
|
228
|
+
completion_args["region"] = region
|
|
229
|
+
|
|
230
|
+
# Credential alias field
|
|
231
|
+
credential_alias = (
|
|
232
|
+
usage_metadata.get("credential_alias") or
|
|
233
|
+
usage_metadata.get("credentialAlias") or
|
|
234
|
+
trace_fields.get_credential_alias()
|
|
235
|
+
)
|
|
236
|
+
if credential_alias:
|
|
237
|
+
completion_args["credential_alias"] = credential_alias
|
|
238
|
+
|
|
239
|
+
# Trace type field (with validation)
|
|
240
|
+
trace_type = (
|
|
241
|
+
usage_metadata.get("trace_type") or
|
|
242
|
+
usage_metadata.get("traceType") or
|
|
243
|
+
trace_fields.get_trace_type()
|
|
244
|
+
)
|
|
245
|
+
if trace_type:
|
|
246
|
+
if usage_metadata.get("trace_type") or usage_metadata.get("traceType"):
|
|
247
|
+
trace_type = trace_fields.validate_trace_type(trace_type)
|
|
248
|
+
if trace_type:
|
|
249
|
+
completion_args["trace_type"] = trace_type
|
|
250
|
+
|
|
251
|
+
# Trace name field (with validation)
|
|
252
|
+
trace_name = (
|
|
253
|
+
usage_metadata.get("trace_name") or
|
|
254
|
+
usage_metadata.get("traceName") or
|
|
255
|
+
trace_fields.get_trace_name()
|
|
256
|
+
)
|
|
257
|
+
if trace_name:
|
|
258
|
+
if usage_metadata.get("trace_name") or usage_metadata.get("traceName"):
|
|
259
|
+
trace_name = trace_fields.validate_trace_name(trace_name)
|
|
260
|
+
if trace_name:
|
|
261
|
+
completion_args["trace_name"] = trace_name
|
|
262
|
+
|
|
263
|
+
# Parent transaction ID field
|
|
264
|
+
parent_transaction_id = (
|
|
265
|
+
usage_metadata.get("parent_transaction_id") or
|
|
266
|
+
usage_metadata.get("parentTransactionId") or
|
|
267
|
+
trace_fields.get_parent_transaction_id()
|
|
268
|
+
)
|
|
269
|
+
if parent_transaction_id:
|
|
270
|
+
completion_args["parent_transaction_id"] = parent_transaction_id
|
|
271
|
+
|
|
272
|
+
# Transaction name field (with fallback to task_type)
|
|
273
|
+
transaction_name = trace_fields.get_transaction_name(usage_metadata)
|
|
274
|
+
if transaction_name:
|
|
275
|
+
completion_args["transaction_name"] = transaction_name
|
|
276
|
+
|
|
277
|
+
# Retry number field
|
|
278
|
+
retry_number = trace_fields.get_retry_number()
|
|
279
|
+
if retry_number > 0:
|
|
280
|
+
completion_args["retry_number"] = retry_number
|
|
281
|
+
|
|
282
|
+
# Log the arguments at debug level
|
|
283
|
+
logger.debug("Calling client.ai.create_completion with args: %s", completion_args)
|
|
284
|
+
|
|
285
|
+
# The client.ai.create_completion method is not async, so don't use await
|
|
286
|
+
result = client.ai.create_completion(**completion_args)
|
|
287
|
+
logger.debug("Metering call result: %s", result)
|
|
288
|
+
|
|
289
|
+
# Print usage summary if enabled (fire-and-forget)
|
|
290
|
+
# Summary can still show token/duration metrics even without API key
|
|
291
|
+
revenium_api_key = get_api_key()
|
|
292
|
+
|
|
293
|
+
# Support both snake_case and camelCase for trace_id
|
|
294
|
+
trace_id = usage_metadata.get("trace_id")
|
|
295
|
+
if trace_id is None:
|
|
296
|
+
trace_id = usage_metadata.get("traceId")
|
|
297
|
+
|
|
298
|
+
print_usage_summary(
|
|
299
|
+
model=completion_args.get("model", "unknown"),
|
|
300
|
+
provider=completion_args.get("provider", "LITELLM"),
|
|
301
|
+
request_duration=request_duration,
|
|
302
|
+
input_token_count=prompt_tokens,
|
|
303
|
+
output_token_count=completion_tokens,
|
|
304
|
+
total_token_count=total_tokens,
|
|
305
|
+
transaction_id=response_id,
|
|
306
|
+
trace_id=trace_id,
|
|
307
|
+
revenium_api_key=revenium_api_key,
|
|
308
|
+
)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
if not shutdown_event.is_set():
|
|
311
|
+
logger.warning(f"Error in metering call: {str(e)}")
|
|
312
|
+
# Log the full traceback for better debugging
|
|
313
|
+
import traceback
|
|
314
|
+
logger.warning(f"Traceback: {traceback.format_exc()}")
|
|
315
|
+
|
|
316
|
+
logger.debug("Handling LiteLLM response: {}".format(response))
|
|
317
|
+
thread = run_async_in_thread(metering_call())
|
|
318
|
+
logger.debug("Metering thread started: %s", thread)
|
|
319
|
+
|
|
320
|
+
# Return the original response
|
|
321
|
+
return response
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal summary output module for Revenium LiteLLM middleware.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to print usage summaries after API requests
|
|
5
|
+
in either human-readable or JSON format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .config import (
|
|
17
|
+
get_print_summary_config,
|
|
18
|
+
get_team_id,
|
|
19
|
+
get_base_url,
|
|
20
|
+
SUMMARY_RETRY_ATTEMPTS,
|
|
21
|
+
SUMMARY_RETRY_DELAY,
|
|
22
|
+
SUMMARY_API_TIMEOUT,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CompletionMetrics:
|
|
29
|
+
"""Data class to hold cost information from Revenium API."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, total_cost: Optional[float] = None):
|
|
32
|
+
self.total_cost = total_cost
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_completion_metrics(
|
|
36
|
+
transaction_id: str,
|
|
37
|
+
revenium_api_key: str,
|
|
38
|
+
) -> CompletionMetrics:
|
|
39
|
+
"""
|
|
40
|
+
Fetch cost data from Revenium profitstream API.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
transaction_id: The transaction ID to fetch metrics for
|
|
44
|
+
revenium_api_key: The Revenium API key
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
CompletionMetrics with cost data (total_cost=None if fetch fails)
|
|
48
|
+
"""
|
|
49
|
+
team_id = get_team_id()
|
|
50
|
+
if not team_id:
|
|
51
|
+
logger.debug("No team ID configured, cannot fetch cost metrics")
|
|
52
|
+
return CompletionMetrics(total_cost=None)
|
|
53
|
+
|
|
54
|
+
base_url = get_base_url()
|
|
55
|
+
# Use urlencode to properly encode query parameters
|
|
56
|
+
params = urllib.parse.urlencode({
|
|
57
|
+
'teamId': team_id,
|
|
58
|
+
'transactionId': transaction_id
|
|
59
|
+
})
|
|
60
|
+
url = (
|
|
61
|
+
f"{base_url}/profitstream/v2/api/sources/metrics/ai/completions"
|
|
62
|
+
f"?{params}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
headers = {
|
|
66
|
+
"Authorization": f"Bearer {revenium_api_key}",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for attempt in range(SUMMARY_RETRY_ATTEMPTS):
|
|
71
|
+
try:
|
|
72
|
+
request = urllib.request.Request(url, headers=headers, method="GET")
|
|
73
|
+
with urllib.request.urlopen(
|
|
74
|
+
request, timeout=SUMMARY_API_TIMEOUT
|
|
75
|
+
) as response:
|
|
76
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
77
|
+
# Check both camelCase and snake_case
|
|
78
|
+
# Don't use 'or' to avoid treating 0.0 as falsy
|
|
79
|
+
total_cost = data.get("totalCost")
|
|
80
|
+
if total_cost is None:
|
|
81
|
+
total_cost = data.get("total_cost")
|
|
82
|
+
return CompletionMetrics(total_cost=total_cost)
|
|
83
|
+
except urllib.error.HTTPError as e:
|
|
84
|
+
logger.debug(f"HTTP error fetching metrics (attempt {attempt + 1}): {e}")
|
|
85
|
+
except urllib.error.URLError as e:
|
|
86
|
+
logger.debug(f"URL error fetching metrics (attempt {attempt + 1}): {e}")
|
|
87
|
+
except json.JSONDecodeError as e:
|
|
88
|
+
logger.debug(f"JSON decode error (attempt {attempt + 1}): {e}")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.debug(f"Error fetching metrics (attempt {attempt + 1}): {e}")
|
|
91
|
+
|
|
92
|
+
if attempt < SUMMARY_RETRY_ATTEMPTS - 1:
|
|
93
|
+
time.sleep(SUMMARY_RETRY_DELAY)
|
|
94
|
+
|
|
95
|
+
# Return consistent type - CompletionMetrics with None cost
|
|
96
|
+
return CompletionMetrics(total_cost=None)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_and_print_json_summary(
|
|
100
|
+
model: str,
|
|
101
|
+
provider: str,
|
|
102
|
+
duration_seconds: float,
|
|
103
|
+
input_token_count: Optional[int],
|
|
104
|
+
output_token_count: Optional[int],
|
|
105
|
+
total_token_count: Optional[int],
|
|
106
|
+
cost: Optional[float],
|
|
107
|
+
cost_status: str,
|
|
108
|
+
trace_id: Optional[str],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Print single-line JSON output with usage summary.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
model: The model name
|
|
115
|
+
provider: The provider name
|
|
116
|
+
duration_seconds: Request duration in seconds
|
|
117
|
+
input_token_count: Number of input tokens
|
|
118
|
+
output_token_count: Number of output tokens
|
|
119
|
+
total_token_count: Total tokens
|
|
120
|
+
cost: Cost value or None
|
|
121
|
+
cost_status: Status string for cost ('available', 'pending', 'unavailable')
|
|
122
|
+
trace_id: Optional trace ID
|
|
123
|
+
"""
|
|
124
|
+
output = {
|
|
125
|
+
"model": model,
|
|
126
|
+
"provider": provider,
|
|
127
|
+
"durationSeconds": round(duration_seconds, 3),
|
|
128
|
+
"inputTokenCount": input_token_count,
|
|
129
|
+
"outputTokenCount": output_token_count,
|
|
130
|
+
"totalTokenCount": total_token_count,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if cost is not None:
|
|
134
|
+
output["cost"] = cost
|
|
135
|
+
output["costStatus"] = cost_status
|
|
136
|
+
|
|
137
|
+
if trace_id:
|
|
138
|
+
output["traceId"] = trace_id
|
|
139
|
+
|
|
140
|
+
print(json.dumps(output, separators=(",", ":")))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def format_and_print_human_summary(
|
|
144
|
+
model: str,
|
|
145
|
+
provider: str,
|
|
146
|
+
duration_seconds: float,
|
|
147
|
+
input_token_count: Optional[int],
|
|
148
|
+
output_token_count: Optional[int],
|
|
149
|
+
total_token_count: Optional[int],
|
|
150
|
+
cost: Optional[float],
|
|
151
|
+
cost_status: str,
|
|
152
|
+
trace_id: Optional[str],
|
|
153
|
+
unavailable_reason: Optional[str] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Print professional human-readable output with usage summary.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
model: The model name
|
|
160
|
+
provider: The provider name
|
|
161
|
+
duration_seconds: Request duration in seconds
|
|
162
|
+
input_token_count: Number of input tokens
|
|
163
|
+
output_token_count: Number of output tokens
|
|
164
|
+
total_token_count: Total tokens
|
|
165
|
+
cost: Cost value or None
|
|
166
|
+
cost_status: Status string for cost
|
|
167
|
+
trace_id: Optional trace ID
|
|
168
|
+
unavailable_reason: Reason cost is unavailable
|
|
169
|
+
"""
|
|
170
|
+
separator = "=" * 60
|
|
171
|
+
|
|
172
|
+
lines = [
|
|
173
|
+
separator,
|
|
174
|
+
"REVENIUM USAGE SUMMARY",
|
|
175
|
+
separator,
|
|
176
|
+
f"Model: {model}",
|
|
177
|
+
f"Provider: {provider}",
|
|
178
|
+
f"Duration: {duration_seconds:.2f}s",
|
|
179
|
+
"",
|
|
180
|
+
"Token Usage:",
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
# Handle null token counts gracefully
|
|
184
|
+
input_str = str(input_token_count) if input_token_count is not None else "N/A"
|
|
185
|
+
output_str = str(output_token_count) if output_token_count is not None else "N/A"
|
|
186
|
+
total_str = str(total_token_count) if total_token_count is not None else "N/A"
|
|
187
|
+
|
|
188
|
+
lines.extend([
|
|
189
|
+
f" Input Tokens: {input_str}",
|
|
190
|
+
f" Output Tokens: {output_str}",
|
|
191
|
+
f" Total Tokens: {total_str}",
|
|
192
|
+
"",
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
# Format cost based on status
|
|
196
|
+
if cost is not None:
|
|
197
|
+
lines.append(f"Cost: ${cost:.6f}")
|
|
198
|
+
elif cost_status == "pending":
|
|
199
|
+
lines.append("Cost: Pending (aggregating... check Revenium dashboard)")
|
|
200
|
+
else:
|
|
201
|
+
# Provide specific message based on what's missing
|
|
202
|
+
if unavailable_reason == "api_key_missing":
|
|
203
|
+
lines.append("Cost: Add REVENIUM_METERING_API_KEY to see pricing")
|
|
204
|
+
elif unavailable_reason == "team_id_missing":
|
|
205
|
+
lines.append("Cost: Add REVENIUM_TEAM_ID to see pricing")
|
|
206
|
+
else:
|
|
207
|
+
lines.append("Cost: Add REVENIUM_TEAM_ID to see pricing")
|
|
208
|
+
|
|
209
|
+
if trace_id:
|
|
210
|
+
lines.extend(["", f"Trace ID: {trace_id}"])
|
|
211
|
+
|
|
212
|
+
lines.append(separator)
|
|
213
|
+
|
|
214
|
+
print("\n".join(lines))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def print_usage_summary(
|
|
218
|
+
model: str,
|
|
219
|
+
provider: str,
|
|
220
|
+
request_duration: float,
|
|
221
|
+
input_token_count: Optional[int],
|
|
222
|
+
output_token_count: Optional[int],
|
|
223
|
+
total_token_count: Optional[int],
|
|
224
|
+
transaction_id: str,
|
|
225
|
+
trace_id: Optional[str],
|
|
226
|
+
revenium_api_key: Optional[str],
|
|
227
|
+
) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Main entry point for printing usage summary.
|
|
230
|
+
|
|
231
|
+
This function is fire-and-forget - it will never raise exceptions to the caller.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
model: The model name
|
|
235
|
+
provider: The provider name
|
|
236
|
+
request_duration: Request duration in milliseconds
|
|
237
|
+
input_token_count: Number of input tokens
|
|
238
|
+
output_token_count: Number of output tokens
|
|
239
|
+
total_token_count: Total tokens
|
|
240
|
+
transaction_id: The transaction ID
|
|
241
|
+
trace_id: Optional trace ID
|
|
242
|
+
revenium_api_key: The Revenium API key (optional)
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
summary_config = get_print_summary_config()
|
|
246
|
+
|
|
247
|
+
# If disabled, return immediately
|
|
248
|
+
if summary_config is False:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
# Convert duration from milliseconds to seconds
|
|
252
|
+
duration_seconds = request_duration / 1000.0
|
|
253
|
+
|
|
254
|
+
# Determine cost status and fetch metrics if team_id is available
|
|
255
|
+
team_id = get_team_id()
|
|
256
|
+
cost: Optional[float] = None
|
|
257
|
+
cost_status: str = "unavailable"
|
|
258
|
+
# Track reason for unavailability for better user messaging
|
|
259
|
+
unavailable_reason: Optional[str] = None
|
|
260
|
+
|
|
261
|
+
if team_id and revenium_api_key:
|
|
262
|
+
metrics = fetch_completion_metrics(transaction_id, revenium_api_key)
|
|
263
|
+
if metrics and metrics.total_cost is not None:
|
|
264
|
+
cost = metrics.total_cost
|
|
265
|
+
cost_status = "available"
|
|
266
|
+
else:
|
|
267
|
+
cost_status = "pending"
|
|
268
|
+
else:
|
|
269
|
+
cost_status = "unavailable"
|
|
270
|
+
if not revenium_api_key:
|
|
271
|
+
unavailable_reason = "api_key_missing"
|
|
272
|
+
elif not team_id:
|
|
273
|
+
unavailable_reason = "team_id_missing"
|
|
274
|
+
|
|
275
|
+
# Print in the appropriate format
|
|
276
|
+
if summary_config == "json":
|
|
277
|
+
format_and_print_json_summary(
|
|
278
|
+
model=model,
|
|
279
|
+
provider=provider,
|
|
280
|
+
duration_seconds=duration_seconds,
|
|
281
|
+
input_token_count=input_token_count,
|
|
282
|
+
output_token_count=output_token_count,
|
|
283
|
+
total_token_count=total_token_count,
|
|
284
|
+
cost=cost,
|
|
285
|
+
cost_status=cost_status,
|
|
286
|
+
trace_id=trace_id,
|
|
287
|
+
)
|
|
288
|
+
else: # human format
|
|
289
|
+
format_and_print_human_summary(
|
|
290
|
+
model=model,
|
|
291
|
+
provider=provider,
|
|
292
|
+
duration_seconds=duration_seconds,
|
|
293
|
+
input_token_count=input_token_count,
|
|
294
|
+
output_token_count=output_token_count,
|
|
295
|
+
total_token_count=total_token_count,
|
|
296
|
+
cost=cost,
|
|
297
|
+
cost_status=cost_status,
|
|
298
|
+
trace_id=trace_id,
|
|
299
|
+
unavailable_reason=unavailable_reason,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
except Exception as e:
|
|
303
|
+
# Fire-and-forget: log but never propagate exceptions
|
|
304
|
+
logger.debug(f"Failed to print summary: {e}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
__all__ = [
|
|
308
|
+
'CompletionMetrics',
|
|
309
|
+
'fetch_completion_metrics',
|
|
310
|
+
'format_and_print_json_summary',
|
|
311
|
+
'format_and_print_human_summary',
|
|
312
|
+
'print_usage_summary',
|
|
313
|
+
]
|
|
314
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trace visualization field capture and validation.
|
|
3
|
+
|
|
4
|
+
This module provides functions to capture trace visualization fields from
|
|
5
|
+
environment variables and validate them according to the specification.
|
|
6
|
+
|
|
7
|
+
Shared functions are imported from _core.trace_fields. This module retains
|
|
8
|
+
only the LiteLLM-specific detect_operation_type function.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Optional, Dict, Any
|
|
12
|
+
|
|
13
|
+
from revenium_middleware._core.trace_fields import ( # noqa: F401 — re-exported
|
|
14
|
+
TRACE_TYPE_MAX_LENGTH,
|
|
15
|
+
TRACE_NAME_MAX_LENGTH,
|
|
16
|
+
TRACE_TYPE_PATTERN,
|
|
17
|
+
get_environment,
|
|
18
|
+
get_region,
|
|
19
|
+
get_credential_alias,
|
|
20
|
+
get_trace_type,
|
|
21
|
+
get_trace_name,
|
|
22
|
+
get_parent_transaction_id,
|
|
23
|
+
get_transaction_name,
|
|
24
|
+
get_retry_number,
|
|
25
|
+
validate_trace_type,
|
|
26
|
+
validate_trace_name,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_operation_type(method_name: str, request_body: Optional[Dict[str, Any]] = None) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Auto-detect operation type from method name and request body.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
method_name: The name of the method being called
|
|
36
|
+
request_body: Optional request body to check for tools/functions
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Operation type: 'CHAT', 'EMBED', or 'TOOL_CALL'
|
|
40
|
+
"""
|
|
41
|
+
# Check for embeddings
|
|
42
|
+
if 'embed' in method_name.lower():
|
|
43
|
+
return 'EMBED'
|
|
44
|
+
|
|
45
|
+
# Check for tool/function calls in request body
|
|
46
|
+
if request_body:
|
|
47
|
+
if request_body.get('tools') or request_body.get('functions'):
|
|
48
|
+
return 'TOOL_CALL'
|
|
49
|
+
|
|
50
|
+
# Default to CHAT for completion/generation operations
|
|
51
|
+
return 'CHAT'
|