lucidicai 2.1.1__py3-none-any.whl → 2.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lucidicai/__init__.py CHANGED
@@ -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
lucidicai/api/client.py CHANGED
@@ -1,15 +1,13 @@
1
1
  """Pure HTTP client for Lucidic API communication.
2
2
 
3
- This module contains only the HTTP client logic, separated from
4
- session management and other concerns.
3
+ This module contains only the HTTP client logic using httpx,
4
+ supporting both synchronous and asynchronous operations.
5
5
  """
6
- import json
6
+ import asyncio
7
+ from datetime import datetime, timezone
7
8
  from typing import Any, Dict, Optional
8
- from urllib.parse import urlencode
9
9
 
10
- import requests
11
- from requests.adapters import HTTPAdapter
12
- from urllib3.util import Retry
10
+ import httpx
13
11
 
14
12
  from ..core.config import SDKConfig, get_config
15
13
  from ..core.errors import APIKeyVerificationError
@@ -17,7 +15,7 @@ from ..utils.logger import debug, info, warning, error, mask_sensitive, truncate
17
15
 
18
16
 
19
17
  class HttpClient:
20
- """HTTP client for API communication."""
18
+ """HTTP client for API communication with sync and async support."""
21
19
 
22
20
  def __init__(self, config: Optional[SDKConfig] = None):
23
21
  """Initialize the HTTP client.
@@ -28,36 +26,26 @@ class HttpClient:
28
26
  self.config = config or get_config()
29
27
  self.base_url = self.config.network.base_url
30
28
 
31
- # Create session with connection pooling
32
- self.session = requests.Session()
29
+ # Build default headers
30
+ self._headers = self._build_headers()
33
31
 
34
- # Configure retries
35
- retry_cfg = Retry(
36
- total=self.config.network.max_retries,
37
- backoff_factor=self.config.network.backoff_factor,
38
- status_forcelist=[502, 503, 504],
39
- allowed_methods=["GET", "POST", "PUT", "DELETE"],
40
- )
32
+ # Transport configuration for connection pooling and retries
33
+ self._transport_kwargs = {
34
+ "retries": self.config.network.max_retries,
35
+ }
41
36
 
42
- # Configure adapter with connection pooling
43
- adapter = HTTPAdapter(
44
- max_retries=retry_cfg,
45
- pool_connections=self.config.network.connection_pool_size,
46
- pool_maxsize=self.config.network.connection_pool_maxsize
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,
47
41
  )
48
42
 
49
- self.session.mount("https://", adapter)
50
- self.session.mount("http://", adapter)
51
-
52
- # Set headers
53
- self._update_headers()
54
-
55
- # Verify API key if configured
56
- if self.config.api_key:
57
- self._verify_api_key()
43
+ # Lazy-initialized clients
44
+ self._sync_client: Optional[httpx.Client] = None
45
+ self._async_client: Optional[httpx.AsyncClient] = None
58
46
 
59
- def _update_headers(self) -> None:
60
- """Update session headers with authentication."""
47
+ def _build_headers(self) -> Dict[str, str]:
48
+ """Build default headers for requests."""
61
49
  headers = {
62
50
  "User-Agent": "lucidic-sdk/2.0",
63
51
  "Content-Type": "application/json"
@@ -69,24 +57,91 @@ class HttpClient:
69
57
  if self.config.agent_id:
70
58
  headers["x-agent-id"] = self.config.agent_id
71
59
 
72
- self.session.headers.update(headers)
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
73
96
 
74
- def _verify_api_key(self) -> None:
75
- """Verify the API key with the backend."""
76
- debug("[HTTP] Verifying API key")
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
77
127
  try:
78
- response = self.get("verifyapikey")
79
- # Backend returns 200 OK for valid key, check if we got a response
80
- if response is None:
81
- raise APIKeyVerificationError("No response from API")
82
- info("[HTTP] API key verified successfully")
83
- except APIKeyVerificationError:
84
- raise
85
- except requests.RequestException as e:
86
- raise APIKeyVerificationError(f"Could not verify API key: {e}")
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 ====================
87
142
 
88
143
  def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
89
- """Make a GET request.
144
+ """Make a synchronous GET request.
90
145
 
91
146
  Args:
92
147
  endpoint: API endpoint (without base URL)
@@ -98,7 +153,7 @@ class HttpClient:
98
153
  return self.request("GET", endpoint, params=params)
99
154
 
100
155
  def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
101
- """Make a POST request.
156
+ """Make a synchronous POST request.
102
157
 
103
158
  Args:
104
159
  endpoint: API endpoint (without base URL)
@@ -107,15 +162,11 @@ class HttpClient:
107
162
  Returns:
108
163
  Response data as dictionary
109
164
  """
110
- # Add current_time to all POST requests like TypeScript SDK does
111
- from datetime import datetime, timezone
112
- if data is None:
113
- data = {}
114
- data["current_time"] = datetime.now(timezone.utc).isoformat()
165
+ data = self._add_timestamp(data)
115
166
  return self.request("POST", endpoint, json=data)
116
167
 
117
168
  def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
118
- """Make a PUT request.
169
+ """Make a synchronous PUT request.
119
170
 
120
171
  Args:
121
172
  endpoint: API endpoint (without base URL)
@@ -124,15 +175,11 @@ class HttpClient:
124
175
  Returns:
125
176
  Response data as dictionary
126
177
  """
127
- # Add current_time to all PUT requests like TypeScript SDK does
128
- from datetime import datetime, timezone
129
- if data is None:
130
- data = {}
131
- data["current_time"] = datetime.now(timezone.utc).isoformat()
178
+ data = self._add_timestamp(data)
132
179
  return self.request("PUT", endpoint, json=data)
133
180
 
134
181
  def delete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
135
- """Make a DELETE request.
182
+ """Make a synchronous DELETE request.
136
183
 
137
184
  Args:
138
185
  endpoint: API endpoint (without base URL)
@@ -151,68 +198,160 @@ class HttpClient:
151
198
  json: Optional[Dict[str, Any]] = None,
152
199
  **kwargs
153
200
  ) -> Dict[str, Any]:
154
- """Make an HTTP request.
201
+ """Make a synchronous HTTP request.
155
202
 
156
203
  Args:
157
204
  method: HTTP method
158
205
  endpoint: API endpoint (without base URL)
159
206
  params: Query parameters
160
207
  json: Request body (for POST/PUT)
161
- **kwargs: Additional arguments for requests
208
+ **kwargs: Additional arguments for httpx
162
209
 
163
210
  Returns:
164
211
  Response data as dictionary
165
212
 
166
213
  Raises:
167
- requests.RequestException: On HTTP errors
214
+ httpx.HTTPError: On HTTP errors
168
215
  """
169
- url = f"{self.base_url}/{endpoint}"
216
+ url = f"/{endpoint}"
170
217
 
171
218
  # Log request details
172
- debug(f"[HTTP] {method} {url}")
219
+ debug(f"[HTTP] {method} {self.base_url}{url}")
173
220
  if params:
174
221
  debug(f"[HTTP] Query params: {mask_sensitive(params)}")
175
222
  if json:
176
223
  debug(f"[HTTP] Request body: {truncate_data(mask_sensitive(json))}")
177
224
 
178
- response = self.session.request(
225
+ response = self.sync_client.request(
179
226
  method=method,
180
227
  url=url,
181
228
  params=params,
182
229
  json=json,
183
- timeout=self.config.network.timeout,
184
230
  **kwargs
185
231
  )
186
232
 
187
- # Raise for HTTP errors with more detail
188
- if not response.ok:
189
- # Try to get error details from response
190
- try:
191
- error_data = response.json()
192
- error_msg = error_data.get('detail', response.text)
193
- except:
194
- error_msg = response.text
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
195
243
 
196
- error(f"[HTTP] Error {response.status_code}: {error_msg}")
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.
197
251
 
198
- response.raise_for_status()
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.
199
264
 
200
- # Parse JSON response
201
- try:
202
- data = response.json()
203
- except ValueError:
204
- # For empty responses (like verifyapikey), return success
205
- if response.status_code == 200 and not response.text:
206
- data = {"success": True}
207
- else:
208
- # Return text if not JSON
209
- data = {"response": response.text}
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.
210
277
 
211
- debug(f"[HTTP] Response ({response.status_code}): {truncate_data(data)}")
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.
212
296
 
213
- return data
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 ====================
214
330
 
215
331
  def close(self) -> None:
216
- """Close the HTTP session."""
217
- if self.session:
218
- self.session.close()
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()
lucidicai/sdk/context.py CHANGED
@@ -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: