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.
- lucidicai/__init__.py +367 -899
- lucidicai/api/__init__.py +1 -0
- lucidicai/api/client.py +218 -0
- lucidicai/api/resources/__init__.py +1 -0
- lucidicai/api/resources/dataset.py +192 -0
- lucidicai/api/resources/event.py +88 -0
- lucidicai/api/resources/session.py +126 -0
- lucidicai/core/__init__.py +1 -0
- lucidicai/core/config.py +223 -0
- lucidicai/core/errors.py +60 -0
- lucidicai/core/types.py +35 -0
- lucidicai/sdk/__init__.py +1 -0
- lucidicai/sdk/context.py +231 -0
- lucidicai/sdk/decorators.py +187 -0
- lucidicai/sdk/error_boundary.py +299 -0
- lucidicai/sdk/event.py +126 -0
- lucidicai/sdk/event_builder.py +304 -0
- lucidicai/sdk/features/__init__.py +1 -0
- lucidicai/sdk/features/dataset.py +605 -0
- lucidicai/sdk/features/feature_flag.py +383 -0
- lucidicai/sdk/init.py +361 -0
- lucidicai/sdk/shutdown_manager.py +302 -0
- lucidicai/telemetry/context_bridge.py +82 -0
- lucidicai/telemetry/context_capture_processor.py +25 -9
- lucidicai/telemetry/litellm_bridge.py +20 -24
- lucidicai/telemetry/lucidic_exporter.py +99 -60
- lucidicai/telemetry/openai_patch.py +295 -0
- lucidicai/telemetry/openai_uninstrument.py +87 -0
- lucidicai/telemetry/telemetry_init.py +16 -1
- lucidicai/telemetry/utils/model_pricing.py +278 -0
- lucidicai/utils/__init__.py +1 -0
- lucidicai/utils/images.py +337 -0
- lucidicai/utils/logger.py +168 -0
- lucidicai/utils/queue.py +393 -0
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/METADATA +1 -1
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/RECORD +38 -9
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/WHEEL +0 -0
- {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")
|