lucidicai 2.1.1__tar.gz → 2.1.3__tar.gz

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 (49) hide show
  1. {lucidicai-2.1.1 → lucidicai-2.1.3}/PKG-INFO +1 -1
  2. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/__init__.py +5 -1
  3. lucidicai-2.1.3/lucidicai/api/client.py +357 -0
  4. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/context.py +42 -0
  5. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/features/dataset.py +23 -237
  6. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/init.py +74 -0
  7. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/lucidic_exporter.py +13 -0
  8. lucidicai-2.1.3/lucidicai/telemetry/openai_patch.py +425 -0
  9. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/telemetry_init.py +3 -3
  10. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/utils/images.py +3 -3
  11. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/utils/queue.py +40 -8
  12. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai.egg-info/PKG-INFO +1 -1
  13. {lucidicai-2.1.1 → lucidicai-2.1.3}/setup.py +1 -1
  14. lucidicai-2.1.1/lucidicai/api/client.py +0 -218
  15. lucidicai-2.1.1/lucidicai/telemetry/openai_patch.py +0 -295
  16. {lucidicai-2.1.1 → lucidicai-2.1.3}/README.md +0 -0
  17. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/api/__init__.py +0 -0
  18. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/api/resources/__init__.py +0 -0
  19. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/api/resources/dataset.py +0 -0
  20. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/api/resources/event.py +0 -0
  21. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/api/resources/session.py +0 -0
  22. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/core/__init__.py +0 -0
  23. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/core/config.py +0 -0
  24. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/core/errors.py +0 -0
  25. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/core/types.py +0 -0
  26. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/__init__.py +0 -0
  27. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/decorators.py +0 -0
  28. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/error_boundary.py +0 -0
  29. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/event.py +0 -0
  30. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/event_builder.py +0 -0
  31. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/features/__init__.py +0 -0
  32. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/features/feature_flag.py +0 -0
  33. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/sdk/shutdown_manager.py +0 -0
  34. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/__init__.py +0 -0
  35. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/context_bridge.py +0 -0
  36. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/context_capture_processor.py +0 -0
  37. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/extract.py +0 -0
  38. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/litellm_bridge.py +0 -0
  39. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/openai_agents_instrumentor.py +0 -0
  40. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/openai_uninstrument.py +0 -0
  41. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/utils/__init__.py +0 -0
  42. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/telemetry/utils/model_pricing.py +0 -0
  43. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/utils/__init__.py +0 -0
  44. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai/utils/logger.py +0 -0
  45. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai.egg-info/SOURCES.txt +0 -0
  46. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai.egg-info/dependency_links.txt +0 -0
  47. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai.egg-info/requires.txt +0 -0
  48. {lucidicai-2.1.1 → lucidicai-2.1.3}/lucidicai.egg-info/top_level.txt +0 -0
  49. {lucidicai-2.1.1 → lucidicai-2.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lucidicai
3
- Version: 2.1.1
3
+ Version: 2.1.3
4
4
  Summary: Lucidic AI Python SDK
5
5
  Author: Andy Liang
6
6
  Author-email: andy@lucidic.ai
@@ -100,6 +100,7 @@ def _end_session(
100
100
  ):
101
101
  """End the current session."""
102
102
  from .sdk.init import get_resources, get_session_id, get_event_queue
103
+ from .sdk.shutdown_manager import get_shutdown_manager
103
104
 
104
105
  # Use provided session_id or fall back to context
105
106
  if not session_id:
@@ -125,6 +126,9 @@ def _end_session(
125
126
  # Clear session context
126
127
  clear_active_session()
127
128
 
129
+ # unregister from shutdown manager
130
+ get_shutdown_manager().unregister_session(session_id)
131
+
128
132
 
129
133
  def _get_session():
130
134
  """Get the current session object."""
@@ -293,7 +297,7 @@ get_error_history = error_boundary.get_error_history
293
297
  clear_error_history = error_boundary.clear_error_history
294
298
 
295
299
  # Version
296
- __version__ = "2.1.1"
300
+ __version__ = "2.1.3"
297
301
 
298
302
  # Apply error boundary wrapping to all SDK functions
299
303
  from .sdk.error_boundary import wrap_sdk_function
@@ -0,0 +1,357 @@
1
+ """Pure HTTP client for Lucidic API communication.
2
+
3
+ This module contains only the HTTP client logic using httpx,
4
+ supporting both synchronous and asynchronous operations.
5
+ """
6
+ import asyncio
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, Optional
9
+
10
+ import httpx
11
+
12
+ from ..core.config import SDKConfig, get_config
13
+ from ..core.errors import APIKeyVerificationError
14
+ from ..utils.logger import debug, info, warning, error, mask_sensitive, truncate_data
15
+
16
+
17
+ class HttpClient:
18
+ """HTTP client for API communication with sync and async support."""
19
+
20
+ def __init__(self, config: Optional[SDKConfig] = None):
21
+ """Initialize the HTTP client.
22
+
23
+ Args:
24
+ config: SDK configuration (uses global if not provided)
25
+ """
26
+ self.config = config or get_config()
27
+ self.base_url = self.config.network.base_url
28
+
29
+ # Build default headers
30
+ self._headers = self._build_headers()
31
+
32
+ # Transport configuration for connection pooling and retries
33
+ self._transport_kwargs = {
34
+ "retries": self.config.network.max_retries,
35
+ }
36
+
37
+ # Connection limits for pooling
38
+ self._limits = httpx.Limits(
39
+ max_connections=self.config.network.connection_pool_maxsize,
40
+ max_keepalive_connections=self.config.network.connection_pool_size,
41
+ )
42
+
43
+ # Lazy-initialized clients
44
+ self._sync_client: Optional[httpx.Client] = None
45
+ self._async_client: Optional[httpx.AsyncClient] = None
46
+
47
+ def _build_headers(self) -> Dict[str, str]:
48
+ """Build default headers for requests."""
49
+ headers = {
50
+ "User-Agent": "lucidic-sdk/2.0",
51
+ "Content-Type": "application/json"
52
+ }
53
+
54
+ if self.config.api_key:
55
+ headers["Authorization"] = f"Api-Key {self.config.api_key}"
56
+
57
+ if self.config.agent_id:
58
+ headers["x-agent-id"] = self.config.agent_id
59
+
60
+ return headers
61
+
62
+ @property
63
+ def sync_client(self) -> httpx.Client:
64
+ """Get or create the synchronous HTTP client."""
65
+ if self._sync_client is None or self._sync_client.is_closed:
66
+ transport = httpx.HTTPTransport(**self._transport_kwargs)
67
+ self._sync_client = httpx.Client(
68
+ base_url=self.base_url,
69
+ headers=self._headers,
70
+ timeout=httpx.Timeout(self.config.network.timeout),
71
+ limits=self._limits,
72
+ transport=transport,
73
+ )
74
+ return self._sync_client
75
+
76
+ @property
77
+ def async_client(self) -> httpx.AsyncClient:
78
+ """Get or create the asynchronous HTTP client."""
79
+ if self._async_client is None or self._async_client.is_closed:
80
+ transport = httpx.AsyncHTTPTransport(**self._transport_kwargs)
81
+ self._async_client = httpx.AsyncClient(
82
+ base_url=self.base_url,
83
+ headers=self._headers,
84
+ timeout=httpx.Timeout(self.config.network.timeout),
85
+ limits=self._limits,
86
+ transport=transport,
87
+ )
88
+ return self._async_client
89
+
90
+ def _add_timestamp(self, data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
91
+ """Add current_time to request data."""
92
+ if data is None:
93
+ data = {}
94
+ data["current_time"] = datetime.now(timezone.utc).isoformat()
95
+ return data
96
+
97
+ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
98
+ """Handle HTTP response and parse JSON.
99
+
100
+ Args:
101
+ response: httpx Response object
102
+
103
+ Returns:
104
+ Response data as dictionary
105
+
106
+ Raises:
107
+ APIKeyVerificationError: On 401 Unauthorized responses
108
+ httpx.HTTPStatusError: On other HTTP errors
109
+ """
110
+ # Log and raise for HTTP errors
111
+ if not response.is_success:
112
+ try:
113
+ error_data = response.json()
114
+ error_msg = error_data.get('detail', response.text)
115
+ except Exception:
116
+ error_msg = response.text
117
+
118
+ error(f"[HTTP] Error {response.status_code}: {error_msg}")
119
+
120
+ # Raise specific error for authentication/authorization failures
121
+ if response.status_code in (401, 403):
122
+ raise APIKeyVerificationError(f"Authentication failed: {error_msg}")
123
+
124
+ response.raise_for_status()
125
+
126
+ # Parse JSON response
127
+ try:
128
+ data = response.json()
129
+ except ValueError:
130
+ # For empty responses (like verifyapikey), return success
131
+ if response.status_code == 200 and not response.text:
132
+ data = {"success": True}
133
+ else:
134
+ # Return text if not JSON
135
+ data = {"response": response.text}
136
+
137
+ debug(f"[HTTP] Response ({response.status_code}): {truncate_data(data)}")
138
+
139
+ return data
140
+
141
+ # ==================== Synchronous Methods ====================
142
+
143
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
144
+ """Make a synchronous GET request.
145
+
146
+ Args:
147
+ endpoint: API endpoint (without base URL)
148
+ params: Query parameters
149
+
150
+ Returns:
151
+ Response data as dictionary
152
+ """
153
+ return self.request("GET", endpoint, params=params)
154
+
155
+ def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
156
+ """Make a synchronous POST request.
157
+
158
+ Args:
159
+ endpoint: API endpoint (without base URL)
160
+ data: Request body data
161
+
162
+ Returns:
163
+ Response data as dictionary
164
+ """
165
+ data = self._add_timestamp(data)
166
+ return self.request("POST", endpoint, json=data)
167
+
168
+ def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
169
+ """Make a synchronous PUT request.
170
+
171
+ Args:
172
+ endpoint: API endpoint (without base URL)
173
+ data: Request body data
174
+
175
+ Returns:
176
+ Response data as dictionary
177
+ """
178
+ data = self._add_timestamp(data)
179
+ return self.request("PUT", endpoint, json=data)
180
+
181
+ def delete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
182
+ """Make a synchronous DELETE request.
183
+
184
+ Args:
185
+ endpoint: API endpoint (without base URL)
186
+ params: Query parameters
187
+
188
+ Returns:
189
+ Response data as dictionary
190
+ """
191
+ return self.request("DELETE", endpoint, params=params)
192
+
193
+ def request(
194
+ self,
195
+ method: str,
196
+ endpoint: str,
197
+ params: Optional[Dict[str, Any]] = None,
198
+ json: Optional[Dict[str, Any]] = None,
199
+ **kwargs
200
+ ) -> Dict[str, Any]:
201
+ """Make a synchronous HTTP request.
202
+
203
+ Args:
204
+ method: HTTP method
205
+ endpoint: API endpoint (without base URL)
206
+ params: Query parameters
207
+ json: Request body (for POST/PUT)
208
+ **kwargs: Additional arguments for httpx
209
+
210
+ Returns:
211
+ Response data as dictionary
212
+
213
+ Raises:
214
+ httpx.HTTPError: On HTTP errors
215
+ """
216
+ url = f"/{endpoint}"
217
+
218
+ # Log request details
219
+ debug(f"[HTTP] {method} {self.base_url}{url}")
220
+ if params:
221
+ debug(f"[HTTP] Query params: {mask_sensitive(params)}")
222
+ if json:
223
+ debug(f"[HTTP] Request body: {truncate_data(mask_sensitive(json))}")
224
+
225
+ response = self.sync_client.request(
226
+ method=method,
227
+ url=url,
228
+ params=params,
229
+ json=json,
230
+ **kwargs
231
+ )
232
+
233
+ return self._handle_response(response)
234
+
235
+ # ==================== Asynchronous Methods ====================
236
+
237
+ async def aget(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
238
+ """Make an asynchronous GET request.
239
+
240
+ Args:
241
+ endpoint: API endpoint (without base URL)
242
+ params: Query parameters
243
+
244
+ Returns:
245
+ Response data as dictionary
246
+ """
247
+ return await self.arequest("GET", endpoint, params=params)
248
+
249
+ async def apost(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
250
+ """Make an asynchronous POST request.
251
+
252
+ Args:
253
+ endpoint: API endpoint (without base URL)
254
+ data: Request body data
255
+
256
+ Returns:
257
+ Response data as dictionary
258
+ """
259
+ data = self._add_timestamp(data)
260
+ return await self.arequest("POST", endpoint, json=data)
261
+
262
+ async def aput(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
263
+ """Make an asynchronous PUT request.
264
+
265
+ Args:
266
+ endpoint: API endpoint (without base URL)
267
+ data: Request body data
268
+
269
+ Returns:
270
+ Response data as dictionary
271
+ """
272
+ data = self._add_timestamp(data)
273
+ return await self.arequest("PUT", endpoint, json=data)
274
+
275
+ async def adelete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
276
+ """Make an asynchronous DELETE request.
277
+
278
+ Args:
279
+ endpoint: API endpoint (without base URL)
280
+ params: Query parameters
281
+
282
+ Returns:
283
+ Response data as dictionary
284
+ """
285
+ return await self.arequest("DELETE", endpoint, params=params)
286
+
287
+ async def arequest(
288
+ self,
289
+ method: str,
290
+ endpoint: str,
291
+ params: Optional[Dict[str, Any]] = None,
292
+ json: Optional[Dict[str, Any]] = None,
293
+ **kwargs
294
+ ) -> Dict[str, Any]:
295
+ """Make an asynchronous HTTP request.
296
+
297
+ Args:
298
+ method: HTTP method
299
+ endpoint: API endpoint (without base URL)
300
+ params: Query parameters
301
+ json: Request body (for POST/PUT)
302
+ **kwargs: Additional arguments for httpx
303
+
304
+ Returns:
305
+ Response data as dictionary
306
+
307
+ Raises:
308
+ httpx.HTTPError: On HTTP errors
309
+ """
310
+ url = f"/{endpoint}"
311
+
312
+ # Log request details
313
+ debug(f"[HTTP] {method} {self.base_url}{url}")
314
+ if params:
315
+ debug(f"[HTTP] Query params: {mask_sensitive(params)}")
316
+ if json:
317
+ debug(f"[HTTP] Request body: {truncate_data(mask_sensitive(json))}")
318
+
319
+ response = await self.async_client.request(
320
+ method=method,
321
+ url=url,
322
+ params=params,
323
+ json=json,
324
+ **kwargs
325
+ )
326
+
327
+ return self._handle_response(response)
328
+
329
+ # ==================== Lifecycle Methods ====================
330
+
331
+ def close(self) -> None:
332
+ """Close the synchronous HTTP client."""
333
+ if self._sync_client is not None and not self._sync_client.is_closed:
334
+ self._sync_client.close()
335
+ self._sync_client = None
336
+
337
+ async def aclose(self) -> None:
338
+ """Close the asynchronous HTTP client."""
339
+ if self._async_client is not None and not self._async_client.is_closed:
340
+ await self._async_client.aclose()
341
+ self._async_client = None
342
+
343
+ def __enter__(self) -> "HttpClient":
344
+ """Context manager entry for sync client."""
345
+ return self
346
+
347
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
348
+ """Context manager exit for sync client."""
349
+ self.close()
350
+
351
+ async def __aenter__(self) -> "HttpClient":
352
+ """Async context manager entry."""
353
+ return self
354
+
355
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
356
+ """Async context manager exit."""
357
+ await self.aclose()
@@ -151,6 +151,27 @@ def session(**init_params) -> Iterator[None]:
151
151
  clear_thread_session()
152
152
  current_session_id.reset(token)
153
153
  try:
154
+ # Force flush OpenTelemetry spans before ending session
155
+ from .init import get_tracer_provider
156
+ from ..utils.logger import debug, info
157
+ import time
158
+
159
+ tracer_provider = get_tracer_provider()
160
+ if tracer_provider:
161
+ debug(f"[Session] Force flushing OpenTelemetry spans for session {session_id}")
162
+ try:
163
+ # Force flush with 5 second timeout to ensure all spans are exported
164
+ flush_result = tracer_provider.force_flush(timeout_millis=5000)
165
+ debug(f"[Session] Tracer provider force_flush returned: {flush_result}")
166
+
167
+ # Give a small additional delay to ensure the exporter processes everything
168
+ # This is necessary because force_flush on the provider flushes the processors,
169
+ # but the exporter might still be processing the spans
170
+ time.sleep(0.5)
171
+ debug(f"[Session] Successfully flushed spans for session {session_id}")
172
+ except Exception as e:
173
+ debug(f"[Session] Error flushing spans: {e}")
174
+
154
175
  # Pass session_id explicitly to avoid context issues
155
176
  lai.end_session(session_id=session_id)
156
177
  except Exception:
@@ -184,6 +205,27 @@ async def session_async(**init_params) -> AsyncIterator[None]:
184
205
  clear_task_session()
185
206
  current_session_id.reset(token)
186
207
  try:
208
+ # Force flush OpenTelemetry spans before ending session
209
+ from .init import get_tracer_provider
210
+ from ..utils.logger import debug, info
211
+ import asyncio
212
+
213
+ tracer_provider = get_tracer_provider()
214
+ if tracer_provider:
215
+ debug(f"[Session] Force flushing OpenTelemetry spans for async session {session_id}")
216
+ try:
217
+ # Force flush with 5 second timeout to ensure all spans are exported
218
+ flush_result = tracer_provider.force_flush(timeout_millis=5000)
219
+ debug(f"[Session] Tracer provider force_flush returned: {flush_result}")
220
+
221
+ # Give a small additional delay to ensure the exporter processes everything
222
+ # This is necessary because force_flush on the provider flushes the processors,
223
+ # but the exporter might still be processing the spans
224
+ await asyncio.sleep(0.5)
225
+ debug(f"[Session] Successfully flushed spans for async session {session_id}")
226
+ except Exception as e:
227
+ debug(f"[Session] Error flushing spans: {e}")
228
+
187
229
  # Pass session_id explicitly to avoid context issues in async
188
230
  lai.end_session(session_id=session_id)
189
231
  except Exception: