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,1111 @@
1
+ """
2
+ Common utilities for Google AI middleware.
3
+
4
+ This module contains shared functionality used by both Google AI SDK
5
+ and Vertex AI SDK middleware implementations.
6
+ """
7
+
8
+ import datetime
9
+ import logging
10
+ import os
11
+ import uuid
12
+ from typing import Dict, Any, Optional
13
+
14
+ from revenium_middleware import client, run_async_in_thread, shutdown_event
15
+
16
+ from .types import UsageData, OperationType, ProviderMetadata, TokenCounts
17
+ from .exceptions import MeteringError, APIResponseError, safe_extract
18
+ from .protocols import has_token_counts, safe_getattr, get_token_count
19
+ from . import trace_fields
20
+ from .summary_printer import print_usage_summary
21
+
22
+ logger = logging.getLogger("revenium_middleware.extension")
23
+
24
+
25
+ def is_debug_logging_enabled() -> bool:
26
+ """
27
+ Check if debug logging is currently enabled for Revenium middleware.
28
+
29
+ Returns:
30
+ bool: True if debug logging is enabled, False otherwise
31
+ """
32
+ # Check environment variable first
33
+ log_level_str = os.getenv("REVENIUM_LOG_LEVEL", "INFO").upper()
34
+ if log_level_str == "DEBUG":
35
+ return True
36
+
37
+ # Check actual logger level as fallback
38
+ revenium_logger = logging.getLogger("revenium_middleware")
39
+ return revenium_logger.getEffectiveLevel() <= logging.DEBUG
40
+
41
+
42
+ def generate_transaction_id() -> str:
43
+ """Generate a unique transaction ID."""
44
+ return str(uuid.uuid4())
45
+
46
+
47
+ def format_timestamp(dt: datetime.datetime) -> str:
48
+ """Format datetime as ISO string for API calls."""
49
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
50
+
51
+
52
+ def extract_field_with_fallback(
53
+ usage_metadata: Dict[str, Any],
54
+ new_snake: str,
55
+ new_camel: str,
56
+ old_snake: str,
57
+ old_camel: str,
58
+ field_label: str,
59
+ ) -> Optional[str]:
60
+ """
61
+ Extract field with 4-level fallback and deprecation warning.
62
+
63
+ Args:
64
+ usage_metadata: Dictionary containing usage metadata
65
+ new_snake: New snake_case field name (e.g., "organization_name")
66
+ new_camel: New camelCase field name (e.g., "organizationName")
67
+ old_snake: Old snake_case field name (e.g., "organization_id")
68
+ old_camel: Old camelCase field name (e.g., "organizationId")
69
+ field_label: Human-readable label for the field (e.g., "organization")
70
+
71
+ Returns:
72
+ The field value with precedence: new_snake > new_camel > old_snake > old_camel
73
+ """
74
+ # Extract with precedence: new_snake > new_camel > old_snake > old_camel
75
+ value = usage_metadata.get(new_snake)
76
+ if value is None:
77
+ value = usage_metadata.get(new_camel)
78
+ if value is None:
79
+ value = usage_metadata.get(old_snake)
80
+ if value is None:
81
+ value = usage_metadata.get(old_camel)
82
+
83
+ # Log deprecation warning if old fields are used without new fields
84
+ if usage_metadata.get(old_snake) or usage_metadata.get(old_camel):
85
+ if not (usage_metadata.get(new_snake) or usage_metadata.get(new_camel)):
86
+ logger.warning(
87
+ f"Fields '{old_camel}' and '{old_snake}' are deprecated. "
88
+ f"Use '{new_camel}' or '{new_snake}' instead. "
89
+ "The old fields will be removed in a future version."
90
+ )
91
+
92
+ return value
93
+
94
+
95
+ def calculate_duration_ms(
96
+ start_time: datetime.datetime, end_time: datetime.datetime
97
+ ) -> int:
98
+ """Calculate duration in milliseconds between two timestamps."""
99
+ return int((end_time - start_time).total_seconds() * 1000)
100
+
101
+
102
+ async def log_token_usage(
103
+ transaction_id: str,
104
+ model: str,
105
+ prompt_tokens: int,
106
+ completion_tokens: int,
107
+ total_tokens: int,
108
+ cached_tokens: int,
109
+ stop_reason: str,
110
+ request_time: str,
111
+ response_time: str,
112
+ request_duration: int,
113
+ usage_metadata: Dict[str, Any],
114
+ provider: str = "Google",
115
+ model_source: str = "GOOGLE",
116
+ is_streamed: bool = False,
117
+ time_to_first_token: int = 0,
118
+ operation_type: OperationType = OperationType.CHAT,
119
+ # Prompt capture fields
120
+ system_prompt: Optional[str] = None,
121
+ input_messages: Optional[str] = None,
122
+ output_response: Optional[str] = None,
123
+ prompts_truncated: Optional[bool] = None,
124
+ ) -> None:
125
+ """
126
+ Log token usage to Revenium.
127
+
128
+ Args:
129
+ transaction_id: Unique identifier for this API call
130
+ model: Model name used for the request
131
+ prompt_tokens: Number of input tokens
132
+ completion_tokens: Number of output tokens
133
+ total_tokens: Total token count
134
+ cached_tokens: Number of cached tokens used
135
+ stop_reason: Reason the generation stopped
136
+ request_time: ISO timestamp of request start
137
+ response_time: ISO timestamp of response completion
138
+ request_duration: Duration in milliseconds
139
+ usage_metadata: Additional metadata for the request
140
+ provider: Provider name (always "Google")
141
+ model_source: Model source (always "GOOGLE")
142
+ is_streamed: Whether this was a streaming response
143
+ time_to_first_token: Time to first token in milliseconds
144
+ operation_type: Type of operation (CHAT or EMBED)
145
+ system_prompt: System instruction/prompt text
146
+ input_messages: Input messages as JSON string
147
+ output_response: Output response text
148
+ prompts_truncated: Whether any prompts were truncated
149
+ """
150
+ if shutdown_event.is_set():
151
+ logger.warning("Skipping metering call during shutdown")
152
+ return
153
+
154
+ logger.debug(
155
+ "Metering call to Revenium for %s operation %s",
156
+ operation_type.lower(),
157
+ transaction_id,
158
+ )
159
+
160
+ # Prepare arguments for create_completion (using snake_case for Python client library)
161
+ completion_args = {
162
+ "cache_creation_token_count": cached_tokens,
163
+ "cache_read_token_count": 0,
164
+ "input_token_cost": None, # Let backend calculate from model pricing
165
+ "output_token_cost": None, # Let backend calculate from model pricing
166
+ "total_cost": None, # Let backend calculate from model pricing
167
+ "output_token_count": completion_tokens,
168
+ "cost_type": "AI",
169
+ "model": model,
170
+ "input_token_count": prompt_tokens,
171
+ "provider": provider,
172
+ "model_source": model_source,
173
+ "reasoning_token_count": 0,
174
+ "request_time": request_time,
175
+ "response_time": response_time,
176
+ "completion_start_time": response_time,
177
+ "request_duration": int(request_duration),
178
+ "stop_reason": stop_reason,
179
+ "total_token_count": total_tokens,
180
+ "transaction_id": transaction_id,
181
+ "is_streamed": is_streamed,
182
+ "operation_type": operation_type.value, # Convert enum to string
183
+ "time_to_first_token": time_to_first_token,
184
+ "middleware_source": "python", # Required parameter for Google Python middleware
185
+ }
186
+
187
+ # Add optional metadata fields if they exist (using snake_case for Python client)
188
+ if usage_metadata.get("trace_id"):
189
+ completion_args["trace_id"] = usage_metadata.get("trace_id")
190
+ if usage_metadata.get("task_type"):
191
+ completion_args["task_type"] = usage_metadata.get("task_type")
192
+
193
+ # Extract organization and product names with deprecation warnings
194
+ organization_name = extract_field_with_fallback(
195
+ usage_metadata,
196
+ "organization_name",
197
+ "organizationName",
198
+ "organization_id",
199
+ "organizationId",
200
+ "organization",
201
+ )
202
+ product_name = extract_field_with_fallback(
203
+ usage_metadata,
204
+ "product_name",
205
+ "productName",
206
+ "product_id",
207
+ "productId",
208
+ "product",
209
+ )
210
+
211
+ # Add to completion_args using new field names
212
+ if organization_name:
213
+ completion_args["organization_name"] = organization_name
214
+
215
+ if product_name:
216
+ completion_args["product_name"] = product_name
217
+
218
+ if usage_metadata.get("subscription_id"):
219
+ completion_args["subscription_id"] = usage_metadata.get("subscription_id")
220
+ if usage_metadata.get("agent"):
221
+ completion_args["agent"] = usage_metadata.get("agent")
222
+ if usage_metadata.get("response_quality_score"):
223
+ completion_args["response_quality_score"] = usage_metadata.get(
224
+ "response_quality_score"
225
+ )
226
+
227
+ # Add vision content flag if detected
228
+ if usage_metadata.get("has_vision_content"):
229
+ completion_args["has_vision_content"] = True
230
+
231
+ # Add trace visualization fields (v0.2.0+)
232
+ # These fields support both environment variables and usage_metadata parameters
233
+ # Priority: usage_metadata > environment variable
234
+
235
+ # Environment field
236
+ environment = (
237
+ usage_metadata.get("environment") or
238
+ trace_fields.get_environment()
239
+ )
240
+ if environment:
241
+ completion_args["environment"] = environment
242
+
243
+ # Region field
244
+ region = (
245
+ usage_metadata.get("region") or
246
+ trace_fields.get_region()
247
+ )
248
+ if region:
249
+ completion_args["region"] = region
250
+
251
+ # Credential alias field
252
+ credential_alias = (
253
+ usage_metadata.get("credential_alias") or
254
+ usage_metadata.get("credentialAlias") or
255
+ trace_fields.get_credential_alias()
256
+ )
257
+ if credential_alias:
258
+ completion_args["credential_alias"] = credential_alias
259
+
260
+ # Trace type field
261
+ trace_type = (
262
+ usage_metadata.get("trace_type") or
263
+ usage_metadata.get("traceType") or
264
+ trace_fields.get_trace_type()
265
+ )
266
+ if trace_type:
267
+ # Validate if coming from usage_metadata
268
+ if usage_metadata.get("trace_type") or usage_metadata.get("traceType"):
269
+ trace_type = trace_fields.validate_trace_type(trace_type)
270
+ if trace_type:
271
+ completion_args["trace_type"] = trace_type
272
+
273
+ # Trace name field
274
+ trace_name = (
275
+ usage_metadata.get("trace_name") or
276
+ usage_metadata.get("traceName") or
277
+ trace_fields.get_trace_name()
278
+ )
279
+ if trace_name:
280
+ # Validate if coming from usage_metadata
281
+ if usage_metadata.get("trace_name") or usage_metadata.get("traceName"):
282
+ trace_name = trace_fields.validate_trace_name(trace_name)
283
+ if trace_name:
284
+ completion_args["trace_name"] = trace_name
285
+
286
+ # Parent transaction ID field
287
+ parent_transaction_id = (
288
+ usage_metadata.get("parent_transaction_id") or
289
+ usage_metadata.get("parentTransactionId") or
290
+ trace_fields.get_parent_transaction_id()
291
+ )
292
+ if parent_transaction_id:
293
+ completion_args["parent_transaction_id"] = parent_transaction_id
294
+
295
+ # Transaction name field (with fallback to task_type)
296
+ transaction_name = trace_fields.get_transaction_name(usage_metadata)
297
+ if transaction_name:
298
+ completion_args["transaction_name"] = transaction_name
299
+
300
+ # Retry number field
301
+ retry_number = trace_fields.get_retry_number()
302
+ if retry_number > 0:
303
+ completion_args["retry_number"] = retry_number
304
+
305
+ # Build subscriber object - support both nested and flat formats
306
+ subscriber_data = {}
307
+ flat_keys_used = []
308
+
309
+ # Prefer nested format if present (recommended structure)
310
+ if "subscriber" in usage_metadata:
311
+ nested_subscriber = usage_metadata["subscriber"]
312
+ if isinstance(nested_subscriber, dict):
313
+ # Use nested structure directly
314
+ subscriber_data = nested_subscriber.copy()
315
+ logger.debug("Using nested subscriber format (recommended)")
316
+ else:
317
+ # Fall back to flat keys for backward compatibility
318
+ subscriber_id = usage_metadata.get("subscriber_id")
319
+ subscriber_email = usage_metadata.get("subscriber_email")
320
+ credential_name = usage_metadata.get("subscriber_credential_name")
321
+ credential_value = usage_metadata.get("subscriber_credential")
322
+
323
+ if subscriber_id:
324
+ subscriber_data["id"] = subscriber_id
325
+ flat_keys_used.append("subscriber_id")
326
+ if subscriber_email:
327
+ subscriber_data["email"] = subscriber_email
328
+ flat_keys_used.append("subscriber_email")
329
+
330
+ # Add credential sub-object if credential data is provided
331
+ credential_data = {}
332
+ if credential_name:
333
+ credential_data["name"] = credential_name
334
+ flat_keys_used.append("subscriber_credential_name")
335
+ if credential_value:
336
+ credential_data["value"] = credential_value
337
+ flat_keys_used.append("subscriber_credential")
338
+
339
+ if credential_data:
340
+ subscriber_data["credential"] = credential_data
341
+
342
+ # Log deprecation warning if flat keys were used
343
+ if flat_keys_used:
344
+ logger.warning(
345
+ f"Flat subscriber keys are deprecated: {flat_keys_used}. "
346
+ "Please use nested 'subscriber' object format: "
347
+ "{'subscriber': {'id': '...', 'email': '...', 'credential': {'name': '...', 'value': '...'}}}"
348
+ )
349
+
350
+ # Only add subscriber to completion_args if we have subscriber data
351
+ if subscriber_data:
352
+ completion_args["subscriber"] = subscriber_data
353
+
354
+ # Add prompt capture fields only if they have values
355
+ if system_prompt is not None:
356
+ completion_args["system_prompt"] = system_prompt
357
+ if input_messages is not None:
358
+ completion_args["input_messages"] = input_messages
359
+ if output_response is not None:
360
+ completion_args["output_response"] = output_response
361
+ if prompts_truncated is not None:
362
+ completion_args["prompts_truncated"] = prompts_truncated
363
+
364
+ # Log the arguments at debug level (redact sensitive prompt data)
365
+ safe_args = {k: v for k, v in completion_args.items()
366
+ if k not in ('system_prompt', 'input_messages', 'output_response')}
367
+ if 'system_prompt' in completion_args:
368
+ safe_args['system_prompt'] = '[REDACTED]'
369
+ if 'input_messages' in completion_args:
370
+ safe_args['input_messages'] = '[REDACTED]'
371
+ if 'output_response' in completion_args:
372
+ safe_args['output_response'] = '[REDACTED]'
373
+ logger.debug("Calling client.ai.create_completion with args: %s", safe_args)
374
+
375
+ # Debug logging for metering call
376
+ logger.debug(
377
+ f"Metering call for {operation_type.value}: {transaction_id}, tokens: {prompt_tokens}+{completion_tokens}={total_tokens}"
378
+ )
379
+
380
+ try:
381
+ # The client.ai.create_completion method is not async, so don't use await
382
+ result = client.ai.create_completion(**completion_args)
383
+ logger.debug("Metering call result: %s", result)
384
+ logger.info(" REVENIUM SUCCESS: Metering call successful: %s", result.id)
385
+ except Exception as e:
386
+ if not shutdown_event.is_set():
387
+ # Create a structured error for better handling
388
+ error_details = {
389
+ "transaction_id": transaction_id,
390
+ "model": model,
391
+ "error_type": type(e).__name__,
392
+ "completion_args_keys": list(completion_args.keys()),
393
+ }
394
+
395
+ # Log error with structured information
396
+ logger.error(" REVENIUM FAILURE: Error in metering call: %s", str(e))
397
+ logger.error(" REVENIUM FAILURE: Error details: %s", error_details)
398
+
399
+ # Log traceback at debug level to avoid spam
400
+ logger.debug("Metering call traceback:", exc_info=True)
401
+
402
+ # Raise a specific MeteringError for better error handling upstream
403
+ raise MeteringError(
404
+ f"Failed to send metering data: {str(e)}",
405
+ transaction_id=transaction_id,
406
+ api_response=None,
407
+ error_details=error_details,
408
+ ) from e
409
+ else:
410
+ logger.debug("Metering call failed during shutdown - this is expected")
411
+
412
+
413
+ def create_metering_call(
414
+ usage_data: UsageData,
415
+ usage_metadata: Dict[str, Any],
416
+ time_to_first_token: int = 0,
417
+ is_streamed: bool = False,
418
+ # Prompt capture fields
419
+ system_prompt: Optional[str] = None,
420
+ input_messages: Optional[str] = None,
421
+ output_response: Optional[str] = None,
422
+ prompts_truncated: Optional[bool] = None,
423
+ ) -> None:
424
+ """
425
+ Create and execute a metering call using UsageData.
426
+
427
+ This is a higher-level function that uses the standardized UsageData structure.
428
+ """
429
+ # Override streaming and timing info
430
+ usage_data.is_streamed = is_streamed
431
+ usage_data.time_to_first_token = time_to_first_token
432
+
433
+ # Create async metering call
434
+ async def metering_call():
435
+ await log_token_usage(
436
+ transaction_id=usage_data.transaction_id,
437
+ model=usage_data.model,
438
+ prompt_tokens=usage_data.input_token_count, # These are positional parameters
439
+ completion_tokens=usage_data.output_token_count, # These are positional parameters
440
+ total_tokens=usage_data.total_token_count, # These are positional parameters
441
+ cached_tokens=usage_data.cache_creation_token_count,
442
+ stop_reason=usage_data.stop_reason,
443
+ request_time=usage_data.request_time,
444
+ response_time=usage_data.response_time,
445
+ request_duration=usage_data.request_duration,
446
+ usage_metadata=usage_metadata,
447
+ provider=usage_data.provider,
448
+ model_source=usage_data.model_source,
449
+ is_streamed=usage_data.is_streamed,
450
+ time_to_first_token=usage_data.time_to_first_token,
451
+ operation_type=OperationType(usage_data.operation_type),
452
+ # Prompt capture fields
453
+ system_prompt=system_prompt,
454
+ input_messages=input_messages,
455
+ output_response=output_response,
456
+ prompts_truncated=prompts_truncated,
457
+ )
458
+
459
+ # Execute in background thread
460
+ run_async_in_thread(metering_call())
461
+
462
+ # Print usage summary if enabled (fire-and-forget)
463
+ # Get the API key from the client
464
+ revenium_api_key = getattr(client, "api_key", None)
465
+ if revenium_api_key:
466
+ # Support both snake_case and camelCase for trace_id
467
+ trace_id = usage_metadata.get("trace_id")
468
+ if trace_id is None:
469
+ trace_id = usage_metadata.get("traceId")
470
+
471
+ # Run summary printing in background thread to avoid blocking
472
+ def _print_summary_sync():
473
+ print_usage_summary(
474
+ model=usage_data.model,
475
+ provider=usage_data.provider,
476
+ request_duration=usage_data.request_duration,
477
+ input_token_count=usage_data.input_token_count,
478
+ output_token_count=usage_data.output_token_count,
479
+ total_token_count=usage_data.total_token_count,
480
+ transaction_id=usage_data.transaction_id,
481
+ trace_id=trace_id,
482
+ revenium_api_key=revenium_api_key,
483
+ )
484
+
485
+ run_async_in_thread(_print_summary_sync)
486
+
487
+
488
+ def _build_common_metadata_args(usage_metadata: Dict[str, Any]) -> Dict[str, Any]:
489
+ """
490
+ Build common metadata arguments shared across all metering endpoints.
491
+
492
+ Extracts trace fields, subscriber data, organization/product names, etc.
493
+ from usage_metadata and environment variables.
494
+
495
+ Returns:
496
+ Dictionary of common keyword arguments for metering API calls.
497
+ """
498
+ args = {}
499
+
500
+ # Optional metadata fields
501
+ if usage_metadata.get("trace_id"):
502
+ args["trace_id"] = usage_metadata["trace_id"]
503
+ if usage_metadata.get("task_type"):
504
+ args["task_type"] = usage_metadata["task_type"]
505
+
506
+ # Organization and product names with deprecation fallback
507
+ organization_name = extract_field_with_fallback(
508
+ usage_metadata, "organization_name", "organizationName",
509
+ "organization_id", "organizationId", "organization",
510
+ )
511
+ product_name = extract_field_with_fallback(
512
+ usage_metadata, "product_name", "productName",
513
+ "product_id", "productId", "product",
514
+ )
515
+ if organization_name:
516
+ args["organization_name"] = organization_name
517
+ if product_name:
518
+ args["product_name"] = product_name
519
+
520
+ if usage_metadata.get("subscription_id"):
521
+ args["subscription_id"] = usage_metadata["subscription_id"]
522
+ if usage_metadata.get("agent"):
523
+ args["agent"] = usage_metadata["agent"]
524
+
525
+ # Trace visualization fields
526
+ environment = usage_metadata.get("environment") or trace_fields.get_environment()
527
+ if environment:
528
+ args["environment"] = environment
529
+
530
+ region = usage_metadata.get("region") or trace_fields.get_region()
531
+ if region:
532
+ args["region"] = region
533
+
534
+ credential_alias = (
535
+ usage_metadata.get("credential_alias") or
536
+ usage_metadata.get("credentialAlias") or
537
+ trace_fields.get_credential_alias()
538
+ )
539
+ if credential_alias:
540
+ args["credential_alias"] = credential_alias
541
+
542
+ trace_type = (
543
+ usage_metadata.get("trace_type") or
544
+ usage_metadata.get("traceType") or
545
+ trace_fields.get_trace_type()
546
+ )
547
+ if trace_type:
548
+ if usage_metadata.get("trace_type") or usage_metadata.get("traceType"):
549
+ trace_type = trace_fields.validate_trace_type(trace_type)
550
+ if trace_type:
551
+ args["trace_type"] = trace_type
552
+
553
+ trace_name = (
554
+ usage_metadata.get("trace_name") or
555
+ usage_metadata.get("traceName") or
556
+ trace_fields.get_trace_name()
557
+ )
558
+ if trace_name:
559
+ if usage_metadata.get("trace_name") or usage_metadata.get("traceName"):
560
+ trace_name = trace_fields.validate_trace_name(trace_name)
561
+ if trace_name:
562
+ args["trace_name"] = trace_name
563
+
564
+ parent_transaction_id = (
565
+ usage_metadata.get("parent_transaction_id") or
566
+ usage_metadata.get("parentTransactionId") or
567
+ trace_fields.get_parent_transaction_id()
568
+ )
569
+ if parent_transaction_id:
570
+ args["parent_transaction_id"] = parent_transaction_id
571
+
572
+ transaction_name = trace_fields.get_transaction_name(usage_metadata)
573
+ if transaction_name:
574
+ args["transaction_name"] = transaction_name
575
+
576
+ retry_number = trace_fields.get_retry_number()
577
+ if retry_number > 0:
578
+ args["retry_number"] = retry_number
579
+
580
+ # Subscriber data
581
+ subscriber_data = {}
582
+ flat_keys_used = []
583
+ if "subscriber" in usage_metadata:
584
+ nested_subscriber = usage_metadata["subscriber"]
585
+ if isinstance(nested_subscriber, dict):
586
+ subscriber_data = nested_subscriber.copy()
587
+ else:
588
+ subscriber_id = usage_metadata.get("subscriber_id")
589
+ subscriber_email = usage_metadata.get("subscriber_email")
590
+ credential_name = usage_metadata.get("subscriber_credential_name")
591
+ credential_value = usage_metadata.get("subscriber_credential")
592
+ if subscriber_id:
593
+ subscriber_data["id"] = subscriber_id
594
+ flat_keys_used.append("subscriber_id")
595
+ if subscriber_email:
596
+ subscriber_data["email"] = subscriber_email
597
+ flat_keys_used.append("subscriber_email")
598
+ credential_data = {}
599
+ if credential_name:
600
+ credential_data["name"] = credential_name
601
+ flat_keys_used.append("subscriber_credential_name")
602
+ if credential_value:
603
+ credential_data["value"] = credential_value
604
+ flat_keys_used.append("subscriber_credential")
605
+ if credential_data:
606
+ subscriber_data["credential"] = credential_data
607
+ if flat_keys_used:
608
+ logger.warning(
609
+ f"Flat subscriber keys are deprecated: {flat_keys_used}. "
610
+ "Please use nested 'subscriber' object format."
611
+ )
612
+ if subscriber_data:
613
+ args["subscriber"] = subscriber_data
614
+
615
+ return args
616
+
617
+
618
+ async def log_image_usage(
619
+ transaction_id: str,
620
+ model: str,
621
+ requested_image_count: int,
622
+ actual_image_count: int,
623
+ request_time: str,
624
+ response_time: str,
625
+ request_duration: int,
626
+ usage_metadata: Dict[str, Any],
627
+ provider: str = "Google",
628
+ model_source: str = "GOOGLE",
629
+ operation_subtype: str = "generation",
630
+ resolution: Optional[str] = None,
631
+ quality: Optional[str] = None,
632
+ style: Optional[str] = None,
633
+ aspect_ratio: Optional[str] = None,
634
+ ) -> None:
635
+ """
636
+ Log image generation usage to Revenium via /meter/v2/ai/images.
637
+
638
+ Args:
639
+ transaction_id: Unique identifier for this API call
640
+ model: Model name (e.g., "imagen-3.0-generate-001")
641
+ requested_image_count: Number of images requested
642
+ actual_image_count: Number of images actually generated
643
+ request_time: ISO timestamp of request start
644
+ response_time: ISO timestamp of response completion
645
+ request_duration: Duration in milliseconds
646
+ usage_metadata: Additional metadata for the request
647
+ provider: Provider name (always "Google")
648
+ model_source: Model source (always "GOOGLE")
649
+ operation_subtype: "generation", "edit", or "upscale"
650
+ resolution: Image resolution (e.g., "1024x1024")
651
+ quality: Image quality setting
652
+ style: Image style setting
653
+ aspect_ratio: Aspect ratio (e.g., "16:9")
654
+ """
655
+ if shutdown_event.is_set():
656
+ logger.warning("Skipping image metering call during shutdown")
657
+ return
658
+
659
+ logger.debug(
660
+ "Image metering call to Revenium for %s operation %s",
661
+ operation_subtype,
662
+ transaction_id,
663
+ )
664
+
665
+ image_args = {
666
+ "model": model,
667
+ "provider": provider,
668
+ "model_source": model_source,
669
+ "request_time": request_time,
670
+ "response_time": response_time,
671
+ "request_duration": int(request_duration),
672
+ "transaction_id": transaction_id,
673
+ "requested_image_count": requested_image_count,
674
+ "actual_image_count": actual_image_count,
675
+ "operation_subtype": operation_subtype,
676
+ "middleware_source": "python",
677
+ }
678
+
679
+ if resolution:
680
+ image_args["resolution"] = resolution
681
+ if quality:
682
+ image_args["quality"] = quality
683
+ if style:
684
+ image_args["style"] = style
685
+ if aspect_ratio:
686
+ image_args["aspect_ratio"] = aspect_ratio
687
+
688
+ # Add common metadata
689
+ image_args.update(_build_common_metadata_args(usage_metadata))
690
+
691
+ logger.debug("Calling client.ai.create_image with args: %s", image_args)
692
+
693
+ try:
694
+ result = client.ai.create_image(**image_args)
695
+ logger.debug("Image metering call result: %s", result)
696
+ logger.info(" REVENIUM SUCCESS: Image metering call successful: %s", result.id)
697
+ except Exception as e:
698
+ if not shutdown_event.is_set():
699
+ error_details = {
700
+ "transaction_id": transaction_id,
701
+ "model": model,
702
+ "error_type": type(e).__name__,
703
+ }
704
+ logger.error(" REVENIUM FAILURE: Error in image metering call: %s", str(e))
705
+ logger.error(" REVENIUM FAILURE: Error details: %s", error_details)
706
+ logger.debug("Image metering call traceback:", exc_info=True)
707
+ raise MeteringError(
708
+ f"Failed to send image metering data: {str(e)}",
709
+ transaction_id=transaction_id,
710
+ api_response=None,
711
+ error_details=error_details,
712
+ ) from e
713
+
714
+
715
+ async def log_video_usage(
716
+ transaction_id: str,
717
+ model: str,
718
+ duration_seconds: float,
719
+ request_time: str,
720
+ response_time: str,
721
+ request_duration: int,
722
+ usage_metadata: Dict[str, Any],
723
+ provider: str = "Google",
724
+ model_source: str = "GOOGLE",
725
+ operation_subtype: str = "generation",
726
+ resolution: Optional[str] = None,
727
+ fps: Optional[int] = None,
728
+ aspect_ratio: Optional[str] = None,
729
+ video_job_id: Optional[str] = None,
730
+ async_operation: bool = False,
731
+ ) -> None:
732
+ """
733
+ Log video generation usage to Revenium via /meter/v2/ai/video.
734
+
735
+ Args:
736
+ transaction_id: Unique identifier for this API call
737
+ model: Model name (e.g., "veo-2.0-generate-001")
738
+ duration_seconds: Duration of generated video in seconds
739
+ request_time: ISO timestamp of request start
740
+ response_time: ISO timestamp of response completion
741
+ request_duration: Duration in milliseconds
742
+ usage_metadata: Additional metadata for the request
743
+ provider: Provider name (always "Google")
744
+ model_source: Model source (always "GOOGLE")
745
+ operation_subtype: "generation", "extend", or "upscale"
746
+ resolution: Video resolution (e.g., "1080p")
747
+ fps: Frames per second
748
+ aspect_ratio: Aspect ratio (e.g., "16:9")
749
+ video_job_id: Job ID for async operations
750
+ async_operation: Whether this was an async operation
751
+ """
752
+ if shutdown_event.is_set():
753
+ logger.warning("Skipping video metering call during shutdown")
754
+ return
755
+
756
+ logger.debug(
757
+ "Video metering call to Revenium for %s operation %s",
758
+ operation_subtype,
759
+ transaction_id,
760
+ )
761
+
762
+ video_args = {
763
+ "model": model,
764
+ "provider": provider,
765
+ "model_source": model_source,
766
+ "request_time": request_time,
767
+ "response_time": response_time,
768
+ "request_duration": int(request_duration),
769
+ "transaction_id": transaction_id,
770
+ "duration_seconds": duration_seconds,
771
+ "operation_subtype": operation_subtype,
772
+ "middleware_source": "python",
773
+ }
774
+
775
+ if resolution:
776
+ video_args["resolution"] = resolution
777
+ if fps is not None:
778
+ video_args["fps"] = fps
779
+ if aspect_ratio:
780
+ video_args["aspect_ratio"] = aspect_ratio
781
+ if video_job_id:
782
+ video_args["video_job_id"] = video_job_id
783
+ if async_operation:
784
+ video_args["async_operation"] = async_operation
785
+
786
+ # Add common metadata
787
+ video_args.update(_build_common_metadata_args(usage_metadata))
788
+
789
+ logger.debug("Calling client.ai.create_video with args: %s", video_args)
790
+
791
+ try:
792
+ result = client.ai.create_video(**video_args)
793
+ logger.debug("Video metering call result: %s", result)
794
+ logger.info(" REVENIUM SUCCESS: Video metering call successful: %s", result.id)
795
+ except Exception as e:
796
+ if not shutdown_event.is_set():
797
+ error_details = {
798
+ "transaction_id": transaction_id,
799
+ "model": model,
800
+ "error_type": type(e).__name__,
801
+ }
802
+ logger.error(" REVENIUM FAILURE: Error in video metering call: %s", str(e))
803
+ logger.error(" REVENIUM FAILURE: Error details: %s", error_details)
804
+ logger.debug("Video metering call traceback:", exc_info=True)
805
+ raise MeteringError(
806
+ f"Failed to send video metering data: {str(e)}",
807
+ transaction_id=transaction_id,
808
+ api_response=None,
809
+ error_details=error_details,
810
+ ) from e
811
+
812
+
813
+ def create_image_metering_call(
814
+ model: str,
815
+ requested_image_count: int,
816
+ actual_image_count: int,
817
+ request_time_dt: datetime.datetime,
818
+ response_time_dt: datetime.datetime,
819
+ usage_metadata: Dict[str, Any],
820
+ provider: str = "Google",
821
+ model_source: str = "GOOGLE",
822
+ operation_subtype: str = "generation",
823
+ resolution: Optional[str] = None,
824
+ quality: Optional[str] = None,
825
+ style: Optional[str] = None,
826
+ aspect_ratio: Optional[str] = None,
827
+ ) -> None:
828
+ """Create and execute an image metering call in a background thread."""
829
+ transaction_id = generate_transaction_id()
830
+ request_time = format_timestamp(request_time_dt)
831
+ response_time = format_timestamp(response_time_dt)
832
+ request_duration = calculate_duration_ms(request_time_dt, response_time_dt)
833
+
834
+ async def metering_call():
835
+ await log_image_usage(
836
+ transaction_id=transaction_id,
837
+ model=model,
838
+ requested_image_count=requested_image_count,
839
+ actual_image_count=actual_image_count,
840
+ request_time=request_time,
841
+ response_time=response_time,
842
+ request_duration=request_duration,
843
+ usage_metadata=usage_metadata,
844
+ provider=provider,
845
+ model_source=model_source,
846
+ operation_subtype=operation_subtype,
847
+ resolution=resolution,
848
+ quality=quality,
849
+ style=style,
850
+ aspect_ratio=aspect_ratio,
851
+ )
852
+
853
+ run_async_in_thread(metering_call())
854
+
855
+ # Print usage summary if enabled
856
+ revenium_api_key = getattr(client, "api_key", None)
857
+ if revenium_api_key:
858
+ trace_id = usage_metadata.get("trace_id") or usage_metadata.get("traceId")
859
+
860
+ def _print_summary_sync():
861
+ print_usage_summary(
862
+ model=model,
863
+ provider=provider,
864
+ request_duration=request_duration,
865
+ input_token_count=0,
866
+ output_token_count=0,
867
+ total_token_count=0,
868
+ transaction_id=transaction_id,
869
+ trace_id=trace_id,
870
+ revenium_api_key=revenium_api_key,
871
+ )
872
+
873
+ run_async_in_thread(_print_summary_sync)
874
+
875
+
876
+ def create_video_metering_call(
877
+ model: str,
878
+ duration_seconds: float,
879
+ request_time_dt: datetime.datetime,
880
+ response_time_dt: datetime.datetime,
881
+ usage_metadata: Dict[str, Any],
882
+ provider: str = "Google",
883
+ model_source: str = "GOOGLE",
884
+ operation_subtype: str = "generation",
885
+ resolution: Optional[str] = None,
886
+ fps: Optional[int] = None,
887
+ aspect_ratio: Optional[str] = None,
888
+ video_job_id: Optional[str] = None,
889
+ async_operation: bool = False,
890
+ ) -> None:
891
+ """Create and execute a video metering call in a background thread."""
892
+ transaction_id = generate_transaction_id()
893
+ request_time = format_timestamp(request_time_dt)
894
+ response_time = format_timestamp(response_time_dt)
895
+ request_duration = calculate_duration_ms(request_time_dt, response_time_dt)
896
+
897
+ async def metering_call():
898
+ await log_video_usage(
899
+ transaction_id=transaction_id,
900
+ model=model,
901
+ duration_seconds=duration_seconds,
902
+ request_time=request_time,
903
+ response_time=response_time,
904
+ request_duration=request_duration,
905
+ usage_metadata=usage_metadata,
906
+ provider=provider,
907
+ model_source=model_source,
908
+ operation_subtype=operation_subtype,
909
+ resolution=resolution,
910
+ fps=fps,
911
+ aspect_ratio=aspect_ratio,
912
+ video_job_id=video_job_id,
913
+ async_operation=async_operation,
914
+ )
915
+
916
+ run_async_in_thread(metering_call())
917
+
918
+ # Print usage summary if enabled
919
+ revenium_api_key = getattr(client, "api_key", None)
920
+ if revenium_api_key:
921
+ trace_id = usage_metadata.get("trace_id") or usage_metadata.get("traceId")
922
+
923
+ def _print_summary_sync():
924
+ print_usage_summary(
925
+ model=model,
926
+ provider=provider,
927
+ request_duration=request_duration,
928
+ input_token_count=0,
929
+ output_token_count=0,
930
+ total_token_count=0,
931
+ transaction_id=transaction_id,
932
+ trace_id=trace_id,
933
+ revenium_api_key=revenium_api_key,
934
+ )
935
+
936
+ run_async_in_thread(_print_summary_sync)
937
+
938
+
939
+ @safe_extract
940
+ def extract_model_name(response: Any, fallback: Optional[str] = None) -> str:
941
+ """
942
+ Extract model name from API response with fallback.
943
+
944
+ Args:
945
+ response: API response object
946
+ fallback: Fallback model name if extraction fails
947
+
948
+ Returns:
949
+ Model name string
950
+
951
+ Raises:
952
+ TokenExtractionError: If extraction fails and no fallback is provided
953
+ """
954
+ if response is None:
955
+ if fallback:
956
+ logger.debug("Response is None, using fallback model name: %s", fallback)
957
+ return fallback
958
+ raise APIResponseError("Response is None and no fallback model name provided")
959
+
960
+ # Try common model name attributes using safe access
961
+ model_attrs = ["model", "model_name", "_model_name", "model_version"]
962
+ for attr in model_attrs:
963
+ model_name = safe_getattr(response, attr)
964
+ if model_name:
965
+ return str(model_name)
966
+
967
+ # Try nested model attributes
968
+ usage = safe_getattr(response, "usage")
969
+ if usage:
970
+ model_name = safe_getattr(usage, "model")
971
+ if model_name:
972
+ return str(model_name)
973
+
974
+ # Use fallback if provided
975
+ if fallback:
976
+ logger.debug(
977
+ "Could not extract model name from response, using fallback: %s", fallback
978
+ )
979
+ return fallback
980
+
981
+ # Log available attributes for debugging
982
+ available_attrs = [attr for attr in dir(response) if not attr.startswith("_")]
983
+ logger.warning(
984
+ "Could not extract model name from response. Available attributes: %s",
985
+ available_attrs[:10],
986
+ )
987
+
988
+ return "unknown-model"
989
+
990
+
991
+ @safe_extract
992
+ def extract_token_counts(response: Any, operation_type: OperationType) -> TokenCounts:
993
+ """
994
+ Extract token counts from API response.
995
+
996
+ This function handles the differences between SDKs and operation types.
997
+
998
+ Args:
999
+ response: API response object
1000
+ operation_type: Type of operation (CHAT or EMBED)
1001
+
1002
+ Returns:
1003
+ TokenCounts object with extracted counts
1004
+
1005
+ Raises:
1006
+ TokenExtractionError: If response is invalid
1007
+ """
1008
+ if response is None:
1009
+ logger.warning("Response is None, returning zero token counts")
1010
+ return TokenCounts(
1011
+ input_tokens=0, output_tokens=0, total_tokens=0, cached_tokens=0
1012
+ )
1013
+
1014
+ # Initialize with zeros
1015
+ input_tokens = 0
1016
+ output_tokens = 0
1017
+ total_tokens = 0
1018
+ cached_tokens = 0
1019
+
1020
+ # Try to extract from usage_metadata first (Google AI SDK pattern)
1021
+ usage = safe_getattr(response, "usage_metadata") or safe_getattr(response, "usage")
1022
+
1023
+ if usage and has_token_counts(usage):
1024
+ # Use the utility function for safe token extraction
1025
+ input_tokens = get_token_count(
1026
+ usage, ["prompt_token_count", "prompt_tokens", "input_tokens"]
1027
+ )
1028
+ output_tokens = get_token_count(
1029
+ usage, ["candidates_token_count", "completion_tokens", "output_tokens"]
1030
+ )
1031
+ total_tokens = get_token_count(usage, ["total_token_count", "total_tokens"])
1032
+ cached_tokens = get_token_count(
1033
+ usage, ["cached_content_token_count", "cached_tokens"]
1034
+ )
1035
+
1036
+ logger.debug(
1037
+ "Extracted token counts from usage: input=%d, output=%d, total=%d, cached=%d",
1038
+ input_tokens,
1039
+ output_tokens,
1040
+ total_tokens,
1041
+ cached_tokens,
1042
+ )
1043
+ else:
1044
+ logger.debug("No usage metadata found or no token counts available in response")
1045
+
1046
+ # For embeddings, output tokens are always 0
1047
+ if operation_type == OperationType.EMBED:
1048
+ output_tokens = 0
1049
+ logger.debug("Operation type is EMBED, setting output_tokens to 0")
1050
+
1051
+ # Calculate total if not provided but we have input/output
1052
+ if total_tokens == 0 and (input_tokens > 0 or output_tokens > 0):
1053
+ total_tokens = input_tokens + output_tokens
1054
+ logger.debug(
1055
+ "Calculated total_tokens as %d (input=%d + output=%d)",
1056
+ total_tokens,
1057
+ input_tokens,
1058
+ output_tokens,
1059
+ )
1060
+
1061
+ return TokenCounts(
1062
+ input_tokens=input_tokens,
1063
+ output_tokens=output_tokens,
1064
+ total_tokens=total_tokens,
1065
+ cached_tokens=cached_tokens,
1066
+ )
1067
+
1068
+
1069
+ def create_usage_data(
1070
+ response: Any,
1071
+ operation_type: OperationType,
1072
+ provider_metadata: ProviderMetadata,
1073
+ request_time: datetime.datetime,
1074
+ response_time: datetime.datetime,
1075
+ model_name_fallback: Optional[str] = None,
1076
+ stop_reason_fallback: str = "END",
1077
+ ) -> UsageData:
1078
+ """
1079
+ Create standardized UsageData from API response.
1080
+
1081
+ This is the main function for converting SDK-specific responses
1082
+ to our common UsageData format.
1083
+ """
1084
+ # Extract token counts
1085
+ token_counts = extract_token_counts(response, operation_type)
1086
+
1087
+ # Extract model name
1088
+ model_name = extract_model_name(response, model_name_fallback)
1089
+
1090
+ # Extract stop reason (SDK-specific logic should be handled by caller)
1091
+ stop_reason = stop_reason_fallback
1092
+ if hasattr(response, "finish_reason"):
1093
+ stop_reason = response.finish_reason or stop_reason_fallback
1094
+ elif hasattr(response, "candidates") and response.candidates:
1095
+ candidate = response.candidates[0]
1096
+ if hasattr(candidate, "finish_reason"):
1097
+ stop_reason = candidate.finish_reason or stop_reason_fallback
1098
+
1099
+ # Create UsageData
1100
+ return UsageData.create(
1101
+ operation_type=operation_type,
1102
+ input_tokens=token_counts.input_tokens,
1103
+ output_tokens=token_counts.output_tokens,
1104
+ total_tokens=token_counts.total_tokens,
1105
+ model=model_name,
1106
+ provider_metadata=provider_metadata,
1107
+ stop_reason=stop_reason,
1108
+ request_time=request_time,
1109
+ response_time=response_time,
1110
+ cache_creation_token_count=token_counts.cached_tokens,
1111
+ )