lucidicai 2.1.2__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 (39) hide show
  1. lucidicai/__init__.py +32 -390
  2. lucidicai/api/client.py +260 -92
  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 +408 -232
  19. lucidicai/sdk/features/feature_flag.py +344 -3
  20. lucidicai/sdk/init.py +50 -279
  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 +30 -14
  34. lucidicai/utils/queue.py +2 -2
  35. lucidicai/utils/serialization.py +27 -0
  36. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/METADATA +1 -1
  37. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/RECORD +39 -30
  38. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/WHEEL +0 -0
  39. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/top_level.txt +0 -0
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,27 @@ 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
46
+ self._async_client_loop: Optional[asyncio.AbstractEventLoop] = None
58
47
 
59
- def _update_headers(self) -> None:
60
- """Update session headers with authentication."""
48
+ def _build_headers(self) -> Dict[str, str]:
49
+ """Build default headers for requests."""
61
50
  headers = {
62
51
  "User-Agent": "lucidic-sdk/2.0",
63
52
  "Content-Type": "application/json"
@@ -69,24 +58,119 @@ class HttpClient:
69
58
  if self.config.agent_id:
70
59
  headers["x-agent-id"] = self.config.agent_id
71
60
 
72
- self.session.headers.update(headers)
61
+ return headers
73
62
 
74
- def _verify_api_key(self) -> None:
75
- """Verify the API key with the backend."""
76
- debug("[HTTP] Verifying API key")
63
+ @property
64
+ def sync_client(self) -> httpx.Client:
65
+ """Get or create the synchronous HTTP client."""
66
+ if self._sync_client is None or self._sync_client.is_closed:
67
+ transport = httpx.HTTPTransport(**self._transport_kwargs)
68
+ self._sync_client = httpx.Client(
69
+ base_url=self.base_url,
70
+ headers=self._headers,
71
+ timeout=httpx.Timeout(self.config.network.timeout),
72
+ limits=self._limits,
73
+ transport=transport,
74
+ )
75
+ return self._sync_client
76
+
77
+ @property
78
+ def async_client(self) -> httpx.AsyncClient:
79
+ """Get or create the asynchronous HTTP client.
80
+
81
+ The client is recreated if the event loop has changed, since
82
+ httpx.AsyncClient is tied to a specific event loop.
83
+ """
84
+ # Check if we need to recreate the client
85
+ current_loop = None
86
+ try:
87
+ current_loop = asyncio.get_running_loop()
88
+ except RuntimeError:
89
+ pass # No running loop
90
+
91
+ # Recreate client if: no client, client closed, or event loop changed
92
+ needs_new_client = (
93
+ self._async_client is None or
94
+ self._async_client.is_closed or
95
+ (current_loop is not None and self._async_client_loop is not current_loop)
96
+ )
97
+
98
+ if needs_new_client:
99
+ # Close old client if it exists and isn't already closed
100
+ if self._async_client is not None and not self._async_client.is_closed:
101
+ try:
102
+ # Can't await in a property, so we just let it be garbage collected
103
+ pass
104
+ except Exception:
105
+ pass
106
+
107
+ transport = httpx.AsyncHTTPTransport(**self._transport_kwargs)
108
+ self._async_client = httpx.AsyncClient(
109
+ base_url=self.base_url,
110
+ headers=self._headers,
111
+ timeout=httpx.Timeout(self.config.network.timeout),
112
+ limits=self._limits,
113
+ transport=transport,
114
+ )
115
+ self._async_client_loop = current_loop
116
+
117
+ return self._async_client
118
+
119
+ def _add_timestamp(self, data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
120
+ """Add current_time to request data."""
121
+ if data is None:
122
+ data = {}
123
+ data["current_time"] = datetime.now(timezone.utc).isoformat()
124
+ return data
125
+
126
+ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
127
+ """Handle HTTP response and parse JSON.
128
+
129
+ Args:
130
+ response: httpx Response object
131
+
132
+ Returns:
133
+ Response data as dictionary
134
+
135
+ Raises:
136
+ APIKeyVerificationError: On 401 Unauthorized responses
137
+ httpx.HTTPStatusError: On other HTTP errors
138
+ """
139
+ # Log and raise for HTTP errors
140
+ if not response.is_success:
141
+ try:
142
+ error_data = response.json()
143
+ error_msg = error_data.get('detail', response.text)
144
+ except Exception:
145
+ error_msg = response.text
146
+
147
+ error(f"[HTTP] Error {response.status_code}: {error_msg}")
148
+
149
+ # Raise specific error for authentication/authorization failures
150
+ if response.status_code in (401, 403):
151
+ raise APIKeyVerificationError(f"Authentication failed: {error_msg}")
152
+
153
+ response.raise_for_status()
154
+
155
+ # Parse JSON response
77
156
  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}")
157
+ data = response.json()
158
+ except ValueError:
159
+ # For empty responses (like verifyapikey), return success
160
+ if response.status_code == 200 and not response.text:
161
+ data = {"success": True}
162
+ else:
163
+ # Return text if not JSON
164
+ data = {"response": response.text}
165
+
166
+ debug(f"[HTTP] Response ({response.status_code}): {truncate_data(data)}")
167
+
168
+ return data
169
+
170
+ # ==================== Synchronous Methods ====================
87
171
 
88
172
  def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
89
- """Make a GET request.
173
+ """Make a synchronous GET request.
90
174
 
91
175
  Args:
92
176
  endpoint: API endpoint (without base URL)
@@ -98,7 +182,7 @@ class HttpClient:
98
182
  return self.request("GET", endpoint, params=params)
99
183
 
100
184
  def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
101
- """Make a POST request.
185
+ """Make a synchronous POST request.
102
186
 
103
187
  Args:
104
188
  endpoint: API endpoint (without base URL)
@@ -107,15 +191,11 @@ class HttpClient:
107
191
  Returns:
108
192
  Response data as dictionary
109
193
  """
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()
194
+ data = self._add_timestamp(data)
115
195
  return self.request("POST", endpoint, json=data)
116
196
 
117
197
  def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
118
- """Make a PUT request.
198
+ """Make a synchronous PUT request.
119
199
 
120
200
  Args:
121
201
  endpoint: API endpoint (without base URL)
@@ -124,15 +204,11 @@ class HttpClient:
124
204
  Returns:
125
205
  Response data as dictionary
126
206
  """
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()
207
+ data = self._add_timestamp(data)
132
208
  return self.request("PUT", endpoint, json=data)
133
209
 
134
210
  def delete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
135
- """Make a DELETE request.
211
+ """Make a synchronous DELETE request.
136
212
 
137
213
  Args:
138
214
  endpoint: API endpoint (without base URL)
@@ -151,68 +227,160 @@ class HttpClient:
151
227
  json: Optional[Dict[str, Any]] = None,
152
228
  **kwargs
153
229
  ) -> Dict[str, Any]:
154
- """Make an HTTP request.
230
+ """Make a synchronous HTTP request.
155
231
 
156
232
  Args:
157
233
  method: HTTP method
158
234
  endpoint: API endpoint (without base URL)
159
235
  params: Query parameters
160
236
  json: Request body (for POST/PUT)
161
- **kwargs: Additional arguments for requests
237
+ **kwargs: Additional arguments for httpx
162
238
 
163
239
  Returns:
164
240
  Response data as dictionary
165
241
 
166
242
  Raises:
167
- requests.RequestException: On HTTP errors
243
+ httpx.HTTPError: On HTTP errors
168
244
  """
169
- url = f"{self.base_url}/{endpoint}"
245
+ url = f"/{endpoint}"
170
246
 
171
247
  # Log request details
172
- debug(f"[HTTP] {method} {url}")
248
+ debug(f"[HTTP] {method} {self.base_url}{url}")
173
249
  if params:
174
250
  debug(f"[HTTP] Query params: {mask_sensitive(params)}")
175
251
  if json:
176
252
  debug(f"[HTTP] Request body: {truncate_data(mask_sensitive(json))}")
177
253
 
178
- response = self.session.request(
254
+ response = self.sync_client.request(
179
255
  method=method,
180
256
  url=url,
181
257
  params=params,
182
258
  json=json,
183
- timeout=self.config.network.timeout,
184
259
  **kwargs
185
260
  )
186
261
 
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
262
+ return self._handle_response(response)
263
+
264
+ # ==================== Asynchronous Methods ====================
265
+
266
+ async def aget(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
267
+ """Make an asynchronous GET request.
268
+
269
+ Args:
270
+ endpoint: API endpoint (without base URL)
271
+ params: Query parameters
195
272
 
196
- error(f"[HTTP] Error {response.status_code}: {error_msg}")
273
+ Returns:
274
+ Response data as dictionary
275
+ """
276
+ return await self.arequest("GET", endpoint, params=params)
277
+
278
+ async def apost(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
279
+ """Make an asynchronous POST request.
197
280
 
198
- response.raise_for_status()
281
+ Args:
282
+ endpoint: API endpoint (without base URL)
283
+ data: Request body data
284
+
285
+ Returns:
286
+ Response data as dictionary
287
+ """
288
+ data = self._add_timestamp(data)
289
+ return await self.arequest("POST", endpoint, json=data)
290
+
291
+ async def aput(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
292
+ """Make an asynchronous PUT request.
199
293
 
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}
294
+ Args:
295
+ endpoint: API endpoint (without base URL)
296
+ data: Request body data
297
+
298
+ Returns:
299
+ Response data as dictionary
300
+ """
301
+ data = self._add_timestamp(data)
302
+ return await self.arequest("PUT", endpoint, json=data)
303
+
304
+ async def adelete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
305
+ """Make an asynchronous DELETE request.
210
306
 
211
- debug(f"[HTTP] Response ({response.status_code}): {truncate_data(data)}")
307
+ Args:
308
+ endpoint: API endpoint (without base URL)
309
+ params: Query parameters
310
+
311
+ Returns:
312
+ Response data as dictionary
313
+ """
314
+ return await self.arequest("DELETE", endpoint, params=params)
315
+
316
+ async def arequest(
317
+ self,
318
+ method: str,
319
+ endpoint: str,
320
+ params: Optional[Dict[str, Any]] = None,
321
+ json: Optional[Dict[str, Any]] = None,
322
+ **kwargs
323
+ ) -> Dict[str, Any]:
324
+ """Make an asynchronous HTTP request.
212
325
 
213
- return data
326
+ Args:
327
+ method: HTTP method
328
+ endpoint: API endpoint (without base URL)
329
+ params: Query parameters
330
+ json: Request body (for POST/PUT)
331
+ **kwargs: Additional arguments for httpx
332
+
333
+ Returns:
334
+ Response data as dictionary
335
+
336
+ Raises:
337
+ httpx.HTTPError: On HTTP errors
338
+ """
339
+ url = f"/{endpoint}"
340
+
341
+ # Log request details
342
+ debug(f"[HTTP] {method} {self.base_url}{url}")
343
+ if params:
344
+ debug(f"[HTTP] Query params: {mask_sensitive(params)}")
345
+ if json:
346
+ debug(f"[HTTP] Request body: {truncate_data(mask_sensitive(json))}")
347
+
348
+ response = await self.async_client.request(
349
+ method=method,
350
+ url=url,
351
+ params=params,
352
+ json=json,
353
+ **kwargs
354
+ )
355
+
356
+ return self._handle_response(response)
357
+
358
+ # ==================== Lifecycle Methods ====================
214
359
 
215
360
  def close(self) -> None:
216
- """Close the HTTP session."""
217
- if self.session:
218
- self.session.close()
361
+ """Close the synchronous HTTP client."""
362
+ if self._sync_client is not None and not self._sync_client.is_closed:
363
+ self._sync_client.close()
364
+ self._sync_client = None
365
+
366
+ async def aclose(self) -> None:
367
+ """Close the asynchronous HTTP client."""
368
+ if self._async_client is not None and not self._async_client.is_closed:
369
+ await self._async_client.aclose()
370
+ self._async_client = None
371
+
372
+ def __enter__(self) -> "HttpClient":
373
+ """Context manager entry for sync client."""
374
+ return self
375
+
376
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
377
+ """Context manager exit for sync client."""
378
+ self.close()
379
+
380
+ async def __aenter__(self) -> "HttpClient":
381
+ """Async context manager entry."""
382
+ return self
383
+
384
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
385
+ """Async context manager exit."""
386
+ await self.aclose()
@@ -1 +1,16 @@
1
- """API resource modules."""
1
+ """API resource modules."""
2
+ from .session import SessionResource
3
+ from .event import EventResource
4
+ from .dataset import DatasetResource
5
+ from .experiment import ExperimentResource
6
+ from .prompt import PromptResource
7
+ from .feature_flag import FeatureFlagResource
8
+
9
+ __all__ = [
10
+ "SessionResource",
11
+ "EventResource",
12
+ "DatasetResource",
13
+ "ExperimentResource",
14
+ "PromptResource",
15
+ "FeatureFlagResource",
16
+ ]