lucidicai 2.1.3__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.
- lucidicai/__init__.py +32 -390
- lucidicai/api/client.py +31 -2
- lucidicai/api/resources/__init__.py +16 -1
- lucidicai/api/resources/dataset.py +422 -82
- lucidicai/api/resources/event.py +399 -27
- lucidicai/api/resources/experiment.py +108 -0
- lucidicai/api/resources/feature_flag.py +78 -0
- lucidicai/api/resources/prompt.py +84 -0
- lucidicai/api/resources/session.py +545 -38
- lucidicai/client.py +395 -480
- lucidicai/core/config.py +73 -48
- lucidicai/core/errors.py +3 -3
- lucidicai/sdk/bound_decorators.py +321 -0
- lucidicai/sdk/context.py +20 -2
- lucidicai/sdk/decorators.py +283 -74
- lucidicai/sdk/event.py +538 -36
- lucidicai/sdk/event_builder.py +2 -4
- lucidicai/sdk/features/dataset.py +391 -1
- lucidicai/sdk/features/feature_flag.py +344 -3
- lucidicai/sdk/init.py +49 -347
- lucidicai/sdk/session.py +502 -0
- lucidicai/sdk/shutdown_manager.py +103 -46
- lucidicai/session_obj.py +321 -0
- lucidicai/telemetry/context_capture_processor.py +13 -6
- lucidicai/telemetry/extract.py +60 -63
- lucidicai/telemetry/litellm_bridge.py +3 -44
- lucidicai/telemetry/lucidic_exporter.py +143 -131
- lucidicai/telemetry/openai_agents_instrumentor.py +2 -2
- lucidicai/telemetry/openai_patch.py +7 -6
- lucidicai/telemetry/telemetry_manager.py +183 -0
- lucidicai/telemetry/utils/model_pricing.py +21 -30
- lucidicai/telemetry/utils/provider.py +77 -0
- lucidicai/utils/images.py +27 -11
- lucidicai/utils/serialization.py +27 -0
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/METADATA +1 -1
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/RECORD +38 -29
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/WHEEL +0 -0
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/top_level.txt +0 -0
lucidicai/client.py
CHANGED
|
@@ -1,513 +1,428 @@
|
|
|
1
|
+
"""LucidicAI Client - Instance-based SDK for AI observability.
|
|
2
|
+
|
|
3
|
+
This module provides the main LucidicAI class, which is the entry point
|
|
4
|
+
for all SDK operations. Each client instance maintains its own state,
|
|
5
|
+
HTTP connections, and telemetry context.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from lucidicai import LucidicAI
|
|
9
|
+
|
|
10
|
+
client = LucidicAI(api_key="...", agent_id="...", providers=["openai"])
|
|
11
|
+
|
|
12
|
+
with client.create_session(session_name="My Session") as session:
|
|
13
|
+
@client.event
|
|
14
|
+
def my_function():
|
|
15
|
+
# LLM calls are automatically tracked
|
|
16
|
+
pass
|
|
17
|
+
my_function()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
1
21
|
import os
|
|
2
|
-
import time
|
|
3
22
|
import threading
|
|
4
|
-
|
|
5
|
-
from typing import
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
|
25
|
+
|
|
26
|
+
from .api.client import HttpClient
|
|
27
|
+
from .api.resources.session import SessionResource
|
|
28
|
+
from .api.resources.event import EventResource
|
|
29
|
+
from .api.resources.dataset import DatasetResource
|
|
30
|
+
from .api.resources.experiment import ExperimentResource
|
|
31
|
+
from .api.resources.prompt import PromptResource
|
|
32
|
+
from .api.resources.feature_flag import FeatureFlagResource
|
|
33
|
+
from .core.config import SDKConfig
|
|
34
|
+
from .core.errors import LucidicError
|
|
35
|
+
from .session_obj import Session
|
|
36
|
+
from .sdk.shutdown_manager import ShutdownManager, SessionState
|
|
6
37
|
|
|
7
|
-
|
|
8
|
-
import logging
|
|
9
|
-
import json
|
|
10
|
-
from requests.adapters import HTTPAdapter, Retry
|
|
11
|
-
from urllib3.util import Retry
|
|
38
|
+
logger = logging.getLogger("Lucidic")
|
|
12
39
|
|
|
40
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
41
|
|
|
14
|
-
from .errors import APIKeyVerificationError, InvalidOperationError, LucidicNotInitializedError
|
|
15
|
-
from .session import Session
|
|
16
|
-
from .singleton import singleton, clear_singletons
|
|
17
|
-
from .lru import LRUCache
|
|
18
|
-
from .event import Event
|
|
19
|
-
from .event_queue import EventQueue
|
|
20
|
-
import uuid
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
def get_shutdown_manager() -> ShutdownManager:
|
|
44
|
+
"""Get the singleton ShutdownManager instance."""
|
|
45
|
+
return ShutdownManager()
|
|
23
46
|
|
|
24
|
-
logger = logging.getLogger("Lucidic")
|
|
25
47
|
|
|
48
|
+
class LucidicAI:
|
|
49
|
+
"""Instance-based Lucidic AI client for observability.
|
|
50
|
+
|
|
51
|
+
Each LucidicAI instance maintains its own:
|
|
52
|
+
- HTTP connections and configuration
|
|
53
|
+
- API resources (sessions, events, datasets)
|
|
54
|
+
- Active sessions
|
|
55
|
+
- Telemetry registration
|
|
56
|
+
|
|
57
|
+
Multiple clients can coexist with different configurations.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
api_key: Lucidic API key. Falls back to LUCIDIC_API_KEY env var.
|
|
61
|
+
agent_id: Agent identifier. Falls back to LUCIDIC_AGENT_ID env var.
|
|
62
|
+
providers: List of LLM providers to instrument (e.g., ["openai", "anthropic"]).
|
|
63
|
+
auto_end: Whether sessions auto-end on context exit or process shutdown.
|
|
64
|
+
production: If True, suppress SDK errors. If None, checks LUCIDIC_PRODUCTION env var.
|
|
65
|
+
region: Deployment region ("us", "india"). Falls back to LUCIDIC_REGION env var.
|
|
66
|
+
**kwargs: Additional configuration options passed to SDKConfig.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If required configuration is missing, invalid, or region is unrecognized.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
# Basic usage
|
|
73
|
+
client = LucidicAI(api_key="...", agent_id="...")
|
|
74
|
+
|
|
75
|
+
# With providers for auto-instrumentation
|
|
76
|
+
client = LucidicAI(
|
|
77
|
+
api_key="...",
|
|
78
|
+
agent_id="...",
|
|
79
|
+
providers=["openai", "anthropic"]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Production mode (suppress errors)
|
|
83
|
+
client = LucidicAI(
|
|
84
|
+
api_key="...",
|
|
85
|
+
agent_id="...",
|
|
86
|
+
production=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# India region
|
|
90
|
+
client = LucidicAI(
|
|
91
|
+
api_key="...",
|
|
92
|
+
agent_id="...",
|
|
93
|
+
region="india"
|
|
94
|
+
)
|
|
95
|
+
"""
|
|
26
96
|
|
|
27
|
-
@singleton
|
|
28
|
-
class Client:
|
|
29
97
|
def __init__(
|
|
30
98
|
self,
|
|
31
|
-
api_key: str,
|
|
32
|
-
agent_id: str,
|
|
99
|
+
api_key: Optional[str] = None,
|
|
100
|
+
agent_id: Optional[str] = None,
|
|
101
|
+
providers: Optional[List[str]] = None,
|
|
102
|
+
auto_end: bool = True,
|
|
103
|
+
production: Optional[bool] = None,
|
|
104
|
+
region: Optional[str] = None,
|
|
105
|
+
**kwargs,
|
|
33
106
|
):
|
|
34
|
-
|
|
35
|
-
self.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
self.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
self.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
allowed_methods=["GET", "POST", "PUT", "DELETE"],
|
|
107
|
+
# Generate unique client ID for telemetry routing
|
|
108
|
+
self._client_id = str(uuid.uuid4())
|
|
109
|
+
|
|
110
|
+
# Resolve production mode: arg > env > default (False)
|
|
111
|
+
if production is None:
|
|
112
|
+
production = os.getenv("LUCIDIC_PRODUCTION", "").lower() in ("true", "1")
|
|
113
|
+
self._production = production
|
|
114
|
+
|
|
115
|
+
# Build configuration
|
|
116
|
+
self._config = SDKConfig.from_env(
|
|
117
|
+
api_key=api_key,
|
|
118
|
+
agent_id=agent_id,
|
|
119
|
+
auto_end=auto_end,
|
|
120
|
+
region=region,
|
|
121
|
+
**kwargs,
|
|
50
122
|
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
self.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
#
|
|
66
|
-
self.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
self.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def clear(self):
|
|
78
|
-
# Clean up singleton state
|
|
79
|
-
clear_singletons()
|
|
80
|
-
self.initialized = False
|
|
81
|
-
self.session = None
|
|
82
|
-
del self
|
|
83
|
-
|
|
84
|
-
def verify_api_key(self, base_url: str, api_key: str) -> Tuple[str, str]:
|
|
85
|
-
data = self.make_request('verifyapikey', 'GET', {}) # TODO: Verify against agent ID provided
|
|
86
|
-
return data["project"], data["project_id"]
|
|
87
|
-
|
|
88
|
-
def set_provider(self, provider) -> None:
|
|
89
|
-
"""Deprecated: manual provider overrides removed (no-op)."""
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
def init_session(
|
|
93
|
-
self,
|
|
94
|
-
session_name: str,
|
|
95
|
-
task: Optional[str] = None,
|
|
96
|
-
rubrics: Optional[list] = None,
|
|
97
|
-
tags: Optional[list] = None,
|
|
98
|
-
production_monitoring: Optional[bool] = False,
|
|
99
|
-
session_id: Optional[str] = None,
|
|
100
|
-
experiment_id: Optional[str] = None,
|
|
101
|
-
dataset_item_id: Optional[str] = None,
|
|
102
|
-
) -> None:
|
|
103
|
-
if session_id:
|
|
104
|
-
# Check if it's a known session ID, maybe custom and maybe real
|
|
105
|
-
if session_id in self.custom_session_id_translations:
|
|
106
|
-
session_id = self.custom_session_id_translations[session_id]
|
|
107
|
-
# Check if it's the same as the current session
|
|
108
|
-
if self.session and self.session.session_id == session_id:
|
|
109
|
-
return self.session.session_id
|
|
110
|
-
# Check if it's a previous session that we have saved
|
|
111
|
-
if session_id in self.previous_sessions:
|
|
112
|
-
if self.session:
|
|
113
|
-
self.previous_sessions[self.session.session_id] = self.session
|
|
114
|
-
self.session = self.previous_sessions.pop(session_id) # Remove from previous sessions because it's now the current session
|
|
115
|
-
return self.session.session_id
|
|
116
|
-
|
|
117
|
-
# Either there's no session ID, or we don't know about the old session
|
|
118
|
-
# We need to go to the backend in both cases
|
|
119
|
-
request_data = {
|
|
120
|
-
"agent_id": self.agent_id,
|
|
121
|
-
"session_name": session_name,
|
|
122
|
-
"task": task,
|
|
123
|
-
"experiment_id": experiment_id,
|
|
124
|
-
"rubrics": rubrics,
|
|
125
|
-
"tags": tags,
|
|
126
|
-
"session_id": session_id,
|
|
127
|
-
"dataset_item_id": dataset_item_id,
|
|
128
|
-
"production_monitoring": production_monitoring
|
|
123
|
+
|
|
124
|
+
# Validate configuration
|
|
125
|
+
errors = self._config.validate()
|
|
126
|
+
if errors:
|
|
127
|
+
error_msg = f"Invalid configuration: {', '.join(errors)}"
|
|
128
|
+
if self._production:
|
|
129
|
+
logger.error(f"[LucidicAI] {error_msg}")
|
|
130
|
+
# In production mode, allow initialization but mark as invalid
|
|
131
|
+
self._valid = False
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(error_msg)
|
|
134
|
+
else:
|
|
135
|
+
self._valid = True
|
|
136
|
+
|
|
137
|
+
# Initialize HTTP client
|
|
138
|
+
self._http = HttpClient(self._config)
|
|
139
|
+
|
|
140
|
+
# Initialize API resources
|
|
141
|
+
self._resources: Dict[str, Any] = {
|
|
142
|
+
"sessions": SessionResource(self._http, self, self._config, self._production),
|
|
143
|
+
"events": EventResource(self._http, self._production),
|
|
144
|
+
"datasets": DatasetResource(self._http, self._config.agent_id, self._production),
|
|
145
|
+
"experiments": ExperimentResource(self._http, self._config.agent_id, self._production),
|
|
146
|
+
"prompts": PromptResource(self._http, self._production),
|
|
147
|
+
"feature_flags": FeatureFlagResource(self._http, self._config.agent_id, self._production),
|
|
129
148
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
|
|
150
|
+
# Active sessions for this client
|
|
151
|
+
self._sessions: Dict[str, Session] = {}
|
|
152
|
+
self._session_lock = threading.Lock()
|
|
153
|
+
|
|
154
|
+
# Store providers list
|
|
155
|
+
self._providers = providers or []
|
|
156
|
+
|
|
157
|
+
# Initialize telemetry if providers specified
|
|
158
|
+
if self._providers:
|
|
159
|
+
self._initialize_telemetry()
|
|
160
|
+
|
|
161
|
+
# Register with shutdown manager
|
|
162
|
+
shutdown_manager = get_shutdown_manager()
|
|
163
|
+
shutdown_manager.register_client(self)
|
|
164
|
+
|
|
165
|
+
logger.info(
|
|
166
|
+
f"[LucidicAI] Initialized client {self._client_id[:8]}... "
|
|
167
|
+
f"(production={self._production}, providers={self._providers})"
|
|
146
168
|
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
self._active_sessions.add(real_session_id)
|
|
151
|
-
if logger.isEnabledFor(logging.DEBUG):
|
|
152
|
-
logger.debug(f"[Client] Added active session {real_session_id[:8]}..., total: {len(self._active_sessions)}")
|
|
153
|
-
|
|
154
|
-
self.initialized = True
|
|
155
|
-
return self.session.session_id
|
|
156
|
-
|
|
157
|
-
def mark_session_inactive(self, session_id: str) -> None:
|
|
158
|
-
"""Mark a session as inactive. Used when ending a session."""
|
|
159
|
-
with self._active_sessions_lock:
|
|
160
|
-
if session_id in self._active_sessions:
|
|
161
|
-
self._active_sessions.discard(session_id)
|
|
162
|
-
if logger.isEnabledFor(logging.DEBUG):
|
|
163
|
-
logger.debug(f"[Client] Removed active session {session_id[:8]}..., remaining: {len(self._active_sessions)}")
|
|
164
|
-
|
|
165
|
-
def has_active_sessions(self) -> bool:
|
|
166
|
-
"""Check if there are any active sessions."""
|
|
167
|
-
with self._active_sessions_lock:
|
|
168
|
-
return len(self._active_sessions) > 0
|
|
169
|
-
|
|
170
|
-
def create_event_for_session(self, session_id: str, **kwargs) -> str:
|
|
171
|
-
"""Create an event for a specific session id (new typed model).
|
|
172
|
-
|
|
173
|
-
This avoids mutating the global session and directly uses the new
|
|
174
|
-
event API. Prefer passing typed fields and a 'type' argument.
|
|
175
|
-
"""
|
|
176
|
-
kwargs = dict(kwargs)
|
|
177
|
-
kwargs['session_id'] = session_id
|
|
178
|
-
return self.create_event(**kwargs)
|
|
179
|
-
|
|
180
|
-
def create_experiment(self, **kwargs) -> str:
|
|
181
|
-
kwargs['agent_id'] = self.agent_id
|
|
182
|
-
return self.make_request('createexperiment', 'POST', kwargs)['experiment_id']
|
|
183
|
-
|
|
184
|
-
def get_prompt(self, prompt_name, cache_ttl, label) -> str:
|
|
185
|
-
current_time = time.time()
|
|
186
|
-
key = (prompt_name, label)
|
|
187
|
-
if key in self.prompts:
|
|
188
|
-
prompt, expiration_time = self.prompts[key]
|
|
189
|
-
if expiration_time == float('inf') or current_time < expiration_time:
|
|
190
|
-
return prompt
|
|
191
|
-
params={
|
|
192
|
-
"agent_id": self.agent_id,
|
|
193
|
-
"prompt_name": prompt_name,
|
|
194
|
-
"label": label
|
|
195
|
-
}
|
|
196
|
-
prompt = self.make_request('getprompt', 'GET', params)['prompt_content']
|
|
197
|
-
|
|
198
|
-
if cache_ttl != 0:
|
|
199
|
-
if cache_ttl == -1:
|
|
200
|
-
expiration_time = float('inf')
|
|
201
|
-
else:
|
|
202
|
-
expiration_time = current_time + cache_ttl
|
|
203
|
-
self.prompts[key] = (prompt, expiration_time)
|
|
204
|
-
return prompt
|
|
205
|
-
|
|
206
|
-
def make_request(self, endpoint, method, data):
|
|
207
|
-
# Check if client is shutting down
|
|
208
|
-
if self._shutdown:
|
|
209
|
-
logger.warning(f"[HTTP] Attempted request after shutdown: {endpoint}")
|
|
210
|
-
return {}
|
|
211
|
-
|
|
212
|
-
data = {k: v for k, v in data.items() if v is not None}
|
|
213
|
-
|
|
214
|
-
http_methods = {
|
|
215
|
-
"GET": lambda data: self.request_session.get(f"{self.base_url}/{endpoint}", params=data),
|
|
216
|
-
"POST": lambda data: self.request_session.post(f"{self.base_url}/{endpoint}", json=data),
|
|
217
|
-
"PUT": lambda data: self.request_session.put(f"{self.base_url}/{endpoint}", json=data),
|
|
218
|
-
"DELETE": lambda data: self.request_session.delete(f"{self.base_url}/{endpoint}", params=data),
|
|
219
|
-
} # TODO: make into enum
|
|
220
|
-
data['current_time'] = datetime.now().astimezone(timezone.utc).isoformat()
|
|
221
|
-
# Debug: print final payload about to be sent
|
|
169
|
+
|
|
170
|
+
def _initialize_telemetry(self) -> None:
|
|
171
|
+
"""Initialize telemetry and register this client."""
|
|
222
172
|
try:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
173
|
+
from .telemetry.telemetry_manager import get_telemetry_manager
|
|
174
|
+
|
|
175
|
+
manager = get_telemetry_manager()
|
|
176
|
+
manager.ensure_initialized(self._providers)
|
|
177
|
+
manager.register_client(self)
|
|
178
|
+
logger.debug(f"[LucidicAI] Registered with telemetry manager")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
if self._production:
|
|
181
|
+
logger.error(f"[LucidicAI] Failed to initialize telemetry: {e}")
|
|
182
|
+
else:
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def client_id(self) -> str:
|
|
187
|
+
"""Get the unique client identifier."""
|
|
188
|
+
return self._client_id
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def config(self) -> SDKConfig:
|
|
192
|
+
"""Get the client configuration."""
|
|
193
|
+
return self._config
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def agent_id(self) -> Optional[str]:
|
|
197
|
+
"""Get the agent ID."""
|
|
198
|
+
return self._config.agent_id
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def is_valid(self) -> bool:
|
|
202
|
+
"""Check if the client is properly configured."""
|
|
203
|
+
return self._valid
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def experiments(self) -> ExperimentResource:
|
|
207
|
+
"""Access experiments resource.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
experiment_id = client.experiments.create(
|
|
211
|
+
experiment_name="My Experiment",
|
|
212
|
+
description="Testing new model"
|
|
213
|
+
)
|
|
214
|
+
"""
|
|
215
|
+
return self._resources["experiments"]
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def prompts(self) -> PromptResource:
|
|
219
|
+
"""Access prompts resource.
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
prompt = client.prompts.get(
|
|
223
|
+
prompt_name="greeting",
|
|
224
|
+
variables={"name": "Alice"}
|
|
225
|
+
)
|
|
226
|
+
"""
|
|
227
|
+
return self._resources["prompts"]
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def feature_flags(self) -> FeatureFlagResource:
|
|
231
|
+
"""Access feature flags resource.
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
flag_value = client.feature_flags.get(
|
|
235
|
+
flag_name="new_feature",
|
|
236
|
+
default=False
|
|
237
|
+
)
|
|
238
|
+
"""
|
|
239
|
+
return self._resources["feature_flags"]
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def sessions(self) -> SessionResource:
|
|
243
|
+
"""Access sessions resource.
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
with client.sessions.create(session_name="My Session") as session:
|
|
247
|
+
# Do work
|
|
234
248
|
pass
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if response.status_code == 401:
|
|
238
|
-
raise APIKeyVerificationError("Invalid API key: 401 Unauthorized")
|
|
239
|
-
if response.status_code == 402:
|
|
240
|
-
raise InvalidOperationError("Invalid operation: 402 Insufficient Credits")
|
|
241
|
-
if response.status_code == 403:
|
|
242
|
-
raise APIKeyVerificationError(f"Invalid API key: 403 Forbidden")
|
|
243
|
-
try:
|
|
244
|
-
response.raise_for_status()
|
|
245
|
-
except requests.exceptions.HTTPError as e:
|
|
246
|
-
raise InvalidOperationError(f"Request to Lucidic AI Backend failed: {e.response.text}")
|
|
247
|
-
return response.json()
|
|
248
|
-
|
|
249
|
-
# ==== New Typed Event Model Helpers ====
|
|
250
|
-
def _build_payload(self, type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
251
|
-
"""Build type-specific payload and place unrecognized keys in misc."""
|
|
252
|
-
# Remove non-payload top-level fields from kwargs copy
|
|
253
|
-
non_payload_fields = [
|
|
254
|
-
'parent_event_id', 'tags', 'metadata', 'occurred_at', 'duration', 'session_id',
|
|
255
|
-
'event_id'
|
|
256
|
-
]
|
|
257
|
-
for field in non_payload_fields:
|
|
258
|
-
if field in kwargs:
|
|
259
|
-
kwargs.pop(field, None)
|
|
260
|
-
|
|
261
|
-
if type == "llm_generation":
|
|
262
|
-
return self._build_llm_payload(kwargs)
|
|
263
|
-
elif type == "function_call":
|
|
264
|
-
return self._build_function_payload(kwargs)
|
|
265
|
-
elif type == "error_traceback":
|
|
266
|
-
return self._build_error_payload(kwargs)
|
|
267
|
-
else:
|
|
268
|
-
return self._build_generic_payload(kwargs)
|
|
269
|
-
|
|
270
|
-
def _build_llm_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
271
|
-
payload: Dict[str, Any] = {
|
|
272
|
-
"request": {},
|
|
273
|
-
"response": {},
|
|
274
|
-
"usage": {},
|
|
275
|
-
"status": "ok",
|
|
276
|
-
"misc": {}
|
|
277
|
-
}
|
|
278
|
-
# Request fields
|
|
279
|
-
for field in ["provider", "model", "messages", "params"]:
|
|
280
|
-
if field in kwargs:
|
|
281
|
-
payload["request"][field] = kwargs.pop(field)
|
|
282
|
-
# Response fields
|
|
283
|
-
for field in ["output", "messages", "tool_calls", "thinking", "raw"]:
|
|
284
|
-
if field in kwargs:
|
|
285
|
-
payload["response"][field] = kwargs.pop(field)
|
|
286
|
-
# Usage fields
|
|
287
|
-
for field in ["input_tokens", "output_tokens", "cache", "cost"]:
|
|
288
|
-
if field in kwargs:
|
|
289
|
-
payload["usage"][field] = kwargs.pop(field)
|
|
290
|
-
# Status / error
|
|
291
|
-
if 'status' in kwargs:
|
|
292
|
-
payload['status'] = kwargs.pop('status')
|
|
293
|
-
if 'error' in kwargs:
|
|
294
|
-
payload['error'] = kwargs.pop('error')
|
|
295
|
-
payload["misc"] = kwargs
|
|
296
|
-
return payload
|
|
297
|
-
|
|
298
|
-
def _build_function_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
299
|
-
payload: Dict[str, Any] = {
|
|
300
|
-
"function_name": kwargs.pop("function_name", "unknown"),
|
|
301
|
-
"arguments": kwargs.pop("arguments", {}),
|
|
302
|
-
"return_value": kwargs.pop("return_value", None),
|
|
303
|
-
"misc": kwargs
|
|
304
|
-
}
|
|
305
|
-
return payload
|
|
249
|
+
"""
|
|
250
|
+
return self._resources["sessions"]
|
|
306
251
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
"traceback": kwargs.pop("traceback", ""),
|
|
311
|
-
"misc": kwargs
|
|
312
|
-
}
|
|
313
|
-
return payload
|
|
252
|
+
@property
|
|
253
|
+
def events(self) -> EventResource:
|
|
254
|
+
"""Access events resource.
|
|
314
255
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
256
|
+
Example:
|
|
257
|
+
event_id = client.events.create(
|
|
258
|
+
type="custom_event",
|
|
259
|
+
data={"key": "value"}
|
|
260
|
+
)
|
|
261
|
+
"""
|
|
262
|
+
return self._resources["events"]
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def datasets(self) -> DatasetResource:
|
|
266
|
+
"""Access datasets resource.
|
|
267
|
+
|
|
268
|
+
Example:
|
|
269
|
+
dataset = client.datasets.get(dataset_id)
|
|
270
|
+
client.datasets.create(name="My Dataset")
|
|
271
|
+
"""
|
|
272
|
+
return self._resources["datasets"]
|
|
273
|
+
|
|
274
|
+
# ==================== Decorators ====================
|
|
275
|
+
|
|
276
|
+
def event(
|
|
277
|
+
self, func: Optional[Callable] = None, **decorator_kwargs
|
|
278
|
+
) -> Callable[[F], F]:
|
|
279
|
+
"""Create an event decorator bound to this client.
|
|
280
|
+
|
|
281
|
+
The decorator tracks function calls as events when the current
|
|
282
|
+
context belongs to this client.
|
|
283
|
+
|
|
284
|
+
Can be used with or without parentheses:
|
|
285
|
+
@client.event
|
|
286
|
+
def my_function(): ...
|
|
287
|
+
|
|
288
|
+
@client.event()
|
|
289
|
+
def my_function(): ...
|
|
290
|
+
|
|
291
|
+
@client.event(tags=["important"])
|
|
292
|
+
def my_function(): ...
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
func: The function to decorate (when used without parentheses).
|
|
296
|
+
**decorator_kwargs: Additional event metadata.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
A decorator function or decorated function.
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
@client.event
|
|
303
|
+
def process_data(data):
|
|
304
|
+
# Function call is tracked as an event
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
@client.event(tags=["important"])
|
|
308
|
+
async def async_process(data):
|
|
309
|
+
# Async functions are also supported
|
|
310
|
+
return result
|
|
311
|
+
"""
|
|
312
|
+
from .sdk.decorators import event as event_decorator
|
|
313
|
+
|
|
314
|
+
decorator = event_decorator(client=self, **decorator_kwargs)
|
|
315
|
+
|
|
316
|
+
# If func is provided, we're being used without parentheses
|
|
317
|
+
if func is not None:
|
|
318
|
+
return decorator(func)
|
|
319
|
+
|
|
320
|
+
# Otherwise, return the decorator for later application
|
|
321
|
+
return decorator
|
|
321
322
|
|
|
322
|
-
|
|
323
|
-
"""Create a typed event (non-blocking) and return client-side UUID.
|
|
323
|
+
# ==================== Lifecycle ====================
|
|
324
324
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
325
|
+
def close(self) -> None:
|
|
326
|
+
"""Close the client and clean up resources.
|
|
327
|
+
|
|
328
|
+
This ends all active sessions and unregisters from telemetry.
|
|
329
329
|
"""
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
330
|
+
logger.info(f"[LucidicAI] Closing client {self._client_id[:8]}...")
|
|
331
|
+
|
|
332
|
+
# Collect session IDs under lock (don't clear - let end() handle removal)
|
|
333
|
+
with self._session_lock:
|
|
334
|
+
session_ids = list(self._sessions.keys())
|
|
335
|
+
|
|
336
|
+
# End sessions WITHOUT holding lock (HTTP calls can be slow)
|
|
337
|
+
# end() will acquire lock and pop each session from _sessions
|
|
338
|
+
for session_id in session_ids:
|
|
333
339
|
try:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if not session_id:
|
|
341
|
-
raise InvalidOperationError("No active session for event creation")
|
|
342
|
-
|
|
343
|
-
# Parent event id from kwargs or parent context (client-side)
|
|
344
|
-
parent_event_id = kwargs.get('parent_event_id')
|
|
345
|
-
if not parent_event_id:
|
|
340
|
+
self.sessions.end(session_id)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.debug(f"[LucidicAI] Error ending session on close: {e}")
|
|
343
|
+
|
|
344
|
+
# Unregister from telemetry
|
|
345
|
+
if self._providers:
|
|
346
346
|
try:
|
|
347
|
-
from .
|
|
348
|
-
parent_event_id = current_parent_event_id.get(None)
|
|
349
|
-
except Exception:
|
|
350
|
-
parent_event_id = None
|
|
351
|
-
|
|
352
|
-
# Build payload (typed)
|
|
353
|
-
payload = self._build_payload(type, dict(kwargs))
|
|
354
|
-
|
|
355
|
-
# Occurred-at
|
|
356
|
-
from datetime import datetime as _dt
|
|
357
|
-
_occ = kwargs.get("occurred_at")
|
|
358
|
-
if isinstance(_occ, str):
|
|
359
|
-
occurred_at_str = _occ
|
|
360
|
-
elif isinstance(_occ, _dt):
|
|
361
|
-
if _occ.tzinfo is None:
|
|
362
|
-
local_tz = _dt.now().astimezone().tzinfo
|
|
363
|
-
occurred_at_str = _occ.replace(tzinfo=local_tz).isoformat()
|
|
364
|
-
else:
|
|
365
|
-
occurred_at_str = _occ.isoformat()
|
|
366
|
-
else:
|
|
367
|
-
occurred_at_str = _dt.now().astimezone().isoformat()
|
|
368
|
-
|
|
369
|
-
# Client-side UUIDs
|
|
370
|
-
client_event_id = kwargs.get('event_id') or str(uuid.uuid4())
|
|
371
|
-
|
|
372
|
-
# Build request body with client ids
|
|
373
|
-
event_request: Dict[str, Any] = {
|
|
374
|
-
"session_id": session_id,
|
|
375
|
-
"client_event_id": client_event_id,
|
|
376
|
-
"client_parent_event_id": parent_event_id,
|
|
377
|
-
"type": type,
|
|
378
|
-
"occurred_at": occurred_at_str,
|
|
379
|
-
"duration": kwargs.get("duration"),
|
|
380
|
-
"tags": kwargs.get("tags", []),
|
|
381
|
-
"metadata": kwargs.get("metadata", {}),
|
|
382
|
-
"payload": payload,
|
|
383
|
-
}
|
|
347
|
+
from .telemetry.telemetry_manager import get_telemetry_manager
|
|
384
348
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
349
|
+
manager = get_telemetry_manager()
|
|
350
|
+
manager.unregister_client(self._client_id)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.debug(f"[LucidicAI] Error unregistering from telemetry: {e}")
|
|
388
353
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
354
|
+
# Unregister from shutdown manager
|
|
355
|
+
try:
|
|
356
|
+
shutdown_manager = get_shutdown_manager()
|
|
357
|
+
shutdown_manager.unregister_client(self._client_id)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.debug(f"[LucidicAI] Error unregistering from shutdown manager: {e}")
|
|
392
360
|
|
|
393
|
-
|
|
394
|
-
if not self.masking_function:
|
|
395
|
-
return data
|
|
396
|
-
if not data:
|
|
397
|
-
return data
|
|
361
|
+
# Close HTTP client
|
|
398
362
|
try:
|
|
399
|
-
|
|
363
|
+
self._http.close()
|
|
400
364
|
except Exception as e:
|
|
401
|
-
logger
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def
|
|
406
|
-
"""
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
"""
|
|
417
|
-
with self._telemetry_lock:
|
|
365
|
+
logger.debug(f"[LucidicAI] Error closing HTTP client: {e}")
|
|
366
|
+
|
|
367
|
+
logger.info(f"[LucidicAI] Client {self._client_id[:8]}... closed")
|
|
368
|
+
|
|
369
|
+
async def aclose(self) -> None:
|
|
370
|
+
"""Close the client (async version)."""
|
|
371
|
+
logger.info(f"[LucidicAI] Closing async client {self._client_id[:8]}...")
|
|
372
|
+
|
|
373
|
+
# Collect session IDs under lock (don't clear - let aend() handle removal)
|
|
374
|
+
with self._session_lock:
|
|
375
|
+
session_ids = list(self._sessions.keys())
|
|
376
|
+
|
|
377
|
+
# End sessions WITHOUT holding lock (HTTP calls can be slow)
|
|
378
|
+
# aend() will acquire lock and pop each session from _sessions
|
|
379
|
+
for session_id in session_ids:
|
|
418
380
|
try:
|
|
419
|
-
|
|
420
|
-
if self._tracer_provider is None:
|
|
421
|
-
logger.debug("[Telemetry] Creating TracerProvider (first initialization)")
|
|
422
|
-
|
|
423
|
-
from opentelemetry import trace
|
|
424
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
425
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
426
|
-
from opentelemetry.sdk.resources import Resource
|
|
427
|
-
|
|
428
|
-
resource = Resource.create({
|
|
429
|
-
"service.name": "lucidic-ai",
|
|
430
|
-
"service.version": "1.0.0",
|
|
431
|
-
"lucidic.agent_id": self.agent_id,
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
# Create provider with shutdown_on_exit=False for our control
|
|
435
|
-
self._tracer_provider = TracerProvider(resource=resource, shutdown_on_exit=False)
|
|
436
|
-
|
|
437
|
-
# Add context capture processor FIRST
|
|
438
|
-
from .telemetry.context_capture_processor import ContextCaptureProcessor
|
|
439
|
-
context_processor = ContextCaptureProcessor()
|
|
440
|
-
self._tracer_provider.add_span_processor(context_processor)
|
|
441
|
-
|
|
442
|
-
# Add exporter processor for sending spans to Lucidic
|
|
443
|
-
from .telemetry.lucidic_exporter import LucidicSpanExporter
|
|
444
|
-
exporter = LucidicSpanExporter()
|
|
445
|
-
# Configure for faster export: 100ms interval instead of default 5000ms
|
|
446
|
-
# This matches the TypeScript SDK's flush interval pattern
|
|
447
|
-
export_processor = BatchSpanProcessor(
|
|
448
|
-
exporter,
|
|
449
|
-
schedule_delay_millis=100, # Export every 100ms
|
|
450
|
-
max_export_batch_size=512, # Reasonable batch size
|
|
451
|
-
max_queue_size=2048 # Larger queue for burst handling
|
|
452
|
-
)
|
|
453
|
-
self._tracer_provider.add_span_processor(export_processor)
|
|
454
|
-
|
|
455
|
-
# Set as global provider (only happens once)
|
|
456
|
-
try:
|
|
457
|
-
trace.set_tracer_provider(self._tracer_provider)
|
|
458
|
-
logger.debug("[Telemetry] Set global TracerProvider")
|
|
459
|
-
except Exception as e:
|
|
460
|
-
# This is OK - might already be set
|
|
461
|
-
logger.debug(f"[Telemetry] Global provider already set: {e}")
|
|
462
|
-
|
|
463
|
-
self._telemetry_initialized = True
|
|
464
|
-
|
|
465
|
-
# Now instrument the requested providers (can happen multiple times)
|
|
466
|
-
if providers:
|
|
467
|
-
from .telemetry.telemetry_init import instrument_providers
|
|
468
|
-
new_instrumentors = instrument_providers(providers, self._tracer_provider, self._instrumentors)
|
|
469
|
-
# Update our tracking dict
|
|
470
|
-
self._instrumentors.update(new_instrumentors)
|
|
471
|
-
logger.debug(f"[Telemetry] Instrumented providers: {list(new_instrumentors.keys())}")
|
|
472
|
-
|
|
473
|
-
return True
|
|
474
|
-
|
|
381
|
+
await self.sessions.aend(session_id)
|
|
475
382
|
except Exception as e:
|
|
476
|
-
logger.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
True if flush succeeded, False if timeout occurred
|
|
494
|
-
"""
|
|
383
|
+
logger.debug(f"[LucidicAI] Error ending session on close: {e}")
|
|
384
|
+
|
|
385
|
+
if self._providers:
|
|
386
|
+
try:
|
|
387
|
+
from .telemetry.telemetry_manager import get_telemetry_manager
|
|
388
|
+
|
|
389
|
+
manager = get_telemetry_manager()
|
|
390
|
+
manager.unregister_client(self._client_id)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.debug(f"[LucidicAI] Error unregistering from telemetry: {e}")
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
shutdown_manager = get_shutdown_manager()
|
|
396
|
+
shutdown_manager.unregister_client(self._client_id)
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.debug(f"[LucidicAI] Error unregistering from shutdown manager: {e}")
|
|
399
|
+
|
|
495
400
|
try:
|
|
496
|
-
|
|
497
|
-
# Check if provider is already shutdown
|
|
498
|
-
if hasattr(self._tracer_provider, '_shutdown') and self._tracer_provider._shutdown:
|
|
499
|
-
logger.debug("[Telemetry] TracerProvider already shutdown, skipping flush")
|
|
500
|
-
return True
|
|
501
|
-
|
|
502
|
-
# Convert seconds to milliseconds for OpenTelemetry
|
|
503
|
-
timeout_millis = int(timeout_seconds * 1000)
|
|
504
|
-
success = self._tracer_provider.force_flush(timeout_millis)
|
|
505
|
-
if success:
|
|
506
|
-
logger.debug(f"[Telemetry] Successfully flushed spans (timeout={timeout_seconds}s)")
|
|
507
|
-
else:
|
|
508
|
-
logger.warning(f"[Telemetry] Flush timed out after {timeout_seconds}s")
|
|
509
|
-
return success
|
|
510
|
-
return True # No provider = nothing to flush = success
|
|
401
|
+
await self._http.aclose()
|
|
511
402
|
except Exception as e:
|
|
512
|
-
logger.
|
|
513
|
-
|
|
403
|
+
logger.debug(f"[LucidicAI] Error closing HTTP client: {e}")
|
|
404
|
+
|
|
405
|
+
logger.info(f"[LucidicAI] Async client {self._client_id[:8]}... closed")
|
|
406
|
+
|
|
407
|
+
def __enter__(self) -> "LucidicAI":
|
|
408
|
+
"""Enter context manager."""
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
412
|
+
"""Exit context manager."""
|
|
413
|
+
self.close()
|
|
414
|
+
|
|
415
|
+
async def __aenter__(self) -> "LucidicAI":
|
|
416
|
+
"""Enter async context manager."""
|
|
417
|
+
return self
|
|
418
|
+
|
|
419
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
420
|
+
"""Exit async context manager."""
|
|
421
|
+
await self.aclose()
|
|
422
|
+
|
|
423
|
+
def __repr__(self) -> str:
|
|
424
|
+
return (
|
|
425
|
+
f"<LucidicAI(id={self._client_id[:8]}..., "
|
|
426
|
+
f"agent_id={self._config.agent_id}, "
|
|
427
|
+
f"sessions={len(self._sessions)})>"
|
|
428
|
+
)
|