lucidicai 2.0.2__py3-none-any.whl → 2.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 (35) hide show
  1. lucidicai/__init__.py +350 -899
  2. lucidicai/api/__init__.py +1 -0
  3. lucidicai/api/client.py +218 -0
  4. lucidicai/api/resources/__init__.py +1 -0
  5. lucidicai/api/resources/dataset.py +192 -0
  6. lucidicai/api/resources/event.py +88 -0
  7. lucidicai/api/resources/session.py +126 -0
  8. lucidicai/core/__init__.py +1 -0
  9. lucidicai/core/config.py +223 -0
  10. lucidicai/core/errors.py +60 -0
  11. lucidicai/core/types.py +35 -0
  12. lucidicai/sdk/__init__.py +1 -0
  13. lucidicai/sdk/context.py +144 -0
  14. lucidicai/sdk/decorators.py +187 -0
  15. lucidicai/sdk/error_boundary.py +299 -0
  16. lucidicai/sdk/event.py +122 -0
  17. lucidicai/sdk/event_builder.py +304 -0
  18. lucidicai/sdk/features/__init__.py +1 -0
  19. lucidicai/sdk/features/dataset.py +605 -0
  20. lucidicai/sdk/features/feature_flag.py +383 -0
  21. lucidicai/sdk/init.py +271 -0
  22. lucidicai/sdk/shutdown_manager.py +302 -0
  23. lucidicai/telemetry/context_bridge.py +82 -0
  24. lucidicai/telemetry/context_capture_processor.py +25 -9
  25. lucidicai/telemetry/litellm_bridge.py +18 -24
  26. lucidicai/telemetry/lucidic_exporter.py +51 -36
  27. lucidicai/telemetry/utils/model_pricing.py +278 -0
  28. lucidicai/utils/__init__.py +1 -0
  29. lucidicai/utils/images.py +337 -0
  30. lucidicai/utils/logger.py +168 -0
  31. lucidicai/utils/queue.py +393 -0
  32. {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/METADATA +1 -1
  33. {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/RECORD +35 -8
  34. {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/WHEEL +0 -0
  35. {lucidicai-2.0.2.dist-info → lucidicai-2.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,337 @@
1
+ """Consolidated image handling utilities.
2
+
3
+ This module unifies all image-related functionality from:
4
+ - image_upload.py
5
+ - telemetry/utils/image_storage.py
6
+ - Various extraction functions scattered across the codebase
7
+ """
8
+ import base64
9
+ import io
10
+ import logging
11
+ import threading
12
+ from typing import List, Dict, Any, Optional, Tuple, Union
13
+ from PIL import Image
14
+ import requests
15
+
16
+ logger = logging.getLogger("Lucidic")
17
+
18
+
19
+ class ImageHandler:
20
+ """Centralized image handling for the SDK."""
21
+
22
+ # Thread-local storage for images (from telemetry)
23
+ _thread_local = threading.local()
24
+
25
+ @classmethod
26
+ def extract_base64_images(cls, data: Any) -> List[str]:
27
+ """Extract base64 image URLs from various data structures.
28
+
29
+ Args:
30
+ data: Can be a string, dict, list, or nested structure containing image data
31
+
32
+ Returns:
33
+ List of base64 image data URLs (data:image/...)
34
+ """
35
+ images = []
36
+
37
+ if isinstance(data, str):
38
+ if data.startswith('data:image'):
39
+ images.append(data)
40
+ elif isinstance(data, dict):
41
+ # Check for specific image fields
42
+ if 'image' in data and isinstance(data['image'], str):
43
+ if data['image'].startswith('data:image'):
44
+ images.append(data['image'])
45
+
46
+ # Check for image_url structures (OpenAI format)
47
+ if data.get('type') == 'image_url':
48
+ image_url = data.get('image_url', {})
49
+ if isinstance(image_url, dict) and 'url' in image_url:
50
+ url = image_url['url']
51
+ if url.startswith('data:image'):
52
+ images.append(url)
53
+
54
+ # Recursively check all values
55
+ for value in data.values():
56
+ images.extend(cls.extract_base64_images(value))
57
+
58
+ elif isinstance(data, list):
59
+ for item in data:
60
+ images.extend(cls.extract_base64_images(item))
61
+
62
+ return images
63
+
64
+ @classmethod
65
+ def extract_images_from_messages(cls, messages: List[Dict[str, Any]]) -> List[str]:
66
+ """Extract images from chat messages (OpenAI/Anthropic format).
67
+
68
+ Args:
69
+ messages: List of message dictionaries
70
+
71
+ Returns:
72
+ List of base64 image data URLs
73
+ """
74
+ images = []
75
+
76
+ for message in messages:
77
+ if not isinstance(message, dict):
78
+ continue
79
+
80
+ content = message.get('content', '')
81
+
82
+ # Handle multimodal content
83
+ if isinstance(content, list):
84
+ for item in content:
85
+ if isinstance(item, dict):
86
+ if item.get('type') == 'image_url':
87
+ image_url = item.get('image_url', {})
88
+ if isinstance(image_url, dict):
89
+ url = image_url.get('url', '')
90
+ if url.startswith('data:image'):
91
+ images.append(url)
92
+ elif item.get('type') == 'image':
93
+ # Anthropic format
94
+ source = item.get('source', {})
95
+ if isinstance(source, dict):
96
+ data = source.get('data', '')
97
+ if data:
98
+ media_type = source.get('media_type', 'image/jpeg')
99
+ images.append(f"data:{media_type};base64,{data}")
100
+
101
+ return images
102
+
103
+ @classmethod
104
+ def store_image_thread_local(cls, image_base64: str) -> str:
105
+ """Store image in thread-local storage and return placeholder.
106
+
107
+ Used for working around OpenTelemetry attribute size limits.
108
+
109
+ Args:
110
+ image_base64: Base64 encoded image data
111
+
112
+ Returns:
113
+ Placeholder string for the stored image
114
+ """
115
+ if not hasattr(cls._thread_local, 'images'):
116
+ cls._thread_local.images = []
117
+
118
+ cls._thread_local.images.append(image_base64)
119
+ placeholder = f"lucidic_image_{len(cls._thread_local.images) - 1}"
120
+
121
+ logger.debug(f"[ImageHandler] Stored image in thread-local, placeholder: {placeholder}")
122
+ return placeholder
123
+
124
+ @classmethod
125
+ def get_stored_images(cls) -> List[str]:
126
+ """Get all images stored in thread-local storage."""
127
+ if hasattr(cls._thread_local, 'images'):
128
+ return cls._thread_local.images
129
+ return []
130
+
131
+ @classmethod
132
+ def clear_stored_images(cls) -> None:
133
+ """Clear thread-local image storage."""
134
+ if hasattr(cls._thread_local, 'images'):
135
+ cls._thread_local.images.clear()
136
+
137
+ @classmethod
138
+ def get_image_by_placeholder(cls, placeholder: str) -> Optional[str]:
139
+ """Retrieve image by its placeholder from thread-local storage."""
140
+ if hasattr(cls._thread_local, 'images') and placeholder.startswith('lucidic_image_'):
141
+ try:
142
+ index = int(placeholder.split('_')[-1])
143
+ if 0 <= index < len(cls._thread_local.images):
144
+ return cls._thread_local.images[index]
145
+ except (ValueError, IndexError):
146
+ pass
147
+ return None
148
+
149
+ @classmethod
150
+ def path_to_base64(cls, image_path: str, format: str = "JPEG") -> str:
151
+ """Convert image file to base64 string.
152
+
153
+ Args:
154
+ image_path: Path to the image file
155
+ format: Output format (JPEG or PNG)
156
+
157
+ Returns:
158
+ Base64 encoded image string
159
+ """
160
+ img = Image.open(image_path)
161
+
162
+ if format == "JPEG":
163
+ # Convert to RGB if necessary
164
+ if img.mode in ("RGBA", "LA", "P"):
165
+ background = Image.new("RGB", img.size, (255, 255, 255))
166
+ if img.mode == "RGBA" or img.mode == "LA":
167
+ alpha = img.split()[-1]
168
+ background.paste(img, mask=alpha)
169
+ else:
170
+ background.paste(img)
171
+ img = background
172
+ else:
173
+ img = img.convert("RGB")
174
+
175
+ buffered = io.BytesIO()
176
+ img.save(buffered, format=format)
177
+ img_bytes = buffered.getvalue()
178
+
179
+ return base64.b64encode(img_bytes).decode('utf-8')
180
+
181
+ @classmethod
182
+ def base64_to_pil(cls, base64_str: str) -> Image.Image:
183
+ """Convert base64 string to PIL Image.
184
+
185
+ Args:
186
+ base64_str: Base64 encoded image (with or without data URI prefix)
187
+
188
+ Returns:
189
+ PIL Image object
190
+ """
191
+ # Remove data URI prefix if present
192
+ if base64_str.startswith('data:'):
193
+ base64_str = base64_str.split(',')[1] if ',' in base64_str else base64_str
194
+
195
+ image_data = base64.b64decode(base64_str)
196
+ image_stream = io.BytesIO(image_data)
197
+ return Image.open(image_stream)
198
+
199
+ @classmethod
200
+ def prepare_for_upload(cls, image_data: Union[str, bytes], format: str = "JPEG") -> Tuple[io.BytesIO, str]:
201
+ """Prepare image data for upload to S3.
202
+
203
+ Args:
204
+ image_data: Base64 string or raw bytes
205
+ format: Target format (JPEG or GIF)
206
+
207
+ Returns:
208
+ Tuple of (BytesIO object, content-type)
209
+ """
210
+ if format == "JPEG":
211
+ # Handle base64 string
212
+ if isinstance(image_data, str):
213
+ pil_image = cls.base64_to_pil(image_data)
214
+ else:
215
+ pil_image = Image.open(io.BytesIO(image_data))
216
+
217
+ # Convert to RGB
218
+ if pil_image.mode in ("RGBA", "LA"):
219
+ background = Image.new("RGB", pil_image.size, (255, 255, 255))
220
+ alpha = pil_image.split()[-1]
221
+ background.paste(pil_image, mask=alpha)
222
+ pil_image = background
223
+ else:
224
+ pil_image = pil_image.convert("RGB")
225
+
226
+ image_obj = io.BytesIO()
227
+ pil_image.save(image_obj, format="JPEG")
228
+ image_obj.seek(0)
229
+ content_type = "image/jpeg"
230
+
231
+ elif format == "GIF":
232
+ if isinstance(image_data, str):
233
+ image_data = base64.b64decode(image_data.split(',')[1] if ',' in image_data else image_data)
234
+ image_obj = io.BytesIO(image_data)
235
+ content_type = "image/gif"
236
+
237
+ else:
238
+ raise ValueError(f"Unsupported format: {format}")
239
+
240
+ return image_obj, content_type
241
+
242
+
243
+ class ImageUploader:
244
+ """Handle image uploads to S3."""
245
+
246
+ @staticmethod
247
+ def get_presigned_url(
248
+ agent_id: str,
249
+ session_id: Optional[str] = None,
250
+ event_id: Optional[str] = None,
251
+ nthscreenshot: Optional[int] = None
252
+ ) -> Tuple[str, str, str]:
253
+ """Get a presigned URL for uploading an image to S3.
254
+
255
+ Args:
256
+ agent_id: The ID of the agent
257
+ session_id: Optional session ID for the image
258
+ event_id: Optional event ID for the image
259
+ nthscreenshot: Optional nth screenshot for the image
260
+
261
+ Returns:
262
+ Tuple of (presigned_url, bucket_name, object_key)
263
+ """
264
+ # Import here to avoid circular dependency
265
+ from ..sdk.init import get_http
266
+
267
+ http = get_http()
268
+ if not http:
269
+ raise RuntimeError("SDK not initialized")
270
+
271
+ request_data = {"agent_id": agent_id}
272
+
273
+ if session_id:
274
+ request_data["session_id"] = session_id
275
+
276
+ if event_id:
277
+ request_data["event_id"] = event_id
278
+ if nthscreenshot is None:
279
+ raise ValueError("nth_screenshot is required when event_id is provided")
280
+ request_data["nth_screenshot"] = nthscreenshot
281
+
282
+ response = http.get('getpresigneduploadurl', params=request_data)
283
+ return response['presigned_url'], response['bucket_name'], response['object_key']
284
+
285
+ @staticmethod
286
+ def upload_to_s3(url: str, image_data: Union[str, bytes, io.BytesIO], format: str = "JPEG") -> None:
287
+ """Upload an image to S3 using presigned URL.
288
+
289
+ Args:
290
+ url: The presigned URL for the upload
291
+ image_data: Image data (base64 string, bytes, or BytesIO)
292
+ format: Format of the image (JPEG or GIF)
293
+ """
294
+ # Prepare image for upload
295
+ if isinstance(image_data, io.BytesIO):
296
+ image_obj = image_data
297
+ content_type = "image/jpeg" if format == "JPEG" else "image/gif"
298
+ else:
299
+ image_obj, content_type = ImageHandler.prepare_for_upload(image_data, format)
300
+
301
+ # Upload to S3
302
+ upload_response = requests.put(
303
+ url,
304
+ data=image_obj.getvalue() if hasattr(image_obj, 'getvalue') else image_obj,
305
+ headers={"Content-Type": content_type}
306
+ )
307
+ upload_response.raise_for_status()
308
+
309
+ logger.debug(f"[ImageUploader] Successfully uploaded image to S3")
310
+
311
+
312
+ # Convenience functions for backward compatibility
313
+ def extract_base64_images(data: Any) -> List[str]:
314
+ """Extract base64 images from data (backward compatibility)."""
315
+ return ImageHandler.extract_base64_images(data)
316
+
317
+
318
+ def screenshot_path_to_jpeg(screenshot_path: str) -> str:
319
+ """Convert screenshot to base64 JPEG (backward compatibility)."""
320
+ return ImageHandler.path_to_base64(screenshot_path, "JPEG")
321
+
322
+
323
+ def upload_image_to_s3(url: str, image: Union[str, bytes], format: str) -> None:
324
+ """Upload image to S3 (backward compatibility)."""
325
+ ImageUploader.upload_to_s3(url, image, format)
326
+
327
+
328
+ def get_presigned_url(
329
+ agent_id: str,
330
+ step_id: Optional[str] = None,
331
+ session_id: Optional[str] = None,
332
+ event_id: Optional[str] = None,
333
+ nthscreenshot: Optional[int] = None
334
+ ) -> Tuple[str, str, str]:
335
+ """Get presigned URL (backward compatibility)."""
336
+ # Note: step_id parameter is deprecated
337
+ return ImageUploader.get_presigned_url(agent_id, session_id, event_id, nthscreenshot)
@@ -0,0 +1,168 @@
1
+ """Centralized logging utilities for Lucidic SDK.
2
+
3
+ This module provides consistent logging functions that respect
4
+ LUCIDIC_DEBUG and LUCIDIC_VERBOSE environment variables.
5
+ """
6
+ import os
7
+ import logging
8
+ from typing import Any, Optional
9
+ from dotenv import load_dotenv
10
+
11
+ # Load environment variables from .env file
12
+ load_dotenv()
13
+
14
+ # Configure base logger
15
+ logging.basicConfig(
16
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
17
+ level=logging.WARNING # Default to WARNING, will be overridden by env vars
18
+ )
19
+
20
+ # Get the logger
21
+ logger = logging.getLogger("Lucidic")
22
+
23
+
24
+ def _env_true(value: Optional[str]) -> bool:
25
+ """Check if environment variable is truthy."""
26
+ if value is None:
27
+ return False
28
+ return value.lower() in ('true', '1', 'yes')
29
+
30
+
31
+ def is_debug() -> bool:
32
+ """Check if debug mode is enabled."""
33
+ return _env_true(os.getenv('LUCIDIC_DEBUG'))
34
+
35
+
36
+ def is_verbose() -> bool:
37
+ """Check if verbose mode is enabled."""
38
+ return _env_true(os.getenv('LUCIDIC_VERBOSE'))
39
+
40
+
41
+ def debug(message: str, *args: Any, **kwargs: Any) -> None:
42
+ """Log debug message if LUCIDIC_DEBUG is enabled.
43
+
44
+ Args:
45
+ message: Log message with optional formatting
46
+ *args: Positional arguments for message formatting
47
+ **kwargs: Keyword arguments for logging
48
+ """
49
+ if is_debug():
50
+ logger.debug(f"[DEBUG] {message}", *args, **kwargs)
51
+
52
+
53
+ def info(message: str, *args: Any, **kwargs: Any) -> None:
54
+ """Log info message (always visible).
55
+
56
+ Args:
57
+ message: Log message with optional formatting
58
+ *args: Positional arguments for message formatting
59
+ **kwargs: Keyword arguments for logging
60
+ """
61
+ logger.info(message, *args, **kwargs)
62
+
63
+
64
+ def warning(message: str, *args: Any, **kwargs: Any) -> None:
65
+ """Log warning message (always visible).
66
+
67
+ Args:
68
+ message: Log message with optional formatting
69
+ *args: Positional arguments for message formatting
70
+ **kwargs: Keyword arguments for logging
71
+ """
72
+ logger.warning(message, *args, **kwargs)
73
+
74
+
75
+ def error(message: str, *args: Any, **kwargs: Any) -> None:
76
+ """Log error message (always visible).
77
+
78
+ Args:
79
+ message: Log message with optional formatting
80
+ *args: Positional arguments for message formatting
81
+ **kwargs: Keyword arguments for logging
82
+ """
83
+ logger.error(message, *args, **kwargs)
84
+
85
+
86
+ def verbose(message: str, *args: Any, **kwargs: Any) -> None:
87
+ """Log verbose message if LUCIDIC_VERBOSE or LUCIDIC_DEBUG is enabled.
88
+
89
+ Args:
90
+ message: Log message with optional formatting
91
+ *args: Positional arguments for message formatting
92
+ **kwargs: Keyword arguments for logging
93
+ """
94
+ if is_debug() or is_verbose():
95
+ logger.info(f"[VERBOSE] {message}", *args, **kwargs)
96
+
97
+
98
+ def truncate_id(id_str: Optional[str], length: int = 8) -> str:
99
+ """Truncate UUID for logging.
100
+
101
+ Args:
102
+ id_str: UUID string to truncate
103
+ length: Number of characters to keep
104
+
105
+ Returns:
106
+ Truncated ID with ellipsis
107
+ """
108
+ if not id_str:
109
+ return "None"
110
+ if len(id_str) <= length:
111
+ return id_str
112
+ return f"{id_str[:length]}..."
113
+
114
+
115
+ def mask_sensitive(data: dict, sensitive_keys: set = None) -> dict:
116
+ """Mask sensitive data in dictionary for logging.
117
+
118
+ Args:
119
+ data: Dictionary potentially containing sensitive data
120
+ sensitive_keys: Set of keys to mask (default: common sensitive keys)
121
+
122
+ Returns:
123
+ Dictionary with sensitive values masked
124
+ """
125
+ if sensitive_keys is None:
126
+ sensitive_keys = {
127
+ 'api_key', 'apikey', 'api-key',
128
+ 'token', 'auth', 'authorization',
129
+ 'password', 'secret', 'key',
130
+ 'x-api-key', 'x-auth-token'
131
+ }
132
+
133
+ masked = {}
134
+ for key, value in data.items():
135
+ if any(k in key.lower() for k in sensitive_keys):
136
+ if value:
137
+ # Show first few chars for debugging
138
+ masked[key] = f"{str(value)[:4]}...MASKED" if len(str(value)) > 4 else "MASKED"
139
+ else:
140
+ masked[key] = value
141
+ else:
142
+ masked[key] = value
143
+ return masked
144
+
145
+
146
+ def truncate_data(data: Any, max_length: int = 500) -> str:
147
+ """Truncate long data for logging.
148
+
149
+ Args:
150
+ data: Data to truncate
151
+ max_length: Maximum length
152
+
153
+ Returns:
154
+ Truncated string representation
155
+ """
156
+ str_data = str(data)
157
+ if len(str_data) <= max_length:
158
+ return str_data
159
+ return f"{str_data[:max_length]}... (truncated)"
160
+
161
+
162
+ # Configure logger level based on environment
163
+ if is_debug():
164
+ logger.setLevel(logging.DEBUG)
165
+ elif is_verbose():
166
+ logger.setLevel(logging.INFO)
167
+ else:
168
+ logger.setLevel(logging.WARNING)