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
@@ -0,0 +1,665 @@
1
+ """Custom span processor for real-time Lucidic event handling"""
2
+ import os
3
+ import logging
4
+ import json
5
+ from typing import Optional, Dict, Any
6
+ from opentelemetry import context as otel_context
7
+ from opentelemetry.sdk.trace import Span, SpanProcessor
8
+ from opentelemetry.trace import StatusCode
9
+ from opentelemetry.semconv_ai import SpanAttributes
10
+
11
+ from lucidicai.client import Client
12
+ from lucidicai.model_pricing import calculate_cost
13
+ from .utils.image_storage import get_stored_images, clear_stored_images
14
+ from .utils.text_storage import get_stored_text, clear_stored_texts
15
+
16
+ logger = logging.getLogger("Lucidic")
17
+ DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
18
+ VERBOSE = os.getenv("LUCIDIC_VERBOSE", "False") == "True"
19
+
20
+
21
+ class LucidicSpanProcessor(SpanProcessor):
22
+ """
23
+ Real-time span processor that creates Lucidic events as spans start
24
+ and updates them as spans end, maintaining the current SDK behavior
25
+ """
26
+
27
+ def __init__(self):
28
+ self.span_to_event = {} # Map span_id to event_id
29
+ self.span_contexts = {} # Store span start data
30
+
31
+ def on_start(self, span: Span, parent_context: Optional[otel_context.Context] = None) -> None:
32
+ """Called when a span is started - create Lucidic event immediately"""
33
+ try:
34
+ if DEBUG:
35
+ logger.info(f"[SpanProcessor] on_start called for span: {span.name}")
36
+ # logger.info(f"[SpanProcessor] Span attributes at start: {dict(span.attributes or {})}")
37
+
38
+ client = Client()
39
+ if not client.session:
40
+ logger.debug("No active session, skipping span tracking")
41
+ return
42
+
43
+ # Only process LLM spans
44
+ if not self._is_llm_span(span):
45
+ if DEBUG:
46
+ logger.info(f"[SpanProcessor] Skipping non-LLM span: {span.name}")
47
+ return
48
+
49
+ # Store span info for processing - we'll create the event on_end when all attributes are available
50
+ span_id = span.get_span_context().span_id
51
+ self.span_contexts[span_id] = {
52
+ 'start_time': span.start_time,
53
+ 'name': span.name,
54
+ 'attributes': dict(span.attributes or {}),
55
+ 'span': span
56
+ }
57
+
58
+ if DEBUG:
59
+ logger.info(f"[SpanProcessor] Stored span {span_id} for later processing")
60
+
61
+ except Exception as e:
62
+ logger.error(f"Error in on_start: {e}")
63
+ if DEBUG:
64
+ import traceback
65
+ traceback.print_exc()
66
+
67
+ def on_end(self, span: Span) -> None:
68
+ """Called when a span ends - create and complete the Lucidic event"""
69
+ try:
70
+ span_id = span.get_span_context().span_id
71
+
72
+ if DEBUG:
73
+ logger.info(f"[SpanProcessor] on_end called for span: {span.name}")
74
+ # logger.info(f"[SpanProcessor] Span attributes at end: {dict(span.attributes or {})}")
75
+ # logger.info(f"[SpanProcessor] Tracked span contexts: {list(self.span_contexts.keys())}")
76
+ """
77
+ # Log any attributes that might contain message data
78
+ attrs = dict(span.attributes or {})
79
+ for key, value in attrs.items():
80
+ if 'message' in key.lower() or 'prompt' in key.lower() or 'content' in key.lower():
81
+ logger.info(f"[SpanProcessor] Found potential message attr: {key} = {value[:200] if isinstance(value, str) else value}")
82
+ """
83
+
84
+ # Check if we have context for this span
85
+ if span_id not in self.span_contexts:
86
+ if DEBUG:
87
+ logger.warning(f"[SpanProcessor] No context found for span {span_id}")
88
+ return
89
+
90
+ client = Client()
91
+ if not client.session:
92
+ return
93
+
94
+ span_context = self.span_contexts.pop(span_id, {})
95
+
96
+ # Create event with all the attributes now available
97
+ event_id = self._create_event_from_span_end(span, client)
98
+
99
+ if DEBUG:
100
+ logger.info(f"[SpanProcessor] Created and completed event {event_id} for span {span_id}")
101
+
102
+ # Clear thread-local images and texts after processing
103
+ clear_stored_images()
104
+ clear_stored_texts()
105
+
106
+ except Exception as e:
107
+ logger.error(f"Error in on_end: {e}")
108
+ if DEBUG:
109
+ import traceback
110
+ traceback.print_exc()
111
+
112
+ def _is_llm_span(self, span: Span) -> bool:
113
+ """Check if this is an LLM-related span with actual LLM content"""
114
+ # Check if it's an agent span without LLM content
115
+ if span.attributes:
116
+ attrs = dict(span.attributes)
117
+
118
+ # Skip agent spans that don't have prompt/completion attributes
119
+ if attrs.get('gen_ai.operation.name') == 'agent':
120
+ # Check if it has actual LLM content
121
+ has_prompts = any(k for k in attrs.keys() if 'prompt' in k.lower())
122
+ has_completions = any(k for k in attrs.keys() if 'completion' in k.lower())
123
+ if not has_prompts and not has_completions:
124
+ if DEBUG:
125
+ logger.info(f"[SpanProcessor] Skipping agent span without LLM content: {span.name}")
126
+ return False
127
+
128
+ # Check span name
129
+ span_name_lower = span.name.lower()
130
+ llm_patterns = ['openai', 'anthropic', 'chat', 'completion', 'embedding', 'gemini', 'claude']
131
+
132
+ if any(pattern in span_name_lower for pattern in llm_patterns):
133
+ return True
134
+
135
+ # Check attributes
136
+ if span.attributes:
137
+ for key in span.attributes:
138
+ if isinstance(key, str) and (key.startswith('gen_ai.') or key.startswith('llm.')):
139
+ return True
140
+
141
+ return False
142
+
143
+ def _create_event_from_span_start(self, span: Span, client: Client) -> Optional[str]:
144
+ """Create event when span starts"""
145
+ try:
146
+ attributes = dict(span.attributes or {})
147
+
148
+ # Extract description
149
+ if DEBUG:
150
+ logger.info(f"[SpanProcessor -- DEBUG] Extracting Description from span start: {span}")
151
+ description = self._extract_description(span, attributes)
152
+
153
+ # Extract images
154
+ images = self._extract_images(attributes)
155
+
156
+ # Get model
157
+ model = (
158
+ attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or
159
+ attributes.get('gen_ai.request.model') or
160
+ attributes.get('llm.model') or
161
+ 'unknown'
162
+ )
163
+
164
+ # Initial result based on whether it's streaming
165
+ is_streaming = attributes.get(SpanAttributes.LLM_IS_STREAMING, False) or \
166
+ attributes.get('llm.is_streaming', False)
167
+ initial_result = None if is_streaming else "Waiting for response..."
168
+
169
+ # Apply masking to description if configured
170
+ if client.masking_function:
171
+ description = client.mask(description)
172
+
173
+ # Create event - session.create_event will handle temporary step creation if needed
174
+ event_kwargs = {
175
+ 'description': description,
176
+ 'result': initial_result,
177
+ 'model': model
178
+ }
179
+
180
+ if DEBUG:
181
+ logger.info(f"[SpanProcessor -- DEBUG] event_kwargs: {event_kwargs}")
182
+
183
+ if images:
184
+ event_kwargs['screenshots'] = images
185
+
186
+ # Check for step context
187
+ step_id = attributes.get('lucidic.step_id')
188
+ if step_id:
189
+ event_kwargs['step_id'] = step_id
190
+
191
+ return client.session.create_event(**event_kwargs)
192
+
193
+ except Exception as e:
194
+ logger.error(f"Failed to create event: {e}")
195
+ return None
196
+
197
+ def _create_event_from_span_end(self, span: Span, client: Client) -> Optional[str]:
198
+ """Create and complete event when span ends with all attributes available"""
199
+ try:
200
+ attributes = dict(span.attributes or {})
201
+
202
+ if DEBUG:
203
+ logger.info(f"[SpanProcessor] Creating event from span end with {len(attributes)} attributes")
204
+
205
+ # Extract all information
206
+ if VERBOSE:
207
+ logger.info(f"[SpanProcessor -- DEBUG] Extracting Description attributes: {attributes}")
208
+ description = self._extract_description(span, attributes)
209
+
210
+ if VERBOSE:
211
+ logger.info(f"[SpanProcessor -- DEBUG] Extracting Result attributes: {attributes}")
212
+ raw_result = self._extract_result(span, attributes)
213
+
214
+ if VERBOSE:
215
+ logger.info(f"[SpanProcessor -- DEBUG] Extracting Images: span: attributes: {attributes}")
216
+ images = self._extract_images(attributes)
217
+
218
+ if VERBOSE:
219
+ logger.info(f"[SpanProcessor -- DEBUG] Extracting Model: span: {span} attributes: {attributes}")
220
+ model = (
221
+ attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or
222
+ attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or
223
+ attributes.get('gen_ai.response.model') or
224
+ attributes.get('gen_ai.request.model') or
225
+ 'unknown'
226
+ )
227
+
228
+ # Format result as Input/Output
229
+ # The description contains the input (prompts), raw_result contains the output (completions)
230
+ formatted_result = f"{raw_result}"
231
+
232
+ if VERBOSE:
233
+ logger.info(f"[SpanProcessor -- DEBUG] description: {description}, result: {formatted_result}, model: {model}, images: {images}")
234
+
235
+ # Apply masking
236
+ if client.masking_function:
237
+ formatted_result = client.mask(formatted_result)
238
+
239
+ # Calculate cost
240
+ cost = self._calculate_cost(attributes)
241
+
242
+ # Check success
243
+ is_successful = span.status.status_code != StatusCode.ERROR
244
+
245
+ # Create event with all data
246
+ event_kwargs = {
247
+ 'description': description,
248
+ 'result': formatted_result,
249
+ 'model': model,
250
+ 'is_finished': True
251
+ }
252
+
253
+ if images:
254
+ event_kwargs['screenshots'] = images
255
+
256
+ if cost is not None:
257
+ event_kwargs['cost_added'] = cost
258
+
259
+ # Check for step context
260
+ step_id = attributes.get('lucidic.step_id')
261
+ if step_id:
262
+ event_kwargs['step_id'] = step_id
263
+
264
+ # Create the event (already completed)
265
+ event_id = client.session.create_event(**event_kwargs)
266
+
267
+ return event_id
268
+
269
+ except Exception as e:
270
+ logger.error(f"Failed to create event from span end: {e}")
271
+ if DEBUG:
272
+ import traceback
273
+ traceback.print_exc()
274
+ return None
275
+
276
+ def _update_event_from_span_end(self, span: Span, event_id: str, client: Client) -> None:
277
+ """Update event when span ends"""
278
+ try:
279
+ attributes = dict(span.attributes or {})
280
+
281
+ # Extract response
282
+ result = self._extract_result(span, attributes)
283
+
284
+ # Apply masking to result if configured
285
+ if client.masking_function:
286
+ result = client.mask(result)
287
+
288
+ # Calculate cost
289
+ cost = self._calculate_cost(attributes)
290
+
291
+ # Check success
292
+ is_successful = span.status.status_code != StatusCode.ERROR
293
+
294
+ # Update event
295
+ update_kwargs = {
296
+ 'event_id': event_id,
297
+ 'result': result,
298
+ 'is_finished': True
299
+ }
300
+
301
+ if cost is not None:
302
+ update_kwargs['cost_added'] = cost
303
+
304
+ # Update model if we got a response model
305
+ response_model = attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or \
306
+ attributes.get('gen_ai.response.model')
307
+ if response_model:
308
+ update_kwargs['model'] = response_model
309
+
310
+ if DEBUG:
311
+ logger.info(f"[SpanProcessor -- DEBUG] update_kwargs: {update_kwargs}")
312
+
313
+ client.session.update_event(**update_kwargs)
314
+
315
+ except Exception as e:
316
+ logger.error(f"Failed to update event: {e}")
317
+
318
+ def _extract_description(self, span: Span, attributes: Dict[str, Any]) -> str:
319
+ """Extract description from span"""
320
+ if VERBOSE:
321
+ logger.info(f"[SpanProcessor] Extracting description from attributes: {list(attributes.keys())}")
322
+
323
+ # Try to reconstruct messages from indexed attributes (OpenLLMetry format)
324
+ messages = self._extract_indexed_messages(attributes)
325
+ if messages:
326
+ if VERBOSE:
327
+ logger.info(f"[SpanProcessor] Reconstructed {len(messages)} messages from indexed attributes")
328
+ return self._format_messages(messages)
329
+
330
+ # Try prompts first (other formats)
331
+ prompts = attributes.get(SpanAttributes.LLM_PROMPTS) or \
332
+ attributes.get('gen_ai.prompt') or \
333
+ attributes.get('llm.prompts')
334
+
335
+ if prompts:
336
+ if DEBUG:
337
+ logger.info(f"[SpanProcessor] Found prompts: {prompts}")
338
+ return self._format_prompts(prompts)
339
+
340
+ # Try messages
341
+ messages = attributes.get('gen_ai.messages') or \
342
+ attributes.get('llm.messages')
343
+
344
+ if messages:
345
+ if DEBUG:
346
+ logger.info(f"[SpanProcessor] Found messages: {messages}")
347
+ return self._format_messages(messages)
348
+
349
+
350
+ # check for openai agents tool call
351
+ tool_name = attributes.get('gen_ai.tool.name')
352
+ if tool_name:
353
+ if DEBUG:
354
+ logger.info(f"[SpanProcessor] Found openai agents tool call: {tool_name}")
355
+ return f"Agent Tool Call: {tool_name}"
356
+
357
+ # Fallback
358
+ if DEBUG:
359
+ # logger.info(f"[SpanProcessor] span attributes: {attributes}")
360
+ logger.warning(f"[SpanProcessor] No prompts/messages found, using fallback")
361
+ return f"LLM Request: {span.name}"
362
+
363
+ def _extract_indexed_messages(self, attributes: Dict[str, Any]) -> list:
364
+ """Extract messages from indexed attributes (gen_ai.prompt.0.role, gen_ai.prompt.0.content, etc.)"""
365
+ messages = []
366
+ i = 0
367
+
368
+ # Keep extracting messages until we don't find any more
369
+ while True:
370
+ prefix = f"gen_ai.prompt.{i}"
371
+ role = attributes.get(f"{prefix}.role")
372
+
373
+ if not role:
374
+ break
375
+
376
+ message = {"role": role}
377
+
378
+ # Get content
379
+ content = attributes.get(f"{prefix}.content")
380
+ if content:
381
+ # Try to parse JSON content (for multimodal)
382
+ try:
383
+ import json
384
+ parsed_content = json.loads(content)
385
+ message["content"] = parsed_content
386
+ except:
387
+ message["content"] = content
388
+ else:
389
+ # Content might be missing for multimodal messages due to size limits
390
+ # Check if we have stored text and/or images in thread-local storage
391
+ stored_text = get_stored_text(i)
392
+ stored_images = get_stored_images()
393
+
394
+ if stored_text or stored_images:
395
+ if DEBUG:
396
+ logger.info(f"[SpanProcessor] No content for message {i}, but found stored text/images")
397
+
398
+ # Create synthetic content with both text and images
399
+ synthetic_content = []
400
+
401
+ # Add text if available
402
+ if stored_text:
403
+ synthetic_content.append({
404
+ "type": "text",
405
+ "text": stored_text
406
+ })
407
+
408
+ # Add images if available
409
+ if stored_images and i == 0: # Assume first message might have images
410
+ for idx, img in enumerate(stored_images):
411
+ synthetic_content.append({
412
+ "type": "image_url",
413
+ "image_url": {"url": img}
414
+ })
415
+
416
+ if synthetic_content:
417
+ message["content"] = synthetic_content
418
+
419
+ messages.append(message)
420
+ i += 1
421
+
422
+ return messages
423
+
424
+ def _extract_indexed_completions(self, attributes: Dict[str, Any]) -> list:
425
+ """Extract completions from indexed attributes"""
426
+ completions = []
427
+ i = 0
428
+
429
+ while True:
430
+ prefix = f"gen_ai.completion.{i}"
431
+ role = attributes.get(f"{prefix}.role")
432
+ content = attributes.get(f"{prefix}.content")
433
+
434
+ if not role and not content:
435
+ break
436
+
437
+ completion = {}
438
+ if role:
439
+ completion["role"] = role
440
+ if content:
441
+ completion["content"] = content
442
+
443
+ if completion:
444
+ completions.append(completion)
445
+
446
+ i += 1
447
+
448
+ return completions
449
+
450
+ def _extract_result(self, span: Span, attributes: Dict[str, Any]) -> str:
451
+ """Extract result from span"""
452
+ if VERBOSE:
453
+ logger.info(f"[SpanProcessor -- _extract_result -- DEBUG] Extracting result from attributes: {attributes}")
454
+
455
+ # Try indexed completions first (OpenLLMetry format)
456
+ completions = self._extract_indexed_completions(attributes)
457
+ if completions:
458
+ if VERBOSE:
459
+ logger.info(f"[SpanProcessor] Found {len(completions)} indexed completions")
460
+ # Format completions
461
+ results = []
462
+ for comp in completions:
463
+ if "content" in comp:
464
+ results.append(str(comp["content"]))
465
+ if results:
466
+ return "\n".join(results)
467
+
468
+ # Try completions
469
+ completions = attributes.get(SpanAttributes.LLM_COMPLETIONS) or \
470
+ attributes.get('gen_ai.completion') or \
471
+ attributes.get('llm.completions')
472
+
473
+ if completions:
474
+ if isinstance(completions, list):
475
+ return "\n".join(str(c) for c in completions)
476
+ else:
477
+ return str(completions)
478
+
479
+ # Check for error
480
+ if span.status.status_code == StatusCode.ERROR:
481
+ return f"Error: {span.status.description or 'Unknown error'}"
482
+
483
+ # Check streaming
484
+ if attributes.get(SpanAttributes.LLM_IS_STREAMING):
485
+ content = attributes.get('llm.response.content') or \
486
+ attributes.get('gen_ai.response.content')
487
+ if content:
488
+ return content
489
+
490
+ if attributes.get('gen_ai.system') and attributes.get('gen_ai.system') == 'openai_agents':
491
+ if DEBUG:
492
+ logger.info(f"[SpanProcessor -- Agent Tool Call Response Received]") # span attributes: {attributes}")
493
+
494
+ return "Agent Handoff"
495
+
496
+ return "Response received"
497
+
498
+ def _extract_images(self, attributes: Dict[str, Any]) -> list:
499
+ """Extract images from multimodal prompts"""
500
+ images = []
501
+
502
+ if VERBOSE:
503
+ logger.info(f"[SpanProcessor -- _extract_images -- DEBUG] Extracting images from attributes: {attributes}")
504
+
505
+ # First check indexed messages (OpenLLMetry format)
506
+ messages = self._extract_indexed_messages(attributes)
507
+ for msg in messages:
508
+ if isinstance(msg, dict):
509
+ images.extend(self._extract_images_from_message(msg))
510
+
511
+ # Check for multimodal content in prompts
512
+ prompts = attributes.get(SpanAttributes.LLM_PROMPTS) or \
513
+ attributes.get('gen_ai.prompt')
514
+
515
+ if isinstance(prompts, list):
516
+ for prompt in prompts:
517
+ if isinstance(prompt, dict):
518
+ images.extend(self._extract_images_from_message(prompt))
519
+
520
+ # Check messages too
521
+ messages = attributes.get('gen_ai.messages') or \
522
+ attributes.get('llm.messages')
523
+
524
+ if isinstance(messages, list):
525
+ for msg in messages:
526
+ if isinstance(msg, dict):
527
+ images.extend(self._extract_images_from_message(msg))
528
+
529
+ # If no images found but we have stored images in thread-local, retrieve them
530
+ stored_images = get_stored_images()
531
+ if not images and stored_images:
532
+ if DEBUG:
533
+ logger.info(f"[SpanProcessor] No images found in attributes, checking thread-local storage: {len(stored_images)} images")
534
+ for img in stored_images:
535
+ if img and not img.startswith('data:'):
536
+ images.append(f"data:image/jpeg;base64,{img}")
537
+ else:
538
+ images.append(img)
539
+
540
+ if DEBUG and images:
541
+ logger.info(f"[SpanProcessor] Extracted {len(images)} images")
542
+
543
+ return images
544
+
545
+ def _extract_images_from_message(self, message: dict) -> list:
546
+ """Extract images from a single message"""
547
+ images = []
548
+ content = message.get('content', '')
549
+
550
+ if VERBOSE:
551
+ logger.info(f"[SpanProcessor -- _extract_images_from_message -- DEBUG] Extracting images from message: {message}, content: {content}")
552
+
553
+ # Handle case where content might be a JSON string
554
+ if isinstance(content, str) and content.strip().startswith('['):
555
+ try:
556
+ parsed_content = json.loads(content)
557
+ if isinstance(parsed_content, list):
558
+ content = parsed_content
559
+ except json.JSONDecodeError:
560
+ # If parsing fails, keep content as string
561
+ pass
562
+
563
+ if isinstance(content, list):
564
+ for item in content:
565
+ if isinstance(item, dict) and item.get('type') == 'image_url':
566
+ image_url = item.get('image_url', {})
567
+ if isinstance(image_url, dict):
568
+ url = image_url.get('url', '')
569
+ if url.startswith('data:image'):
570
+ images.append(url)
571
+ elif url.startswith('lucidic_image_'):
572
+ # This is a placeholder - retrieve from thread-local storage
573
+ image = self._retrieve_image_from_placeholder(url)
574
+ if image:
575
+ images.append(image)
576
+
577
+ return images
578
+
579
+ def _retrieve_image_from_placeholder(self, placeholder: str) -> Optional[str]:
580
+ """Retrieve image from thread-local storage using placeholder"""
581
+ try:
582
+ base64_data = get_image_by_placeholder(placeholder)
583
+ if base64_data:
584
+ # Ensure it has proper data URI format
585
+ if not base64_data.startswith('data:'):
586
+ # Add data URI prefix if missing
587
+ base64_data = f"data:image/jpeg;base64,{base64_data}"
588
+ return base64_data
589
+ except Exception as e:
590
+ if DEBUG:
591
+ logger.error(f"[SpanProcessor] Failed to retrieve image from placeholder: {e}")
592
+ return None
593
+
594
+ def _format_prompts(self, prompts: Any) -> str:
595
+ """Format prompts into description"""
596
+ if isinstance(prompts, str):
597
+ return prompts
598
+ elif isinstance(prompts, list):
599
+ return self._format_messages(prompts)
600
+ else:
601
+ return "Model request"
602
+
603
+ def _format_messages(self, messages: list) -> str:
604
+ """Format message list"""
605
+ formatted = []
606
+
607
+ for msg in messages:
608
+ if isinstance(msg, dict):
609
+ role = msg.get('role', 'unknown')
610
+ content = msg.get('content', '')
611
+
612
+ if isinstance(content, str):
613
+ formatted.append(f"{role}: {content}")
614
+ elif isinstance(content, list):
615
+ # Extract text from multimodal
616
+ texts = []
617
+ for item in content:
618
+ if isinstance(item, dict) and item.get('type') == 'text':
619
+ texts.append(item.get('text', ''))
620
+ if texts:
621
+ formatted.append(f"{role}: {' '.join(texts)}")
622
+ elif isinstance(msg, str):
623
+ formatted.append(msg)
624
+
625
+ return '\n'.join(formatted) if formatted else "Model request"
626
+
627
+ def _calculate_cost(self, attributes: Dict[str, Any]) -> Optional[float]:
628
+ """Calculate cost from token usage"""
629
+ prompt_tokens = (
630
+ attributes.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS) or
631
+ attributes.get('gen_ai.usage.prompt_tokens') or
632
+ attributes.get('gen_ai.usage.input_tokens') or
633
+ 0
634
+ )
635
+
636
+ completion_tokens = (
637
+ attributes.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS) or
638
+ attributes.get('gen_ai.usage.completion_tokens') or
639
+ attributes.get('gen_ai.usage.output_tokens') or
640
+ 0
641
+ )
642
+
643
+ total_tokens = prompt_tokens + completion_tokens
644
+
645
+ if total_tokens > 0:
646
+ model = (
647
+ attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or
648
+ attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or
649
+ attributes.get('gen_ai.response.model') or
650
+ attributes.get('gen_ai.request.model')
651
+ )
652
+
653
+ if model:
654
+ return calculate_cost(model, {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": total_tokens})
655
+
656
+ return None
657
+
658
+ def shutdown(self, timeout_millis: int = 30000) -> None:
659
+ """Shutdown processor"""
660
+ if self.span_to_event:
661
+ logger.warning(f"Shutting down with {len(self.span_to_event)} incomplete spans")
662
+
663
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
664
+ """Force flush - no-op for this processor"""
665
+ return True