lucidicai 2.1.3__py3-none-any.whl → 3.0.0__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 (38) hide show
  1. lucidicai/__init__.py +32 -390
  2. lucidicai/api/client.py +31 -2
  3. lucidicai/api/resources/__init__.py +16 -1
  4. lucidicai/api/resources/dataset.py +422 -82
  5. lucidicai/api/resources/event.py +399 -27
  6. lucidicai/api/resources/experiment.py +108 -0
  7. lucidicai/api/resources/feature_flag.py +78 -0
  8. lucidicai/api/resources/prompt.py +84 -0
  9. lucidicai/api/resources/session.py +545 -38
  10. lucidicai/client.py +395 -480
  11. lucidicai/core/config.py +73 -48
  12. lucidicai/core/errors.py +3 -3
  13. lucidicai/sdk/bound_decorators.py +321 -0
  14. lucidicai/sdk/context.py +20 -2
  15. lucidicai/sdk/decorators.py +283 -74
  16. lucidicai/sdk/event.py +538 -36
  17. lucidicai/sdk/event_builder.py +2 -4
  18. lucidicai/sdk/features/dataset.py +391 -1
  19. lucidicai/sdk/features/feature_flag.py +344 -3
  20. lucidicai/sdk/init.py +49 -347
  21. lucidicai/sdk/session.py +502 -0
  22. lucidicai/sdk/shutdown_manager.py +103 -46
  23. lucidicai/session_obj.py +321 -0
  24. lucidicai/telemetry/context_capture_processor.py +13 -6
  25. lucidicai/telemetry/extract.py +60 -63
  26. lucidicai/telemetry/litellm_bridge.py +3 -44
  27. lucidicai/telemetry/lucidic_exporter.py +143 -131
  28. lucidicai/telemetry/openai_agents_instrumentor.py +2 -2
  29. lucidicai/telemetry/openai_patch.py +7 -6
  30. lucidicai/telemetry/telemetry_manager.py +183 -0
  31. lucidicai/telemetry/utils/model_pricing.py +21 -30
  32. lucidicai/telemetry/utils/provider.py +77 -0
  33. lucidicai/utils/images.py +27 -11
  34. lucidicai/utils/serialization.py +27 -0
  35. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/METADATA +1 -1
  36. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/RECORD +38 -29
  37. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/WHEEL +0 -0
  38. {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/top_level.txt +0 -0
@@ -1,24 +1,297 @@
1
1
  """Event resource API operations."""
2
+ import logging
3
+ import threading
4
+ import uuid
5
+ from datetime import datetime, timezone
2
6
  from typing import Any, Dict, Optional
3
- from datetime import datetime
4
7
 
5
8
  from ..client import HttpClient
6
9
 
10
+ logger = logging.getLogger("Lucidic")
11
+
12
+
13
+ def _truncate_id(id_str: Optional[str]) -> str:
14
+ """Truncate ID for logging."""
15
+ if not id_str:
16
+ return "None"
17
+ return f"{id_str[:8]}..." if len(id_str) > 8 else id_str
18
+
7
19
 
8
20
  class EventResource:
9
21
  """Handle event-related API operations."""
10
-
11
- def __init__(self, http: HttpClient):
22
+
23
+ def __init__(self, http: HttpClient, production: bool = False):
12
24
  """Initialize event resource.
13
-
25
+
14
26
  Args:
15
27
  http: HTTP client instance
28
+ production: Whether to suppress errors in production mode
16
29
  """
17
30
  self.http = http
18
-
19
- def create_event(self, params: Dict[str, Any]) -> Dict[str, Any]:
31
+ self._production = production
32
+
33
+ # ==================== High-Level Event Methods ====================
34
+
35
+ def create(
36
+ self,
37
+ type: str = "generic",
38
+ event_id: Optional[str] = None,
39
+ session_id: Optional[str] = None,
40
+ **kwargs,
41
+ ) -> str:
20
42
  """Create a new event.
21
-
43
+
44
+ Args:
45
+ type: Event type (e.g., "llm_generation", "function_call", "error_traceback", "generic")
46
+ event_id: Optional client event ID (auto-generated if not provided)
47
+ session_id: Optional session ID (uses current context if not provided)
48
+ **kwargs: Event-specific fields
49
+
50
+ Returns:
51
+ Event ID (client-generated or provided UUID)
52
+
53
+ Example:
54
+ event_id = client.events.create(
55
+ type="custom_event",
56
+ data={"key": "value"}
57
+ )
58
+ """
59
+ from ...sdk.context import current_session_id, current_parent_event_id
60
+ from ...sdk.event_builder import EventBuilder
61
+
62
+ # Generate event ID if not provided
63
+ client_event_id = event_id or str(uuid.uuid4())
64
+
65
+ # Get session from context if not provided
66
+ if not session_id:
67
+ session_id = current_session_id.get(None)
68
+
69
+ if not session_id:
70
+ logger.debug("[EventResource] No active session for create()")
71
+ return client_event_id
72
+
73
+ # Get parent event ID from context
74
+ parent_event_id = None
75
+ try:
76
+ parent_event_id = current_parent_event_id.get(None)
77
+ except Exception:
78
+ pass
79
+
80
+ # Build event request
81
+ params = {
82
+ "type": type,
83
+ "event_id": client_event_id,
84
+ "parent_event_id": parent_event_id,
85
+ "session_id": session_id,
86
+ "occurred_at": kwargs.pop("occurred_at", None)
87
+ or datetime.now(timezone.utc).isoformat(),
88
+ **kwargs,
89
+ }
90
+
91
+ try:
92
+ event_request = EventBuilder.build(params)
93
+ self.create_event(event_request)
94
+ logger.debug(f"[EventResource] Created event {client_event_id[:8]}...")
95
+ except Exception as e:
96
+ if self._production:
97
+ logger.error(f"[EventResource] Failed to create event: {e}")
98
+ else:
99
+ raise
100
+
101
+ return client_event_id
102
+
103
+ async def acreate(
104
+ self,
105
+ type: str = "generic",
106
+ event_id: Optional[str] = None,
107
+ session_id: Optional[str] = None,
108
+ **kwargs,
109
+ ) -> str:
110
+ """Create a new event (async version).
111
+
112
+ See create() for full documentation.
113
+ """
114
+ from ...sdk.context import current_session_id, current_parent_event_id
115
+ from ...sdk.event_builder import EventBuilder
116
+
117
+ client_event_id = event_id or str(uuid.uuid4())
118
+
119
+ if not session_id:
120
+ session_id = current_session_id.get(None)
121
+
122
+ if not session_id:
123
+ logger.debug("[EventResource] No active session for acreate()")
124
+ return client_event_id
125
+
126
+ parent_event_id = None
127
+ try:
128
+ parent_event_id = current_parent_event_id.get(None)
129
+ except Exception:
130
+ pass
131
+
132
+ params = {
133
+ "type": type,
134
+ "event_id": client_event_id,
135
+ "parent_event_id": parent_event_id,
136
+ "session_id": session_id,
137
+ "occurred_at": kwargs.pop("occurred_at", None)
138
+ or datetime.now(timezone.utc).isoformat(),
139
+ **kwargs,
140
+ }
141
+
142
+ try:
143
+ event_request = EventBuilder.build(params)
144
+ await self.acreate_event(event_request)
145
+ logger.debug(f"[EventResource] Created async event {client_event_id[:8]}...")
146
+ except Exception as e:
147
+ if self._production:
148
+ logger.error(f"[EventResource] Failed to create async event: {e}")
149
+ else:
150
+ raise
151
+
152
+ return client_event_id
153
+
154
+ def emit(
155
+ self,
156
+ type: str = "generic",
157
+ event_id: Optional[str] = None,
158
+ session_id: Optional[str] = None,
159
+ **kwargs,
160
+ ) -> str:
161
+ """Fire-and-forget event creation that returns instantly.
162
+
163
+ This function returns immediately with an event ID, while the actual
164
+ event creation happens in a background thread. Perfect for hot path
165
+ telemetry where latency is critical.
166
+
167
+ Args:
168
+ type: Event type (e.g., "llm_generation", "function_call", "generic")
169
+ event_id: Optional client event ID (auto-generated if not provided)
170
+ session_id: Optional session ID (uses current context if not provided)
171
+ **kwargs: Event-specific fields
172
+
173
+ Returns:
174
+ Event ID (client-generated or provided UUID) - returned immediately
175
+
176
+ Example:
177
+ client.events.emit(type="log", message="Something happened")
178
+ """
179
+ from ...sdk.context import current_session_id, current_parent_event_id
180
+
181
+ # Pre-generate event ID for instant return
182
+ client_event_id = event_id or str(uuid.uuid4())
183
+
184
+ # Capture context variables BEFORE creating the thread
185
+ captured_parent_id = kwargs.get("parent_event_id")
186
+ if captured_parent_id is None:
187
+ try:
188
+ captured_parent_id = current_parent_event_id.get(None)
189
+ except Exception:
190
+ pass
191
+
192
+ # Get session from context if not provided
193
+ captured_session_id = session_id
194
+ if not captured_session_id:
195
+ captured_session_id = current_session_id.get(None)
196
+
197
+ if not captured_session_id:
198
+ logger.debug("[EventResource] No active session for emit()")
199
+ return client_event_id
200
+
201
+ # Capture all data for background thread
202
+ captured_kwargs = dict(kwargs)
203
+ captured_kwargs["parent_event_id"] = captured_parent_id
204
+
205
+ def _background_create():
206
+ try:
207
+ self.create(
208
+ type=type,
209
+ event_id=client_event_id,
210
+ session_id=captured_session_id,
211
+ **captured_kwargs,
212
+ )
213
+ except Exception as e:
214
+ logger.debug(f"[EventResource] Background emit() failed: {e}")
215
+
216
+ # Start background thread
217
+ thread = threading.Thread(target=_background_create, daemon=True)
218
+ thread.start()
219
+
220
+ return client_event_id
221
+
222
+ def create_error(
223
+ self,
224
+ error: Any,
225
+ parent_event_id: Optional[str] = None,
226
+ **kwargs,
227
+ ) -> str:
228
+ """Create an error traceback event.
229
+
230
+ Convenience method for creating error events with proper traceback information.
231
+
232
+ Args:
233
+ error: The error message or exception object
234
+ parent_event_id: Optional parent event ID for nesting
235
+ **kwargs: Additional event parameters
236
+
237
+ Returns:
238
+ Event ID of the created error event
239
+
240
+ Example:
241
+ try:
242
+ risky_operation()
243
+ except Exception as e:
244
+ client.events.create_error(e)
245
+ """
246
+ import traceback as tb
247
+
248
+ if isinstance(error, Exception):
249
+ error_str = str(error)
250
+ traceback_str = tb.format_exc()
251
+ else:
252
+ error_str = str(error)
253
+ traceback_str = kwargs.pop("traceback", "")
254
+
255
+ return self.create(
256
+ type="error_traceback",
257
+ error=error_str,
258
+ traceback=traceback_str,
259
+ parent_event_id=parent_event_id,
260
+ **kwargs,
261
+ )
262
+
263
+ async def acreate_error(
264
+ self,
265
+ error: Any,
266
+ parent_event_id: Optional[str] = None,
267
+ **kwargs,
268
+ ) -> str:
269
+ """Create an error traceback event (async version).
270
+
271
+ See create_error() for full documentation.
272
+ """
273
+ import traceback as tb
274
+
275
+ if isinstance(error, Exception):
276
+ error_str = str(error)
277
+ traceback_str = tb.format_exc()
278
+ else:
279
+ error_str = str(error)
280
+ traceback_str = kwargs.pop("traceback", "")
281
+
282
+ return await self.acreate(
283
+ type="error_traceback",
284
+ error=error_str,
285
+ traceback=traceback_str,
286
+ parent_event_id=parent_event_id,
287
+ **kwargs,
288
+ )
289
+
290
+ # ==================== Low-Level HTTP Methods ====================
291
+
292
+ def create_event(self, params: Dict[str, Any]) -> Dict[str, Any]:
293
+ """Create a new event via API.
294
+
22
295
  Args:
23
296
  params: Event parameters including:
24
297
  - client_event_id: Client-generated event ID
@@ -27,36 +300,81 @@ class EventResource:
27
300
  - occurred_at: When the event occurred
28
301
  - payload: Event payload
29
302
  - etc.
30
-
303
+
31
304
  Returns:
32
305
  API response with optional blob_url for large payloads
33
306
  """
34
- return self.http.post("events", params)
35
-
36
- def get_event(self, event_id: str) -> Dict[str, Any]:
307
+ event_id = params.get("client_event_id")
308
+ session_id = params.get("session_id")
309
+ event_type = params.get("type")
310
+ parent_id = params.get("client_parent_event_id")
311
+ logger.debug(
312
+ f"[Event] create_event() called - "
313
+ f"event_id={_truncate_id(event_id)}, session_id={_truncate_id(session_id)}, "
314
+ f"type={event_type!r}, parent_id={_truncate_id(parent_id)}"
315
+ )
316
+
317
+ response = self.http.post("events", params)
318
+
319
+ resp_event_id = response.get("event_id") if response else None
320
+ logger.debug(
321
+ f"[Event] create_event() response - "
322
+ f"event_id={_truncate_id(resp_event_id)}, response_keys={list(response.keys()) if response else 'None'}"
323
+ )
324
+ return response
325
+
326
+ async def acreate_event(self, params: Dict[str, Any]) -> Dict[str, Any]:
327
+ """Create a new event via API (asynchronous).
328
+
329
+ Args:
330
+ params: Event parameters
331
+
332
+ Returns:
333
+ API response with optional blob_url for large payloads
334
+ """
335
+ event_id = params.get("client_event_id")
336
+ session_id = params.get("session_id")
337
+ event_type = params.get("type")
338
+ parent_id = params.get("client_parent_event_id")
339
+ logger.debug(
340
+ f"[Event] acreate_event() called - "
341
+ f"event_id={_truncate_id(event_id)}, session_id={_truncate_id(session_id)}, "
342
+ f"type={event_type!r}, parent_id={_truncate_id(parent_id)}"
343
+ )
344
+
345
+ response = await self.http.apost("events", params)
346
+
347
+ resp_event_id = response.get("event_id") if response else None
348
+ logger.debug(
349
+ f"[Event] acreate_event() response - "
350
+ f"event_id={_truncate_id(resp_event_id)}, response_keys={list(response.keys()) if response else 'None'}"
351
+ )
352
+ return response
353
+
354
+ def get(self, event_id: str) -> Dict[str, Any]:
37
355
  """Get an event by ID.
38
-
356
+
39
357
  Args:
40
358
  event_id: Event ID
41
-
359
+
42
360
  Returns:
43
361
  Event data
44
362
  """
45
363
  return self.http.get(f"events/{event_id}")
46
-
47
- def update_event(self, event_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
364
+
365
+ def update(self, event_id: str, **updates) -> Dict[str, Any]:
48
366
  """Update an existing event.
49
-
367
+
50
368
  Args:
51
369
  event_id: Event ID
52
- updates: Fields to update
53
-
370
+ **updates: Fields to update
371
+
54
372
  Returns:
55
373
  Updated event data
56
374
  """
57
375
  return self.http.put(f"events/{event_id}", updates)
58
-
59
- def list_events(
376
+
377
+ def list(
60
378
  self,
61
379
  session_id: Optional[str] = None,
62
380
  event_type: Optional[str] = None,
@@ -64,25 +382,79 @@ class EventResource:
64
382
  offset: int = 0
65
383
  ) -> Dict[str, Any]:
66
384
  """List events with optional filters.
67
-
385
+
68
386
  Args:
69
387
  session_id: Filter by session ID
70
388
  event_type: Filter by event type
71
389
  limit: Maximum number of events to return
72
390
  offset: Pagination offset
73
-
391
+
74
392
  Returns:
75
393
  List of events and pagination info
76
394
  """
77
- params = {
395
+ params: Dict[str, Any] = {
78
396
  "limit": limit,
79
397
  "offset": offset
80
398
  }
81
-
399
+
82
400
  if session_id:
83
401
  params["session_id"] = session_id
84
-
402
+
85
403
  if event_type:
86
404
  params["type"] = event_type
87
-
88
- return self.http.get("events", params)
405
+
406
+ return self.http.get("events", params)
407
+
408
+ async def aget(self, event_id: str) -> Dict[str, Any]:
409
+ """Get an event by ID (asynchronous).
410
+
411
+ Args:
412
+ event_id: Event ID
413
+
414
+ Returns:
415
+ Event data
416
+ """
417
+ return await self.http.aget(f"events/{event_id}")
418
+
419
+ async def aupdate(self, event_id: str, **updates) -> Dict[str, Any]:
420
+ """Update an existing event (asynchronous).
421
+
422
+ Args:
423
+ event_id: Event ID
424
+ **updates: Fields to update
425
+
426
+ Returns:
427
+ Updated event data
428
+ """
429
+ return await self.http.aput(f"events/{event_id}", updates)
430
+
431
+ async def alist(
432
+ self,
433
+ session_id: Optional[str] = None,
434
+ event_type: Optional[str] = None,
435
+ limit: int = 100,
436
+ offset: int = 0
437
+ ) -> Dict[str, Any]:
438
+ """List events with optional filters (asynchronous).
439
+
440
+ Args:
441
+ session_id: Filter by session ID
442
+ event_type: Filter by event type
443
+ limit: Maximum number of events to return
444
+ offset: Pagination offset
445
+
446
+ Returns:
447
+ List of events and pagination info
448
+ """
449
+ params: Dict[str, Any] = {
450
+ "limit": limit,
451
+ "offset": offset
452
+ }
453
+
454
+ if session_id:
455
+ params["session_id"] = session_id
456
+
457
+ if event_type:
458
+ params["type"] = event_type
459
+
460
+ return await self.http.aget("events", params)
@@ -0,0 +1,108 @@
1
+ """Experiment resource API operations."""
2
+ import logging
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ..client import HttpClient
6
+
7
+ logger = logging.getLogger("Lucidic")
8
+
9
+
10
+ class ExperimentResource:
11
+ """Handle experiment-related API operations."""
12
+
13
+ def __init__(
14
+ self,
15
+ http: HttpClient,
16
+ agent_id: Optional[str] = None,
17
+ production: bool = False,
18
+ ):
19
+ """Initialize experiment resource.
20
+
21
+ Args:
22
+ http: HTTP client instance
23
+ agent_id: Default agent ID for experiments
24
+ production: Whether to suppress errors in production mode
25
+ """
26
+ self.http = http
27
+ self._agent_id = agent_id
28
+ self._production = production
29
+
30
+ def create(
31
+ self,
32
+ experiment_name: str,
33
+ description: Optional[str] = None,
34
+ tags: Optional[List[str]] = None,
35
+ LLM_boolean_evaluators: Optional[List[str]] = None,
36
+ LLM_numeric_evaluators: Optional[List[str]] = None,
37
+ ) -> Optional[str]:
38
+ """Create a new experiment.
39
+
40
+ Args:
41
+ experiment_name: Name of the experiment.
42
+ description: Optional description.
43
+ tags: Optional tags for filtering.
44
+ LLM_boolean_evaluators: Boolean evaluator names.
45
+ LLM_numeric_evaluators: Numeric evaluator names.
46
+
47
+ Returns:
48
+ The experiment ID if created successfully, None otherwise.
49
+ """
50
+ evaluator_names = []
51
+ if LLM_boolean_evaluators:
52
+ evaluator_names.extend(LLM_boolean_evaluators)
53
+ if LLM_numeric_evaluators:
54
+ evaluator_names.extend(LLM_numeric_evaluators)
55
+
56
+ try:
57
+ response = self.http.post(
58
+ "createexperiment",
59
+ {
60
+ "agent_id": self._agent_id,
61
+ "experiment_name": experiment_name,
62
+ "description": description or "",
63
+ "tags": tags or [],
64
+ "evaluator_names": evaluator_names,
65
+ },
66
+ )
67
+ return response.get("experiment_id")
68
+ except Exception as e:
69
+ if self._production:
70
+ logger.error(f"[ExperimentResource] Failed to create experiment: {e}")
71
+ return None
72
+ raise
73
+
74
+ async def acreate(
75
+ self,
76
+ experiment_name: str,
77
+ description: Optional[str] = None,
78
+ tags: Optional[List[str]] = None,
79
+ LLM_boolean_evaluators: Optional[List[str]] = None,
80
+ LLM_numeric_evaluators: Optional[List[str]] = None,
81
+ ) -> Optional[str]:
82
+ """Create a new experiment (asynchronous).
83
+
84
+ See create() for full documentation.
85
+ """
86
+ evaluator_names = []
87
+ if LLM_boolean_evaluators:
88
+ evaluator_names.extend(LLM_boolean_evaluators)
89
+ if LLM_numeric_evaluators:
90
+ evaluator_names.extend(LLM_numeric_evaluators)
91
+
92
+ try:
93
+ response = await self.http.apost(
94
+ "createexperiment",
95
+ {
96
+ "agent_id": self._agent_id,
97
+ "experiment_name": experiment_name,
98
+ "description": description or "",
99
+ "tags": tags or [],
100
+ "evaluator_names": evaluator_names,
101
+ },
102
+ )
103
+ return response.get("experiment_id")
104
+ except Exception as e:
105
+ if self._production:
106
+ logger.error(f"[ExperimentResource] Failed to create experiment: {e}")
107
+ return None
108
+ raise
@@ -0,0 +1,78 @@
1
+ """Feature flag resource API operations."""
2
+ import logging
3
+ from typing import Any, Dict, Optional
4
+
5
+ from ..client import HttpClient
6
+
7
+ logger = logging.getLogger("Lucidic")
8
+
9
+
10
+ class FeatureFlagResource:
11
+ """Handle feature flag-related API operations."""
12
+
13
+ def __init__(
14
+ self,
15
+ http: HttpClient,
16
+ agent_id: Optional[str] = None,
17
+ production: bool = False,
18
+ ):
19
+ """Initialize feature flag resource.
20
+
21
+ Args:
22
+ http: HTTP client instance
23
+ agent_id: Default agent ID for feature flags
24
+ production: Whether to suppress errors in production mode
25
+ """
26
+ self.http = http
27
+ self._agent_id = agent_id
28
+ self._production = production
29
+
30
+ def get(
31
+ self,
32
+ flag_name: str,
33
+ default: Any = None,
34
+ context: Optional[Dict[str, Any]] = None,
35
+ ) -> Any:
36
+ """Get a feature flag value.
37
+
38
+ Args:
39
+ flag_name: Name of the feature flag.
40
+ default: Default value if flag is not found.
41
+ context: Optional context for flag evaluation.
42
+
43
+ Returns:
44
+ The flag value or default.
45
+ """
46
+ try:
47
+ response = self.http.get(
48
+ "featureflags",
49
+ {"flag_name": flag_name, "agent_id": self._agent_id},
50
+ )
51
+ return response.get("value", default)
52
+ except Exception as e:
53
+ if self._production:
54
+ logger.error(f"[FeatureFlagResource] Failed to get feature flag: {e}")
55
+ return default
56
+ raise
57
+
58
+ async def aget(
59
+ self,
60
+ flag_name: str,
61
+ default: Any = None,
62
+ context: Optional[Dict[str, Any]] = None,
63
+ ) -> Any:
64
+ """Get a feature flag value (asynchronous).
65
+
66
+ See get() for full documentation.
67
+ """
68
+ try:
69
+ response = await self.http.aget(
70
+ "featureflags",
71
+ {"flag_name": flag_name, "agent_id": self._agent_id},
72
+ )
73
+ return response.get("value", default)
74
+ except Exception as e:
75
+ if self._production:
76
+ logger.error(f"[FeatureFlagResource] Failed to get feature flag: {e}")
77
+ return default
78
+ raise