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.
Files changed (40) hide show
  1. lucidicai/__init__.py +111 -21
  2. lucidicai/client.py +22 -5
  3. lucidicai/decorators.py +357 -0
  4. lucidicai/event.py +2 -2
  5. lucidicai/image_upload.py +24 -1
  6. lucidicai/providers/anthropic_handler.py +0 -7
  7. lucidicai/providers/image_storage.py +45 -0
  8. lucidicai/providers/langchain.py +0 -78
  9. lucidicai/providers/lucidic_exporter.py +259 -0
  10. lucidicai/providers/lucidic_span_processor.py +648 -0
  11. lucidicai/providers/openai_agents_instrumentor.py +307 -0
  12. lucidicai/providers/openai_handler.py +1 -56
  13. lucidicai/providers/otel_handlers.py +266 -0
  14. lucidicai/providers/otel_init.py +197 -0
  15. lucidicai/providers/otel_provider.py +168 -0
  16. lucidicai/providers/pydantic_ai_handler.py +2 -19
  17. lucidicai/providers/text_storage.py +53 -0
  18. lucidicai/providers/universal_image_interceptor.py +276 -0
  19. lucidicai/session.py +17 -4
  20. lucidicai/step.py +4 -4
  21. lucidicai/streaming.py +2 -3
  22. lucidicai/telemetry/__init__.py +0 -0
  23. lucidicai/telemetry/base_provider.py +21 -0
  24. lucidicai/telemetry/lucidic_exporter.py +259 -0
  25. lucidicai/telemetry/lucidic_span_processor.py +665 -0
  26. lucidicai/telemetry/openai_agents_instrumentor.py +306 -0
  27. lucidicai/telemetry/opentelemetry_converter.py +436 -0
  28. lucidicai/telemetry/otel_handlers.py +266 -0
  29. lucidicai/telemetry/otel_init.py +197 -0
  30. lucidicai/telemetry/otel_provider.py +168 -0
  31. lucidicai/telemetry/pydantic_ai_handler.py +600 -0
  32. lucidicai/telemetry/utils/__init__.py +0 -0
  33. lucidicai/telemetry/utils/image_storage.py +45 -0
  34. lucidicai/telemetry/utils/text_storage.py +53 -0
  35. lucidicai/telemetry/utils/universal_image_interceptor.py +276 -0
  36. {lucidicai-1.2.15.dist-info → lucidicai-1.2.17.dist-info}/METADATA +1 -1
  37. lucidicai-1.2.17.dist-info/RECORD +49 -0
  38. lucidicai-1.2.15.dist-info/RECORD +0 -25
  39. {lucidicai-1.2.15.dist-info → lucidicai-1.2.17.dist-info}/WHEEL +0 -0
  40. {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
- raise InvalidOperationError("No active step to create event in and no step_id provided")
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 self.session and hasattr(self.session, 'active_step') and self.session.active_step:
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 = self.session.create_event(**event_data)
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