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,667 @@
1
+ """
2
+ Google AI SDK middleware for Revenium.
3
+
4
+ This module provides middleware for the Google AI SDK (google-genai package),
5
+ supporting both Gemini Developer API and Vertex AI endpoints through the
6
+ unified google-genai interface.
7
+ """
8
+
9
+ import datetime
10
+ import logging
11
+ from typing import Dict, Any, Optional, Tuple
12
+
13
+ import wrapt
14
+ from revenium_middleware import run_async_in_thread
15
+
16
+ # Import common utilities and types
17
+ from ..common import (
18
+ OperationType,
19
+ UsageData,
20
+ TokenCounts,
21
+ normalize_stop_reason,
22
+ Provider,
23
+ create_metering_call,
24
+ create_image_metering_call,
25
+ extract_model_name,
26
+ extract_token_counts,
27
+ StreamingError,
28
+ handle_metering_error,
29
+ safe_getattr,
30
+ )
31
+ from ..common.trace_fields import detect_vision_content
32
+
33
+ # Google AI specific imports
34
+ from .provider import get_provider_metadata
35
+ from ..prompt_extractor import extract_prompt_data_if_enabled
36
+
37
+ logger = logging.getLogger("revenium_middleware.extension")
38
+
39
+
40
+ def extract_google_ai_usage_data(
41
+ response: Any,
42
+ operation_type: OperationType,
43
+ request_time: datetime.datetime,
44
+ response_time: datetime.datetime,
45
+ client_instance: Optional[Any] = None,
46
+ model_name_fallback: Optional[str] = None,
47
+ ) -> UsageData:
48
+ """
49
+ Extract usage data from Google AI API responses.
50
+
51
+ This function handles the specific quirks of the Google AI SDK,
52
+ particularly the missing token counts for embeddings.
53
+ """
54
+ # Get provider metadata for Google AI SDK
55
+ provider_metadata = get_provider_metadata()
56
+
57
+ # Extract model name
58
+ model_name = extract_model_name(response, model_name_fallback)
59
+
60
+ # Extract token counts with Google AI specific handling
61
+ if operation_type == OperationType.EMBED:
62
+ # CRITICAL: Google AI SDK limitation - embeddings responses don't include token usage
63
+ # The Vertex AI REST API has statistics.token_count, but Google AI SDK doesn't expose it
64
+ token_counts = TokenCounts(
65
+ input_tokens=0, output_tokens=0, total_tokens=0, cached_tokens=0
66
+ )
67
+ stop_reason = "END" # Embeddings always complete successfully
68
+ logger.debug(
69
+ "Google AI SDK limitation: embeddings responses don't include token usage data"
70
+ )
71
+ else: # CHAT
72
+ # Extract usage metadata from Google AI response
73
+ token_counts = TokenCounts(
74
+ input_tokens=0, output_tokens=0, total_tokens=0, cached_tokens=0
75
+ )
76
+
77
+ if hasattr(response, "usage_metadata") and response.usage_metadata:
78
+ usage_metadata = response.usage_metadata
79
+ token_counts.input_tokens = getattr(usage_metadata, "prompt_token_count", 0)
80
+ # Google AI uses 'candidates_token_count' not 'response_token_count'
81
+ token_counts.output_tokens = getattr(
82
+ usage_metadata, "candidates_token_count", 0
83
+ )
84
+ token_counts.total_tokens = getattr(
85
+ usage_metadata,
86
+ "total_token_count",
87
+ token_counts.input_tokens + token_counts.output_tokens,
88
+ )
89
+ token_counts.cached_tokens = getattr(
90
+ usage_metadata, "cached_content_token_count", 0
91
+ )
92
+
93
+ logger.debug(
94
+ f"Chat token usage: prompt={token_counts.input_tokens}, "
95
+ f"candidates={token_counts.output_tokens}, total={token_counts.total_tokens}"
96
+ )
97
+ else:
98
+ logger.warning("No usage metadata found in Google AI chat response")
99
+
100
+ # Determine finish reason from candidates
101
+ google_finish_reason = None
102
+ if hasattr(response, "candidates") and response.candidates:
103
+ candidate = response.candidates[0]
104
+ if hasattr(candidate, "finish_reason"):
105
+ google_finish_reason = candidate.finish_reason
106
+
107
+ stop_reason = normalize_stop_reason(
108
+ google_finish_reason, Provider.GOOGLE_AI_SDK
109
+ )
110
+
111
+ # Create standardized UsageData
112
+ return UsageData.create(
113
+ operation_type=operation_type,
114
+ input_tokens=token_counts.input_tokens,
115
+ output_tokens=token_counts.output_tokens,
116
+ total_tokens=token_counts.total_tokens,
117
+ model=model_name,
118
+ provider_metadata=provider_metadata,
119
+ stop_reason=stop_reason,
120
+ request_time=request_time,
121
+ response_time=response_time,
122
+ cache_creation_token_count=token_counts.cached_tokens,
123
+ )
124
+
125
+
126
+ def create_google_ai_metering_call(
127
+ response: Any,
128
+ operation_type: OperationType,
129
+ request_time_dt: datetime.datetime,
130
+ usage_metadata: Dict[str, Any],
131
+ client_instance: Optional[Any] = None,
132
+ time_to_first_token: int = 0,
133
+ is_streamed: bool = False,
134
+ model_name_fallback: Optional[str] = None,
135
+ # Prompt capture fields
136
+ system_prompt: Optional[str] = None,
137
+ input_messages: Optional[str] = None,
138
+ output_response: Optional[str] = None,
139
+ prompts_truncated: Optional[bool] = None,
140
+ ) -> None:
141
+ """
142
+ Create and execute a metering call for Google AI SDK responses.
143
+
144
+ This is the main function used by the wrapper functions.
145
+ """
146
+ logger.debug("create_google_ai_metering_call started")
147
+
148
+ # Record response timing
149
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
150
+
151
+ # Extract usage data using Google AI specific logic
152
+ logger.debug("Extracting usage data...")
153
+ usage_data = extract_google_ai_usage_data(
154
+ response=response,
155
+ operation_type=operation_type,
156
+ request_time=request_time_dt,
157
+ response_time=response_time_dt,
158
+ client_instance=client_instance,
159
+ model_name_fallback=model_name_fallback,
160
+ )
161
+ logger.debug(f"Usage data extracted: {usage_data}")
162
+
163
+ # Create metering call using common utilities
164
+ logger.debug("About to call create_metering_call from common utilities")
165
+ try:
166
+ create_metering_call(
167
+ usage_data=usage_data,
168
+ usage_metadata=usage_metadata,
169
+ time_to_first_token=time_to_first_token,
170
+ is_streamed=is_streamed,
171
+ # Prompt capture fields
172
+ system_prompt=system_prompt,
173
+ input_messages=input_messages,
174
+ output_response=output_response,
175
+ prompts_truncated=prompts_truncated,
176
+ )
177
+ logger.debug("create_metering_call completed successfully")
178
+ except Exception as e:
179
+ logger.error(f"Error in create_metering_call: {e}")
180
+ import traceback
181
+
182
+ logger.error(f"Traceback: {traceback.format_exc()}")
183
+
184
+
185
+ # Wrapper for Google AI generate_content method
186
+ @wrapt.patch_function_wrapper("google.genai.models", "Models.generate_content")
187
+ @handle_metering_error
188
+ def generate_content_wrapper(wrapped, instance, args, kwargs):
189
+ """Wraps the google.genai.models.Models.generate_content method to log token usage."""
190
+ logger.debug("Google AI generate_content wrapper called")
191
+
192
+ # Extract usage metadata and store it for later use
193
+ usage_metadata = kwargs.pop("usage_metadata", {})
194
+
195
+ # Extract config if present (for system_instruction)
196
+ config = kwargs.get("config")
197
+
198
+ # Record request time
199
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
200
+ logger.debug(
201
+ f"Calling wrapped generate_content function with args: {args}, kwargs: {kwargs}"
202
+ )
203
+
204
+ # Call the original Google AI function
205
+ response = wrapped(*args, **kwargs)
206
+
207
+ logger.debug("Handling generate_content response: %s", response)
208
+
209
+ # Detect vision content in the request
210
+ contents = kwargs.get("contents") or (args[1] if args and len(args) > 1 else None)
211
+ has_vision = detect_vision_content(contents)
212
+ if has_vision:
213
+ usage_metadata["has_vision_content"] = True
214
+ logger.debug("Vision content detected in generate_content request")
215
+
216
+ # Extract prompt data if capture is enabled
217
+ system_prompt, input_messages, output_response, prompts_truncated = (
218
+ extract_prompt_data_if_enabled(kwargs, args=args, config=config, response=response)
219
+ )
220
+
221
+ # Create metering call using unified function
222
+ logger.debug("About to call create_google_ai_metering_call")
223
+ logger.debug(
224
+ f"create_google_ai_metering_call function exists: {create_google_ai_metering_call}"
225
+ )
226
+ try:
227
+ create_google_ai_metering_call(
228
+ response=response,
229
+ operation_type=OperationType.CHAT,
230
+ request_time_dt=request_time_dt,
231
+ usage_metadata=usage_metadata,
232
+ client_instance=getattr(instance, "_api_client", None),
233
+ # Prompt capture fields
234
+ system_prompt=system_prompt,
235
+ input_messages=input_messages,
236
+ output_response=output_response,
237
+ prompts_truncated=prompts_truncated,
238
+ )
239
+ logger.debug("create_google_ai_metering_call completed")
240
+ except Exception as e:
241
+ logger.error(f"Error in create_google_ai_metering_call: {e}")
242
+ import traceback
243
+
244
+ logger.error(f"Traceback: {traceback.format_exc()}")
245
+
246
+ return response
247
+
248
+
249
+ # Wrapper for Google AI embed_content method
250
+ @wrapt.patch_function_wrapper("google.genai.models", "Models.embed_content")
251
+ @handle_metering_error
252
+ def embed_content_wrapper(wrapped, instance, args, kwargs):
253
+ """Wraps the google.genai.models.Models.embed_content method to log token usage."""
254
+ logger.debug("Google AI embed_content wrapper called")
255
+
256
+ # Extract usage metadata and store it for later use
257
+ usage_metadata = kwargs.pop("usage_metadata", {})
258
+
259
+ # CRITICAL: Google AI embeddings responses don't include model name or token usage
260
+ # We need to capture the model name from the API call arguments as a fallback
261
+ model_name_from_call = None
262
+ if args and len(args) > 0:
263
+ model_name_from_call = args[0] # First argument is the model
264
+ elif "model" in kwargs:
265
+ model_name_from_call = kwargs["model"]
266
+
267
+ logger.debug(
268
+ f"Captured model name from embeddings API call: {model_name_from_call}"
269
+ )
270
+
271
+ # Record request time
272
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
273
+ logger.debug(
274
+ f"Calling wrapped embed_content function with args: {args}, kwargs: {kwargs}"
275
+ )
276
+
277
+ # Call the original Google AI function
278
+ response = wrapped(*args, **kwargs)
279
+
280
+ logger.debug("Handling embed_content response: %s", response)
281
+
282
+ # For embeddings, we need to pass the model name since it's not in the response
283
+ create_google_ai_metering_call(
284
+ response=response,
285
+ operation_type=OperationType.EMBED,
286
+ request_time_dt=request_time_dt,
287
+ usage_metadata=usage_metadata,
288
+ client_instance=getattr(instance, "_api_client", None),
289
+ model_name_fallback=model_name_from_call,
290
+ )
291
+
292
+ return response
293
+
294
+
295
+ # Wrapper for Google AI generate_content_stream method (streaming)
296
+ @wrapt.patch_function_wrapper("google.genai.models", "Models.generate_content_stream")
297
+ @handle_metering_error
298
+ def generate_content_stream_wrapper(wrapped, instance, args, kwargs):
299
+ """Wraps the google.genai.models.Models.generate_content_stream method to log token usage."""
300
+ logger.debug("Google AI generate_content_stream wrapper called")
301
+
302
+ # Extract usage metadata and store it for later use
303
+ usage_metadata = kwargs.pop("usage_metadata", {})
304
+
305
+ # Extract config if present (for system_instruction)
306
+ config = kwargs.get("config")
307
+
308
+ # Detect vision content in the streaming request
309
+ contents = kwargs.get("contents") or (args[1] if args and len(args) > 1 else None)
310
+ has_vision = detect_vision_content(contents)
311
+ if has_vision:
312
+ usage_metadata["has_vision_content"] = True
313
+ logger.debug("Vision content detected in generate_content_stream request")
314
+
315
+ # Store kwargs and args for prompt extraction later
316
+ request_kwargs = kwargs.copy()
317
+ request_args = args
318
+
319
+ # Record request time
320
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
321
+ logger.debug(
322
+ f"Calling wrapped generate_content_stream function with args: {args}, kwargs: {kwargs}"
323
+ )
324
+
325
+ # Call the original Google AI function
326
+ stream = wrapped(*args, **kwargs)
327
+
328
+ logger.debug("Handling generate_content_stream response")
329
+
330
+ # Return wrapped stream that will meter usage when complete
331
+ return handle_streaming_response(
332
+ stream=stream,
333
+ request_time_dt=request_time_dt,
334
+ usage_metadata=usage_metadata,
335
+ client_instance=getattr(instance, "_api_client", None),
336
+ request_kwargs=request_kwargs,
337
+ request_args=request_args,
338
+ config=config,
339
+ )
340
+
341
+
342
+ def handle_streaming_response(
343
+ stream, request_time_dt, usage_metadata, client_instance=None, request_kwargs=None, request_args=None, config=None
344
+ ):
345
+ """
346
+ Handle streaming responses from Google AI.
347
+ Wraps the stream to collect metrics and log them after completion.
348
+ """
349
+
350
+ class StreamWrapper:
351
+ def __init__(self, stream):
352
+ self.stream = stream
353
+ self.chunks = []
354
+ self.accumulated_text = [] # For prompt capture
355
+ self.model = None
356
+ self.finish_reason = None
357
+ self.usage_metadata = None
358
+ self.client_instance = client_instance
359
+ self.first_chunk_time = None
360
+ self._closed = False
361
+ self._usage_logged = False
362
+ self.streaming_truncated = False # Track if streaming response was truncated
363
+
364
+ # Limit chunk storage to prevent memory issues
365
+ self._max_chunks = 1000
366
+
367
+ def __iter__(self):
368
+ return self
369
+
370
+ def __next__(self):
371
+ if self._closed:
372
+ raise StopIteration("Stream has been closed")
373
+
374
+ try:
375
+ chunk = next(self.stream)
376
+ self._process_chunk(chunk)
377
+ return chunk
378
+ except StopIteration:
379
+ self._finalize()
380
+ raise
381
+ except Exception as e:
382
+ self._handle_error(e)
383
+ raise
384
+
385
+ def __enter__(self):
386
+ return self
387
+
388
+ def __exit__(self, exc_type, exc_val, exc_tb):
389
+ self.close()
390
+ return False # Don't suppress exceptions
391
+
392
+ def close(self):
393
+ """Properly close the stream and clean up resources."""
394
+ if not self._closed:
395
+ self._closed = True
396
+ if not self._usage_logged:
397
+ try:
398
+ self._log_usage()
399
+ except Exception as e:
400
+ logger.error("Error logging usage during stream cleanup: %s", e)
401
+
402
+ # Clear chunks to free memory
403
+ self.chunks.clear()
404
+
405
+ # Close underlying stream if it has a close method
406
+ if hasattr(self.stream, "close"):
407
+ try:
408
+ self.stream.close()
409
+ except Exception as e:
410
+ logger.debug("Error closing underlying stream: %s", e)
411
+
412
+ def _finalize(self):
413
+ """Finalize the stream and log usage."""
414
+ if not self._usage_logged:
415
+ self._log_usage()
416
+ self._usage_logged = True
417
+
418
+ def _handle_error(self, error: Exception):
419
+ """Handle errors during streaming."""
420
+ logger.error("Error in streaming response: %s", error)
421
+ if not self._usage_logged:
422
+ # Try to log partial usage data
423
+ try:
424
+ self._log_usage()
425
+ self._usage_logged = True
426
+ except Exception as log_error:
427
+ logger.error(
428
+ "Failed to log usage after stream error: %s", log_error
429
+ )
430
+
431
+ def _process_chunk(self, chunk):
432
+ """Process each chunk to extract metadata"""
433
+ # Limit chunk storage to prevent memory issues
434
+ if len(self.chunks) < self._max_chunks:
435
+ self.chunks.append(chunk)
436
+ elif len(self.chunks) == self._max_chunks:
437
+ logger.warning(
438
+ "Reached maximum chunk limit (%d), not storing additional chunks",
439
+ self._max_chunks,
440
+ )
441
+
442
+ # Record time of first chunk
443
+ if self.first_chunk_time is None:
444
+ self.first_chunk_time = datetime.datetime.now(datetime.timezone.utc)
445
+
446
+ # Extract model name from chunk using safe access
447
+ if self.model is None:
448
+ self.model = safe_getattr(chunk, "model_version")
449
+
450
+ # Accumulate text for prompt capture (with early truncation to prevent unbounded memory growth)
451
+ from ..config import Config
452
+ current_len = sum(len(t) for t in self.accumulated_text)
453
+
454
+ if hasattr(chunk, 'text') and chunk.text:
455
+ # Check if adding this chunk would exceed the limit
456
+ chunk_len = len(chunk.text)
457
+ if current_len + chunk_len <= Config.MAX_PROMPT_LENGTH:
458
+ self.accumulated_text.append(chunk.text)
459
+ elif current_len < Config.MAX_PROMPT_LENGTH:
460
+ # Partial append: only add what fits
461
+ remaining = Config.MAX_PROMPT_LENGTH - current_len
462
+ self.accumulated_text.append(chunk.text[:remaining])
463
+ self.streaming_truncated = True
464
+ else:
465
+ # Already at limit, mark as truncated
466
+ self.streaming_truncated = True
467
+ elif hasattr(chunk, 'candidates') and chunk.candidates:
468
+ for candidate in chunk.candidates:
469
+ if hasattr(candidate, 'content') and candidate.content:
470
+ if hasattr(candidate.content, 'parts'):
471
+ for part in candidate.content.parts:
472
+ if hasattr(part, 'text') and part.text:
473
+ part_len = len(part.text)
474
+ if current_len + part_len <= Config.MAX_PROMPT_LENGTH:
475
+ self.accumulated_text.append(part.text)
476
+ current_len += part_len
477
+ elif current_len < Config.MAX_PROMPT_LENGTH:
478
+ # Partial append: only add what fits
479
+ remaining = Config.MAX_PROMPT_LENGTH - current_len
480
+ self.accumulated_text.append(part.text[:remaining])
481
+ current_len = Config.MAX_PROMPT_LENGTH
482
+ self.streaming_truncated = True
483
+ break
484
+ else:
485
+ # Already at limit
486
+ self.streaming_truncated = True
487
+ break
488
+
489
+ # Check for finish reason and usage metadata in the chunk
490
+ candidates = safe_getattr(chunk, "candidates")
491
+ if candidates and len(candidates) > 0:
492
+ candidate = candidates[0]
493
+ finish_reason = safe_getattr(candidate, "finish_reason")
494
+ if finish_reason:
495
+ self.finish_reason = finish_reason
496
+
497
+ # Check for usage metadata in the chunk (final chunk typically has this)
498
+ usage_metadata = safe_getattr(chunk, "usage_metadata")
499
+ if usage_metadata:
500
+ self.usage_metadata = usage_metadata
501
+
502
+ def _log_usage(self):
503
+ """Log usage after stream completion"""
504
+ try:
505
+ if not self.chunks:
506
+ logger.warning("No chunks received in streaming response")
507
+ return
508
+
509
+ # Calculate time to first token
510
+ time_to_first_token = 0
511
+ if self.first_chunk_time:
512
+ time_to_first_token = int(
513
+ (self.first_chunk_time - request_time_dt).total_seconds() * 1000
514
+ )
515
+
516
+ # Extract prompt data if capture is enabled
517
+ accumulated_content = ''.join(self.accumulated_text) if self.accumulated_text else None
518
+ # Append truncation marker if streaming was truncated
519
+ if self.streaming_truncated and accumulated_content:
520
+ accumulated_content += "...[TRUNCATED]"
521
+
522
+ system_prompt, input_messages, output_response, prompts_truncated = (
523
+ extract_prompt_data_if_enabled(
524
+ request_kwargs or {},
525
+ args=request_args,
526
+ config=config,
527
+ accumulated_content=accumulated_content
528
+ )
529
+ )
530
+
531
+ # Update truncation flag if streaming was truncated
532
+ if self.streaming_truncated:
533
+ prompts_truncated = True
534
+
535
+ # Create a synthetic response object for usage extraction
536
+ class SyntheticResponse:
537
+ def __init__(self, model_version, usage_metadata, candidates):
538
+ self.model_version = model_version
539
+ self.usage_metadata = usage_metadata
540
+ self.candidates = candidates
541
+
542
+ # Create synthetic response from collected data
543
+ synthetic_response = SyntheticResponse(
544
+ model_version=self.model,
545
+ usage_metadata=self.usage_metadata,
546
+ candidates=(
547
+ [
548
+ type(
549
+ "obj", (object,), {"finish_reason": self.finish_reason}
550
+ )()
551
+ ]
552
+ if self.finish_reason
553
+ else []
554
+ ),
555
+ )
556
+
557
+ # Create metering call for streaming response
558
+ create_google_ai_metering_call(
559
+ response=synthetic_response,
560
+ operation_type=OperationType.CHAT,
561
+ request_time_dt=request_time_dt,
562
+ usage_metadata=usage_metadata,
563
+ client_instance=self.client_instance,
564
+ time_to_first_token=time_to_first_token,
565
+ is_streamed=True,
566
+ # Prompt capture fields
567
+ system_prompt=system_prompt,
568
+ input_messages=input_messages,
569
+ output_response=output_response,
570
+ prompts_truncated=prompts_truncated,
571
+ )
572
+
573
+ logger.debug(
574
+ "Streaming usage logged: model=%s, chunks=%d, time_to_first_token=%dms",
575
+ self.model,
576
+ len(self.chunks),
577
+ time_to_first_token,
578
+ )
579
+
580
+ except Exception as e:
581
+ # Don't let logging errors break the stream
582
+ logger.error("Error logging streaming usage: %s", e)
583
+ raise StreamingError(
584
+ f"Failed to log streaming usage: {str(e)}",
585
+ chunk_count=len(self.chunks) if self.chunks else 0,
586
+ stream_state="completed",
587
+ ) from e
588
+
589
+ return StreamWrapper(stream)
590
+
591
+
592
+ # Wrapper for Google AI generate_images method (Imagen)
593
+ @wrapt.patch_function_wrapper("google.genai.models", "Models.generate_images")
594
+ @handle_metering_error
595
+ def generate_images_wrapper(wrapped, instance, args, kwargs):
596
+ """Wraps the google.genai.models.Models.generate_images method to meter image generation."""
597
+ logger.debug("Google AI generate_images wrapper called (Imagen)")
598
+
599
+ # Extract usage metadata
600
+ usage_metadata = kwargs.pop("usage_metadata", {})
601
+
602
+ # Capture model name from args/kwargs
603
+ model_name = None
604
+ if args and len(args) > 0:
605
+ model_name = args[0]
606
+ elif "model" in kwargs:
607
+ model_name = kwargs["model"]
608
+
609
+ # Capture image generation config
610
+ config = kwargs.get("config", {})
611
+ number_of_images = 1
612
+ aspect_ratio = None
613
+
614
+ if config:
615
+ if hasattr(config, "number_of_images"):
616
+ number_of_images = config.number_of_images or 1
617
+ elif isinstance(config, dict):
618
+ number_of_images = config.get("number_of_images", 1)
619
+
620
+ if hasattr(config, "aspect_ratio"):
621
+ aspect_ratio = config.aspect_ratio
622
+ elif isinstance(config, dict):
623
+ aspect_ratio = config.get("aspect_ratio")
624
+
625
+ logger.debug(
626
+ f"Imagen request: model={model_name}, count={number_of_images}, aspect_ratio={aspect_ratio}"
627
+ )
628
+
629
+ # Record request time
630
+ request_time_dt = datetime.datetime.now(datetime.timezone.utc)
631
+
632
+ # Call the original function
633
+ response = wrapped(*args, **kwargs)
634
+
635
+ # Record response time
636
+ response_time_dt = datetime.datetime.now(datetime.timezone.utc)
637
+
638
+ # Count actual generated images
639
+ actual_image_count = 0
640
+ if hasattr(response, "generated_images") and response.generated_images:
641
+ actual_image_count = len(response.generated_images)
642
+ elif hasattr(response, "images") and response.images:
643
+ actual_image_count = len(response.images)
644
+
645
+ logger.debug(
646
+ f"Imagen response: requested={number_of_images}, actual={actual_image_count}"
647
+ )
648
+
649
+ # Create image metering call
650
+ try:
651
+ create_image_metering_call(
652
+ model=model_name or "imagen-3.0-generate-001",
653
+ requested_image_count=number_of_images,
654
+ actual_image_count=actual_image_count,
655
+ request_time_dt=request_time_dt,
656
+ response_time_dt=response_time_dt,
657
+ usage_metadata=usage_metadata,
658
+ operation_subtype="generation",
659
+ aspect_ratio=aspect_ratio,
660
+ )
661
+ logger.debug("Image metering call completed for Imagen")
662
+ except Exception as e:
663
+ logger.error(f"Error in image metering call: {e}")
664
+ import traceback
665
+ logger.error(f"Traceback: {traceback.format_exc()}")
666
+
667
+ return response