lucidicai 2.0.2__py3-none-any.whl → 2.1.1__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 +367 -899
  2. lucidicai/api/__init__.py +1 -0
  3. lucidicai/api/client.py +218 -0
  4. lucidicai/api/resources/__init__.py +1 -0
  5. lucidicai/api/resources/dataset.py +192 -0
  6. lucidicai/api/resources/event.py +88 -0
  7. lucidicai/api/resources/session.py +126 -0
  8. lucidicai/core/__init__.py +1 -0
  9. lucidicai/core/config.py +223 -0
  10. lucidicai/core/errors.py +60 -0
  11. lucidicai/core/types.py +35 -0
  12. lucidicai/sdk/__init__.py +1 -0
  13. lucidicai/sdk/context.py +231 -0
  14. lucidicai/sdk/decorators.py +187 -0
  15. lucidicai/sdk/error_boundary.py +299 -0
  16. lucidicai/sdk/event.py +126 -0
  17. lucidicai/sdk/event_builder.py +304 -0
  18. lucidicai/sdk/features/__init__.py +1 -0
  19. lucidicai/sdk/features/dataset.py +605 -0
  20. lucidicai/sdk/features/feature_flag.py +383 -0
  21. lucidicai/sdk/init.py +361 -0
  22. lucidicai/sdk/shutdown_manager.py +302 -0
  23. lucidicai/telemetry/context_bridge.py +82 -0
  24. lucidicai/telemetry/context_capture_processor.py +25 -9
  25. lucidicai/telemetry/litellm_bridge.py +20 -24
  26. lucidicai/telemetry/lucidic_exporter.py +99 -60
  27. lucidicai/telemetry/openai_patch.py +295 -0
  28. lucidicai/telemetry/openai_uninstrument.py +87 -0
  29. lucidicai/telemetry/telemetry_init.py +16 -1
  30. lucidicai/telemetry/utils/model_pricing.py +278 -0
  31. lucidicai/utils/__init__.py +1 -0
  32. lucidicai/utils/images.py +337 -0
  33. lucidicai/utils/logger.py +168 -0
  34. lucidicai/utils/queue.py +393 -0
  35. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/METADATA +1 -1
  36. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/RECORD +38 -9
  37. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/WHEEL +0 -0
  38. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,383 @@
1
+ import os
2
+ import logging
3
+ import time
4
+ from typing import Union, List, Dict, Any, Optional, overload, Tuple, Literal
5
+ from dotenv import load_dotenv
6
+
7
+ from ..init import get_http
8
+ from ...core.errors import APIKeyVerificationError, FeatureFlagError
9
+
10
+ logger = logging.getLogger("Lucidic")
11
+
12
+ # Cache implementation
13
+ class FeatureFlagCache:
14
+ def __init__(self):
15
+ self._cache: Dict[str, tuple[Any, float]] = {}
16
+ self._default_ttl = 300 # 5 minutes
17
+
18
+ def get(self, key: str) -> Optional[Any]:
19
+ if key in self._cache:
20
+ value, expiry = self._cache[key]
21
+ if time.time() < expiry:
22
+ return value
23
+ else:
24
+ del self._cache[key]
25
+ return None
26
+
27
+ def set(self, key: str, value: Any, ttl: int = None):
28
+ if ttl is None:
29
+ ttl = self._default_ttl
30
+ if ttl > 0:
31
+ self._cache[key] = (value, time.time() + ttl)
32
+
33
+ def clear(self):
34
+ self._cache.clear()
35
+
36
+ # Global cache instance
37
+ _flag_cache = FeatureFlagCache()
38
+
39
+ # Sentinel value to distinguish None from missing
40
+ MISSING = object()
41
+
42
+ # Function overloads for type safety
43
+ @overload
44
+ def get_feature_flag(
45
+ flag_name: str,
46
+ default: Any = ...,
47
+ *,
48
+ return_missing: Literal[False] = False,
49
+ cache_ttl: Optional[int] = 300,
50
+ api_key: Optional[str] = None,
51
+ agent_id: Optional[str] = None,
52
+ ) -> Any:
53
+ """Get a single feature flag."""
54
+ ...
55
+
56
+ @overload
57
+ def get_feature_flag(
58
+ flag_name: str,
59
+ default: Any = ...,
60
+ *,
61
+ return_missing: Literal[True],
62
+ cache_ttl: Optional[int] = 300,
63
+ api_key: Optional[str] = None,
64
+ agent_id: Optional[str] = None,
65
+ ) -> Tuple[Any, List[str]]:
66
+ """Get a single feature flag with missing info."""
67
+ ...
68
+
69
+ @overload
70
+ def get_feature_flag(
71
+ flag_name: List[str],
72
+ defaults: Optional[Dict[str, Any]] = None,
73
+ *,
74
+ return_missing: Literal[False] = False,
75
+ cache_ttl: Optional[int] = 300,
76
+ api_key: Optional[str] = None,
77
+ agent_id: Optional[str] = None,
78
+ ) -> Dict[str, Any]:
79
+ """Get multiple feature flags."""
80
+ ...
81
+
82
+ @overload
83
+ def get_feature_flag(
84
+ flag_name: List[str],
85
+ defaults: Optional[Dict[str, Any]] = None,
86
+ *,
87
+ return_missing: Literal[True],
88
+ cache_ttl: Optional[int] = 300,
89
+ api_key: Optional[str] = None,
90
+ agent_id: Optional[str] = None,
91
+ ) -> Tuple[Dict[str, Any], List[str]]:
92
+ """Get multiple feature flags with missing info."""
93
+ ...
94
+
95
+ def get_feature_flag(
96
+ flag_name: Union[str, List[str]],
97
+ default_or_defaults: Any = MISSING,
98
+ *,
99
+ return_missing: bool = False,
100
+ cache_ttl: Optional[int] = 300,
101
+ api_key: Optional[str] = None,
102
+ agent_id: Optional[str] = None,
103
+ ) -> Union[Any, Tuple[Any, List[str]], Dict[str, Any], Tuple[Dict[str, Any], List[str]]]:
104
+ """
105
+ Get feature flag(s) from backend. Raises FeatureFlagError on failure unless default provided.
106
+
107
+ Args:
108
+ flag_name: Single flag name (str) or list of flag names
109
+ default_or_defaults:
110
+ - If flag_name is str: default value for that flag (optional)
111
+ - If flag_name is List[str]: dict of defaults {flag_name: default_value}
112
+ cache_ttl: Cache time-to-live in seconds (0 to disable, -1 for forever)
113
+ api_key: Optional API key
114
+ agent_id: Optional agent ID
115
+
116
+ Returns:
117
+ - If flag_name is str: The flag value (or tuple with missing list if return_missing=True)
118
+ - If flag_name is List[str]: Dict mapping flag_name -> value (or tuple with missing list if return_missing=True)
119
+
120
+ Raises:
121
+ FeatureFlagError: If fetch fails and no default provided
122
+ APIKeyVerificationError: If credentials missing
123
+
124
+ Examples:
125
+ # Single flag with default
126
+ retries = lai.get_feature_flag("max_retries", default=3)
127
+
128
+ # Single flag without default (can raise)
129
+ retries = lai.get_feature_flag("max_retries")
130
+
131
+ # Multiple flags
132
+ flags = lai.get_feature_flag(
133
+ ["max_retries", "timeout"],
134
+ defaults={"max_retries": 3}
135
+ )
136
+ """
137
+
138
+ load_dotenv()
139
+
140
+ # Determine if single or batch
141
+ is_single = isinstance(flag_name, str)
142
+ flag_names = [flag_name] if is_single else flag_name
143
+
144
+ # Parse defaults
145
+ if is_single:
146
+ has_default = default_or_defaults is not MISSING
147
+ defaults = {flag_name: default_or_defaults} if has_default else {}
148
+ else:
149
+ defaults = default_or_defaults if default_or_defaults not in (None, MISSING) else {}
150
+
151
+ # Track missing flags
152
+ missing_flags = []
153
+
154
+ # Check cache first
155
+ uncached_flags = []
156
+ cached_results = {}
157
+
158
+ if cache_ttl != 0:
159
+ for name in flag_names:
160
+ cache_key = f"{agent_id}:{name}"
161
+ cached_value = _flag_cache.get(cache_key)
162
+ if cached_value is not None:
163
+ cached_results[name] = cached_value
164
+ else:
165
+ uncached_flags.append(name)
166
+ else:
167
+ uncached_flags = flag_names
168
+
169
+ # Fetch uncached flags if needed
170
+ if uncached_flags:
171
+ # Get credentials
172
+ if api_key is None:
173
+ api_key = os.getenv("LUCIDIC_API_KEY", None)
174
+ if api_key is None:
175
+ raise APIKeyVerificationError(
176
+ "Make sure to either pass your API key or set the LUCIDIC_API_KEY environment variable."
177
+ )
178
+
179
+ if agent_id is None:
180
+ agent_id = os.getenv("LUCIDIC_AGENT_ID", None)
181
+ if agent_id is None:
182
+ raise APIKeyVerificationError(
183
+ "Lucidic agent ID not specified. Make sure to either pass your agent ID or set the LUCIDIC_AGENT_ID environment variable."
184
+ )
185
+
186
+ # Get HTTP client
187
+ http = get_http()
188
+ if not http:
189
+ from ..init import init
190
+ init(api_key=api_key, agent_id=agent_id)
191
+ http = get_http()
192
+
193
+ # check for active session
194
+ from ..init import get_session_id
195
+ session_id = get_session_id()
196
+
197
+ try:
198
+ if len(uncached_flags) == 1:
199
+ # Single flag evaluation
200
+ if session_id:
201
+ # Use session-based evaluation for consistency
202
+ response = http.post('evaluatefeatureflag', {
203
+ 'session_id': session_id,
204
+ 'flag_name': uncached_flags[0],
205
+ 'context': {},
206
+ 'default': defaults.get(uncached_flags[0])
207
+ })
208
+ else:
209
+ # Use stateless evaluation as fallback
210
+ response = http.post('evaluatefeatureflagstateless', {
211
+ 'agent_id': agent_id,
212
+ 'flag_name': uncached_flags[0],
213
+ 'context': {},
214
+ 'default': defaults.get(uncached_flags[0])
215
+ })
216
+
217
+ # Extract value from response
218
+ if 'value' in response:
219
+ value = response['value']
220
+ cached_results[uncached_flags[0]] = value
221
+
222
+ # Cache the result
223
+ if cache_ttl != 0:
224
+ cache_key = f"{agent_id}:{uncached_flags[0]}"
225
+ _flag_cache.set(cache_key, value, ttl=cache_ttl if cache_ttl > 0 else None)
226
+ elif 'error' in response:
227
+ # Flag not found or error
228
+ logger.warning(f"Feature flag error: {response['error']}")
229
+ missing_flags.append(uncached_flags[0])
230
+
231
+ else:
232
+ # Batch evaluation
233
+ if session_id:
234
+ # Use session-based batch evaluation
235
+ response = http.post('evaluatebatchfeatureflags', {
236
+ 'session_id': session_id,
237
+ 'flag_names': uncached_flags,
238
+ 'context': {},
239
+ 'defaults': {k: v for k, v in defaults.items() if k in uncached_flags}
240
+ })
241
+ else:
242
+ # Use stateless batch evaluation
243
+ response = http.post('evaluatebatchfeatureflagsstateless', {
244
+ 'agent_id': agent_id,
245
+ 'flag_names': uncached_flags,
246
+ 'context': {},
247
+ 'defaults': {k: v for k, v in defaults.items() if k in uncached_flags}
248
+ })
249
+
250
+ # Process batch response
251
+ if 'flags' in response:
252
+ for name in uncached_flags:
253
+ flag_data = response['flags'].get(name)
254
+ if flag_data and 'value' in flag_data:
255
+ value = flag_data['value']
256
+ cached_results[name] = value
257
+
258
+ # Cache it
259
+ if cache_ttl != 0:
260
+ cache_key = f"{agent_id}:{name}"
261
+ _flag_cache.set(cache_key, value, ttl=cache_ttl if cache_ttl > 0 else None)
262
+ else:
263
+ missing_flags.append(name)
264
+
265
+ except Exception as e:
266
+ # HTTP client raises on errors, fall back to defaults
267
+ logger.error(f"Failed to fetch feature flags: {e}")
268
+
269
+ # Use defaults for all uncached flags
270
+ for name in uncached_flags:
271
+ if name in defaults:
272
+ cached_results[name] = defaults[name]
273
+ else:
274
+ missing_flags.append(name)
275
+ if is_single and not return_missing:
276
+ raise FeatureFlagError(f"'{name}': {e}") from e
277
+
278
+ # Build final result
279
+ result = {}
280
+ for name in flag_names:
281
+ if name in cached_results:
282
+ result[name] = cached_results[name]
283
+ elif name in defaults:
284
+ result[name] = defaults[name]
285
+ else:
286
+ # No value and no default
287
+ missing_flags.append(name)
288
+ if is_single and not return_missing:
289
+ raise FeatureFlagError(f"'{name}' not found and no default provided")
290
+ else:
291
+ result[name] = None
292
+
293
+ # Return based on input type and return_missing flag
294
+ if return_missing:
295
+ return (result[flag_names[0]] if is_single else result, missing_flags)
296
+ else:
297
+ return result[flag_names[0]] if is_single else result
298
+
299
+
300
+ # Typed convenience functions
301
+ def get_bool_flag(flag_name: str, default: Optional[bool] = None, **kwargs) -> bool:
302
+ """
303
+ Get a boolean feature flag with type validation.
304
+
305
+ Raises:
306
+ FeatureFlagError: If fetch fails and no default provided
307
+ TypeError: If flag value is not a boolean
308
+ """
309
+ value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
310
+ if not isinstance(value, bool):
311
+ if default is not None:
312
+ logger.warning(f"Feature flag '{flag_name}' is not a boolean, using default")
313
+ return default
314
+ raise TypeError(f"Feature flag '{flag_name}' expected boolean, got {type(value).__name__}")
315
+ return value
316
+
317
+
318
+ def get_int_flag(flag_name: str, default: Optional[int] = None, **kwargs) -> int:
319
+ """
320
+ Get an integer feature flag with type validation.
321
+
322
+ Raises:
323
+ FeatureFlagError: If fetch fails and no default provided
324
+ TypeError: If flag value is not an integer
325
+ """
326
+ value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
327
+ if not isinstance(value, int) or isinstance(value, bool): # bool is subclass of int
328
+ if default is not None:
329
+ logger.warning(f"Feature flag '{flag_name}' is not an integer, using default")
330
+ return default
331
+ raise TypeError(f"Feature flag '{flag_name}' expected integer, got {type(value).__name__}")
332
+ return value
333
+
334
+
335
+ def get_float_flag(flag_name: str, default: Optional[float] = None, **kwargs) -> float:
336
+ """
337
+ Get a float feature flag with type validation.
338
+
339
+ Raises:
340
+ FeatureFlagError: If fetch fails and no default provided
341
+ TypeError: If flag value is not a float
342
+ """
343
+ value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
344
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
345
+ if default is not None:
346
+ logger.warning(f"Feature flag '{flag_name}' is not a float, using default")
347
+ return default
348
+ raise TypeError(f"Feature flag '{flag_name}' expected float, got {type(value).__name__}")
349
+ return float(value)
350
+
351
+
352
+ def get_string_flag(flag_name: str, default: Optional[str] = None, **kwargs) -> str:
353
+ """
354
+ Get a string feature flag with type validation.
355
+
356
+ Raises:
357
+ FeatureFlagError: If fetch fails and no default provided
358
+ TypeError: If flag value is not a string
359
+ """
360
+ value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
361
+ if not isinstance(value, str):
362
+ if default is not None:
363
+ logger.warning(f"Feature flag '{flag_name}' is not a string, using default")
364
+ return default
365
+ raise TypeError(f"Feature flag '{flag_name}' expected string, got {type(value).__name__}")
366
+ return value
367
+
368
+
369
+ def get_json_flag(flag_name: str, default: Optional[dict] = None, **kwargs) -> dict:
370
+ """
371
+ Get a JSON object feature flag.
372
+
373
+ Raises:
374
+ FeatureFlagError: If fetch fails and no default provided
375
+ """
376
+ value = get_feature_flag(flag_name, default=default if default is not None else MISSING, **kwargs)
377
+ return value
378
+
379
+
380
+ def clear_feature_flag_cache():
381
+ """Clear the feature flag cache."""
382
+ _flag_cache.clear()
383
+ logger.debug("Feature flag cache cleared")