revenium-python-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- revenium_middleware/__init__.py +184 -0
- revenium_middleware/_core/__init__.py +65 -0
- revenium_middleware/_core/config.py +165 -0
- revenium_middleware/_core/context.py +109 -0
- revenium_middleware/_core/decorators.py +202 -0
- revenium_middleware/_core/metering.py +207 -0
- revenium_middleware/_core/prompt_extraction.py +55 -0
- revenium_middleware/_core/subscriber.py +51 -0
- revenium_middleware/_core/trace_fields.py +265 -0
- revenium_middleware/anthropic/__init__.py +108 -0
- revenium_middleware/anthropic/bedrock_adapter.py +753 -0
- revenium_middleware/anthropic/config.py +29 -0
- revenium_middleware/anthropic/middleware.py +1070 -0
- revenium_middleware/anthropic/prompt_extractor.py +178 -0
- revenium_middleware/anthropic/provider.py +141 -0
- revenium_middleware/anthropic/summary_printer.py +286 -0
- revenium_middleware/anthropic/trace_fields.py +158 -0
- revenium_middleware/google/__init__.py +114 -0
- revenium_middleware/google/common/__init__.py +127 -0
- revenium_middleware/google/common/exceptions.py +137 -0
- revenium_middleware/google/common/protocols.py +192 -0
- revenium_middleware/google/common/summary_printer.py +271 -0
- revenium_middleware/google/common/trace_fields.py +205 -0
- revenium_middleware/google/common/types.py +208 -0
- revenium_middleware/google/common/utils.py +1111 -0
- revenium_middleware/google/config.py +64 -0
- revenium_middleware/google/google_ai/__init__.py +53 -0
- revenium_middleware/google/google_ai/middleware.py +667 -0
- revenium_middleware/google/google_ai/provider.py +135 -0
- revenium_middleware/google/prompt_extractor.py +396 -0
- revenium_middleware/google/vertex_ai/__init__.py +56 -0
- revenium_middleware/google/vertex_ai/middleware.py +1162 -0
- revenium_middleware/google/vertex_ai/provider.py +99 -0
- revenium_middleware/litellm/__init__.py +25 -0
- revenium_middleware/litellm/client/__init__.py +81 -0
- revenium_middleware/litellm/client/config.py +53 -0
- revenium_middleware/litellm/client/context.py +198 -0
- revenium_middleware/litellm/client/decorators.py +912 -0
- revenium_middleware/litellm/client/hooks.py +192 -0
- revenium_middleware/litellm/client/integrations/__init__.py +26 -0
- revenium_middleware/litellm/client/integrations/crewai.py +446 -0
- revenium_middleware/litellm/client/middleware.py +321 -0
- revenium_middleware/litellm/client/summary_printer.py +314 -0
- revenium_middleware/litellm/client/trace_fields.py +51 -0
- revenium_middleware/litellm/client/validation.py +207 -0
- revenium_middleware/litellm/proxy/__init__.py +25 -0
- revenium_middleware/litellm/proxy/middleware.py +217 -0
- revenium_middleware/ollama/__init__.py +28 -0
- revenium_middleware/ollama/middleware.py +569 -0
- revenium_middleware/ollama/trace_fields.py +63 -0
- revenium_middleware/openai/__init__.py +23 -0
- revenium_middleware/openai/azure_config.py +169 -0
- revenium_middleware/openai/azure_model_resolver.py +219 -0
- revenium_middleware/openai/config.py +45 -0
- revenium_middleware/openai/exceptions.py +115 -0
- revenium_middleware/openai/langchain/__init__.py +114 -0
- revenium_middleware/openai/langchain/_utils.py +129 -0
- revenium_middleware/openai/langchain/unified_handler.py +526 -0
- revenium_middleware/openai/middleware.py +1451 -0
- revenium_middleware/openai/prompt_extractor.py +173 -0
- revenium_middleware/openai/provider.py +170 -0
- revenium_middleware/openai/summary_printer.py +292 -0
- revenium_middleware/openai/trace_fields.py +98 -0
- revenium_middleware/perplexity/__init__.py +97 -0
- revenium_middleware/perplexity/middleware.py +379 -0
- revenium_middleware/perplexity/perplexity_sdk.py +256 -0
- revenium_middleware/perplexity/provider.py +84 -0
- revenium_middleware/perplexity/trace_fields.py +25 -0
- revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
- revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
- revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- revenium_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|