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,135 @@
1
+ """
2
+ Provider detection and configuration for Google AI SDK support.
3
+
4
+ This module handles detection of Gemini Developer API vs Vertex AI endpoints
5
+ when using the Google AI SDK (google-genai package). Both endpoints report
6
+ as "Google" provider for unified analytics.
7
+
8
+ Migrated from the original provider.py with updates for the new architecture.
9
+ """
10
+
11
+ import os
12
+ import logging
13
+ from enum import Enum, auto
14
+ from typing import Optional, Any
15
+
16
+ from ..common import ProviderMetadata
17
+
18
+ logger = logging.getLogger("revenium_middleware.extension")
19
+
20
+
21
+ class GoogleAIEndpoint(Enum):
22
+ """Google AI SDK endpoint types."""
23
+
24
+ GEMINI_DEVELOPER_API = auto()
25
+ VERTEX_AI = auto()
26
+
27
+
28
+ def detect_provider(client: Optional[Any] = None) -> GoogleAIEndpoint:
29
+ """
30
+ Detect which Google AI endpoint is being used with the Google AI SDK.
31
+
32
+ Detection priority:
33
+ 1. Client configuration (vertexai parameter) - most reliable
34
+ 2. Environment variables (GOOGLE_GENAI_USE_VERTEXAI)
35
+ 3. Check for project/location vs API key configuration
36
+ 4. Default to Gemini Developer API
37
+
38
+ Args:
39
+ client: Google GenAI client instance
40
+
41
+ Returns:
42
+ GoogleAIEndpoint enum indicating detected endpoint
43
+ """
44
+ logger.debug("Detecting Google AI SDK endpoint...")
45
+
46
+ # 1. Check client configuration first (most reliable)
47
+ if client and hasattr(client, "_vertexai") and client._vertexai:
48
+ logger.debug("Vertex AI endpoint detected via client configuration")
49
+ return GoogleAIEndpoint.VERTEX_AI
50
+
51
+ # 2. Check environment variable
52
+ if os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1", "yes"):
53
+ logger.debug(
54
+ "Vertex AI endpoint detected via GOOGLE_GENAI_USE_VERTEXAI environment variable"
55
+ )
56
+ return GoogleAIEndpoint.VERTEX_AI
57
+
58
+ # 3. Check for Vertex AI configuration (project + location)
59
+ if os.getenv("GOOGLE_CLOUD_PROJECT") and os.getenv("GOOGLE_CLOUD_LOCATION"):
60
+ logger.debug(
61
+ "Vertex AI endpoint detected via GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION"
62
+ )
63
+ return GoogleAIEndpoint.VERTEX_AI
64
+
65
+ # 4. Check if we have API key (Gemini Developer API)
66
+ if os.getenv("GOOGLE_API_KEY"):
67
+ logger.debug("Gemini Developer API endpoint detected via GOOGLE_API_KEY")
68
+ return GoogleAIEndpoint.GEMINI_DEVELOPER_API
69
+
70
+ # 5. Default to Gemini Developer API
71
+ logger.debug("Defaulting to Gemini Developer API endpoint")
72
+ return GoogleAIEndpoint.GEMINI_DEVELOPER_API
73
+
74
+
75
+ def get_provider_metadata() -> ProviderMetadata:
76
+ """
77
+ Get provider metadata for Google AI SDK usage records.
78
+
79
+ Returns:
80
+ ProviderMetadata with standardized "Google" provider name
81
+ """
82
+ # Google AI SDK always uses the same provider metadata
83
+ return ProviderMetadata.for_google_ai_sdk()
84
+
85
+
86
+ def is_vertex_ai_endpoint(endpoint: GoogleAIEndpoint) -> bool:
87
+ """
88
+ Check if the endpoint is Vertex AI.
89
+
90
+ Args:
91
+ endpoint: Endpoint to check
92
+
93
+ Returns:
94
+ True if Vertex AI endpoint, False otherwise
95
+ """
96
+ return endpoint == GoogleAIEndpoint.VERTEX_AI
97
+
98
+
99
+ # Global endpoint cache to avoid repeated detection
100
+ _detected_endpoint: Optional[GoogleAIEndpoint] = None
101
+ _endpoint_detection_attempted: bool = False
102
+
103
+
104
+ def get_or_detect_provider(
105
+ client: Optional[Any] = None, force_redetect: bool = False
106
+ ) -> GoogleAIEndpoint:
107
+ """
108
+ Get cached endpoint or detect if not already done.
109
+
110
+ This provides lazy loading - detection only happens when needed and is cached.
111
+
112
+ Args:
113
+ client: Google GenAI client instance
114
+ force_redetect: Force re-detection even if cached
115
+
116
+ Returns:
117
+ Detected endpoint
118
+ """
119
+ global _detected_endpoint, _endpoint_detection_attempted
120
+
121
+ if force_redetect or not _endpoint_detection_attempted:
122
+ _detected_endpoint = detect_provider(client)
123
+ _endpoint_detection_attempted = True
124
+ logger.debug(
125
+ f"Google AI SDK endpoint detection completed: {_detected_endpoint}"
126
+ )
127
+
128
+ return _detected_endpoint
129
+
130
+
131
+ def reset_provider_cache():
132
+ """Reset endpoint detection cache. Useful for testing."""
133
+ global _detected_endpoint, _endpoint_detection_attempted
134
+ _detected_endpoint = None
135
+ _endpoint_detection_attempted = False
@@ -0,0 +1,396 @@
1
+ """
2
+ Prompt extraction utilities for Google Gemini API.
3
+
4
+ This module provides functions to extract system instructions, input messages,
5
+ and output responses from Google Gemini API requests and responses.
6
+
7
+ Google Gemini API Format:
8
+ - system_instruction: Separate field with parts array
9
+ - contents: Array of content objects with role and parts
10
+ - parts: Array of text/data objects within each content
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ from typing import Dict, Any, Optional, Tuple
16
+
17
+ from .config import Config
18
+ from revenium_middleware._core.prompt_extraction import (
19
+ extract_streaming_response_content as _core_extract_streaming_response_content,
20
+ )
21
+
22
+ logger = logging.getLogger("revenium_middleware.extension")
23
+
24
+
25
+ def _sanitize_content_dict(content: Dict[str, Any]) -> Dict[str, Any]:
26
+ """
27
+ Sanitize a content dict to remove binary data and non-JSON-serializable values.
28
+
29
+ Args:
30
+ content: Content dict that may contain inline_data or other binary fields
31
+
32
+ Returns:
33
+ Sanitized content dict safe for JSON serialization
34
+ """
35
+ sanitized = {'role': content.get('role', 'user'), 'parts': []}
36
+
37
+ for part in content.get('parts', []):
38
+ if isinstance(part, dict):
39
+ # Check for text content
40
+ if 'text' in part:
41
+ sanitized['parts'].append({'text': part['text']})
42
+ # Check for binary data fields and replace with placeholder
43
+ elif 'inline_data' in part:
44
+ sanitized['parts'].append({'inline_data': '[BINARY_DATA]'})
45
+ elif 'file_data' in part:
46
+ sanitized['parts'].append({'file_data': '[FILE_DATA]'})
47
+ elif 'function_call' in part:
48
+ # Keep function calls but sanitize them
49
+ sanitized['parts'].append({'function_call': '[FUNCTION_CALL]'})
50
+ elif 'function_response' in part:
51
+ sanitized['parts'].append({'function_response': '[FUNCTION_RESPONSE]'})
52
+ else:
53
+ # Unknown part type - include only safe string representation
54
+ sanitized['parts'].append({'unknown': '[UNKNOWN_PART_TYPE]'})
55
+ else:
56
+ # Non-dict part - skip it
57
+ logger.debug(f"Skipping non-dict part in content: {type(part)}")
58
+
59
+ return sanitized
60
+
61
+
62
+ def extract_prompts_from_request(
63
+ kwargs: Dict[str, Any],
64
+ config: Optional[Any] = None,
65
+ args: Optional[Tuple] = None
66
+ ) -> Dict[str, Any]:
67
+ """
68
+ Extract system instruction and input contents from Google Gemini API request.
69
+
70
+ Args:
71
+ kwargs: The kwargs dict passed to the Google Gemini API call
72
+ config: Optional GenerateContentConfig object (for google-genai SDK)
73
+ args: Optional positional arguments tuple (first arg is usually contents)
74
+
75
+ Returns:
76
+ Dict containing:
77
+ - systemPrompt: String or None (system instruction content)
78
+ - inputMessages: JSON string or None (contents array)
79
+ - promptsTruncated: Boolean (True if any field was truncated)
80
+ """
81
+ system_prompt = None
82
+ input_messages = None
83
+ prompts_truncated = False
84
+ marker = "...[TRUNCATED]"
85
+ marker_len = len(marker)
86
+
87
+ # Extract system instruction
88
+ # Can be in config object (google-genai) or kwargs (both SDKs)
89
+ system_instruction = None
90
+
91
+ # Try config object first (google-genai SDK)
92
+ if config and hasattr(config, 'system_instruction'):
93
+ system_instruction = config.system_instruction
94
+
95
+ # Try kwargs (both SDKs support this)
96
+ if not system_instruction and 'system_instruction' in kwargs:
97
+ system_instruction = kwargs.get('system_instruction')
98
+
99
+ # Extract text from system_instruction
100
+ if system_instruction:
101
+ try:
102
+ if isinstance(system_instruction, str):
103
+ system_prompt = system_instruction
104
+ elif hasattr(system_instruction, 'parts'):
105
+ # system_instruction is a Content object with parts
106
+ parts_text = []
107
+ for part in system_instruction.parts:
108
+ if hasattr(part, 'text') and part.text:
109
+ parts_text.append(part.text)
110
+ elif isinstance(part, dict) and 'text' in part:
111
+ parts_text.append(part['text'])
112
+ if parts_text:
113
+ system_prompt = '\n'.join(parts_text)
114
+ elif isinstance(system_instruction, dict) and 'parts' in system_instruction:
115
+ # system_instruction is a dict with parts array
116
+ parts_text = []
117
+ for part in system_instruction['parts']:
118
+ if isinstance(part, dict) and 'text' in part:
119
+ parts_text.append(part['text'])
120
+ if parts_text:
121
+ system_prompt = '\n'.join(parts_text)
122
+
123
+ # Apply truncation to system prompt
124
+ if system_prompt and len(system_prompt) > Config.MAX_PROMPT_LENGTH:
125
+ truncate_at = Config.MAX_PROMPT_LENGTH - marker_len
126
+ system_prompt = system_prompt[:truncate_at] + marker
127
+ prompts_truncated = True
128
+ logger.debug(
129
+ f"System instruction truncated to {Config.MAX_PROMPT_LENGTH} characters"
130
+ )
131
+ except Exception as e:
132
+ logger.warning(f"Failed to extract system instruction: {e}")
133
+ system_prompt = None
134
+
135
+ # Extract contents (input messages)
136
+ # Try kwargs first, then fall back to positional args
137
+ contents = kwargs.get('contents')
138
+ if not contents and args and len(args) > 0:
139
+ # First positional argument is typically the contents
140
+ contents = args[0]
141
+ if contents:
142
+ try:
143
+ # Normalize contents to list of dicts
144
+ normalized_contents = []
145
+
146
+ # Handle different input formats
147
+ if isinstance(contents, str):
148
+ # Simple string input
149
+ normalized_contents.append({
150
+ 'role': 'user',
151
+ 'parts': [{'text': contents}]
152
+ })
153
+ elif isinstance(contents, list):
154
+ for content in contents:
155
+ if isinstance(content, str):
156
+ # String in list
157
+ normalized_contents.append({
158
+ 'role': 'user',
159
+ 'parts': [{'text': content}]
160
+ })
161
+ elif isinstance(content, dict):
162
+ # Already a dict - sanitize it to prevent binary data leakage
163
+ normalized_contents.append(_sanitize_content_dict(content))
164
+ elif hasattr(content, 'role') and hasattr(content, 'parts'):
165
+ # Content object - convert to dict
166
+ content_dict = {'role': content.role, 'parts': []}
167
+ for part in content.parts:
168
+ if hasattr(part, 'text') and part.text:
169
+ content_dict['parts'].append({'text': part.text})
170
+ elif hasattr(part, 'inline_data'):
171
+ # Skip binary data, just note it exists
172
+ content_dict['parts'].append({'inline_data': '[BINARY_DATA]'})
173
+ elif hasattr(part, 'file_data'):
174
+ content_dict['parts'].append({'file_data': '[FILE_DATA]'})
175
+ normalized_contents.append(content_dict)
176
+ elif hasattr(contents, 'role') and hasattr(contents, 'parts'):
177
+ # Single Content object
178
+ content_dict = {'role': contents.role, 'parts': []}
179
+ for part in contents.parts:
180
+ if hasattr(part, 'text') and part.text:
181
+ content_dict['parts'].append({'text': part.text})
182
+ elif hasattr(part, 'inline_data'):
183
+ content_dict['parts'].append({'inline_data': '[BINARY_DATA]'})
184
+ normalized_contents.append(content_dict)
185
+
186
+ # Truncate if needed
187
+ if normalized_contents:
188
+ # Serialize to JSON first to check size
189
+ input_messages = json.dumps(normalized_contents, ensure_ascii=False)
190
+
191
+ # Apply truncation if too long
192
+ if len(input_messages) > Config.MAX_PROMPT_LENGTH:
193
+ # Truncate at the content level to maintain valid JSON
194
+ # Strategy: Truncate text within parts, then re-serialize
195
+ truncated_contents = []
196
+ remaining_length = Config.MAX_PROMPT_LENGTH - marker_len - 100 # Reserve space for JSON structure and marker
197
+ current_length = 0
198
+
199
+ for content in normalized_contents:
200
+ if current_length >= remaining_length:
201
+ break
202
+
203
+ truncated_content = {'role': content.get('role', 'user'), 'parts': []}
204
+ for part in content.get('parts', []):
205
+ if current_length >= remaining_length:
206
+ break
207
+
208
+ if 'text' in part:
209
+ text = part['text']
210
+ available = remaining_length - current_length
211
+ if len(text) > available:
212
+ # Truncate this text part
213
+ truncated_content['parts'].append({'text': text[:available] + '...'})
214
+ current_length += available
215
+ break
216
+ else:
217
+ truncated_content['parts'].append(part)
218
+ current_length += len(text)
219
+ else:
220
+ # Non-text part (e.g., binary data marker)
221
+ truncated_content['parts'].append(part)
222
+ current_length += 20 # Estimate for non-text parts
223
+
224
+ if truncated_content['parts']:
225
+ truncated_contents.append(truncated_content)
226
+
227
+ # Re-serialize the truncated content and add marker as a note in the last message
228
+ # This keeps the JSON valid while indicating truncation
229
+ if truncated_contents:
230
+ # Add truncation marker as a special part in the last message
231
+ truncated_contents[-1]['parts'].append({'text': marker})
232
+
233
+ input_messages = json.dumps(truncated_contents, ensure_ascii=False)
234
+
235
+ # Verify the serialized length and trim further if needed
236
+ # (JSON escaping/structure can cause the serialized output to exceed the budget)
237
+ if len(input_messages) > Config.MAX_PROMPT_LENGTH:
238
+ # Instead of hard truncating (which can produce invalid JSON),
239
+ # return a minimal valid JSON structure with truncation notice
240
+ truncation_notice = {
241
+ "role": "user",
242
+ "parts": [{"text": f"[Content truncated - exceeded {Config.MAX_PROMPT_LENGTH} character limit]"}]
243
+ }
244
+ input_messages = json.dumps([truncation_notice], ensure_ascii=False)
245
+ logger.warning(
246
+ f"Serialized JSON exceeded limit after content truncation "
247
+ f"({len(input_messages)} > {Config.MAX_PROMPT_LENGTH}). "
248
+ f"Returning minimal valid JSON structure with truncation notice."
249
+ )
250
+
251
+ prompts_truncated = True
252
+ logger.debug(
253
+ f"Input contents truncated to {len(input_messages)} characters (valid JSON maintained)"
254
+ )
255
+ except (TypeError, ValueError) as e:
256
+ logger.warning(f"Failed to serialize input contents to JSON: {e}")
257
+ input_messages = None
258
+
259
+ return {
260
+ 'systemPrompt': system_prompt,
261
+ 'inputMessages': input_messages,
262
+ 'promptsTruncated': prompts_truncated
263
+ }
264
+
265
+
266
+ def extract_response_content(response: Any, prompts_truncated: bool = False) -> Dict[str, Any]:
267
+ """
268
+ Extract output response content from Google Gemini API response.
269
+
270
+ Args:
271
+ response: Google Gemini API response object (GenerateContentResponse)
272
+ prompts_truncated: Whether prompts were already truncated (from request)
273
+
274
+ Returns:
275
+ Dict containing:
276
+ - outputResponse: String or None (model response content)
277
+ - promptsTruncated: Boolean (True if any field was truncated)
278
+ """
279
+ output_response = None
280
+ was_truncated = prompts_truncated
281
+ marker = "...[TRUNCATED]"
282
+ marker_len = len(marker)
283
+
284
+ try:
285
+ # Extract content from response.candidates[0].content.parts
286
+ if hasattr(response, 'candidates') and response.candidates and len(response.candidates) > 0:
287
+ first_candidate = response.candidates[0]
288
+
289
+ if hasattr(first_candidate, 'content') and first_candidate.content:
290
+ content = first_candidate.content
291
+
292
+ if hasattr(content, 'parts') and content.parts:
293
+ # Extract text from all parts
294
+ text_parts = []
295
+ for part in content.parts:
296
+ if hasattr(part, 'text') and part.text:
297
+ text_parts.append(part.text)
298
+
299
+ if text_parts:
300
+ output_response = '\n'.join(text_parts)
301
+
302
+ # Apply truncation
303
+ if len(output_response) > Config.MAX_PROMPT_LENGTH:
304
+ truncate_at = Config.MAX_PROMPT_LENGTH - marker_len
305
+ output_response = output_response[:truncate_at] + marker
306
+ was_truncated = True
307
+ logger.debug(
308
+ f"Output response truncated to {Config.MAX_PROMPT_LENGTH} characters"
309
+ )
310
+
311
+ # Fallback: try response.text property
312
+ if not output_response and hasattr(response, 'text'):
313
+ output_response = response.text
314
+
315
+ if output_response and len(output_response) > Config.MAX_PROMPT_LENGTH:
316
+ truncate_at = Config.MAX_PROMPT_LENGTH - marker_len
317
+ output_response = output_response[:truncate_at] + marker
318
+ was_truncated = True
319
+ logger.debug(
320
+ f"Output response (from .text) truncated to {Config.MAX_PROMPT_LENGTH} characters"
321
+ )
322
+ except Exception as e:
323
+ logger.warning(f"Failed to extract response content: {e}")
324
+ output_response = None
325
+
326
+ return {
327
+ 'outputResponse': output_response,
328
+ 'promptsTruncated': was_truncated
329
+ }
330
+
331
+
332
+
333
+ def extract_streaming_response_content(accumulated_content: str, prompts_truncated: bool = False) -> Dict[str, Any]:
334
+ """Extract streaming response content using Google's MAX_PROMPT_LENGTH."""
335
+ return _core_extract_streaming_response_content(
336
+ accumulated_content, prompts_truncated, max_prompt_length=Config.MAX_PROMPT_LENGTH
337
+ )
338
+
339
+
340
+ def extract_prompt_data_if_enabled(
341
+ kwargs: Dict[str, Any],
342
+ args: Optional[Tuple] = None,
343
+ config: Optional[Any] = None,
344
+ response: Any = None,
345
+ accumulated_content: str = None
346
+ ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[bool]]:
347
+ """
348
+ Extract prompt data if capture is enabled.
349
+
350
+ This is a shared helper function used by both Google AI and Vertex AI middleware.
351
+
352
+ Args:
353
+ kwargs: Request kwargs containing contents and system_instruction
354
+ args: Optional positional arguments (first arg is usually contents)
355
+ config: Optional config object (for Google AI SDK)
356
+ response: API response object (for non-streaming)
357
+ accumulated_content: Accumulated streaming content (for streaming)
358
+
359
+ Returns:
360
+ Tuple of (system_prompt, input_messages, output_response, prompts_truncated)
361
+ """
362
+ if not Config.CAPTURE_PROMPTS:
363
+ return None, None, None, None
364
+
365
+ try:
366
+ # Extract request data (system instruction and contents)
367
+ request_data = extract_prompts_from_request(kwargs, config=config, args=args)
368
+ system_prompt = request_data.get('systemPrompt')
369
+ input_messages = request_data.get('inputMessages')
370
+ prompts_truncated = request_data.get('promptsTruncated', False)
371
+
372
+ # Extract response data
373
+ output_response = None
374
+ if response is not None:
375
+ # Non-streaming response
376
+ response_data = extract_response_content(response, prompts_truncated)
377
+ output_response = response_data.get('outputResponse')
378
+ prompts_truncated = response_data.get('promptsTruncated', prompts_truncated)
379
+ elif accumulated_content is not None:
380
+ # Streaming response
381
+ response_data = extract_streaming_response_content(accumulated_content, prompts_truncated)
382
+ output_response = response_data.get('outputResponse')
383
+ prompts_truncated = response_data.get('promptsTruncated', prompts_truncated)
384
+
385
+ logger.debug(
386
+ f"Prompt capture - system_prompt: {bool(system_prompt)}, "
387
+ f"input_messages: {bool(input_messages)}, "
388
+ f"output_response: {bool(output_response)}, "
389
+ f"truncated: {prompts_truncated}"
390
+ )
391
+
392
+ return system_prompt, input_messages, output_response, prompts_truncated
393
+ except Exception as e:
394
+ logger.warning(f"Failed to extract prompt data: {e}")
395
+ return None, None, None, None
396
+
@@ -0,0 +1,56 @@
1
+ """
2
+ Vertex AI SDK middleware for Revenium.
3
+
4
+ This module provides middleware for the native Vertex AI SDK (vertexai package),
5
+ offering enhanced features like comprehensive token counting and local tokenization.
6
+ """
7
+
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Import provider (no SDK dependency)
13
+ from . import provider
14
+
15
+ from .provider import (
16
+ detect_provider,
17
+ get_provider_metadata,
18
+ validate_vertex_ai_configuration,
19
+ get_vertex_ai_config,
20
+ is_vertex_ai_available,
21
+ )
22
+
23
+ # Conditionally import middleware (requires vertexai SDK)
24
+ try:
25
+ import vertexai # noqa: F401
26
+ from . import middleware
27
+ from .middleware import (
28
+ extract_vertex_ai_usage_data,
29
+ extract_vertex_ai_generation_tokens,
30
+ extract_vertex_ai_embedding_tokens,
31
+ create_vertex_ai_metering_call,
32
+ handle_vertex_ai_streaming_response,
33
+ )
34
+ except ImportError:
35
+ logger.debug("Vertex AI SDK (vertexai) not available, middleware not loaded")
36
+ middleware = None # type: ignore
37
+ extract_vertex_ai_usage_data = None # type: ignore
38
+ extract_vertex_ai_generation_tokens = None # type: ignore
39
+ extract_vertex_ai_embedding_tokens = None # type: ignore
40
+ create_vertex_ai_metering_call = None # type: ignore
41
+ handle_vertex_ai_streaming_response = None # type: ignore
42
+
43
+ __all__ = [
44
+ # Middleware functions
45
+ "extract_vertex_ai_usage_data",
46
+ "extract_vertex_ai_generation_tokens",
47
+ "extract_vertex_ai_embedding_tokens",
48
+ "create_vertex_ai_metering_call",
49
+ "handle_vertex_ai_streaming_response",
50
+ # Provider functions
51
+ "detect_provider",
52
+ "get_provider_metadata",
53
+ "validate_vertex_ai_configuration",
54
+ "get_vertex_ai_config",
55
+ "is_vertex_ai_available",
56
+ ]