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.
- lucidicai/__init__.py +93 -19
- lucidicai/client.py +3 -2
- lucidicai/decorators.py +357 -0
- lucidicai/image_upload.py +24 -1
- lucidicai/providers/image_storage.py +45 -0
- lucidicai/providers/lucidic_exporter.py +259 -0
- lucidicai/providers/lucidic_span_processor.py +648 -0
- lucidicai/providers/openai_agents_instrumentor.py +307 -0
- lucidicai/providers/otel_handlers.py +266 -0
- lucidicai/providers/otel_init.py +197 -0
- lucidicai/providers/otel_provider.py +168 -0
- lucidicai/providers/pydantic_ai_handler.py +1 -1
- lucidicai/providers/text_storage.py +53 -0
- lucidicai/providers/universal_image_interceptor.py +276 -0
- lucidicai/session.py +7 -0
- lucidicai/telemetry/__init__.py +0 -0
- lucidicai/telemetry/base_provider.py +21 -0
- lucidicai/telemetry/lucidic_exporter.py +259 -0
- lucidicai/telemetry/lucidic_span_processor.py +665 -0
- lucidicai/telemetry/openai_agents_instrumentor.py +306 -0
- lucidicai/telemetry/opentelemetry_converter.py +436 -0
- lucidicai/telemetry/otel_handlers.py +266 -0
- lucidicai/telemetry/otel_init.py +197 -0
- lucidicai/telemetry/otel_provider.py +168 -0
- lucidicai/telemetry/pydantic_ai_handler.py +600 -0
- lucidicai/telemetry/utils/__init__.py +0 -0
- lucidicai/telemetry/utils/image_storage.py +45 -0
- lucidicai/telemetry/utils/text_storage.py +53 -0
- lucidicai/telemetry/utils/universal_image_interceptor.py +276 -0
- {lucidicai-1.2.16.dist-info → lucidicai-1.2.17.dist-info}/METADATA +1 -1
- lucidicai-1.2.17.dist-info/RECORD +49 -0
- lucidicai-1.2.16.dist-info/RECORD +0 -25
- {lucidicai-1.2.16.dist-info → lucidicai-1.2.17.dist-info}/WHEEL +0 -0
- {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(
|
|
61
|
+
client.set_provider(OTelOpenAIHandler())
|
|
46
62
|
setup_providers.add("openai")
|
|
47
63
|
elif provider == "anthropic":
|
|
48
|
-
client.set_provider(
|
|
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(
|
|
71
|
+
client.set_provider(OTelPydanticAIHandler())
|
|
55
72
|
setup_providers.add("pydantic_ai")
|
|
56
73
|
elif provider == "openai_agents":
|
|
57
74
|
try:
|
|
58
|
-
|
|
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
|
-
'
|
|
91
|
-
'
|
|
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 .
|
|
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)
|
|
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
|
lucidicai/decorators.py
ADDED
|
@@ -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
|