lucidicai 2.1.0__tar.gz → 2.1.2__tar.gz

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 (48) hide show
  1. {lucidicai-2.1.0 → lucidicai-2.1.2}/PKG-INFO +1 -1
  2. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/__init__.py +28 -7
  3. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/config.py +2 -2
  4. lucidicai-2.1.2/lucidicai/sdk/context.py +273 -0
  5. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/event.py +9 -5
  6. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/init.py +100 -5
  7. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/litellm_bridge.py +2 -0
  8. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/lucidic_exporter.py +63 -26
  9. lucidicai-2.1.2/lucidicai/telemetry/openai_patch.py +425 -0
  10. lucidicai-2.1.2/lucidicai/telemetry/openai_uninstrument.py +87 -0
  11. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/telemetry_init.py +16 -1
  12. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/queue.py +38 -6
  13. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/PKG-INFO +1 -1
  14. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/SOURCES.txt +2 -0
  15. {lucidicai-2.1.0 → lucidicai-2.1.2}/setup.py +1 -1
  16. lucidicai-2.1.0/lucidicai/sdk/context.py +0 -144
  17. {lucidicai-2.1.0 → lucidicai-2.1.2}/README.md +0 -0
  18. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/__init__.py +0 -0
  19. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/client.py +0 -0
  20. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/__init__.py +0 -0
  21. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/dataset.py +0 -0
  22. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/event.py +0 -0
  23. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/api/resources/session.py +0 -0
  24. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/__init__.py +0 -0
  25. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/errors.py +0 -0
  26. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/core/types.py +0 -0
  27. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/__init__.py +0 -0
  28. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/decorators.py +0 -0
  29. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/error_boundary.py +0 -0
  30. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/event_builder.py +0 -0
  31. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/features/__init__.py +0 -0
  32. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/features/dataset.py +0 -0
  33. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/features/feature_flag.py +0 -0
  34. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/sdk/shutdown_manager.py +0 -0
  35. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/__init__.py +0 -0
  36. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/context_bridge.py +0 -0
  37. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/context_capture_processor.py +0 -0
  38. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/extract.py +0 -0
  39. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/openai_agents_instrumentor.py +0 -0
  40. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/utils/__init__.py +0 -0
  41. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/telemetry/utils/model_pricing.py +0 -0
  42. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/__init__.py +0 -0
  43. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/images.py +0 -0
  44. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai/utils/logger.py +0 -0
  45. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/dependency_links.txt +0 -0
  46. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/requires.txt +0 -0
  47. {lucidicai-2.1.0 → lucidicai-2.1.2}/lucidicai.egg-info/top_level.txt +0 -0
  48. {lucidicai-2.1.0 → lucidicai-2.1.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lucidicai
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Lucidic AI Python SDK
5
5
  Author: Andy Liang
6
6
  Author-email: andy@lucidic.ai
@@ -14,6 +14,10 @@ from .sdk.init import (
14
14
  init as _init,
15
15
  get_session_id as _get_session_id,
16
16
  clear_state as _clear_state,
17
+ # Thread-local session management (advanced users)
18
+ set_thread_session,
19
+ clear_thread_session,
20
+ get_thread_session,
17
21
  )
18
22
 
19
23
  from .sdk.event import (
@@ -32,6 +36,7 @@ from .sdk.context import (
32
36
  session_async,
33
37
  run_session,
34
38
  run_in_session,
39
+ thread_worker_with_session, # Thread isolation helper
35
40
  current_session_id,
36
41
  current_parent_event_id,
37
42
  )
@@ -55,12 +60,15 @@ def _update_session(
55
60
  session_eval=None,
56
61
  session_eval_reason=None,
57
62
  is_successful=None,
58
- is_successful_reason=None
63
+ is_successful_reason=None,
64
+ session_id=None # Accept explicit session_id
59
65
  ):
60
66
  """Update the current session."""
61
67
  from .sdk.init import get_resources, get_session_id
62
-
63
- session_id = get_session_id()
68
+
69
+ # Use provided session_id or fall back to context
70
+ if not session_id:
71
+ session_id = get_session_id()
64
72
  if not session_id:
65
73
  return
66
74
 
@@ -87,12 +95,16 @@ def _end_session(
87
95
  session_eval_reason=None,
88
96
  is_successful=None,
89
97
  is_successful_reason=None,
90
- wait_for_flush=True
98
+ wait_for_flush=True,
99
+ session_id=None # Accept explicit session_id
91
100
  ):
92
101
  """End the current session."""
93
102
  from .sdk.init import get_resources, get_session_id, get_event_queue
94
-
95
- session_id = get_session_id()
103
+ from .sdk.shutdown_manager import get_shutdown_manager
104
+
105
+ # Use provided session_id or fall back to context
106
+ if not session_id:
107
+ session_id = get_session_id()
96
108
  if not session_id:
97
109
  return
98
110
 
@@ -114,6 +126,9 @@ def _end_session(
114
126
  # Clear session context
115
127
  clear_active_session()
116
128
 
129
+ # unregister from shutdown manager
130
+ get_shutdown_manager().unregister_session(session_id)
131
+
117
132
 
118
133
  def _get_session():
119
134
  """Get the current session object."""
@@ -282,7 +297,7 @@ get_error_history = error_boundary.get_error_history
282
297
  clear_error_history = error_boundary.clear_error_history
283
298
 
284
299
  # Version
285
- __version__ = "2.0.0"
300
+ __version__ = "2.1.2"
286
301
 
287
302
  # Apply error boundary wrapping to all SDK functions
288
303
  from .sdk.error_boundary import wrap_sdk_function
@@ -371,8 +386,14 @@ __all__ = [
371
386
  'session_async',
372
387
  'run_session',
373
388
  'run_in_session',
389
+ 'thread_worker_with_session',
374
390
  'current_session_id',
375
391
  'current_parent_event_id',
392
+
393
+ # Thread-local session management (advanced)
394
+ 'set_thread_session',
395
+ 'clear_thread_session',
396
+ 'get_thread_session',
376
397
 
377
398
  # Error types
378
399
  'LucidicError',
@@ -19,7 +19,7 @@ class Environment(Enum):
19
19
  @dataclass
20
20
  class NetworkConfig:
21
21
  """Network and connection settings"""
22
- base_url: str = "https://api.lucidic.ai/api"
22
+ base_url: str = "https://backend.lucidic.ai/api"
23
23
  timeout: int = 30
24
24
  max_retries: int = 3
25
25
  backoff_factor: float = 0.5
@@ -31,7 +31,7 @@ class NetworkConfig:
31
31
  """Load network configuration from environment variables"""
32
32
  debug = os.getenv("LUCIDIC_DEBUG", "False").lower() == "true"
33
33
  return cls(
34
- base_url="http://localhost:8000/api" if debug else "https://api.lucidic.ai/api",
34
+ base_url="http://localhost:8000/api" if debug else "https://backend.lucidic.ai/api",
35
35
  timeout=int(os.getenv("LUCIDIC_TIMEOUT", "30")),
36
36
  max_retries=int(os.getenv("LUCIDIC_MAX_RETRIES", "3")),
37
37
  backoff_factor=float(os.getenv("LUCIDIC_BACKOFF_FACTOR", "0.5")),
@@ -0,0 +1,273 @@
1
+ """Async-safe and thread-safe context helpers for session (and step, extensible).
2
+
3
+ This module exposes context variables and helpers to bind a Lucidic
4
+ session to the current execution context (threads/async tasks), so
5
+ OpenTelemetry spans can be deterministically attributed to the correct
6
+ session under concurrency.
7
+ """
8
+
9
+ from contextlib import contextmanager, asynccontextmanager
10
+ import contextvars
11
+ from typing import Optional, Iterator, AsyncIterator, Callable, Any, Dict
12
+ import logging
13
+ import os
14
+ import threading
15
+
16
+
17
+ # Context variable for the active Lucidic session id
18
+ current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
19
+ "lucidic.session_id", default=None
20
+ )
21
+
22
+
23
+ # NEW: Context variable for parent event nesting
24
+ current_parent_event_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
25
+ "lucidic.parent_event_id", default=None
26
+ )
27
+
28
+
29
+ def set_active_session(session_id: Optional[str]) -> None:
30
+ """Bind the given session id to the current execution context.
31
+
32
+ Sets both contextvar and thread-local storage when in a thread.
33
+ """
34
+ from .init import set_thread_session, is_main_thread
35
+
36
+ current_session_id.set(session_id)
37
+
38
+ # Also set thread-local storage if we're in a non-main thread
39
+ if session_id and not is_main_thread():
40
+ set_thread_session(session_id)
41
+
42
+
43
+ def clear_active_session() -> None:
44
+ """Clear any active session binding in the current execution context.
45
+
46
+ Clears both contextvar and thread-local storage when in a thread.
47
+ """
48
+ from .init import clear_thread_session, is_main_thread
49
+
50
+ current_session_id.set(None)
51
+
52
+ # Also clear thread-local storage if we're in a non-main thread
53
+ if not is_main_thread():
54
+ clear_thread_session()
55
+
56
+
57
+ @contextmanager
58
+ def bind_session(session_id: str) -> Iterator[None]:
59
+ """Context manager to temporarily bind an active session id.
60
+
61
+ Handles both thread-local and context variable storage for proper isolation.
62
+ """
63
+ from .init import set_thread_session, clear_thread_session, is_main_thread
64
+
65
+ token = current_session_id.set(session_id)
66
+
67
+ # If we're in a non-main thread, also set thread-local storage
68
+ thread_local_set = False
69
+ if not is_main_thread():
70
+ set_thread_session(session_id)
71
+ thread_local_set = True
72
+
73
+ try:
74
+ yield
75
+ finally:
76
+ if thread_local_set:
77
+ clear_thread_session()
78
+ current_session_id.reset(token)
79
+
80
+
81
+ @asynccontextmanager
82
+ async def bind_session_async(session_id: str) -> AsyncIterator[None]:
83
+ """Async context manager to temporarily bind an active session id."""
84
+ from .init import set_task_session, clear_task_session
85
+
86
+ token = current_session_id.set(session_id)
87
+
88
+ # Also set task-local for async isolation
89
+ set_task_session(session_id)
90
+
91
+ try:
92
+ yield
93
+ finally:
94
+ clear_task_session()
95
+ current_session_id.reset(token)
96
+
97
+
98
+ # NEW: Parent event context managers
99
+ @contextmanager
100
+ def event_context(event_id: str) -> Iterator[None]:
101
+ token = current_parent_event_id.set(event_id)
102
+ try:
103
+ yield
104
+ finally:
105
+ current_parent_event_id.reset(token)
106
+
107
+
108
+ @asynccontextmanager
109
+ async def event_context_async(event_id: str) -> AsyncIterator[None]:
110
+ token = current_parent_event_id.set(event_id)
111
+ try:
112
+ yield
113
+ finally:
114
+ current_parent_event_id.reset(token)
115
+
116
+
117
+ @contextmanager
118
+ def session(**init_params) -> Iterator[None]:
119
+ """All-in-one context manager: init → bind → yield → clear → end.
120
+
121
+ Notes:
122
+ - Ignores any provided auto_end parameter and ends the session on context exit.
123
+ - If LUCIDIC_DEBUG is true, logs a warning about ignoring auto_end.
124
+ - Handles thread-local storage for proper thread isolation.
125
+ """
126
+ # Lazy import to avoid circular imports
127
+ import lucidicai as lai # type: ignore
128
+ from .init import set_thread_session, clear_thread_session, is_main_thread
129
+
130
+ # Force auto_end to False inside a context manager to control explicit end
131
+ user_auto_end = init_params.get('auto_end', None)
132
+ init_params = dict(init_params)
133
+ init_params['auto_end'] = False
134
+
135
+ if os.getenv('LUCIDIC_DEBUG', 'False') == 'True' and user_auto_end is not None:
136
+ logging.getLogger('Lucidic').warning('session(...) ignores auto_end and will end the session at context exit')
137
+
138
+ session_id = lai.init(**init_params)
139
+ token = current_session_id.set(session_id)
140
+
141
+ # If we're in a non-main thread, also set thread-local storage
142
+ thread_local_set = False
143
+ if not is_main_thread():
144
+ set_thread_session(session_id)
145
+ thread_local_set = True
146
+
147
+ try:
148
+ yield
149
+ finally:
150
+ if thread_local_set:
151
+ clear_thread_session()
152
+ current_session_id.reset(token)
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
+
175
+ # Pass session_id explicitly to avoid context issues
176
+ lai.end_session(session_id=session_id)
177
+ except Exception:
178
+ # Avoid masking the original exception from the with-block
179
+ pass
180
+
181
+
182
+ @asynccontextmanager
183
+ async def session_async(**init_params) -> AsyncIterator[None]:
184
+ """Async counterpart of session(...)."""
185
+ import lucidicai as lai # type: ignore
186
+ from .init import set_task_session, clear_task_session
187
+
188
+ user_auto_end = init_params.get('auto_end', None)
189
+ init_params = dict(init_params)
190
+ init_params['auto_end'] = False
191
+
192
+ if os.getenv('LUCIDIC_DEBUG', 'False') == 'True' and user_auto_end is not None:
193
+ logging.getLogger('Lucidic').warning('session_async(...) ignores auto_end and will end the session at context exit')
194
+
195
+ session_id = lai.init(**init_params)
196
+ token = current_session_id.set(session_id)
197
+
198
+ # Set task-local session for true isolation in async
199
+ set_task_session(session_id)
200
+
201
+ try:
202
+ yield
203
+ finally:
204
+ # Clear task-local session first
205
+ clear_task_session()
206
+ current_session_id.reset(token)
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
+
229
+ # Pass session_id explicitly to avoid context issues in async
230
+ lai.end_session(session_id=session_id)
231
+ except Exception:
232
+ pass
233
+
234
+
235
+ def run_session(fn: Callable[..., Any], *fn_args: Any, init_params: Optional[Dict[str, Any]] = None, **fn_kwargs: Any) -> Any:
236
+ """Run a callable within a full Lucidic session lifecycle context."""
237
+ with session(**(init_params or {})):
238
+ return fn(*fn_args, **fn_kwargs)
239
+
240
+
241
+ def run_in_session(session_id: str, fn: Callable[..., Any], *fn_args: Any, **fn_kwargs: Any) -> Any:
242
+ """Run a callable with a bound session id. Does not end the session."""
243
+ with bind_session(session_id):
244
+ return fn(*fn_args, **fn_kwargs)
245
+
246
+
247
+ def thread_worker_with_session(session_id: str, target: Callable[..., Any], *args, **kwargs) -> Any:
248
+ """Wrapper for thread worker functions that ensures proper session isolation.
249
+
250
+ Use this as the target function for threads to ensure each thread gets
251
+ its own session context without bleeding from the parent thread.
252
+
253
+ Example:
254
+ thread = Thread(
255
+ target=thread_worker_with_session,
256
+ args=(session_id, actual_worker_function, arg1, arg2),
257
+ kwargs={'key': 'value'}
258
+ )
259
+ """
260
+ from .init import set_thread_session, clear_thread_session
261
+
262
+ # Set thread-local session immediately
263
+ set_thread_session(session_id)
264
+
265
+ try:
266
+ # Also bind to contextvar for compatibility
267
+ with bind_session(session_id):
268
+ return target(*args, **kwargs)
269
+ finally:
270
+ # Clean up thread-local storage
271
+ clear_thread_session()
272
+
273
+
@@ -12,23 +12,27 @@ from ..utils.logger import debug, truncate_id
12
12
  def create_event(
13
13
  type: str = "generic",
14
14
  event_id: Optional[str] = None,
15
+ session_id: Optional[str] = None, # accept explicit session_id
15
16
  **kwargs
16
17
  ) -> str:
17
18
  """Create a new event.
18
-
19
+
19
20
  Args:
20
21
  type: Event type (llm_generation, function_call, error_traceback, generic)
21
22
  event_id: Optional client event ID (will generate if not provided)
23
+ session_id: Optional session ID (will use context if not provided)
22
24
  **kwargs: Event-specific fields
23
-
25
+
24
26
  Returns:
25
27
  Event ID (client-generated or provided UUID)
26
28
  """
27
29
  # Import here to avoid circular dependency
28
30
  from ..sdk.init import get_session_id, get_event_queue
29
-
30
- # Get current session
31
- session_id = get_session_id()
31
+
32
+ # Use provided session_id or fall back to context
33
+ if not session_id:
34
+ session_id = get_session_id()
35
+
32
36
  if not session_id:
33
37
  # No active session, return dummy ID
34
38
  debug("[Event] No active session, returning dummy event ID")
@@ -4,6 +4,9 @@ This module handles SDK initialization, separating concerns from the main __init
4
4
  """
5
5
  import uuid
6
6
  from typing import List, Optional
7
+ import asyncio
8
+ import threading
9
+ from weakref import WeakKeyDictionary
7
10
 
8
11
  from ..api.client import HttpClient
9
12
  from ..api.resources.event import EventResource
@@ -21,14 +24,18 @@ from opentelemetry.sdk.trace import TracerProvider
21
24
 
22
25
  class SDKState:
23
26
  """Container for SDK runtime state."""
24
-
27
+
25
28
  def __init__(self):
26
29
  self.http: Optional[HttpClient] = None
27
30
  self.event_queue: Optional[EventQueue] = None
28
31
  self.session_id: Optional[str] = None
29
32
  self.tracer_provider: Optional[TracerProvider] = None
30
33
  self.resources = {}
31
-
34
+ # Task-local storage for async task isolation
35
+ self.task_sessions: WeakKeyDictionary = WeakKeyDictionary()
36
+ # Thread-local storage for thread isolation
37
+ self.thread_local = threading.local()
38
+
32
39
  def reset(self):
33
40
  """Reset SDK state."""
34
41
  # Shutdown telemetry first to ensure all spans are exported
@@ -42,17 +49,21 @@ class SDKState:
42
49
  debug("[SDK] TracerProvider shutdown complete")
43
50
  except Exception as e:
44
51
  error(f"[SDK] Error shutting down TracerProvider: {e}")
45
-
52
+
46
53
  if self.event_queue:
47
54
  self.event_queue.shutdown()
48
55
  if self.http:
49
56
  self.http.close()
50
-
57
+
51
58
  self.http = None
52
59
  self.event_queue = None
53
60
  self.session_id = None
54
61
  self.tracer_provider = None
55
62
  self.resources = {}
63
+ self.task_sessions.clear()
64
+ # Clear thread-local storage for current thread
65
+ if hasattr(self.thread_local, 'session_id'):
66
+ delattr(self.thread_local, 'session_id')
56
67
 
57
68
 
58
69
  # Global SDK state
@@ -243,8 +254,87 @@ def _initialize_telemetry(providers: List[str]) -> None:
243
254
  info(f"[Telemetry] Initialized for providers: {providers}")
244
255
 
245
256
 
257
+ def set_task_session(session_id: str) -> None:
258
+ """Set session ID for current async task (if in async context)."""
259
+ try:
260
+ if task := asyncio.current_task():
261
+ _sdk_state.task_sessions[task] = session_id
262
+ debug(f"[SDK] Set task-local session {truncate_id(session_id)} for task {task.get_name()}")
263
+ except RuntimeError:
264
+ # Not in async context, ignore
265
+ pass
266
+
267
+
268
+ def clear_task_session() -> None:
269
+ """Clear session ID for current async task (if in async context)."""
270
+ try:
271
+ if task := asyncio.current_task():
272
+ _sdk_state.task_sessions.pop(task, None)
273
+ debug(f"[SDK] Cleared task-local session for task {task.get_name()}")
274
+ except RuntimeError:
275
+ # Not in async context, ignore
276
+ pass
277
+
278
+
279
+ def set_thread_session(session_id: str) -> None:
280
+ """Set session ID for current thread.
281
+
282
+ This provides true thread-local storage that doesn't inherit from parent thread.
283
+ """
284
+ _sdk_state.thread_local.session_id = session_id
285
+ current_thread = threading.current_thread()
286
+ debug(f"[SDK] Set thread-local session {truncate_id(session_id)} for thread {current_thread.name}")
287
+
288
+
289
+ def clear_thread_session() -> None:
290
+ """Clear session ID for current thread."""
291
+ if hasattr(_sdk_state.thread_local, 'session_id'):
292
+ delattr(_sdk_state.thread_local, 'session_id')
293
+ current_thread = threading.current_thread()
294
+ debug(f"[SDK] Cleared thread-local session for thread {current_thread.name}")
295
+
296
+
297
+ def get_thread_session() -> Optional[str]:
298
+ """Get session ID from thread-local storage."""
299
+ return getattr(_sdk_state.thread_local, 'session_id', None)
300
+
301
+
302
+ def is_main_thread() -> bool:
303
+ """Check if we're running in the main thread."""
304
+ return threading.current_thread() is threading.main_thread()
305
+
306
+
246
307
  def get_session_id() -> Optional[str]:
247
- """Get the current session ID."""
308
+ """Get the current session ID.
309
+
310
+ Priority:
311
+ 1. Task-local session (for async tasks)
312
+ 2. Thread-local session (for threads) - NO FALLBACK for threads
313
+ 3. SDK state session (for main thread)
314
+ 4. Context variable session (fallback for main thread only)
315
+ """
316
+ # First check task-local storage for async isolation
317
+ try:
318
+ if task := asyncio.current_task():
319
+ if task_session := _sdk_state.task_sessions.get(task):
320
+ debug(f"[SDK] Using task-local session {truncate_id(task_session)}")
321
+ return task_session
322
+ except RuntimeError:
323
+ # Not in async context
324
+ pass
325
+
326
+ # Check if we're in a thread
327
+ if not is_main_thread():
328
+ # For threads, ONLY use thread-local storage - no fallback!
329
+ # This prevents inheriting the parent thread's session
330
+ thread_session = get_thread_session()
331
+ if thread_session:
332
+ debug(f"[SDK] Using thread-local session {truncate_id(thread_session)}")
333
+ else:
334
+ debug(f"[SDK] Thread {threading.current_thread().name} has no thread-local session")
335
+ return thread_session # Return None if not set - don't fall back!
336
+
337
+ # For main thread only: fall back to SDK state or context variable
248
338
  return _sdk_state.session_id or current_session_id.get()
249
339
 
250
340
 
@@ -263,6 +353,11 @@ def get_resources() -> dict:
263
353
  return _sdk_state.resources
264
354
 
265
355
 
356
+ def get_tracer_provider() -> Optional[TracerProvider]:
357
+ """Get the tracer provider instance."""
358
+ return _sdk_state.tracer_provider
359
+
360
+
266
361
  def clear_state() -> None:
267
362
  """Clear SDK state (for testing)."""
268
363
  global _sdk_state
@@ -149,6 +149,7 @@ class LucidicLiteLLMCallback(CustomLogger):
149
149
  # Create event with correct field names
150
150
  create_event(
151
151
  type="llm_generation",
152
+ session_id=session_id, # Pass session_id explicitly
152
153
  provider=provider,
153
154
  model=model,
154
155
  messages=messages,
@@ -210,6 +211,7 @@ class LucidicLiteLLMCallback(CustomLogger):
210
211
 
211
212
  create_event(
212
213
  type="error_traceback",
214
+ session_id=session_id, # Pass session_id explicitly
213
215
  error=error_msg,
214
216
  traceback="",
215
217
  parent_event_id=parent_id, # This will be normalized by EventBuilder