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,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
|
+
]
|