revenium-python-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. revenium_middleware/__init__.py +184 -0
  2. revenium_middleware/_core/__init__.py +65 -0
  3. revenium_middleware/_core/config.py +165 -0
  4. revenium_middleware/_core/context.py +109 -0
  5. revenium_middleware/_core/decorators.py +202 -0
  6. revenium_middleware/_core/metering.py +207 -0
  7. revenium_middleware/_core/prompt_extraction.py +55 -0
  8. revenium_middleware/_core/subscriber.py +51 -0
  9. revenium_middleware/_core/trace_fields.py +265 -0
  10. revenium_middleware/anthropic/__init__.py +108 -0
  11. revenium_middleware/anthropic/bedrock_adapter.py +753 -0
  12. revenium_middleware/anthropic/config.py +29 -0
  13. revenium_middleware/anthropic/middleware.py +1070 -0
  14. revenium_middleware/anthropic/prompt_extractor.py +178 -0
  15. revenium_middleware/anthropic/provider.py +141 -0
  16. revenium_middleware/anthropic/summary_printer.py +286 -0
  17. revenium_middleware/anthropic/trace_fields.py +158 -0
  18. revenium_middleware/google/__init__.py +114 -0
  19. revenium_middleware/google/common/__init__.py +127 -0
  20. revenium_middleware/google/common/exceptions.py +137 -0
  21. revenium_middleware/google/common/protocols.py +192 -0
  22. revenium_middleware/google/common/summary_printer.py +271 -0
  23. revenium_middleware/google/common/trace_fields.py +205 -0
  24. revenium_middleware/google/common/types.py +208 -0
  25. revenium_middleware/google/common/utils.py +1111 -0
  26. revenium_middleware/google/config.py +64 -0
  27. revenium_middleware/google/google_ai/__init__.py +53 -0
  28. revenium_middleware/google/google_ai/middleware.py +667 -0
  29. revenium_middleware/google/google_ai/provider.py +135 -0
  30. revenium_middleware/google/prompt_extractor.py +396 -0
  31. revenium_middleware/google/vertex_ai/__init__.py +56 -0
  32. revenium_middleware/google/vertex_ai/middleware.py +1162 -0
  33. revenium_middleware/google/vertex_ai/provider.py +99 -0
  34. revenium_middleware/litellm/__init__.py +25 -0
  35. revenium_middleware/litellm/client/__init__.py +81 -0
  36. revenium_middleware/litellm/client/config.py +53 -0
  37. revenium_middleware/litellm/client/context.py +198 -0
  38. revenium_middleware/litellm/client/decorators.py +912 -0
  39. revenium_middleware/litellm/client/hooks.py +192 -0
  40. revenium_middleware/litellm/client/integrations/__init__.py +26 -0
  41. revenium_middleware/litellm/client/integrations/crewai.py +446 -0
  42. revenium_middleware/litellm/client/middleware.py +321 -0
  43. revenium_middleware/litellm/client/summary_printer.py +314 -0
  44. revenium_middleware/litellm/client/trace_fields.py +51 -0
  45. revenium_middleware/litellm/client/validation.py +207 -0
  46. revenium_middleware/litellm/proxy/__init__.py +25 -0
  47. revenium_middleware/litellm/proxy/middleware.py +217 -0
  48. revenium_middleware/ollama/__init__.py +28 -0
  49. revenium_middleware/ollama/middleware.py +569 -0
  50. revenium_middleware/ollama/trace_fields.py +63 -0
  51. revenium_middleware/openai/__init__.py +23 -0
  52. revenium_middleware/openai/azure_config.py +169 -0
  53. revenium_middleware/openai/azure_model_resolver.py +219 -0
  54. revenium_middleware/openai/config.py +45 -0
  55. revenium_middleware/openai/exceptions.py +115 -0
  56. revenium_middleware/openai/langchain/__init__.py +114 -0
  57. revenium_middleware/openai/langchain/_utils.py +129 -0
  58. revenium_middleware/openai/langchain/unified_handler.py +526 -0
  59. revenium_middleware/openai/middleware.py +1451 -0
  60. revenium_middleware/openai/prompt_extractor.py +173 -0
  61. revenium_middleware/openai/provider.py +170 -0
  62. revenium_middleware/openai/summary_printer.py +292 -0
  63. revenium_middleware/openai/trace_fields.py +98 -0
  64. revenium_middleware/perplexity/__init__.py +97 -0
  65. revenium_middleware/perplexity/middleware.py +379 -0
  66. revenium_middleware/perplexity/perplexity_sdk.py +256 -0
  67. revenium_middleware/perplexity/provider.py +84 -0
  68. revenium_middleware/perplexity/trace_fields.py +25 -0
  69. revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
  70. revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
  71. revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
  72. revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  73. revenium_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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'