lucidicai 1.2.16__py3-none-any.whl → 1.2.18__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 (34) hide show
  1. lucidicai/__init__.py +105 -30
  2. lucidicai/client.py +10 -4
  3. lucidicai/decorators.py +357 -0
  4. lucidicai/image_upload.py +24 -1
  5. lucidicai/providers/image_storage.py +45 -0
  6. lucidicai/providers/lucidic_exporter.py +259 -0
  7. lucidicai/providers/lucidic_span_processor.py +648 -0
  8. lucidicai/providers/openai_agents_instrumentor.py +307 -0
  9. lucidicai/providers/otel_handlers.py +266 -0
  10. lucidicai/providers/otel_init.py +197 -0
  11. lucidicai/providers/otel_provider.py +168 -0
  12. lucidicai/providers/pydantic_ai_handler.py +1 -1
  13. lucidicai/providers/text_storage.py +53 -0
  14. lucidicai/providers/universal_image_interceptor.py +276 -0
  15. lucidicai/session.py +9 -1
  16. lucidicai/telemetry/__init__.py +0 -0
  17. lucidicai/telemetry/base_provider.py +21 -0
  18. lucidicai/telemetry/lucidic_exporter.py +259 -0
  19. lucidicai/telemetry/lucidic_span_processor.py +665 -0
  20. lucidicai/telemetry/openai_agents_instrumentor.py +306 -0
  21. lucidicai/telemetry/opentelemetry_converter.py +436 -0
  22. lucidicai/telemetry/otel_handlers.py +266 -0
  23. lucidicai/telemetry/otel_init.py +197 -0
  24. lucidicai/telemetry/otel_provider.py +168 -0
  25. lucidicai/telemetry/pydantic_ai_handler.py +600 -0
  26. lucidicai/telemetry/utils/__init__.py +0 -0
  27. lucidicai/telemetry/utils/image_storage.py +45 -0
  28. lucidicai/telemetry/utils/text_storage.py +53 -0
  29. lucidicai/telemetry/utils/universal_image_interceptor.py +276 -0
  30. {lucidicai-1.2.16.dist-info → lucidicai-1.2.18.dist-info}/METADATA +1 -1
  31. lucidicai-1.2.18.dist-info/RECORD +49 -0
  32. lucidicai-1.2.16.dist-info/RECORD +0 -25
  33. {lucidicai-1.2.16.dist-info → lucidicai-1.2.18.dist-info}/WHEEL +0 -0
  34. {lucidicai-1.2.16.dist-info → lucidicai-1.2.18.dist-info}/top_level.txt +0 -0
lucidicai/__init__.py CHANGED
@@ -7,14 +7,24 @@ from typing import List, Literal, Optional
7
7
  from .client import Client
8
8
  from .errors import APIKeyVerificationError, InvalidOperationError, LucidicNotInitializedError, PromptError
9
9
  from .event import Event
10
- from .providers.anthropic_handler import AnthropicHandler
11
- from .providers.langchain import LucidicLangchainHandler
12
- from .providers.openai_handler import OpenAIHandler
13
- from .providers.openai_agents_handler import OpenAIAgentsHandler
14
- from .providers.pydantic_ai_handler import PydanticAIHandler
15
10
  from .session import Session
16
11
  from .step import Step
17
12
 
13
+ # Import OpenTelemetry-based handlers
14
+ from .telemetry.otel_handlers import (
15
+ OTelOpenAIHandler,
16
+ OTelAnthropicHandler,
17
+ OTelLangChainHandler,
18
+ OTelPydanticAIHandler,
19
+ OTelOpenAIAgentsHandler
20
+ )
21
+
22
+ # Import telemetry manager
23
+ from .telemetry.otel_init import LucidicTelemetry
24
+
25
+ # Import decorators
26
+ from .decorators import step, event
27
+
18
28
  ProviderType = Literal["openai", "anthropic", "langchain", "pydantic_ai", "openai_agents"]
19
29
 
20
30
  # Configure logging
@@ -37,31 +47,33 @@ def _setup_providers(client: Client, providers: List[ProviderType]) -> None:
37
47
  # Track which providers have been set up to avoid duplication
38
48
  setup_providers = set()
39
49
 
50
+ # Initialize telemetry if using OpenTelemetry
51
+ if providers:
52
+ telemetry = LucidicTelemetry()
53
+ if not telemetry.is_initialized():
54
+ telemetry.initialize(agent_id=client.agent_id)
55
+
40
56
  for provider in providers:
41
57
  if provider in setup_providers:
42
58
  continue
43
59
 
44
60
  if provider == "openai":
45
- client.set_provider(OpenAIHandler())
61
+ client.set_provider(OTelOpenAIHandler())
46
62
  setup_providers.add("openai")
47
63
  elif provider == "anthropic":
48
- client.set_provider(AnthropicHandler())
64
+ client.set_provider(OTelAnthropicHandler())
49
65
  setup_providers.add("anthropic")
50
66
  elif provider == "langchain":
67
+ client.set_provider(OTelLangChainHandler())
51
68
  logger.info("For LangChain, make sure to create a handler and attach it to your top-level Agent class.")
52
69
  setup_providers.add("langchain")
53
70
  elif provider == "pydantic_ai":
54
- client.set_provider(PydanticAIHandler())
71
+ client.set_provider(OTelPydanticAIHandler())
55
72
  setup_providers.add("pydantic_ai")
56
73
  elif provider == "openai_agents":
57
74
  try:
58
- # For OpenAI Agents SDK, we want both handlers
59
- client.set_provider(OpenAIAgentsHandler())
75
+ client.set_provider(OTelOpenAIAgentsHandler())
60
76
  setup_providers.add("openai_agents")
61
- # Also enable OpenAI handler if not already set up
62
- if "openai" not in setup_providers:
63
- client.set_provider(OpenAIHandler())
64
- setup_providers.add("openai")
65
77
  except Exception as e:
66
78
  logger.error(f"Failed to set up OpenAI Agents provider: {e}")
67
79
  raise
@@ -87,16 +99,14 @@ __all__ = [
87
99
  'LucidicNotInitializedError',
88
100
  'PromptError',
89
101
  'InvalidOperationError',
90
- 'LucidicLangchainHandler',
91
- 'AnthropicHandler',
92
- 'OpenAIHandler',
93
- 'OpenAIAgentsHandler',
94
- 'PydanticAIHandler'
102
+ 'step',
103
+ 'event',
95
104
  ]
96
105
 
97
106
 
98
107
  def init(
99
- session_name: str,
108
+ session_name: Optional[str] = None,
109
+ session_id: Optional[str] = None,
100
110
  lucidic_api_key: Optional[str] = None,
101
111
  agent_id: Optional[str] = None,
102
112
  task: Optional[str] = None,
@@ -106,12 +116,14 @@ def init(
106
116
  rubrics: Optional[list] = None,
107
117
  tags: Optional[list] = None,
108
118
  masking_function = None,
119
+ auto_end: Optional[bool] = True,
109
120
  ) -> str:
110
121
  """
111
122
  Initialize the Lucidic client.
112
123
 
113
124
  Args:
114
- session_name: The name of the session.
125
+ session_name: The display name of the session.
126
+ session_id: Custom ID of the session. If not provided, a random ID will be generated.
115
127
  lucidic_api_key: API key for authentication. If not provided, will use the LUCIDIC_API_KEY environment variable.
116
128
  agent_id: Agent ID. If not provided, will use the LUCIDIC_AGENT_ID environment variable.
117
129
  task: Task description.
@@ -120,6 +132,7 @@ def init(
120
132
  rubrics: Optional rubrics for evaluation, list of strings.
121
133
  tags: Optional tags for the session, list of strings.
122
134
  masking_function: Optional function to mask sensitive data.
135
+ auto_end: If True, automatically end the session on process exit. Defaults to True.
123
136
 
124
137
  Raises:
125
138
  InvalidOperationError: If the client is already initialized.
@@ -147,6 +160,10 @@ def init(
147
160
  else:
148
161
  production_monitoring = False
149
162
 
163
+ # Handle auto_end with environment variable support
164
+ if auto_end is None:
165
+ auto_end = os.getenv("LUCIDIC_AUTO_END", "True").lower() == "true"
166
+
150
167
  # Set up providers
151
168
  _setup_providers(client, providers)
152
169
  session_id = client.init_session(
@@ -156,9 +173,14 @@ def init(
156
173
  rubrics=rubrics,
157
174
  tags=tags,
158
175
  production_monitoring=production_monitoring,
176
+ custom_session_id=session_id,
159
177
  )
160
178
  if masking_function:
161
179
  client.masking_function = masking_function
180
+
181
+ # Set the auto_end flag on the client
182
+ client.auto_end = auto_end
183
+
162
184
  logger.info("Session initialized successfully")
163
185
  return session_id
164
186
 
@@ -169,6 +191,7 @@ def continue_session(
169
191
  agent_id: Optional[str] = None,
170
192
  providers: Optional[List[ProviderType]] = [],
171
193
  masking_function = None,
194
+ auto_end: Optional[bool] = True,
172
195
  ):
173
196
  if lucidic_api_key is None:
174
197
  lucidic_api_key = os.getenv("LUCIDIC_API_KEY", None)
@@ -178,21 +201,27 @@ def continue_session(
178
201
  agent_id = os.getenv("LUCIDIC_AGENT_ID", None)
179
202
  if agent_id is None:
180
203
  raise APIKeyVerificationError("Lucidic agent ID not specified. Make sure to either pass your agent ID into lai.init() or set the LUCIDIC_AGENT_ID environment variable.")
181
- try:
182
- client = Client()
183
- if client.session:
184
- raise InvalidOperationError("[Lucidic] Session already in progress. Please call lai.end_session() first.")
185
- except LucidicNotInitializedError:
186
- client = Client(
187
- lucidic_api_key=lucidic_api_key,
188
- agent_id=agent_id,
189
- )
204
+
205
+ client = Client()
206
+ if client.session:
207
+ raise InvalidOperationError("[Lucidic] Session already in progress. Please call lai.end_session() or lai.reset_sdk() first.")
208
+ # if not yet initialized or still the NullClient -> create a real client when init is called
209
+ if not getattr(client, 'initialized', False):
210
+ client = Client(lucidic_api_key=lucidic_api_key, agent_id=agent_id)
211
+
212
+ # Handle auto_end with environment variable support
213
+ if auto_end is None:
214
+ auto_end = os.getenv("LUCIDIC_AUTO_END", "True").lower() == "true"
190
215
 
191
216
  # Set up providers
192
217
  _setup_providers(client, providers)
193
218
  session_id = client.continue_session(session_id=session_id)
194
219
  if masking_function:
195
220
  client.masking_function = masking_function
221
+
222
+ # Set the auto_end flag on the client
223
+ client.auto_end = auto_end
224
+
196
225
  logger.info(f"Session {session_id} continuing...")
197
226
  return session_id # For consistency
198
227
 
@@ -249,9 +278,55 @@ def reset_sdk() -> None:
249
278
  client = Client()
250
279
  if not client.initialized:
251
280
  return
281
+
282
+ # Shutdown OpenTelemetry if it was initialized
283
+ telemetry = LucidicTelemetry()
284
+ if telemetry.is_initialized():
285
+ telemetry.uninstrument_all()
286
+
252
287
  client.clear()
253
288
 
254
289
 
290
+ def _cleanup_telemetry():
291
+ """Cleanup function for OpenTelemetry shutdown"""
292
+ try:
293
+ telemetry = LucidicTelemetry()
294
+ if telemetry.is_initialized():
295
+ telemetry.uninstrument_all()
296
+ logger.info("OpenTelemetry instrumentation cleaned up")
297
+ except Exception as e:
298
+ logger.error(f"Error during telemetry cleanup: {e}")
299
+
300
+
301
+ def _auto_end_session():
302
+ """Automatically end session on exit if auto_end is enabled"""
303
+ try:
304
+ client = Client()
305
+ if hasattr(client, 'auto_end') and client.auto_end and client.session and not client.session.is_finished:
306
+ logger.info("Auto-ending active session on exit")
307
+ end_session()
308
+ except Exception as e:
309
+ logger.debug(f"Error during auto-end session: {e}")
310
+
311
+
312
+ def _signal_handler(signum, frame):
313
+ """Handle interruption signals"""
314
+ _auto_end_session()
315
+ _cleanup_telemetry()
316
+ # Re-raise the signal for default handling
317
+ signal.signal(signum, signal.SIG_DFL)
318
+ os.kill(os.getpid(), signum)
319
+
320
+
321
+ # Register cleanup functions (auto-end runs first due to LIFO order)
322
+ atexit.register(_cleanup_telemetry)
323
+ atexit.register(_auto_end_session)
324
+
325
+ # Register signal handlers for graceful shutdown
326
+ signal.signal(signal.SIGINT, _signal_handler)
327
+ signal.signal(signal.SIGTERM, _signal_handler)
328
+
329
+
255
330
  def create_mass_sim(
256
331
  mass_sim_name: str,
257
332
  total_num_sessions: int,
lucidicai/client.py CHANGED
@@ -10,7 +10,7 @@ from urllib3.util import Retry
10
10
 
11
11
 
12
12
  from .errors import APIKeyVerificationError, InvalidOperationError, LucidicNotInitializedError
13
- from .providers.base_providers import BaseProvider
13
+ from .telemetry.base_provider import BaseProvider
14
14
  from .session import Session
15
15
  from .singleton import singleton, clear_singletons
16
16
 
@@ -32,6 +32,7 @@ class Client:
32
32
  self.api_key = lucidic_api_key
33
33
  self.agent_id = agent_id
34
34
  self.masking_function = None
35
+ self.auto_end = False # Default to False until explicitly set during init
35
36
  self.request_session = requests.Session()
36
37
  retry_cfg = Retry(
37
38
  total=3, # 3 attempts in total
@@ -76,7 +77,8 @@ class Client:
76
77
  task: Optional[str] = None,
77
78
  rubrics: Optional[list] = None,
78
79
  tags: Optional[list] = None,
79
- production_monitoring: Optional[bool] = False
80
+ production_monitoring: Optional[bool] = False,
81
+ custom_session_id: Optional[str] = None,
80
82
  ) -> None:
81
83
  self.session = Session(
82
84
  agent_id=self.agent_id,
@@ -85,16 +87,20 @@ class Client:
85
87
  task=task,
86
88
  rubrics=rubrics,
87
89
  tags=tags,
88
- production_monitoring=production_monitoring
90
+ production_monitoring=production_monitoring,
91
+ custom_session_id=custom_session_id
89
92
  )
90
93
  self.initialized = True
91
94
  return self.session.session_id
92
95
 
93
- def continue_session(self, session_id: str) -> None:
96
+ def continue_session(self, session_id: str):
94
97
  self.session = Session(
95
98
  agent_id=self.agent_id,
96
99
  session_id=session_id
97
100
  )
101
+ if self.session.session_id != session_id:
102
+ # Custom session ID provided
103
+ self.session.custom_session_id = session_id
98
104
  self.initialized = True
99
105
  return self.session.session_id
100
106
 
@@ -0,0 +1,357 @@
1
+ """Decorators for the Lucidic SDK to simplify step and event tracking."""
2
+ import functools
3
+ import contextvars
4
+ import inspect
5
+ import json
6
+ import logging
7
+ from typing import Any, Callable, Optional, TypeVar, Union
8
+
9
+ from .client import Client
10
+ from .errors import LucidicNotInitializedError
11
+
12
+ logger = logging.getLogger("Lucidic")
13
+
14
+ F = TypeVar('F', bound=Callable[..., Any])
15
+
16
+ # Create context variables to store the current step and event
17
+ _current_step = contextvars.ContextVar("current_step", default=None)
18
+ _current_event = contextvars.ContextVar("current_event", default=None)
19
+
20
+ def get_decorator_step():
21
+ return _current_step.get()
22
+
23
+ def get_decorator_event():
24
+ return _current_event.get()
25
+
26
+
27
+ def step(
28
+ state: Optional[str] = None,
29
+ action: Optional[str] = None,
30
+ goal: Optional[str] = None,
31
+ screenshot_path: Optional[str] = None,
32
+ eval_score: Optional[float] = None,
33
+ eval_description: Optional[str] = None
34
+ ) -> Callable[[F], F]:
35
+ """
36
+ Decorator that wraps a function with step tracking.
37
+
38
+ The decorated function will be wrapped with create_step() at the start
39
+ and end_step() at the end, ensuring proper cleanup even on exceptions.
40
+
41
+ Args:
42
+ state: State description for the step
43
+ action: Action description for the step
44
+ goal: Goal description for the step
45
+ eval_score: Evaluation score for the step
46
+ eval_description: Evaluation description for the step
47
+
48
+ Example:
49
+ @lai.step(
50
+ state="Processing user input",
51
+ action="Validate and parse request",
52
+ goal="Extract intent from user message"
53
+ )
54
+ def process_user_input(message: str) -> dict:
55
+ # Function logic here
56
+ return parsed_intent
57
+ """
58
+ def decorator(func: F) -> F:
59
+ @functools.wraps(func)
60
+ def sync_wrapper(*args, **kwargs):
61
+ # Check if SDK is initialized
62
+ try:
63
+ client = Client()
64
+ if not client.session:
65
+ # No active session, run function normally
66
+ logger.warning("No active session, running function normally")
67
+ return func(*args, **kwargs)
68
+ except LucidicNotInitializedError:
69
+ # SDK not initialized, run function normally
70
+ logger.warning("Lucidic not initialized, running function normally")
71
+ return func(*args, **kwargs)
72
+
73
+ # Create the step
74
+ step_params = {
75
+ 'state': state,
76
+ 'action': action,
77
+ 'goal': goal,
78
+ 'screenshot_path': screenshot_path,
79
+ 'eval_score': eval_score,
80
+ 'eval_description': eval_description
81
+ }
82
+ # Remove None values
83
+ step_params = {k: v for k, v in step_params.items() if v is not None}
84
+
85
+ # Import here to avoid circular imports
86
+ from . import create_step, end_step
87
+ step_id = create_step(**step_params)
88
+ tok = _current_step.set(step_id)
89
+
90
+ try:
91
+ # Execute the wrapped function
92
+ result = func(*args, **kwargs)
93
+ # End step successfully
94
+ end_step(step_id=step_id)
95
+ _current_step.reset(tok)
96
+ return result
97
+ except Exception as e:
98
+ # End step with error indication
99
+ try:
100
+ end_step(
101
+ step_id=step_id,
102
+ eval_score=0.0,
103
+ eval_description=f"Step failed with error: {str(e)}"
104
+ )
105
+ _current_step.reset(tok)
106
+ except Exception:
107
+ # If end_step fails, just log it
108
+ logger.error(f"Failed to end step {step_id} after error")
109
+ raise
110
+
111
+ @functools.wraps(func)
112
+ async def async_wrapper(*args, **kwargs):
113
+ # Check if SDK is initialized
114
+ try:
115
+ client = Client()
116
+ if not client.session:
117
+ # No active session, run function normally
118
+ logger.warning("No active session, running function normally")
119
+ return await func(*args, **kwargs)
120
+ except LucidicNotInitializedError:
121
+ # SDK not initialized, run function normally
122
+ logger.warning("Lucidic not initialized, running function normally")
123
+ return await func(*args, **kwargs)
124
+
125
+ # Create the step
126
+ step_params = {
127
+ 'state': state,
128
+ 'action': action,
129
+ 'goal': goal,
130
+ 'screenshot_path': screenshot_path,
131
+ 'eval_score': eval_score,
132
+ 'eval_description': eval_description
133
+ }
134
+ # Remove None values
135
+ step_params = {k: v for k, v in step_params.items() if v is not None}
136
+
137
+ # Import here to avoid circular imports
138
+ from . import create_step, end_step
139
+
140
+ step_id = create_step(**step_params)
141
+ tok = _current_step.set(step_id)
142
+ try:
143
+ # Execute the wrapped function
144
+ result = await func(*args, **kwargs)
145
+ # End step successfully
146
+ end_step(step_id=step_id)
147
+ _current_step.reset(tok)
148
+ return result
149
+ except Exception as e:
150
+ # End step with error indication
151
+ try:
152
+ end_step(
153
+ step_id=step_id,
154
+ eval_score=0.0,
155
+ eval_description=f"Step failed with error: {str(e)}"
156
+ )
157
+ _current_step.reset(tok)
158
+ except Exception:
159
+ # If end_step fails, just log it
160
+ logger.error(f"Failed to end step {step_id} after error")
161
+ raise
162
+
163
+ # Return appropriate wrapper based on function type
164
+ if inspect.iscoroutinefunction(func):
165
+ return async_wrapper
166
+ else:
167
+ return sync_wrapper
168
+
169
+ return decorator
170
+
171
+
172
+ ### -- TODO -- Updating even within function causes function result to not be recorded.
173
+ def event(
174
+ description: Optional[str] = None,
175
+ result: Optional[str] = None,
176
+ model: Optional[str] = None,
177
+ cost_added: Optional[float] = 0
178
+ ) -> Callable[[F], F]:
179
+ """
180
+ Decorator that creates an event for a function call.
181
+
182
+ The decorated function will create an event that captures:
183
+ - Function inputs (as string representation) if description not provided
184
+ - Function output (as string representation) if result not provided
185
+
186
+ LLM calls within the function will create their own events as normal.
187
+
188
+ Args:
189
+ description: Custom description for the event. If not provided,
190
+ will use string representation of function inputs
191
+ result: Custom result for the event. If not provided,
192
+ will use string representation of function output
193
+ model: Model name if this function represents a model call (default: None)
194
+ cost_added: Cost to add for this event (default: 0)
195
+
196
+ Example:
197
+ @lai.event(description="Parse user query", model="custom-parser")
198
+ def parse_query(query: str) -> dict:
199
+ # Function logic here
200
+ return {"intent": "search", "query": query}
201
+ """
202
+ def decorator(func: F) -> F:
203
+ @functools.wraps(func)
204
+ def sync_wrapper(*args, **kwargs):
205
+ # Check if SDK is initialized
206
+ try:
207
+ client = Client()
208
+ if not client.session:
209
+ # No active session, run function normally
210
+ logger.warning("No active session, running function normally")
211
+ return func(*args, **kwargs)
212
+ except (LucidicNotInitializedError, AttributeError):
213
+ # SDK not initialized or no session, run function normally
214
+ logger.warning("Lucidic not initialized, running function normally")
215
+ return func(*args, **kwargs)
216
+
217
+ # Import here to avoid circular imports
218
+ from . import create_event, end_event
219
+
220
+ # Build event description from inputs if not provided
221
+ event_desc = description
222
+ if not event_desc:
223
+ # Get function signature
224
+ sig = inspect.signature(func)
225
+ bound_args = sig.bind(*args, **kwargs)
226
+ bound_args.apply_defaults()
227
+
228
+ # Create string representation of inputs
229
+ input_parts = []
230
+ for param_name, param_value in bound_args.arguments.items():
231
+ try:
232
+ input_parts.append(f"{param_name}={repr(param_value)}")
233
+ except Exception:
234
+ input_parts.append(f"{param_name}=<{type(param_value).__name__}>")
235
+
236
+ event_desc = f"{func.__name__}({', '.join(input_parts)})"
237
+
238
+ # Create the event
239
+ event_id = create_event(
240
+ description=event_desc,
241
+ model=model,
242
+ cost_added=cost_added
243
+ )
244
+ tok = _current_event.set(event_id)
245
+ try:
246
+ # Execute the wrapped function
247
+ function_result = func(*args, **kwargs)
248
+
249
+ # Build event result from output if not provided
250
+ event_result = result
251
+ if not event_result:
252
+ try:
253
+ event_result = repr(function_result)
254
+ except Exception:
255
+ event_result = str(function_result)
256
+
257
+ # Update and end the event
258
+ end_event(
259
+ event_id=event_id,
260
+ result=event_result,
261
+ )
262
+ _current_event.reset(tok)
263
+ return function_result
264
+
265
+ except Exception as e:
266
+ # Update event with error
267
+ try:
268
+ end_event(
269
+ event_id=event_id,
270
+ result=f"Error: {str(e)}",
271
+ )
272
+ _current_event.reset(tok)
273
+ except Exception:
274
+ logger.error(f"Failed to end event {event_id} after error")
275
+ raise
276
+
277
+ @functools.wraps(func)
278
+ async def async_wrapper(*args, **kwargs):
279
+ # Check if SDK is initialized
280
+ try:
281
+ client = Client()
282
+ if not client.session:
283
+ # No active session, run function normally
284
+ logger.warning("No active session, running function normally")
285
+ return await func(*args, **kwargs)
286
+ except (LucidicNotInitializedError, AttributeError):
287
+ # SDK not initialized or no session, run function normally
288
+ logger.warning("Lucidic not initialized, running function normally")
289
+ return await func(*args, **kwargs)
290
+
291
+ # Import here to avoid circular imports
292
+ from . import create_event, end_event
293
+
294
+ # Build event description from inputs if not provided
295
+ event_desc = description
296
+ if not event_desc:
297
+ # Get function signature
298
+ sig = inspect.signature(func)
299
+ bound_args = sig.bind(*args, **kwargs)
300
+ bound_args.apply_defaults()
301
+
302
+ # Create string representation of inputs
303
+ input_parts = []
304
+ for param_name, param_value in bound_args.arguments.items():
305
+ try:
306
+ input_parts.append(f"{param_name}={repr(param_value)}")
307
+ except Exception:
308
+ input_parts.append(f"{param_name}=<{type(param_value).__name__}>")
309
+
310
+ event_desc = f"{func.__name__}({', '.join(input_parts)})"
311
+
312
+ # Create the event
313
+ event_id = create_event(
314
+ description=event_desc,
315
+ model=model,
316
+ cost_added=cost_added
317
+ )
318
+ tok = _current_event.set(event_id)
319
+ try:
320
+ # Execute the wrapped function
321
+ function_result = await func(*args, **kwargs)
322
+
323
+ # Build event result from output if not provided
324
+ event_result = result
325
+ if not event_result:
326
+ try:
327
+ event_result = repr(function_result)
328
+ except Exception:
329
+ event_result = str(function_result)
330
+
331
+ # Update and end the event
332
+ end_event(
333
+ event_id=event_id,
334
+ result=event_result,
335
+ )
336
+ _current_event.reset(tok)
337
+ return function_result
338
+
339
+ except Exception as e:
340
+ # Update event with error
341
+ try:
342
+ end_event(
343
+ event_id=event_id,
344
+ result=f"Error: {str(e)}",
345
+ )
346
+ _current_event.reset(tok)
347
+ except Exception:
348
+ logger.error(f"Failed to end event {event_id} after error")
349
+ raise
350
+
351
+ # Return appropriate wrapper based on function type
352
+ if inspect.iscoroutinefunction(func):
353
+ return async_wrapper
354
+ else:
355
+ return sync_wrapper
356
+
357
+ return decorator
lucidicai/image_upload.py CHANGED
@@ -85,4 +85,27 @@ def screenshot_path_to_jpeg(screenshot_path):
85
85
  buffered = io.BytesIO()
86
86
  img.save(buffered, format="JPEG")
87
87
  img_byte = buffered.getvalue()
88
- return base64.b64encode(img_byte).decode('utf-8')
88
+ return base64.b64encode(img_byte).decode('utf-8')
89
+
90
+ def extract_base64_images(data):
91
+ """Extract base64 image URLs from various data structures
92
+
93
+ Args:
94
+ data: Can be a string, dict, list, or nested structure containing image data
95
+
96
+ Returns:
97
+ List of base64 image data URLs (data:image/...)
98
+ """
99
+ images = []
100
+
101
+ if isinstance(data, str):
102
+ if data.startswith('data:image'):
103
+ images.append(data)
104
+ elif isinstance(data, dict):
105
+ for value in data.values():
106
+ images.extend(extract_base64_images(value))
107
+ elif isinstance(data, list):
108
+ for item in data:
109
+ images.extend(extract_base64_images(item))
110
+
111
+ return images