lucidicai 1.2.16__py3-none-any.whl → 1.2.17__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 +93 -19
  2. lucidicai/client.py +3 -2
  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 +7 -0
  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.17.dist-info}/METADATA +1 -1
  31. lucidicai-1.2.17.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.17.dist-info}/WHEEL +0 -0
  34. {lucidicai-1.2.16.dist-info → lucidicai-1.2.17.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,11 +99,8 @@ __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
 
@@ -106,6 +115,7 @@ def init(
106
115
  rubrics: Optional[list] = None,
107
116
  tags: Optional[list] = None,
108
117
  masking_function = None,
118
+ auto_end: Optional[bool] = True,
109
119
  ) -> str:
110
120
  """
111
121
  Initialize the Lucidic client.
@@ -120,6 +130,7 @@ def init(
120
130
  rubrics: Optional rubrics for evaluation, list of strings.
121
131
  tags: Optional tags for the session, list of strings.
122
132
  masking_function: Optional function to mask sensitive data.
133
+ auto_end: If True, automatically end the session on process exit. Defaults to True.
123
134
 
124
135
  Raises:
125
136
  InvalidOperationError: If the client is already initialized.
@@ -147,6 +158,10 @@ def init(
147
158
  else:
148
159
  production_monitoring = False
149
160
 
161
+ # Handle auto_end with environment variable support
162
+ if auto_end is None:
163
+ auto_end = os.getenv("LUCIDIC_AUTO_END", "True").lower() == "true"
164
+
150
165
  # Set up providers
151
166
  _setup_providers(client, providers)
152
167
  session_id = client.init_session(
@@ -159,6 +174,10 @@ def init(
159
174
  )
160
175
  if masking_function:
161
176
  client.masking_function = masking_function
177
+
178
+ # Set the auto_end flag on the client
179
+ client.auto_end = auto_end
180
+
162
181
  logger.info("Session initialized successfully")
163
182
  return session_id
164
183
 
@@ -169,6 +188,7 @@ def continue_session(
169
188
  agent_id: Optional[str] = None,
170
189
  providers: Optional[List[ProviderType]] = [],
171
190
  masking_function = None,
191
+ auto_end: Optional[bool] = True,
172
192
  ):
173
193
  if lucidic_api_key is None:
174
194
  lucidic_api_key = os.getenv("LUCIDIC_API_KEY", None)
@@ -188,11 +208,19 @@ def continue_session(
188
208
  agent_id=agent_id,
189
209
  )
190
210
 
211
+ # Handle auto_end with environment variable support
212
+ if auto_end is None:
213
+ auto_end = os.getenv("LUCIDIC_AUTO_END", "True").lower() == "true"
214
+
191
215
  # Set up providers
192
216
  _setup_providers(client, providers)
193
217
  session_id = client.continue_session(session_id=session_id)
194
218
  if masking_function:
195
219
  client.masking_function = masking_function
220
+
221
+ # Set the auto_end flag on the client
222
+ client.auto_end = auto_end
223
+
196
224
  logger.info(f"Session {session_id} continuing...")
197
225
  return session_id # For consistency
198
226
 
@@ -249,9 +277,55 @@ def reset_sdk() -> None:
249
277
  client = Client()
250
278
  if not client.initialized:
251
279
  return
280
+
281
+ # Shutdown OpenTelemetry if it was initialized
282
+ telemetry = LucidicTelemetry()
283
+ if telemetry.is_initialized():
284
+ telemetry.uninstrument_all()
285
+
252
286
  client.clear()
253
287
 
254
288
 
289
+ def _cleanup_telemetry():
290
+ """Cleanup function for OpenTelemetry shutdown"""
291
+ try:
292
+ telemetry = LucidicTelemetry()
293
+ if telemetry.is_initialized():
294
+ telemetry.uninstrument_all()
295
+ logger.info("OpenTelemetry instrumentation cleaned up")
296
+ except Exception as e:
297
+ logger.error(f"Error during telemetry cleanup: {e}")
298
+
299
+
300
+ def _auto_end_session():
301
+ """Automatically end session on exit if auto_end is enabled"""
302
+ try:
303
+ client = Client()
304
+ if hasattr(client, 'auto_end') and client.auto_end and client.session and not client.session.is_finished:
305
+ logger.info("Auto-ending active session on exit")
306
+ end_session()
307
+ except Exception as e:
308
+ logger.debug(f"Error during auto-end session: {e}")
309
+
310
+
311
+ def _signal_handler(signum, frame):
312
+ """Handle interruption signals"""
313
+ _auto_end_session()
314
+ _cleanup_telemetry()
315
+ # Re-raise the signal for default handling
316
+ signal.signal(signum, signal.SIG_DFL)
317
+ os.kill(os.getpid(), signum)
318
+
319
+
320
+ # Register cleanup functions (auto-end runs first due to LIFO order)
321
+ atexit.register(_cleanup_telemetry)
322
+ atexit.register(_auto_end_session)
323
+
324
+ # Register signal handlers for graceful shutdown
325
+ signal.signal(signal.SIGINT, _signal_handler)
326
+ signal.signal(signal.SIGTERM, _signal_handler)
327
+
328
+
255
329
  def create_mass_sim(
256
330
  mass_sim_name: str,
257
331
  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
@@ -90,7 +91,7 @@ class Client:
90
91
  self.initialized = True
91
92
  return self.session.session_id
92
93
 
93
- def continue_session(self, session_id: str) -> None:
94
+ def continue_session(self, session_id: str):
94
95
  self.session = Session(
95
96
  agent_id=self.agent_id,
96
97
  session_id=session_id
@@ -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
@@ -0,0 +1,45 @@
1
+ """Thread-local storage for images to work around OpenTelemetry attribute size limits"""
2
+ import threading
3
+ import logging
4
+ import os
5
+
6
+ logger = logging.getLogger("Lucidic")
7
+ DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
8
+
9
+ # Thread-local storage for images
10
+ _thread_local = threading.local()
11
+
12
+ def store_image(image_base64: str) -> str:
13
+ """Store image in thread-local storage and return placeholder"""
14
+ if not hasattr(_thread_local, 'images'):
15
+ _thread_local.images = []
16
+
17
+ _thread_local.images.append(image_base64)
18
+ placeholder = f"lucidic_image_{len(_thread_local.images) - 1}"
19
+
20
+ if DEBUG:
21
+ logger.info(f"[ImageStorage] Stored image of size {len(image_base64)}, placeholder: {placeholder}")
22
+
23
+ return placeholder
24
+
25
+ def get_stored_images():
26
+ """Get all stored images"""
27
+ if hasattr(_thread_local, 'images'):
28
+ return _thread_local.images
29
+ return []
30
+
31
+ def clear_stored_images():
32
+ """Clear stored images"""
33
+ if hasattr(_thread_local, 'images'):
34
+ _thread_local.images.clear()
35
+
36
+ def get_image_by_placeholder(placeholder: str):
37
+ """Get image by placeholder"""
38
+ if hasattr(_thread_local, 'images') and placeholder.startswith('lucidic_image_'):
39
+ try:
40
+ index = int(placeholder.split('_')[-1])
41
+ if 0 <= index < len(_thread_local.images):
42
+ return _thread_local.images[index]
43
+ except (ValueError, IndexError):
44
+ pass
45
+ return None