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,192 @@
1
+ """
2
+ Protocol definitions for Google AI API responses.
3
+
4
+ This module defines Protocol classes that describe the expected structure
5
+ of API responses from Google AI and Vertex AI SDKs, improving type safety
6
+ and enabling better static analysis.
7
+ """
8
+
9
+ from typing import Protocol, Optional, List, Any, Union
10
+ from typing_extensions import runtime_checkable
11
+
12
+
13
+ @runtime_checkable
14
+ class UsageMetadataProtocol(Protocol):
15
+ """Protocol for usage metadata in API responses."""
16
+
17
+ # Google AI SDK attributes
18
+ prompt_token_count: Optional[int]
19
+ candidates_token_count: Optional[int]
20
+ total_token_count: Optional[int]
21
+ cached_content_token_count: Optional[int]
22
+
23
+ # Alternative attribute names (for compatibility)
24
+ prompt_tokens: Optional[int]
25
+ completion_tokens: Optional[int]
26
+ total_tokens: Optional[int]
27
+ cached_tokens: Optional[int]
28
+ input_tokens: Optional[int]
29
+ output_tokens: Optional[int]
30
+
31
+
32
+ @runtime_checkable
33
+ class CandidateProtocol(Protocol):
34
+ """Protocol for candidate objects in API responses."""
35
+
36
+ finish_reason: Optional[str]
37
+ content: Optional[Any]
38
+ text: Optional[str]
39
+
40
+
41
+ @runtime_checkable
42
+ class EmbeddingProtocol(Protocol):
43
+ """Protocol for embedding objects."""
44
+
45
+ values: List[float]
46
+ statistics: Optional[Any]
47
+ _prediction_response: Optional[Any]
48
+
49
+
50
+ @runtime_checkable
51
+ class EmbeddingStatisticsProtocol(Protocol):
52
+ """Protocol for embedding statistics."""
53
+
54
+ token_count: Optional[int]
55
+ billableCharacterCount: Optional[int]
56
+
57
+
58
+ @runtime_checkable
59
+ class ChatResponseProtocol(Protocol):
60
+ """Protocol for chat/generation API responses."""
61
+
62
+ # Model information
63
+ model: Optional[str]
64
+ model_name: Optional[str]
65
+ model_version: Optional[str]
66
+ _model_name: Optional[str]
67
+
68
+ # Usage information
69
+ usage: Optional[UsageMetadataProtocol]
70
+ usage_metadata: Optional[UsageMetadataProtocol]
71
+
72
+ # Response content
73
+ text: Optional[str]
74
+ content: Optional[str]
75
+ candidates: Optional[List[CandidateProtocol]]
76
+
77
+ # Completion information
78
+ finish_reason: Optional[str]
79
+ stop_reason: Optional[str]
80
+
81
+
82
+ @runtime_checkable
83
+ class EmbeddingResponseProtocol(Protocol):
84
+ """Protocol for embedding API responses."""
85
+
86
+ # Model information
87
+ model: Optional[str]
88
+ model_name: Optional[str]
89
+
90
+ # Embeddings data
91
+ embeddings: Optional[List[EmbeddingProtocol]]
92
+ values: Optional[List[float]] # For single embedding responses
93
+
94
+ # Usage information
95
+ usage: Optional[UsageMetadataProtocol]
96
+ usage_metadata: Optional[UsageMetadataProtocol]
97
+ statistics: Optional[EmbeddingStatisticsProtocol]
98
+
99
+
100
+ @runtime_checkable
101
+ class StreamChunkProtocol(Protocol):
102
+ """Protocol for streaming response chunks."""
103
+
104
+ # Model information
105
+ model: Optional[str]
106
+ model_version: Optional[str]
107
+
108
+ # Content
109
+ text: Optional[str]
110
+ content: Optional[str]
111
+ candidates: Optional[List[CandidateProtocol]]
112
+
113
+ # Usage information (typically in final chunk)
114
+ usage_metadata: Optional[UsageMetadataProtocol]
115
+
116
+ # Completion information
117
+ finish_reason: Optional[str]
118
+
119
+
120
+ @runtime_checkable
121
+ class ClientProtocol(Protocol):
122
+ """Protocol for Google AI client objects."""
123
+
124
+ _vertexai: Optional[bool]
125
+ _api_client: Optional[Any]
126
+
127
+
128
+ # Type guards for runtime type checking
129
+ def is_chat_response(response: Any) -> bool:
130
+ """Check if response matches ChatResponseProtocol."""
131
+ return isinstance(response, ChatResponseProtocol)
132
+
133
+
134
+ def is_embedding_response(response: Any) -> bool:
135
+ """Check if response matches EmbeddingResponseProtocol."""
136
+ return isinstance(response, EmbeddingResponseProtocol)
137
+
138
+
139
+ def is_stream_chunk(chunk: Any) -> bool:
140
+ """Check if chunk matches StreamChunkProtocol."""
141
+ return isinstance(chunk, StreamChunkProtocol)
142
+
143
+
144
+ def has_usage_metadata(response: Any) -> bool:
145
+ """Check if response has usage metadata."""
146
+ return (
147
+ hasattr(response, "usage_metadata") and response.usage_metadata is not None
148
+ ) or (hasattr(response, "usage") and response.usage is not None)
149
+
150
+
151
+ def has_token_counts(usage: Any) -> bool:
152
+ """Check if usage object has token count information."""
153
+ if not usage:
154
+ return False
155
+
156
+ # Check for any token count attributes
157
+ token_attrs = [
158
+ "total_token_count",
159
+ "total_tokens",
160
+ "prompt_token_count",
161
+ "prompt_tokens",
162
+ "input_tokens",
163
+ "candidates_token_count",
164
+ "completion_tokens",
165
+ "output_tokens",
166
+ ]
167
+
168
+ return any(
169
+ hasattr(usage, attr) and getattr(usage, attr, 0) > 0 for attr in token_attrs
170
+ )
171
+
172
+
173
+ # Utility functions for safe attribute access
174
+ def safe_getattr(obj: Any, attr: str, default: Any = None) -> Any:
175
+ """Safely get attribute with fallback to default."""
176
+ try:
177
+ return getattr(obj, attr, default)
178
+ except (AttributeError, TypeError):
179
+ return default
180
+
181
+
182
+ def get_token_count(usage: Any, attr_names: List[str]) -> int:
183
+ """Get token count from usage object, trying multiple attribute names."""
184
+ if not usage:
185
+ return 0
186
+
187
+ for attr_name in attr_names:
188
+ value = safe_getattr(usage, attr_name, 0)
189
+ if isinstance(value, int) and value > 0:
190
+ return value
191
+
192
+ return 0
@@ -0,0 +1,271 @@
1
+ """
2
+ Summary printer for terminal output of API usage metrics.
3
+
4
+ This module provides functionality to display cost and metrics information
5
+ after each API request in human-readable or JSON format.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import time
11
+ import urllib.parse
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+
15
+ import requests
16
+
17
+ from . import trace_fields
18
+
19
+ logger = logging.getLogger("revenium_middleware.extension")
20
+
21
+
22
+ @dataclass
23
+ class CompletionMetrics:
24
+ """Data class to hold cost information from the Revenium API."""
25
+
26
+ total_cost: Optional[float] = None
27
+
28
+
29
+ def fetch_completion_metrics(
30
+ transaction_id: str,
31
+ revenium_api_key: str,
32
+ ) -> Optional[CompletionMetrics]:
33
+ """
34
+ Fetch cost data from Revenium profitstream API.
35
+
36
+ Implements retry logic (3 attempts with 1-second delay).
37
+
38
+ Args:
39
+ transaction_id: The transaction ID to fetch metrics for
40
+ revenium_api_key: The Revenium API key for authentication
41
+
42
+ Returns:
43
+ CompletionMetrics with cost data, or None if fetch fails
44
+ """
45
+ team_id = trace_fields.get_team_id()
46
+ if not team_id:
47
+ logger.debug("No REVENIUM_TEAM_ID set, skipping cost fetch")
48
+ return CompletionMetrics(total_cost=None)
49
+
50
+ base_url = trace_fields.get_base_url()
51
+ # Use urlencode to properly encode query parameters
52
+ params = urllib.parse.urlencode({
53
+ 'teamId': team_id,
54
+ 'transactionId': transaction_id
55
+ })
56
+ url = (
57
+ f"{base_url}/profitstream/v2/api/sources/metrics/ai/completions"
58
+ f"?{params}"
59
+ )
60
+
61
+ headers = {
62
+ "Authorization": f"Bearer {revenium_api_key}",
63
+ "Content-Type": "application/json",
64
+ }
65
+
66
+ for attempt in range(trace_fields.SUMMARY_RETRY_ATTEMPTS):
67
+ try:
68
+ response = requests.get(
69
+ url,
70
+ headers=headers,
71
+ timeout=trace_fields.SUMMARY_API_TIMEOUT,
72
+ )
73
+
74
+ if response.status_code == 200:
75
+ data = response.json()
76
+ # Check both camelCase and snake_case
77
+ # Don't use 'or' to avoid treating 0.0 as falsy
78
+ total_cost = data.get("totalCost")
79
+ if total_cost is None:
80
+ total_cost = data.get("total_cost")
81
+ return CompletionMetrics(total_cost=total_cost)
82
+ else:
83
+ logger.debug(
84
+ f"Metrics API returned status {response.status_code} "
85
+ f"on attempt {attempt + 1}"
86
+ )
87
+
88
+ except requests.exceptions.RequestException as e:
89
+ logger.debug(
90
+ f"Failed to fetch metrics on attempt {attempt + 1}: {e}"
91
+ )
92
+
93
+ # Wait before retry (except on last attempt)
94
+ if attempt < trace_fields.SUMMARY_RETRY_ATTEMPTS - 1:
95
+ time.sleep(trace_fields.SUMMARY_RETRY_DELAY)
96
+
97
+ logger.debug(
98
+ f"Failed to fetch metrics after {trace_fields.SUMMARY_RETRY_ATTEMPTS} attempts"
99
+ )
100
+ return None
101
+
102
+
103
+ def format_and_print_json_summary(
104
+ model: str,
105
+ provider: str,
106
+ duration_seconds: float,
107
+ input_token_count: Optional[int],
108
+ output_token_count: Optional[int],
109
+ total_token_count: Optional[int],
110
+ cost: Optional[float],
111
+ trace_id: Optional[str],
112
+ ) -> None:
113
+ """
114
+ Print single-line JSON output for machine-readable summary.
115
+
116
+ Args:
117
+ model: Model name used
118
+ provider: Provider name (e.g., "GOOGLE")
119
+ duration_seconds: Request duration in seconds
120
+ input_token_count: Number of input tokens
121
+ output_token_count: Number of output tokens
122
+ total_token_count: Total token count
123
+ cost: Cost in dollars (or None if unavailable)
124
+ trace_id: Trace ID for the request
125
+ """
126
+ summary = {
127
+ "model": model,
128
+ "provider": provider,
129
+ "durationSeconds": round(duration_seconds, 3),
130
+ "inputTokenCount": input_token_count,
131
+ "outputTokenCount": output_token_count,
132
+ "totalTokenCount": total_token_count,
133
+ }
134
+
135
+ if cost is not None:
136
+ summary["cost"] = cost
137
+ summary["costStatus"] = "available"
138
+ else:
139
+ summary["costStatus"] = "unavailable"
140
+
141
+ if trace_id:
142
+ summary["traceId"] = trace_id
143
+
144
+ print(json.dumps(summary))
145
+
146
+
147
+ def format_and_print_human_summary(
148
+ model: str,
149
+ provider: str,
150
+ duration_seconds: float,
151
+ input_token_count: Optional[int],
152
+ output_token_count: Optional[int],
153
+ total_token_count: Optional[int],
154
+ cost: Optional[float],
155
+ trace_id: Optional[str],
156
+ team_id: Optional[str],
157
+ ) -> None:
158
+ """
159
+ Print professional human-readable output.
160
+
161
+ NO EMOJIS - professional format only.
162
+
163
+ Args:
164
+ model: Model name used
165
+ provider: Provider name (e.g., "GOOGLE")
166
+ duration_seconds: Request duration in seconds
167
+ input_token_count: Number of input tokens
168
+ output_token_count: Number of output tokens
169
+ total_token_count: Total token count
170
+ cost: Cost in dollars (or None if unavailable)
171
+ trace_id: Trace ID for the request
172
+ team_id: Team ID (used for cost status message)
173
+ """
174
+ separator = "=" * 60
175
+
176
+ print(separator)
177
+ print("REVENIUM USAGE SUMMARY")
178
+ print(separator)
179
+ print(f"Model: {model}")
180
+ print(f"Provider: {provider}")
181
+ print(f"Duration: {duration_seconds:.2f}s")
182
+ print()
183
+ print("Token Usage:")
184
+ print(f" Input Tokens: {input_token_count or 0}")
185
+ print(f" Output Tokens: {output_token_count or 0}")
186
+ print(f" Total Tokens: {total_token_count or 0}")
187
+ print()
188
+
189
+ if cost is not None:
190
+ print(f"Cost: ${cost:.6f}")
191
+ elif team_id:
192
+ print("Cost: Pending (aggregating... check Revenium dashboard)")
193
+ else:
194
+ print("Cost: Add REVENIUM_TEAM_ID to see pricing")
195
+
196
+ if trace_id:
197
+ print()
198
+ print(f"Trace ID: {trace_id}")
199
+
200
+ print(separator)
201
+
202
+
203
+ def print_usage_summary(
204
+ model: str,
205
+ provider: str,
206
+ request_duration: int,
207
+ input_token_count: Optional[int],
208
+ output_token_count: Optional[int],
209
+ total_token_count: Optional[int],
210
+ transaction_id: str,
211
+ trace_id: Optional[str],
212
+ revenium_api_key: str,
213
+ ) -> None:
214
+ """
215
+ Main entry point for printing usage summary.
216
+
217
+ Fire-and-forget: Wrapped in try/except to never fail the main API call.
218
+
219
+ Args:
220
+ model: Model name used
221
+ provider: Provider name (e.g., "Google")
222
+ request_duration: Request duration in milliseconds
223
+ input_token_count: Number of input tokens
224
+ output_token_count: Number of output tokens
225
+ total_token_count: Total token count
226
+ transaction_id: Transaction ID for fetching metrics
227
+ trace_id: Trace ID for the request
228
+ revenium_api_key: Revenium API key for authentication
229
+ """
230
+ try:
231
+ # Check if summary output is enabled
232
+ summary_format = trace_fields.get_print_summary_config()
233
+ if summary_format is False:
234
+ return
235
+
236
+ # Convert duration from milliseconds to seconds
237
+ duration_seconds = request_duration / 1000.0
238
+
239
+ # Attempt to fetch cost metrics
240
+ metrics = fetch_completion_metrics(transaction_id, revenium_api_key)
241
+ cost = metrics.total_cost if metrics else None
242
+ team_id = trace_fields.get_team_id()
243
+
244
+ if summary_format == "json":
245
+ format_and_print_json_summary(
246
+ model=model,
247
+ provider=provider,
248
+ duration_seconds=duration_seconds,
249
+ input_token_count=input_token_count,
250
+ output_token_count=output_token_count,
251
+ total_token_count=total_token_count,
252
+ cost=cost,
253
+ trace_id=trace_id,
254
+ )
255
+ else: # "human"
256
+ format_and_print_human_summary(
257
+ model=model,
258
+ provider=provider,
259
+ duration_seconds=duration_seconds,
260
+ input_token_count=input_token_count,
261
+ output_token_count=output_token_count,
262
+ total_token_count=total_token_count,
263
+ cost=cost,
264
+ trace_id=trace_id,
265
+ team_id=team_id,
266
+ )
267
+
268
+ except Exception as e:
269
+ # Fire-and-forget: Never fail the main operation
270
+ logger.debug(f"Failed to print summary: {e}")
271
+
@@ -0,0 +1,205 @@
1
+ """
2
+ Trace visualization field capture and validation.
3
+
4
+ This module provides functions to capture trace visualization fields from
5
+ environment variables and validate them according to the specification.
6
+
7
+ Shared functions are imported from _core.trace_fields. This module retains
8
+ only Google-specific functions: detect_vision_content and detect_operation_type.
9
+ """
10
+
11
+ import logging
12
+ from typing import Optional, Dict, Any, Union, Literal
13
+
14
+ from revenium_middleware._core.config import (
15
+ Config as _CoreConfig,
16
+ SummaryFormat,
17
+ parse_print_summary_value,
18
+ get_print_summary_config,
19
+ get_team_id,
20
+ get_base_url,
21
+ )
22
+ from revenium_middleware._core.trace_fields import ( # noqa: F401 — re-exported
23
+ TRACE_TYPE_MAX_LENGTH,
24
+ TRACE_NAME_MAX_LENGTH,
25
+ TRACE_TYPE_PATTERN,
26
+ ENV_REVENIUM_ENVIRONMENT,
27
+ ENV_ENVIRONMENT,
28
+ ENV_DEPLOYMENT_ENV,
29
+ ENV_REVENIUM_REGION,
30
+ ENV_AWS_REGION,
31
+ ENV_AWS_DEFAULT_REGION,
32
+ ENV_AZURE_REGION,
33
+ ENV_GCP_REGION,
34
+ ENV_GOOGLE_CLOUD_REGION,
35
+ ENV_REVENIUM_CREDENTIAL_ALIAS,
36
+ ENV_REVENIUM_TRACE_TYPE,
37
+ ENV_REVENIUM_TRACE_NAME,
38
+ ENV_REVENIUM_PARENT_TRANSACTION_ID,
39
+ ENV_REVENIUM_TRANSACTION_NAME,
40
+ ENV_REVENIUM_RETRY_NUMBER,
41
+ get_environment,
42
+ get_region,
43
+ get_credential_alias,
44
+ get_trace_type,
45
+ get_trace_name,
46
+ get_parent_transaction_id,
47
+ get_transaction_name,
48
+ get_retry_number,
49
+ validate_trace_type,
50
+ validate_trace_name,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # Summary output configuration (re-exported from core)
56
+ ENV_REVENIUM_PRINT_SUMMARY = _CoreConfig.ENV_REVENIUM_PRINT_SUMMARY
57
+ ENV_REVENIUM_TEAM_ID = _CoreConfig.ENV_REVENIUM_TEAM_ID
58
+ ENV_REVENIUM_METERING_BASE_URL = _CoreConfig.ENV_REVENIUM_BASE_URL
59
+
60
+ SUMMARY_RETRY_ATTEMPTS = _CoreConfig.SUMMARY_RETRY_ATTEMPTS
61
+ SUMMARY_RETRY_DELAY: float = 1.0 # Google uses 1.0s
62
+ SUMMARY_API_TIMEOUT = _CoreConfig.SUMMARY_API_TIMEOUT
63
+ DEFAULT_BASE_URL = _CoreConfig.DEFAULT_BASE_URL
64
+
65
+
66
+ def detect_vision_content(contents: Any = None) -> bool:
67
+ """
68
+ Detect if contents contain vision/image content.
69
+
70
+ Google AI content formats that indicate vision input:
71
+ - Part with inline_data containing image/* or video/* MIME type
72
+ - Part with file_data containing image/* or video/* MIME type
73
+ - PIL Image objects passed directly
74
+ - google.genai types.Part with inline_data/file_data
75
+
76
+ Args:
77
+ contents: The contents parameter from generate_content() call.
78
+ Can be a string, list, Part, or Content object.
79
+
80
+ Returns:
81
+ True if any content contains image/video data, False otherwise.
82
+ """
83
+ if contents is None:
84
+ return False
85
+
86
+ # Normalize to a list for uniform processing
87
+ if not isinstance(contents, list):
88
+ contents = [contents]
89
+
90
+ for item in contents:
91
+ if _item_has_vision_content(item, _depth=0):
92
+ return True
93
+
94
+ return False
95
+
96
+
97
+ _MAX_VISION_RECURSION_DEPTH = 10
98
+
99
+
100
+ def _item_has_vision_content(item: Any, _depth: int = 0) -> bool:
101
+ """Check if a single content item contains vision data."""
102
+ if item is None or _depth > _MAX_VISION_RECURSION_DEPTH:
103
+ return False
104
+
105
+ # Check for PIL Image objects
106
+ try:
107
+ import PIL.Image
108
+ if isinstance(item, PIL.Image.Image):
109
+ return True
110
+ except ImportError:
111
+ pass
112
+
113
+ # Check for dict-based content (raw API format)
114
+ if isinstance(item, dict):
115
+ # Check inline_data
116
+ inline_data = item.get("inline_data")
117
+ if inline_data and isinstance(inline_data, dict):
118
+ mime_type = inline_data.get("mime_type", "")
119
+ if mime_type.startswith("image/") or mime_type.startswith("video/"):
120
+ return True
121
+
122
+ # Check file_data
123
+ file_data = item.get("file_data")
124
+ if file_data and isinstance(file_data, dict):
125
+ mime_type = file_data.get("mime_type", "")
126
+ if mime_type.startswith("image/") or mime_type.startswith("video/"):
127
+ return True
128
+
129
+ # Check parts list within a Content dict
130
+ parts = item.get("parts")
131
+ if parts and isinstance(parts, list):
132
+ for part in parts:
133
+ if _item_has_vision_content(part, _depth + 1):
134
+ return True
135
+
136
+ # Check type field (e.g., {"type": "image", ...})
137
+ if item.get("type") in ("image", "video"):
138
+ return True
139
+
140
+ return False
141
+
142
+ # Check for google.genai types (Part objects with inline_data/file_data attributes)
143
+ if hasattr(item, "inline_data") and item.inline_data is not None:
144
+ mime_type = getattr(item.inline_data, "mime_type", "") or ""
145
+ if mime_type.startswith("image/") or mime_type.startswith("video/"):
146
+ return True
147
+
148
+ if hasattr(item, "file_data") and item.file_data is not None:
149
+ mime_type = getattr(item.file_data, "mime_type", "") or ""
150
+ if mime_type.startswith("image/") or mime_type.startswith("video/"):
151
+ return True
152
+
153
+ # Check Content objects with parts
154
+ if hasattr(item, "parts") and item.parts:
155
+ parts = item.parts if isinstance(item.parts, list) else [item.parts]
156
+ for part in parts:
157
+ if _item_has_vision_content(part, _depth + 1):
158
+ return True
159
+
160
+ # Check for bytes (raw image data passed directly)
161
+ if isinstance(item, bytes) and len(item) > 0:
162
+ # Check for common image magic bytes
163
+ if (item[:4] == b'\x89PNG' or # PNG
164
+ item[:2] == b'\xff\xd8' or # JPEG
165
+ item[:4] == b'GIF8' or # GIF
166
+ (len(item) >= 12 and item[:4] == b'RIFF' and item[8:12] == b'WEBP')): # WebP
167
+ return True
168
+
169
+ return False
170
+
171
+
172
+ def detect_operation_type(
173
+ method_name: str,
174
+ request_body: Optional[Dict[str, Any]] = None
175
+ ) -> str:
176
+ """
177
+ Auto-detect operation type from method name and request.
178
+
179
+ Args:
180
+ method_name: API method name (e.g., 'generate_content', 'embed_content')
181
+ request_body: Optional request body to check for tools
182
+
183
+ Returns:
184
+ Operation type string ('CHAT', 'GENERATE', 'EMBED', 'TOOL_CALL')
185
+ """
186
+ request_body = request_body or {}
187
+
188
+ # Embeddings
189
+ if 'embed' in method_name.lower():
190
+ return 'EMBED'
191
+
192
+ # Content generation (chat/generate)
193
+ if 'generate' in method_name.lower() or 'chat' in method_name.lower():
194
+ # Check for tools in request
195
+ has_tools = request_body.get('tools')
196
+ if has_tools:
197
+ return 'TOOL_CALL'
198
+ return 'CHAT'
199
+
200
+ # Default fallback
201
+ return 'CHAT'
202
+
203
+
204
+ # parse_print_summary_value, get_print_summary_config, get_team_id, get_base_url
205
+ # are imported from revenium_middleware._core.config at the top of this file