lucidicai 2.0.2__py3-none-any.whl → 2.1.1__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 (38) hide show
  1. lucidicai/__init__.py +367 -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 +231 -0
  14. lucidicai/sdk/decorators.py +187 -0
  15. lucidicai/sdk/error_boundary.py +299 -0
  16. lucidicai/sdk/event.py +126 -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 +361 -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 +20 -24
  26. lucidicai/telemetry/lucidic_exporter.py +99 -60
  27. lucidicai/telemetry/openai_patch.py +295 -0
  28. lucidicai/telemetry/openai_uninstrument.py +87 -0
  29. lucidicai/telemetry/telemetry_init.py +16 -1
  30. lucidicai/telemetry/utils/model_pricing.py +278 -0
  31. lucidicai/utils/__init__.py +1 -0
  32. lucidicai/utils/images.py +337 -0
  33. lucidicai/utils/logger.py +168 -0
  34. lucidicai/utils/queue.py +393 -0
  35. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/METADATA +1 -1
  36. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/RECORD +38 -9
  37. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/WHEEL +0 -0
  38. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,187 @@
1
+ """Decorators for the Lucidic SDK to create typed, nested events."""
2
+ import functools
3
+ import inspect
4
+ import json
5
+ from datetime import datetime
6
+ import uuid
7
+ from typing import Any, Callable, Optional, TypeVar
8
+ from collections.abc import Iterable
9
+
10
+ from .event import create_event
11
+ from .init import get_session_id
12
+ from ..core.errors import LucidicNotInitializedError
13
+ from .context import current_parent_event_id, event_context, event_context_async
14
+ from ..utils.logger import debug, error as log_error, verbose, truncate_id
15
+
16
+ F = TypeVar('F', bound=Callable[..., Any])
17
+
18
+
19
+ def _serialize(value: Any):
20
+ if isinstance(value, (str, int, float, bool)):
21
+ return value
22
+ if isinstance(value, dict):
23
+ return {k: _serialize(v) for k, v in value.items()}
24
+ if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
25
+ return [_serialize(v) for v in value]
26
+ try:
27
+ return json.loads(json.dumps(value, default=str))
28
+ except Exception:
29
+ return str(value)
30
+
31
+
32
+ def event(**decorator_kwargs) -> Callable[[F], F]:
33
+ """Universal decorator creating FUNCTION_CALL events with nesting and error capture."""
34
+
35
+ def decorator(func: F) -> F:
36
+ @functools.wraps(func)
37
+ def sync_wrapper(*args, **kwargs):
38
+ session_id = get_session_id()
39
+ if not session_id:
40
+ return func(*args, **kwargs)
41
+
42
+ # Build arguments snapshot
43
+ sig = inspect.signature(func)
44
+ bound = sig.bind(*args, **kwargs)
45
+ bound.apply_defaults()
46
+ args_dict = {name: _serialize(val) for name, val in bound.arguments.items()}
47
+
48
+ parent_id = current_parent_event_id.get(None)
49
+ pre_event_id = str(uuid.uuid4())
50
+ debug(f"[Decorator] Starting {func.__name__} with event ID {truncate_id(pre_event_id)}, parent: {truncate_id(parent_id)}")
51
+ start_time = datetime.now().astimezone()
52
+ result = None
53
+ error: Optional[BaseException] = None
54
+
55
+ try:
56
+ with event_context(pre_event_id):
57
+ # Also inject into OpenTelemetry context for instrumentors
58
+ from ..telemetry.context_bridge import inject_lucidic_context
59
+ from opentelemetry import context as otel_context
60
+
61
+ otel_ctx = inject_lucidic_context()
62
+ token = otel_context.attach(otel_ctx)
63
+ try:
64
+ result = func(*args, **kwargs)
65
+ finally:
66
+ otel_context.detach(token)
67
+ return result
68
+ except Exception as e:
69
+ error = e
70
+ log_error(f"[Decorator] {func.__name__} raised exception: {e}")
71
+ raise
72
+ finally:
73
+ try:
74
+ # Store error as return value with type information
75
+ if error:
76
+ return_val = {
77
+ "error": str(error),
78
+ "error_type": type(error).__name__
79
+ }
80
+
81
+ # Create a separate error_traceback event for the exception
82
+ import traceback
83
+ try:
84
+ create_event(
85
+ type="error_traceback",
86
+ error=str(error),
87
+ traceback=traceback.format_exc(),
88
+ parent_event_id=pre_event_id # Parent is the function that threw the error
89
+ )
90
+ debug(f"[Decorator] Created error_traceback event for {func.__name__}")
91
+ except Exception as e:
92
+ debug(f"[Decorator] Failed to create error_traceback event: {e}")
93
+ else:
94
+ return_val = _serialize(result)
95
+
96
+ create_event(
97
+ type="function_call",
98
+ event_id=pre_event_id, # Use the pre-generated ID
99
+ function_name=func.__name__,
100
+ arguments=args_dict,
101
+ return_value=return_val,
102
+ error=str(error) if error else None,
103
+ duration=(datetime.now().astimezone() - start_time).total_seconds(),
104
+ **decorator_kwargs
105
+ )
106
+ debug(f"[Decorator] Created function_call event for {func.__name__}")
107
+ except Exception as e:
108
+ log_error(f"[Decorator] Failed to create function_call event: {e}")
109
+
110
+ @functools.wraps(func)
111
+ async def async_wrapper(*args, **kwargs):
112
+ session_id = get_session_id()
113
+ if not session_id:
114
+ return await func(*args, **kwargs)
115
+
116
+ sig = inspect.signature(func)
117
+ bound = sig.bind(*args, **kwargs)
118
+ bound.apply_defaults()
119
+ args_dict = {name: _serialize(val) for name, val in bound.arguments.items()}
120
+
121
+ parent_id = current_parent_event_id.get(None)
122
+ pre_event_id = str(uuid.uuid4())
123
+ debug(f"[Decorator] Starting {func.__name__} with event ID {truncate_id(pre_event_id)}, parent: {truncate_id(parent_id)}")
124
+ start_time = datetime.now().astimezone()
125
+ result = None
126
+ error: Optional[BaseException] = None
127
+
128
+ try:
129
+ async with event_context_async(pre_event_id):
130
+ # Also inject into OpenTelemetry context for instrumentors
131
+ from ..telemetry.context_bridge import inject_lucidic_context
132
+ from opentelemetry import context as otel_context
133
+
134
+ otel_ctx = inject_lucidic_context()
135
+ token = otel_context.attach(otel_ctx)
136
+ try:
137
+ result = await func(*args, **kwargs)
138
+ finally:
139
+ otel_context.detach(token)
140
+ return result
141
+ except Exception as e:
142
+ error = e
143
+ log_error(f"[Decorator] {func.__name__} raised exception: {e}")
144
+ raise
145
+ finally:
146
+ try:
147
+ # Store error as return value with type information
148
+ if error:
149
+ return_val = {
150
+ "error": str(error),
151
+ "error_type": type(error).__name__
152
+ }
153
+
154
+ # Create a separate error_traceback event for the exception
155
+ import traceback
156
+ try:
157
+ create_event(
158
+ type="error_traceback",
159
+ error=str(error),
160
+ traceback=traceback.format_exc(),
161
+ parent_event_id=pre_event_id # Parent is the function that threw the error
162
+ )
163
+ debug(f"[Decorator] Created error_traceback event for {func.__name__}")
164
+ except Exception as e:
165
+ debug(f"[Decorator] Failed to create error_traceback event: {e}")
166
+ else:
167
+ return_val = _serialize(result)
168
+
169
+ create_event(
170
+ type="function_call",
171
+ event_id=pre_event_id, # Use the pre-generated ID
172
+ function_name=func.__name__,
173
+ arguments=args_dict,
174
+ return_value=return_val,
175
+ error=str(error) if error else None,
176
+ duration=(datetime.now().astimezone() - start_time).total_seconds(),
177
+ **decorator_kwargs
178
+ )
179
+ debug(f"[Decorator] Created function_call event for {func.__name__}")
180
+ except Exception as e:
181
+ log_error(f"[Decorator] Failed to create function_call event: {e}")
182
+
183
+ if inspect.iscoroutinefunction(func):
184
+ return async_wrapper # type: ignore
185
+ return sync_wrapper # type: ignore
186
+
187
+ return decorator
@@ -0,0 +1,299 @@
1
+ """Error boundary pattern for SDK error suppression.
2
+
3
+ Inspired by the TypeScript SDK's error-boundary.ts, this module provides
4
+ a clean, centralized way to handle SDK errors without affecting user code.
5
+ """
6
+ import functools
7
+ import logging
8
+ import threading
9
+ import traceback
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
14
+
15
+ from ..core.config import get_config
16
+
17
+ logger = logging.getLogger("Lucidic")
18
+
19
+ F = TypeVar('F', bound=Callable[..., Any])
20
+
21
+
22
+ @dataclass
23
+ class ErrorContext:
24
+ """Context information about an SDK error."""
25
+ timestamp: datetime
26
+ module: str
27
+ function: str
28
+ error_type: str
29
+ error_message: str
30
+ traceback: str
31
+ suppressed: bool = True
32
+
33
+
34
+ class ErrorBoundary:
35
+ """Centralized error boundary for the SDK.
36
+
37
+ This class manages all error suppression, logging, and cleanup
38
+ in a single place, similar to the TypeScript implementation.
39
+ """
40
+
41
+ def __init__(self):
42
+ self.error_history: List[ErrorContext] = []
43
+ self.max_history_size = 100
44
+ self.cleanup_handlers: List[Callable] = []
45
+ self._lock = threading.Lock()
46
+ self._config = None # Lazy load config
47
+
48
+ def wrap_function(self, func: F, module: str = "unknown") -> F:
49
+ """Wrap a function with error boundary protection.
50
+
51
+ Args:
52
+ func: Function to wrap
53
+ module: Module name for error context
54
+
55
+ Returns:
56
+ Wrapped function that suppresses errors based on config
57
+ """
58
+ @functools.wraps(func)
59
+ def sync_wrapper(*args, **kwargs):
60
+ if not self.is_silent_mode():
61
+ return func(*args, **kwargs)
62
+
63
+ try:
64
+ return func(*args, **kwargs)
65
+ except Exception as e:
66
+ return self._handle_error(e, module, func.__name__, args, kwargs)
67
+
68
+ @functools.wraps(func)
69
+ async def async_wrapper(*args, **kwargs):
70
+ if not self.is_silent_mode():
71
+ return await func(*args, **kwargs)
72
+
73
+ try:
74
+ return await func(*args, **kwargs)
75
+ except Exception as e:
76
+ return self._handle_error(e, module, func.__name__, args, kwargs)
77
+
78
+ # Return appropriate wrapper based on function type
79
+ import asyncio
80
+ if asyncio.iscoroutinefunction(func):
81
+ return async_wrapper # type: ignore
82
+ return sync_wrapper # type: ignore
83
+
84
+ def wrap_module(self, module_dict: Dict[str, Any], module_name: str) -> Dict[str, Any]:
85
+ """Wrap all functions in a module dictionary.
86
+
87
+ Args:
88
+ module_dict: Dictionary of module exports
89
+ module_name: Name of the module
90
+
91
+ Returns:
92
+ Dictionary with all functions wrapped
93
+ """
94
+ if not self.is_silent_mode():
95
+ return module_dict
96
+
97
+ wrapped = {}
98
+ for name, obj in module_dict.items():
99
+ if callable(obj) and not name.startswith('_'):
100
+ wrapped[name] = self.wrap_function(obj, module_name)
101
+ else:
102
+ wrapped[name] = obj
103
+
104
+ return wrapped
105
+
106
+ @property
107
+ def config(self):
108
+ """Lazy load configuration."""
109
+ if self._config is None:
110
+ self._config = get_config()
111
+ return self._config
112
+
113
+ def is_silent_mode(self) -> bool:
114
+ """Check if SDK is in silent mode (error suppression enabled)."""
115
+ return self.config.error_handling.suppress_errors
116
+
117
+ def _handle_error(
118
+ self,
119
+ error: Exception,
120
+ module: str,
121
+ function: str,
122
+ args: tuple,
123
+ kwargs: dict
124
+ ) -> Any:
125
+ """Handle an error that occurred in SDK code.
126
+
127
+ Args:
128
+ error: The exception that occurred
129
+ module: Module where error occurred
130
+ function: Function where error occurred
131
+ args: Function arguments
132
+ kwargs: Function keyword arguments
133
+
134
+ Returns:
135
+ Default return value for the function
136
+ """
137
+ # Create error context
138
+ context = ErrorContext(
139
+ timestamp=datetime.now(),
140
+ module=module,
141
+ function=function,
142
+ error_type=type(error).__name__,
143
+ error_message=str(error),
144
+ traceback=traceback.format_exc(),
145
+ suppressed=True
146
+ )
147
+
148
+ # Add to history
149
+ with self._lock:
150
+ self.error_history.append(context)
151
+ if len(self.error_history) > self.max_history_size:
152
+ self.error_history.pop(0)
153
+
154
+ # Log if configured
155
+ if self.config.error_handling.log_suppressed:
156
+ logger.debug(
157
+ f"[ErrorBoundary] Suppressed {context.error_type} in {module}.{function}: "
158
+ f"{context.error_message}"
159
+ )
160
+ if self.config.debug:
161
+ logger.debug(f"[ErrorBoundary] Traceback:\n{context.traceback}")
162
+
163
+ # Perform cleanup if configured
164
+ if self.config.error_handling.cleanup_on_error:
165
+ self._perform_cleanup()
166
+
167
+ # Return appropriate default
168
+ return self._get_default_return(function)
169
+
170
+ def _get_default_return(self, function_name: str) -> Any:
171
+ """Get appropriate default return value for a function.
172
+
173
+ Args:
174
+ function_name: Name of the function
175
+
176
+ Returns:
177
+ Appropriate default value based on function name
178
+ """
179
+ # init function should return a fallback session ID
180
+ if function_name == 'init':
181
+ return f'fallback-session-{uuid.uuid4()}'
182
+
183
+ # Functions that return IDs
184
+ if any(x in function_name.lower() for x in ['id', 'create_experiment']):
185
+ return str(uuid.uuid4())
186
+
187
+ # Functions that return booleans
188
+ if any(x in function_name.lower() for x in ['is_', 'has_', 'can_', 'should_']):
189
+ return False
190
+
191
+ # Functions that return data
192
+ if function_name.lower().startswith('get_'):
193
+ if 'dataset' in function_name.lower():
194
+ return {}
195
+ elif 'prompt' in function_name.lower():
196
+ return ""
197
+ return None
198
+
199
+ # Default
200
+ return None
201
+
202
+ def _perform_cleanup(self) -> None:
203
+ """Perform cleanup after an error."""
204
+ # Run registered cleanup handlers
205
+ for handler in self.cleanup_handlers:
206
+ try:
207
+ handler()
208
+ except Exception as e:
209
+ logger.debug(f"[ErrorBoundary] Cleanup handler failed: {e}")
210
+
211
+ def register_cleanup_handler(self, handler: Callable) -> None:
212
+ """Register a cleanup handler to run on errors.
213
+
214
+ Args:
215
+ handler: Cleanup function to register
216
+ """
217
+ self.cleanup_handlers.append(handler)
218
+
219
+ def get_error_history(self) -> List[ErrorContext]:
220
+ """Get the error history.
221
+
222
+ Returns:
223
+ List of error contexts
224
+ """
225
+ with self._lock:
226
+ return list(self.error_history)
227
+
228
+ def clear_error_history(self) -> None:
229
+ """Clear the error history."""
230
+ with self._lock:
231
+ self.error_history.clear()
232
+
233
+ def reset_config(self) -> None:
234
+ """Reset cached configuration (for testing)."""
235
+ self._config = None
236
+
237
+
238
+ # Global error boundary instance
239
+ _error_boundary: Optional[ErrorBoundary] = None
240
+
241
+
242
+ def get_error_boundary() -> ErrorBoundary:
243
+ """Get the global error boundary instance."""
244
+ global _error_boundary
245
+ if _error_boundary is None:
246
+ _error_boundary = ErrorBoundary()
247
+ return _error_boundary
248
+
249
+
250
+ def wrap_sdk_function(func: F, module: str = "unknown") -> F:
251
+ """Wrap an SDK function with error boundary protection.
252
+
253
+ Args:
254
+ func: Function to wrap
255
+ module: Module name for error context
256
+
257
+ Returns:
258
+ Wrapped function
259
+ """
260
+ return get_error_boundary().wrap_function(func, module)
261
+
262
+
263
+ def wrap_sdk_module(module_dict: Dict[str, Any], module_name: str) -> Dict[str, Any]:
264
+ """Wrap all functions in an SDK module.
265
+
266
+ Args:
267
+ module_dict: Dictionary of module exports
268
+ module_name: Name of the module
269
+
270
+ Returns:
271
+ Dictionary with wrapped functions
272
+ """
273
+ return get_error_boundary().wrap_module(module_dict, module_name)
274
+
275
+
276
+ def is_silent_mode() -> bool:
277
+ """Check if SDK is in silent mode."""
278
+ return get_error_boundary().is_silent_mode()
279
+
280
+
281
+
282
+
283
+ def get_error_history() -> List[ErrorContext]:
284
+ """Get the SDK error history."""
285
+ return get_error_boundary().get_error_history()
286
+
287
+
288
+ def clear_error_history() -> None:
289
+ """Clear the SDK error history."""
290
+ get_error_boundary().clear_error_history()
291
+
292
+
293
+ def register_cleanup_handler(handler: Callable) -> None:
294
+ """Register a cleanup handler.
295
+
296
+ Args:
297
+ handler: Cleanup function to run on errors
298
+ """
299
+ get_error_boundary().register_cleanup_handler(handler)
lucidicai/sdk/event.py ADDED
@@ -0,0 +1,126 @@
1
+ """SDK event creation and management."""
2
+ import uuid
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ from .context import current_parent_event_id
7
+ from ..core.config import get_config
8
+ from .event_builder import EventBuilder
9
+ from ..utils.logger import debug, truncate_id
10
+
11
+
12
+ def create_event(
13
+ type: str = "generic",
14
+ event_id: Optional[str] = None,
15
+ session_id: Optional[str] = None, # accept explicit session_id
16
+ **kwargs
17
+ ) -> str:
18
+ """Create a new event.
19
+
20
+ Args:
21
+ type: Event type (llm_generation, function_call, error_traceback, generic)
22
+ event_id: Optional client event ID (will generate if not provided)
23
+ session_id: Optional session ID (will use context if not provided)
24
+ **kwargs: Event-specific fields
25
+
26
+ Returns:
27
+ Event ID (client-generated or provided UUID)
28
+ """
29
+ # Import here to avoid circular dependency
30
+ from ..sdk.init import get_session_id, get_event_queue
31
+
32
+ # Use provided session_id or fall back to context
33
+ if not session_id:
34
+ session_id = get_session_id()
35
+
36
+ if not session_id:
37
+ # No active session, return dummy ID
38
+ debug("[Event] No active session, returning dummy event ID")
39
+ return str(uuid.uuid4())
40
+
41
+ # Get parent event ID from context
42
+ parent_event_id = None
43
+ try:
44
+ parent_event_id = current_parent_event_id.get()
45
+ except Exception:
46
+ pass
47
+
48
+ # Use provided event ID or generate new one
49
+ client_event_id = event_id or str(uuid.uuid4())
50
+
51
+ # Build parameters for EventBuilder
52
+ params = {
53
+ 'type': type,
54
+ 'event_id': client_event_id,
55
+ 'parent_event_id': parent_event_id,
56
+ 'session_id': session_id,
57
+ 'occurred_at': kwargs.get('occurred_at') or datetime.now(timezone.utc).isoformat(),
58
+ **kwargs # Include all other kwargs
59
+ }
60
+
61
+ # Use EventBuilder to create normalized event request
62
+ event_request = EventBuilder.build(params)
63
+
64
+ debug(f"[Event] Creating {type} event {truncate_id(client_event_id)} (parent: {truncate_id(parent_event_id)}, session: {truncate_id(session_id)})")
65
+
66
+ # Queue event for async sending
67
+ event_queue = get_event_queue()
68
+ if event_queue:
69
+ event_queue.queue_event(event_request)
70
+
71
+ return client_event_id
72
+
73
+
74
+
75
+ def create_error_event(
76
+ error: Union[str, Exception],
77
+ parent_event_id: Optional[str] = None,
78
+ **kwargs
79
+ ) -> str:
80
+ """Create an error traceback event.
81
+
82
+ This is a convenience function for creating error events with proper
83
+ traceback information.
84
+
85
+ Args:
86
+ error: The error message or exception object
87
+ parent_event_id: Optional parent event ID for nesting
88
+ **kwargs: Additional event parameters
89
+
90
+ Returns:
91
+ Event ID of the created error event
92
+ """
93
+ import traceback
94
+
95
+ if isinstance(error, Exception):
96
+ error_str = str(error)
97
+ traceback_str = traceback.format_exc()
98
+ else:
99
+ error_str = str(error)
100
+ traceback_str = kwargs.pop('traceback', '')
101
+
102
+ return create_event(
103
+ type="error_traceback",
104
+ error=error_str,
105
+ traceback=traceback_str,
106
+ parent_event_id=parent_event_id,
107
+ **kwargs
108
+ )
109
+
110
+
111
+ def flush(timeout_seconds: float = 2.0) -> bool:
112
+ """Flush pending events.
113
+
114
+ Args:
115
+ timeout_seconds: Maximum time to wait for flush
116
+
117
+ Returns:
118
+ True if flush completed, False if timeout
119
+ """
120
+ from ..sdk.init import get_event_queue
121
+ event_queue = get_event_queue()
122
+ if event_queue:
123
+ debug(f"[Event] Forcing flush with {timeout_seconds}s timeout")
124
+ event_queue.force_flush(timeout_seconds)
125
+ return True
126
+ return False