lucidicai 1.2.15__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 +111 -21
- lucidicai/client.py +22 -5
- lucidicai/decorators.py +357 -0
- lucidicai/event.py +2 -2
- lucidicai/image_upload.py +24 -1
- lucidicai/providers/anthropic_handler.py +0 -7
- lucidicai/providers/image_storage.py +45 -0
- lucidicai/providers/langchain.py +0 -78
- 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/openai_handler.py +1 -56
- 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 +2 -19
- lucidicai/providers/text_storage.py +53 -0
- lucidicai/providers/universal_image_interceptor.py +276 -0
- lucidicai/session.py +17 -4
- lucidicai/step.py +4 -4
- lucidicai/streaming.py +2 -3
- 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.15.dist-info → lucidicai-1.2.17.dist-info}/METADATA +1 -1
- lucidicai-1.2.17.dist-info/RECORD +49 -0
- lucidicai-1.2.15.dist-info/RECORD +0 -25
- {lucidicai-1.2.15.dist-info → lucidicai-1.2.17.dist-info}/WHEEL +0 -0
- {lucidicai-1.2.15.dist-info → lucidicai-1.2.17.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Universal image interceptor for all LLM providers to capture images from multimodal requests"""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Union, Tuple
|
|
5
|
+
from .image_storage import store_image, get_stored_images
|
|
6
|
+
from .text_storage import store_text, clear_stored_texts
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("Lucidic")
|
|
9
|
+
DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UniversalImageInterceptor:
|
|
13
|
+
"""Universal image interceptor that can handle different provider formats"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def extract_and_store_images_openai(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
17
|
+
"""Extract and store images from OpenAI-style messages
|
|
18
|
+
|
|
19
|
+
OpenAI format:
|
|
20
|
+
{
|
|
21
|
+
"role": "user",
|
|
22
|
+
"content": [
|
|
23
|
+
{"type": "text", "text": "What's in this image?"},
|
|
24
|
+
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
28
|
+
processed_messages = []
|
|
29
|
+
clear_stored_texts() # Clear any previous texts
|
|
30
|
+
|
|
31
|
+
for msg_idx, message in enumerate(messages):
|
|
32
|
+
if isinstance(message, dict):
|
|
33
|
+
content = message.get('content')
|
|
34
|
+
if isinstance(content, list):
|
|
35
|
+
# Extract and store text content for multimodal messages
|
|
36
|
+
text_parts = []
|
|
37
|
+
has_images = False
|
|
38
|
+
|
|
39
|
+
for item in content:
|
|
40
|
+
if isinstance(item, dict):
|
|
41
|
+
if item.get('type') == 'text':
|
|
42
|
+
text_parts.append(item.get('text', ''))
|
|
43
|
+
elif item.get('type') == 'image_url':
|
|
44
|
+
has_images = True
|
|
45
|
+
image_data = item.get('image_url', {})
|
|
46
|
+
if isinstance(image_data, dict):
|
|
47
|
+
url = image_data.get('url', '')
|
|
48
|
+
if url.startswith('data:image'):
|
|
49
|
+
# Store the image
|
|
50
|
+
placeholder = store_image(url)
|
|
51
|
+
if DEBUG:
|
|
52
|
+
logger.info(f"[Universal Interceptor] Stored OpenAI image, placeholder: {placeholder}")
|
|
53
|
+
|
|
54
|
+
# If we have both text and images, store the text separately
|
|
55
|
+
if text_parts and has_images:
|
|
56
|
+
combined_text = ' '.join(text_parts)
|
|
57
|
+
store_text(combined_text, msg_idx)
|
|
58
|
+
if DEBUG:
|
|
59
|
+
logger.info(f"[Universal Interceptor] Stored text for multimodal message {msg_idx}: {combined_text[:50]}...")
|
|
60
|
+
|
|
61
|
+
processed_messages.append(message)
|
|
62
|
+
return processed_messages
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def extract_and_store_images_anthropic(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
66
|
+
"""Extract and store images from Anthropic-style messages
|
|
67
|
+
|
|
68
|
+
Anthropic format:
|
|
69
|
+
{
|
|
70
|
+
"role": "user",
|
|
71
|
+
"content": [
|
|
72
|
+
{"type": "text", "text": "What's in this image?"},
|
|
73
|
+
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
"""
|
|
77
|
+
processed_messages = []
|
|
78
|
+
for message in messages:
|
|
79
|
+
if isinstance(message, dict):
|
|
80
|
+
content = message.get('content')
|
|
81
|
+
if isinstance(content, list):
|
|
82
|
+
for item in content:
|
|
83
|
+
if isinstance(item, dict) and item.get('type') == 'image':
|
|
84
|
+
source = item.get('source', {})
|
|
85
|
+
if isinstance(source, dict):
|
|
86
|
+
data = source.get('data', '')
|
|
87
|
+
media_type = source.get('media_type', 'image/jpeg')
|
|
88
|
+
if data:
|
|
89
|
+
# Convert to data URL format for consistency
|
|
90
|
+
data_url = f"data:{media_type};base64,{data}"
|
|
91
|
+
placeholder = store_image(data_url)
|
|
92
|
+
if DEBUG:
|
|
93
|
+
logger.info(f"[Universal Interceptor] Stored Anthropic image, placeholder: {placeholder}")
|
|
94
|
+
processed_messages.append(message)
|
|
95
|
+
return processed_messages
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def extract_and_store_images_google(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
99
|
+
"""Extract and store images from Google/Gemini-style messages
|
|
100
|
+
|
|
101
|
+
Google format can vary but often uses:
|
|
102
|
+
{
|
|
103
|
+
"parts": [
|
|
104
|
+
{"text": "What's in this image?"},
|
|
105
|
+
{"inline_data": {"mime_type": "image/jpeg", "data": "..."}}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
"""
|
|
109
|
+
processed_messages = []
|
|
110
|
+
for message in messages:
|
|
111
|
+
if isinstance(message, dict):
|
|
112
|
+
parts = message.get('parts', [])
|
|
113
|
+
for part in parts:
|
|
114
|
+
if isinstance(part, dict) and 'inline_data' in part:
|
|
115
|
+
inline_data = part['inline_data']
|
|
116
|
+
if isinstance(inline_data, dict):
|
|
117
|
+
data = inline_data.get('data', '')
|
|
118
|
+
mime_type = inline_data.get('mime_type', 'image/jpeg')
|
|
119
|
+
if data:
|
|
120
|
+
# Convert to data URL format
|
|
121
|
+
data_url = f"data:{mime_type};base64,{data}"
|
|
122
|
+
placeholder = store_image(data_url)
|
|
123
|
+
if DEBUG:
|
|
124
|
+
logger.info(f"[Universal Interceptor] Stored Google image, placeholder: {placeholder}")
|
|
125
|
+
processed_messages.append(message)
|
|
126
|
+
return processed_messages
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def intercept_images(messages: Any, provider: str = "auto") -> Any:
|
|
130
|
+
"""Universal image interception for any provider
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
messages: The messages to process
|
|
134
|
+
provider: The provider type ("openai", "anthropic", "google", "auto")
|
|
135
|
+
If "auto", will try to detect the format
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The processed messages (unchanged, but images are stored)
|
|
139
|
+
"""
|
|
140
|
+
if not messages or not isinstance(messages, list):
|
|
141
|
+
return messages
|
|
142
|
+
|
|
143
|
+
# Auto-detect provider format if needed
|
|
144
|
+
if provider == "auto":
|
|
145
|
+
provider = UniversalImageInterceptor._detect_provider_format(messages)
|
|
146
|
+
|
|
147
|
+
# Process based on provider
|
|
148
|
+
if provider == "openai":
|
|
149
|
+
return UniversalImageInterceptor.extract_and_store_images_openai(messages)
|
|
150
|
+
elif provider == "anthropic":
|
|
151
|
+
return UniversalImageInterceptor.extract_and_store_images_anthropic(messages)
|
|
152
|
+
elif provider == "google":
|
|
153
|
+
return UniversalImageInterceptor.extract_and_store_images_google(messages)
|
|
154
|
+
else:
|
|
155
|
+
if DEBUG:
|
|
156
|
+
logger.warning(f"[Universal Interceptor] Unknown provider format: {provider}")
|
|
157
|
+
return messages
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _detect_provider_format(messages: List[Dict[str, Any]]) -> str:
|
|
161
|
+
"""Detect the provider format based on message structure"""
|
|
162
|
+
for message in messages:
|
|
163
|
+
if isinstance(message, dict):
|
|
164
|
+
# Check for OpenAI format
|
|
165
|
+
content = message.get('content')
|
|
166
|
+
if isinstance(content, list):
|
|
167
|
+
for item in content:
|
|
168
|
+
if isinstance(item, dict):
|
|
169
|
+
if item.get('type') == 'image_url' and 'image_url' in item:
|
|
170
|
+
return "openai"
|
|
171
|
+
elif item.get('type') == 'image' and 'source' in item:
|
|
172
|
+
return "anthropic"
|
|
173
|
+
|
|
174
|
+
# Check for Google format
|
|
175
|
+
if 'parts' in message:
|
|
176
|
+
return "google"
|
|
177
|
+
|
|
178
|
+
return "unknown"
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def create_interceptor(provider: str):
|
|
182
|
+
"""Create a function interceptor for a specific provider
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
provider: The provider type ("openai", "anthropic", "google")
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A function that can wrap the provider's API call
|
|
189
|
+
"""
|
|
190
|
+
def interceptor(original_func):
|
|
191
|
+
def wrapper(*args, **kwargs):
|
|
192
|
+
# Extract messages from kwargs (common location)
|
|
193
|
+
messages = kwargs.get('messages', None)
|
|
194
|
+
|
|
195
|
+
# For Anthropic, messages might be in args[1]
|
|
196
|
+
if messages is None and len(args) > 1 and isinstance(args[1], list):
|
|
197
|
+
messages = args[1]
|
|
198
|
+
|
|
199
|
+
# Intercept images if messages found
|
|
200
|
+
if messages:
|
|
201
|
+
UniversalImageInterceptor.intercept_images(messages, provider)
|
|
202
|
+
|
|
203
|
+
# Call the original function
|
|
204
|
+
return original_func(*args, **kwargs)
|
|
205
|
+
|
|
206
|
+
return wrapper
|
|
207
|
+
|
|
208
|
+
return interceptor
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def create_async_interceptor(provider: str):
|
|
212
|
+
"""Create an async function interceptor for a specific provider"""
|
|
213
|
+
def interceptor(original_func):
|
|
214
|
+
async def wrapper(*args, **kwargs):
|
|
215
|
+
# Extract messages from kwargs (common location)
|
|
216
|
+
messages = kwargs.get('messages', None)
|
|
217
|
+
|
|
218
|
+
# For Anthropic, messages might be in args[1]
|
|
219
|
+
if messages is None and len(args) > 1 and isinstance(args[1], list):
|
|
220
|
+
messages = args[1]
|
|
221
|
+
|
|
222
|
+
# Intercept images if messages found
|
|
223
|
+
if messages:
|
|
224
|
+
UniversalImageInterceptor.intercept_images(messages, provider)
|
|
225
|
+
|
|
226
|
+
# Call the original function
|
|
227
|
+
return await original_func(*args, **kwargs)
|
|
228
|
+
|
|
229
|
+
return wrapper
|
|
230
|
+
|
|
231
|
+
return interceptor
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def patch_openai_client(client):
|
|
235
|
+
"""Patch an OpenAI client instance to intercept images"""
|
|
236
|
+
interceptor = UniversalImageInterceptor.create_interceptor("openai")
|
|
237
|
+
|
|
238
|
+
if hasattr(client, 'chat') and hasattr(client.chat, 'completions'):
|
|
239
|
+
original_create = client.chat.completions.create
|
|
240
|
+
client.chat.completions.create = interceptor(original_create)
|
|
241
|
+
if DEBUG:
|
|
242
|
+
logger.info("[Universal Interceptor] Patched OpenAI client for image interception")
|
|
243
|
+
return client
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def patch_anthropic_client(client):
|
|
247
|
+
"""Patch an Anthropic client instance to intercept images"""
|
|
248
|
+
interceptor = UniversalImageInterceptor.create_interceptor("anthropic")
|
|
249
|
+
async_interceptor = UniversalImageInterceptor.create_async_interceptor("anthropic")
|
|
250
|
+
|
|
251
|
+
if hasattr(client, 'messages'):
|
|
252
|
+
if hasattr(client.messages, 'create'):
|
|
253
|
+
original_create = client.messages.create
|
|
254
|
+
client.messages.create = interceptor(original_create)
|
|
255
|
+
|
|
256
|
+
# Handle async version
|
|
257
|
+
if hasattr(client.messages, 'acreate'):
|
|
258
|
+
original_acreate = client.messages.acreate
|
|
259
|
+
client.messages.acreate = async_interceptor(original_acreate)
|
|
260
|
+
|
|
261
|
+
if DEBUG:
|
|
262
|
+
logger.info("[Universal Interceptor] Patched Anthropic client for image interception")
|
|
263
|
+
return client
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def patch_google_client(client):
|
|
267
|
+
"""Patch a Google/Gemini client instance to intercept images"""
|
|
268
|
+
interceptor = UniversalImageInterceptor.create_interceptor("google")
|
|
269
|
+
|
|
270
|
+
# Google's client structure varies, but often uses generate_content
|
|
271
|
+
if hasattr(client, 'generate_content'):
|
|
272
|
+
original_generate = client.generate_content
|
|
273
|
+
client.generate_content = interceptor(original_generate)
|
|
274
|
+
if DEBUG:
|
|
275
|
+
logger.info("[Universal Interceptor] Patched Google client for image interception")
|
|
276
|
+
return client
|
lucidicai/session.py
CHANGED
|
@@ -43,7 +43,8 @@ class Session:
|
|
|
43
43
|
"task": kwargs.get("task", None),
|
|
44
44
|
"mass_sim_id": kwargs.get("mass_sim_id", None),
|
|
45
45
|
"rubrics": kwargs.get("rubrics", None),
|
|
46
|
-
"tags": kwargs.get("tags", None)
|
|
46
|
+
"tags": kwargs.get("tags", None),
|
|
47
|
+
"production_monitoring": kwargs.get("production_monitoring", False)
|
|
47
48
|
}
|
|
48
49
|
data = Client().make_request('initsession', 'POST', request_data)
|
|
49
50
|
self.session_id = data["session_id"]
|
|
@@ -73,11 +74,18 @@ class Session:
|
|
|
73
74
|
"is_finished": kwargs.get("is_finished", None),
|
|
74
75
|
"task": kwargs.get("task", None),
|
|
75
76
|
"is_successful": kwargs.get("is_successful", None),
|
|
76
|
-
"is_successful_reason": kwargs.get("is_successful_reason", None),
|
|
77
|
+
"is_successful_reason": Client().mask(kwargs.get("is_successful_reason", None)),
|
|
77
78
|
"session_eval": kwargs.get("session_eval", None),
|
|
78
|
-
"session_eval_reason": kwargs.get("session_eval_reason", None),
|
|
79
|
+
"session_eval_reason": Client().mask(kwargs.get("session_eval_reason", None)),
|
|
79
80
|
"tags": kwargs.get("tags", None)
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
# auto end any unfinished steps
|
|
84
|
+
if kwargs.get("is_finished", None) is True:
|
|
85
|
+
for step_id, step in self.step_history.items():
|
|
86
|
+
if not step.is_finished:
|
|
87
|
+
self.update_step(step_id=step_id, is_finished=True)
|
|
88
|
+
|
|
81
89
|
Client().make_request('updatesession', 'PUT', request_data)
|
|
82
90
|
|
|
83
91
|
def create_step(self, **kwargs) -> str:
|
|
@@ -101,12 +109,14 @@ class Session:
|
|
|
101
109
|
|
|
102
110
|
def create_event(self, **kwargs):
|
|
103
111
|
# Get step_id from kwargs or active step
|
|
112
|
+
temp_step_created = False
|
|
104
113
|
if 'step_id' in kwargs and kwargs['step_id'] is not None:
|
|
105
114
|
step_id = kwargs['step_id']
|
|
106
115
|
elif self._active_step:
|
|
107
116
|
step_id = self._active_step
|
|
108
117
|
else:
|
|
109
|
-
|
|
118
|
+
step_id = self.create_step()
|
|
119
|
+
temp_step_created = True
|
|
110
120
|
kwargs.pop('step_id', None)
|
|
111
121
|
event = Event(
|
|
112
122
|
session_id=self.session_id,
|
|
@@ -115,6 +125,9 @@ class Session:
|
|
|
115
125
|
)
|
|
116
126
|
self.event_history[event.event_id] = event
|
|
117
127
|
self._active_event = event
|
|
128
|
+
if temp_step_created:
|
|
129
|
+
self.update_step(step_id=step_id, is_finished=True)
|
|
130
|
+
self._active_step = None
|
|
118
131
|
return event.event_id
|
|
119
132
|
|
|
120
133
|
def update_event(self, **kwargs):
|
lucidicai/step.py
CHANGED
|
@@ -48,11 +48,11 @@ class Step:
|
|
|
48
48
|
upload_image_to_s3(presigned_url, screenshot, "JPEG")
|
|
49
49
|
request_data = {
|
|
50
50
|
"step_id": self.step_id,
|
|
51
|
-
"goal": kwargs['goal'] if 'goal' in kwargs else None,
|
|
52
|
-
"action": kwargs['action'] if 'action' in kwargs else None,
|
|
53
|
-
"state": kwargs['state'] if 'state' in kwargs else None,
|
|
51
|
+
"goal": Client().mask(kwargs['goal']) if 'goal' in kwargs else None,
|
|
52
|
+
"action": Client().mask(kwargs['action']) if 'action' in kwargs else None,
|
|
53
|
+
"state": Client().mask(kwargs['state']) if 'state' in kwargs else None,
|
|
54
54
|
"eval_score": kwargs['eval_score'] if 'eval_score' in kwargs else None,
|
|
55
|
-
"eval_description": kwargs['eval_description'] if 'eval_description' in kwargs else None,
|
|
55
|
+
"eval_description": Client().mask(kwargs['eval_description']) if 'eval_description' in kwargs else None,
|
|
56
56
|
"is_finished": kwargs['is_finished'] if 'is_finished' in kwargs else None,
|
|
57
57
|
"has_screenshot": True if screenshot else None
|
|
58
58
|
}
|
lucidicai/streaming.py
CHANGED
|
@@ -14,7 +14,6 @@ class StreamingResponseWrapper:
|
|
|
14
14
|
|
|
15
15
|
def __init__(self, response: Any, session: Any, kwargs: Dict[str, Any]):
|
|
16
16
|
self.response = response
|
|
17
|
-
self.session = session
|
|
18
17
|
self.kwargs = kwargs
|
|
19
18
|
self.chunks = []
|
|
20
19
|
self.start_time = time.time()
|
|
@@ -34,7 +33,7 @@ class StreamingResponseWrapper:
|
|
|
34
33
|
logger.info(f"[Streaming] Using existing event ID: {self.event_id}")
|
|
35
34
|
return
|
|
36
35
|
|
|
37
|
-
if
|
|
36
|
+
if Client().session:
|
|
38
37
|
description, images = self._format_messages(self.kwargs.get('messages', ''))
|
|
39
38
|
|
|
40
39
|
event_data = {
|
|
@@ -54,7 +53,7 @@ class StreamingResponseWrapper:
|
|
|
54
53
|
if images:
|
|
55
54
|
event_data['screenshots'] = images
|
|
56
55
|
|
|
57
|
-
self.event_id =
|
|
56
|
+
self.event_id = Client().session.create_event(**event_data)
|
|
58
57
|
logger.debug(f"[Streaming] Created new streaming event with ID: {self.event_id}")
|
|
59
58
|
except Exception as e:
|
|
60
59
|
logger.error(f"[Streaming] Error creating initial streaming event: {str(e)}")
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
class BaseProvider(ABC):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self._provider_name = None
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def handle_response(self, response, kwargs, session: Optional = None):
|
|
10
|
+
"""Handle responses from the LLM provider"""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def override(self):
|
|
15
|
+
"""Override the provider's API methods"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def undo_override(self):
|
|
20
|
+
"""Restore original API methods"""
|
|
21
|
+
pass
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Custom OpenTelemetry exporter for Lucidic backend compatibility"""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Sequence, Optional, Dict, Any, List
|
|
5
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
6
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
7
|
+
from opentelemetry.trace import StatusCode
|
|
8
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
9
|
+
|
|
10
|
+
from lucidicai.client import Client
|
|
11
|
+
from lucidicai.model_pricing import calculate_cost
|
|
12
|
+
from lucidicai.image_upload import extract_base64_images
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("Lucidic")
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
|
|
18
|
+
VERBOSE = os.getenv("LUCIDIC_VERBOSE", "False") == "True"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LucidicSpanExporter(SpanExporter):
|
|
22
|
+
"""Custom exporter that converts OpenTelemetry spans to Lucidic events"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.pending_events = {} # Track events by span_id
|
|
26
|
+
|
|
27
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
28
|
+
"""Export spans by converting them to Lucidic events"""
|
|
29
|
+
try:
|
|
30
|
+
client = Client()
|
|
31
|
+
if not client.session:
|
|
32
|
+
logger.debug("No active session, skipping span export")
|
|
33
|
+
return SpanExportResult.SUCCESS
|
|
34
|
+
|
|
35
|
+
for span in spans:
|
|
36
|
+
self._process_span(span, client)
|
|
37
|
+
|
|
38
|
+
return SpanExportResult.SUCCESS
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.error(f"Failed to export spans: {e}")
|
|
41
|
+
return SpanExportResult.FAILURE
|
|
42
|
+
|
|
43
|
+
def _process_span(self, span: ReadableSpan, client: Client) -> None:
|
|
44
|
+
"""Process a single span and convert to Lucidic event"""
|
|
45
|
+
try:
|
|
46
|
+
# Skip non-LLM spans
|
|
47
|
+
if not self._is_llm_span(span):
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Extract relevant attributes
|
|
51
|
+
attributes = dict(span.attributes or {})
|
|
52
|
+
|
|
53
|
+
# Create or update event based on span lifecycle
|
|
54
|
+
span_id = format(span.context.span_id, '016x')
|
|
55
|
+
|
|
56
|
+
if span_id not in self.pending_events:
|
|
57
|
+
# New span - create event
|
|
58
|
+
event_id = self._create_event_from_span(span, attributes, client)
|
|
59
|
+
if event_id:
|
|
60
|
+
self.pending_events[span_id] = {
|
|
61
|
+
'event_id': event_id,
|
|
62
|
+
'start_time': span.start_time
|
|
63
|
+
}
|
|
64
|
+
else:
|
|
65
|
+
# Span ended - update event
|
|
66
|
+
event_info = self.pending_events.pop(span_id)
|
|
67
|
+
self._update_event_from_span(span, attributes, event_info['event_id'], client)
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.error(f"Failed to process span {span.name}: {e}")
|
|
71
|
+
|
|
72
|
+
def _is_llm_span(self, span: ReadableSpan) -> bool:
|
|
73
|
+
"""Check if this is an LLM-related span"""
|
|
74
|
+
# Check span name patterns
|
|
75
|
+
llm_patterns = ['openai', 'anthropic', 'chat', 'completion', 'embedding', 'llm']
|
|
76
|
+
span_name_lower = span.name.lower()
|
|
77
|
+
|
|
78
|
+
if any(pattern in span_name_lower for pattern in llm_patterns):
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
# Check for LLM attributes
|
|
82
|
+
if span.attributes:
|
|
83
|
+
for key in span.attributes:
|
|
84
|
+
if key.startswith('gen_ai.') or key.startswith('llm.'):
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def _create_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], client: Client) -> Optional[str]:
|
|
90
|
+
"""Create a Lucidic event from span start"""
|
|
91
|
+
try:
|
|
92
|
+
# Extract description from prompts/messages
|
|
93
|
+
description = self._extract_description(span, attributes)
|
|
94
|
+
|
|
95
|
+
# Extract images if present
|
|
96
|
+
images = self._extract_images(attributes)
|
|
97
|
+
|
|
98
|
+
# Get model info
|
|
99
|
+
model = attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or \
|
|
100
|
+
attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or \
|
|
101
|
+
attributes.get('gen_ai.request.model') or 'unknown'
|
|
102
|
+
|
|
103
|
+
# Create event
|
|
104
|
+
event_kwargs = {
|
|
105
|
+
'description': description,
|
|
106
|
+
'result': "Processing...", # Will be updated when span ends
|
|
107
|
+
'model': model
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if images:
|
|
111
|
+
event_kwargs['screenshots'] = images
|
|
112
|
+
|
|
113
|
+
# Check if we have a specific step_id in span attributes
|
|
114
|
+
step_id = attributes.get('lucidic.step_id')
|
|
115
|
+
if step_id:
|
|
116
|
+
event_kwargs['step_id'] = step_id
|
|
117
|
+
|
|
118
|
+
return client.session.create_event(**event_kwargs)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Failed to create event from span: {e}")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _update_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], event_id: str, client: Client) -> None:
|
|
125
|
+
"""Update a Lucidic event from span end"""
|
|
126
|
+
try:
|
|
127
|
+
# Extract response/result
|
|
128
|
+
result = self._extract_result(span, attributes)
|
|
129
|
+
|
|
130
|
+
# Calculate cost if we have token usage
|
|
131
|
+
cost = self._calculate_cost(attributes)
|
|
132
|
+
|
|
133
|
+
# Determine success
|
|
134
|
+
is_successful = span.status.status_code != StatusCode.ERROR
|
|
135
|
+
|
|
136
|
+
update_kwargs = {
|
|
137
|
+
'event_id': event_id,
|
|
138
|
+
'result': result,
|
|
139
|
+
'is_finished': True,
|
|
140
|
+
'is_successful': is_successful
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if cost is not None:
|
|
144
|
+
update_kwargs['cost_added'] = cost
|
|
145
|
+
|
|
146
|
+
client.session.update_event(**update_kwargs)
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Failed to update event from span: {e}")
|
|
150
|
+
|
|
151
|
+
def _extract_description(self, span: ReadableSpan, attributes: Dict[str, Any]) -> str:
|
|
152
|
+
"""Extract description from span attributes"""
|
|
153
|
+
# Try to get prompts/messages
|
|
154
|
+
prompts = attributes.get(SpanAttributes.LLM_PROMPTS) or \
|
|
155
|
+
attributes.get('gen_ai.prompt')
|
|
156
|
+
|
|
157
|
+
if VERBOSE:
|
|
158
|
+
logger.info(f"[SpaneExporter -- DEBUG] Extracting Description attributes: {attributes}, prompts: {prompts}")
|
|
159
|
+
|
|
160
|
+
if prompts:
|
|
161
|
+
if isinstance(prompts, list) and prompts:
|
|
162
|
+
# Handle message list format
|
|
163
|
+
return self._format_messages(prompts)
|
|
164
|
+
elif isinstance(prompts, str):
|
|
165
|
+
return prompts
|
|
166
|
+
|
|
167
|
+
# Fallback to span name
|
|
168
|
+
return f"LLM Call: {span.name}"
|
|
169
|
+
|
|
170
|
+
def _extract_result(self, span: ReadableSpan, attributes: Dict[str, Any]) -> str:
|
|
171
|
+
"""Extract result/response from span attributes"""
|
|
172
|
+
# Try to get completions
|
|
173
|
+
completions = attributes.get(SpanAttributes.LLM_COMPLETIONS) or \
|
|
174
|
+
attributes.get('gen_ai.completion')
|
|
175
|
+
|
|
176
|
+
if completions:
|
|
177
|
+
if isinstance(completions, list) and completions:
|
|
178
|
+
# Handle multiple completions
|
|
179
|
+
return "\n".join(str(c) for c in completions)
|
|
180
|
+
elif isinstance(completions, str):
|
|
181
|
+
return completions
|
|
182
|
+
|
|
183
|
+
# Check for error
|
|
184
|
+
if span.status.status_code == StatusCode.ERROR:
|
|
185
|
+
return f"Error: {span.status.description or 'Unknown error'}"
|
|
186
|
+
|
|
187
|
+
return "Response received"
|
|
188
|
+
|
|
189
|
+
def _extract_images(self, attributes: Dict[str, Any]) -> List[str]:
|
|
190
|
+
"""Extract base64 images from attributes"""
|
|
191
|
+
images = []
|
|
192
|
+
|
|
193
|
+
# Check prompts for multimodal content
|
|
194
|
+
prompts = attributes.get(SpanAttributes.LLM_PROMPTS) or \
|
|
195
|
+
attributes.get('gen_ai.prompt')
|
|
196
|
+
|
|
197
|
+
if isinstance(prompts, list):
|
|
198
|
+
for prompt in prompts:
|
|
199
|
+
if isinstance(prompt, dict) and 'content' in prompt:
|
|
200
|
+
content = prompt['content']
|
|
201
|
+
if isinstance(content, list):
|
|
202
|
+
for item in content:
|
|
203
|
+
if isinstance(item, dict) and item.get('type') == 'image_url':
|
|
204
|
+
image_url = item.get('image_url', {})
|
|
205
|
+
if isinstance(image_url, dict) and 'url' in image_url:
|
|
206
|
+
url = image_url['url']
|
|
207
|
+
if url.startswith('data:image'):
|
|
208
|
+
images.append(url)
|
|
209
|
+
|
|
210
|
+
return images
|
|
211
|
+
|
|
212
|
+
def _format_messages(self, messages: List[Any]) -> str:
|
|
213
|
+
"""Format message list into description"""
|
|
214
|
+
formatted = []
|
|
215
|
+
|
|
216
|
+
for msg in messages:
|
|
217
|
+
if isinstance(msg, dict):
|
|
218
|
+
role = msg.get('role', 'unknown')
|
|
219
|
+
content = msg.get('content', '')
|
|
220
|
+
|
|
221
|
+
if isinstance(content, str):
|
|
222
|
+
formatted.append(f"{role}: {content}")
|
|
223
|
+
elif isinstance(content, list):
|
|
224
|
+
# Handle multimodal content
|
|
225
|
+
text_parts = []
|
|
226
|
+
for item in content:
|
|
227
|
+
if isinstance(item, dict) and item.get('type') == 'text':
|
|
228
|
+
text_parts.append(item.get('text', ''))
|
|
229
|
+
if text_parts:
|
|
230
|
+
formatted.append(f"{role}: {' '.join(text_parts)}")
|
|
231
|
+
|
|
232
|
+
return '\n'.join(formatted) if formatted else "Model request"
|
|
233
|
+
|
|
234
|
+
def _calculate_cost(self, attributes: Dict[str, Any]) -> Optional[float]:
|
|
235
|
+
"""Calculate cost from token usage"""
|
|
236
|
+
prompt_tokens = attributes.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS) or \
|
|
237
|
+
attributes.get('gen_ai.usage.prompt_tokens') or 0
|
|
238
|
+
completion_tokens = attributes.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS) or \
|
|
239
|
+
attributes.get('gen_ai.usage.completion_tokens') or 0
|
|
240
|
+
|
|
241
|
+
if prompt_tokens or completion_tokens:
|
|
242
|
+
model = attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or \
|
|
243
|
+
attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or \
|
|
244
|
+
attributes.get('gen_ai.request.model')
|
|
245
|
+
|
|
246
|
+
if model:
|
|
247
|
+
return calculate_cost(prompt_tokens, completion_tokens, model)
|
|
248
|
+
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def shutdown(self) -> None:
|
|
252
|
+
"""Shutdown the exporter"""
|
|
253
|
+
# Process any remaining pending events
|
|
254
|
+
if self.pending_events:
|
|
255
|
+
logger.warning(f"Shutting down with {len(self.pending_events)} pending events")
|
|
256
|
+
|
|
257
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
258
|
+
"""Force flush any pending spans"""
|
|
259
|
+
return True
|