veris-ai 1.0.0__py3-none-any.whl → 1.1.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.

Potentially problematic release.


This version of veris-ai might be problematic. Click here for more details.

veris_ai/__init__.py CHANGED
@@ -1,7 +1,41 @@
1
1
  """Veris AI Python SDK."""
2
2
 
3
+ from typing import Any
4
+
3
5
  __version__ = "0.1.0"
4
6
 
7
+ # Import lightweight modules that only use base dependencies
8
+ from .jaeger_interface import JaegerClient, SearchQuery
5
9
  from .tool_mock import veris
6
10
 
7
- __all__ = ["veris"]
11
+ # Lazy import for modules with heavy dependencies
12
+ _instrument = None
13
+
14
+
15
+ def instrument(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
16
+ """Lazy loader for the instrument function from braintrust_tracing.
17
+
18
+ This function requires the 'instrument' extra dependencies:
19
+ pip install veris-ai[instrument]
20
+ """
21
+ global _instrument # noqa: PLW0603
22
+ if _instrument is None:
23
+ try:
24
+ from .braintrust_tracing import instrument as _instrument_impl # noqa: PLC0415
25
+
26
+ _instrument = _instrument_impl
27
+ except ImportError as e:
28
+ error_msg = (
29
+ "The 'instrument' function requires additional dependencies. "
30
+ "Please install them with: pip install veris-ai[instrument]"
31
+ )
32
+ raise ImportError(error_msg) from e
33
+ return _instrument(*args, **kwargs)
34
+
35
+
36
+ __all__ = [
37
+ "veris",
38
+ "JaegerClient",
39
+ "SearchQuery",
40
+ "instrument",
41
+ ]
@@ -0,0 +1,282 @@
1
+ """Non-invasive Braintrust + Jaeger (OTEL) instrumentation helper for the `openai-agents` SDK.
2
+
3
+ Typical usage
4
+ -------------
5
+ >>> from our_sdk import braintrust_tracing
6
+ >>> braintrust_tracing.instrument(service_name="openai-agent")
7
+
8
+ After calling :func:`instrument`, any later call to
9
+ ``agents.set_trace_processors([...])`` will be transparently patched so that:
10
+ • the list always contains a BraintrustTracingProcessor (for Braintrust UI)
11
+ • *and* an OpenTelemetry bridge processor that mirrors every span to the
12
+ global OTEL tracer provider (Jaeger by default).
13
+
14
+ Goal: deliver full OTEL compatibility while keeping the official Braintrust
15
+ SDK integration unchanged – **no code modifications required** besides the
16
+ single `instrument()` call.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ from typing import Any, cast
25
+
26
+ import wrapt # type: ignore[import-untyped, import-not-found]
27
+ from braintrust.wrappers.openai import (
28
+ BraintrustTracingProcessor, # type: ignore[import-untyped, import-not-found]
29
+ )
30
+ from opentelemetry import context as otel_context # type: ignore[import-untyped, import-not-found]
31
+ from opentelemetry import trace # type: ignore[import-untyped, import-not-found]
32
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
33
+ OTLPSpanExporter, # type: ignore[import-untyped, import-not-found]
34
+ )
35
+ from opentelemetry.sdk.resources import ( # type: ignore[import-untyped, import-not-found]
36
+ SERVICE_NAME,
37
+ Resource,
38
+ )
39
+ from opentelemetry.sdk.trace import TracerProvider # type: ignore[import-untyped, import-not-found]
40
+ from opentelemetry.sdk.trace.export import (
41
+ BatchSpanProcessor, # type: ignore[import-untyped, import-not-found]
42
+ )
43
+ from opentelemetry.trace import SpanKind # type: ignore[import-untyped, import-not-found]
44
+
45
+ from veris_ai.tool_mock import _session_id_context
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Optional import of *agents* – we fail lazily at runtime if missing.
49
+ # ---------------------------------------------------------------------------
50
+ try:
51
+ import agents # type: ignore[import-untyped] # noqa: TC002
52
+ from agents import TracingProcessor # type: ignore[import-untyped]
53
+
54
+ try:
55
+ from agents.tracing import get_trace_provider # type: ignore[import-untyped]
56
+ except ImportError:
57
+ # Fallback for newer versions that have GLOBAL_TRACE_PROVIDER instead
58
+ from agents.tracing import ( # type: ignore[import-untyped, attr-defined, import-not-found]
59
+ GLOBAL_TRACE_PROVIDER, # type: ignore[import-untyped, attr-defined, import-not-found]
60
+ )
61
+
62
+ get_trace_provider = lambda: GLOBAL_TRACE_PROVIDER # type: ignore[no-any-return] # noqa: E731
63
+ except ModuleNotFoundError as exc: # pragma: no cover
64
+ _IMPORT_ERR: ModuleNotFoundError | None = exc
65
+ TracingProcessor = object # type: ignore[assignment, misc]
66
+ get_trace_provider = None # type: ignore[assignment]
67
+ else:
68
+ _IMPORT_ERR = None
69
+
70
+ __all__ = ["instrument"]
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Internal helper – OTEL bridge processor
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ class AgentsOTELBridgeProcessor(TracingProcessor): # type: ignore[misc]
80
+ """Mirrors every Agents span into a dedicated OTEL tracer provider."""
81
+
82
+ def __init__(
83
+ self,
84
+ braintrust_processor: BraintrustTracingProcessor,
85
+ *,
86
+ service_name: str, # noqa: ARG002
87
+ tracer_provider: trace.TracerProvider,
88
+ ) -> None: # noqa: D401,E501
89
+ self._braintrust = braintrust_processor
90
+ self._tracer = tracer_provider.get_tracer(__name__)
91
+ self._otel_spans: dict[str, trace.Span] = {}
92
+ self._provider = tracer_provider
93
+
94
+ # ----------------------------- utils ---------------------------------
95
+ @staticmethod
96
+ def _flatten(prefix: str, obj: Any, out: dict[str, Any]) -> None: # noqa: PLR0911, ANN401
97
+ """Flatten complex objects into OTEL-compatible primitives."""
98
+ if isinstance(obj, dict):
99
+ for k, v in obj.items():
100
+ AgentsOTELBridgeProcessor._flatten(f"{prefix}.{k}" if prefix else str(k), v, out)
101
+ elif isinstance(obj, str | int | float | bool) or obj is None:
102
+ out[prefix] = obj
103
+ elif isinstance(obj, list | tuple):
104
+ try:
105
+ if all(isinstance(i, str | int | float | bool) or i is None for i in obj):
106
+ out[prefix] = list(obj)
107
+ else:
108
+ out[prefix] = json.dumps(obj, default=str)
109
+ except Exception: # pragma: no cover – defensive
110
+ out[prefix] = json.dumps(obj, default=str)
111
+ else:
112
+ out[prefix] = str(obj)
113
+
114
+ def _log_data_attributes(self, span_obj: agents.tracing.Span) -> dict[str, Any]: # type: ignore[name-defined]
115
+ data = self._braintrust._log_data(span_obj) # pyright: ignore[reportPrivateUsage] # noqa: SLF001
116
+ flat: dict[str, Any] = {}
117
+ self._flatten("bt", data, flat)
118
+
119
+ # Add session_id if available
120
+ session_id = _session_id_context.get()
121
+ if session_id:
122
+ flat["veris.session_id"] = session_id
123
+
124
+ return {k: v for k, v in flat.items() if v is not None}
125
+
126
+ # --------------------- Agents lifecycle hooks ------------------------
127
+ def on_trace_start(self, trace_obj: Any) -> None: # noqa: ANN401, D102
128
+ # Get session_id at trace start
129
+ session_id = _session_id_context.get()
130
+ attributes = {"veris.session_id": session_id} if session_id else {}
131
+
132
+ otel_span = self._tracer.start_span(
133
+ name=trace_obj.name or "agent-trace",
134
+ kind=SpanKind.INTERNAL,
135
+ attributes=attributes,
136
+ )
137
+ self._otel_spans[trace_obj.trace_id] = otel_span
138
+ logger.info(f"VERIS AI BraintrustTracingProcessor: on_trace_start: {trace_obj.trace_id}")
139
+
140
+ def on_trace_end(self, trace_obj: Any) -> None: # noqa: ANN401, D102
141
+ span = self._otel_spans.pop(trace_obj.trace_id, None)
142
+ if span:
143
+ span.end()
144
+
145
+ def on_span_start(self, span: Any) -> None: # noqa: ANN401, D102
146
+ parent_otel = (
147
+ self._otel_spans.get(span.parent_id)
148
+ if span.parent_id
149
+ else self._otel_spans.get(span.trace_id)
150
+ )
151
+ parent_ctx = (
152
+ trace.set_span_in_context(parent_otel) if parent_otel else otel_context.get_current()
153
+ )
154
+
155
+ # Get session_id at span start
156
+ session_id = _session_id_context.get()
157
+ attributes = {"veris.session_id": session_id} if session_id else {}
158
+
159
+ child = self._tracer.start_span(
160
+ name=span.span_data.__class__.__name__,
161
+ context=parent_ctx,
162
+ kind=SpanKind.INTERNAL,
163
+ attributes=attributes,
164
+ )
165
+ self._otel_spans[span.span_id] = child
166
+ logger.info(f"VERIS AI BraintrustTracingProcessor: on_span_start: {span.span_id}")
167
+
168
+ def on_span_end(self, span: Any) -> None: # noqa: ANN401, D102
169
+ child = self._otel_spans.pop(span.span_id, None)
170
+ logger.info(f"VERIS AI BraintrustTracingProcessor: on_span_end: {span.span_id}")
171
+ if child:
172
+ for k, v in self._log_data_attributes(span).items():
173
+ try:
174
+ child.set_attribute(k, v)
175
+ except Exception: # pragma: no cover – bad value type # noqa: S112
176
+ continue
177
+ child.end()
178
+
179
+ # --------------------- house-keeping ---------------------------------
180
+ def shutdown(self) -> None: # noqa: D401
181
+ provider = cast("TracerProvider", self._provider)
182
+ provider.shutdown() # type: ignore[attr-defined]
183
+
184
+ def force_flush(self) -> None: # noqa: D401
185
+ provider = cast("TracerProvider", self._provider)
186
+ provider.force_flush() # type: ignore[attr-defined]
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Public entry point
191
+ # ---------------------------------------------------------------------------
192
+
193
+ _PATCHED: bool = False # ensure idempotent patching
194
+
195
+
196
+ def instrument(
197
+ *,
198
+ service_name: str | None = None,
199
+ otlp_endpoint: str | None = None,
200
+ ) -> None:
201
+ """Bootstrap Braintrust + OTEL instrumentation and patch Agents SDK.
202
+
203
+ Invoke once at any point before `set_trace_processors` is called.
204
+ """
205
+ global _PATCHED # noqa: PLW0603
206
+ if _PATCHED:
207
+ return # already done
208
+
209
+ if _IMPORT_ERR is not None or get_trace_provider is None: # pragma: no cover
210
+ error_msg = "The `agents` package is required but not installed"
211
+ raise RuntimeError(error_msg) from _IMPORT_ERR
212
+
213
+ # ------------------ 0. Validate inputs -----------------------------
214
+ # Resolve service name ─ explicit argument → env var → error
215
+ if not service_name or not str(service_name).strip():
216
+ service_name = os.getenv("VERIS_SERVICE_NAME")
217
+
218
+ if not service_name or not str(service_name).strip():
219
+ error_msg = (
220
+ "`service_name` must be provided either as an argument or via the "
221
+ "VERIS_SERVICE_NAME environment variable"
222
+ )
223
+ raise ValueError(error_msg)
224
+
225
+ # Resolve OTLP endpoint ─ explicit argument → env var → error
226
+ if not otlp_endpoint or not str(otlp_endpoint).strip():
227
+ otlp_endpoint = os.getenv("VERIS_OTLP_ENDPOINT")
228
+
229
+ if not otlp_endpoint or not str(otlp_endpoint).strip():
230
+ error_msg = (
231
+ "`otlp_endpoint` must be provided either as an argument or via the "
232
+ "VERIS_OTLP_ENDPOINT environment variable"
233
+ )
234
+ raise ValueError(error_msg)
235
+
236
+ logger.info(f"service_name: {service_name}")
237
+ logger.info(f"otlp_endpoint: {otlp_endpoint}")
238
+
239
+ # ------------------ 1. Configure OTEL provider ---------------------
240
+ # We create our own provider instance and do NOT set it globally.
241
+ # This avoids conflicts with any other OTEL setup in the application.
242
+ otel_provider = TracerProvider(resource=Resource.create({SERVICE_NAME: service_name}))
243
+ otel_provider.add_span_processor(
244
+ BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)),
245
+ )
246
+
247
+ # ------------------ 2. Define wrapper for patching -------------------
248
+ def _wrapper(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> Any: # noqa: ANN401, ARG001
249
+ """This function wraps `TraceProvider.set_processors`."""
250
+ processors = args[0] if args else []
251
+
252
+ # Find the user's Braintrust processor to pass to our bridge.
253
+ bt_processor = next(
254
+ (p for p in processors if isinstance(p, BraintrustTracingProcessor)), None
255
+ )
256
+
257
+ # If no Braintrust processor is present, our bridge is useless.
258
+ # Also, if a bridge is already there, don't add another one.
259
+ has_bridge = any(isinstance(p, AgentsOTELBridgeProcessor) for p in processors)
260
+ if not bt_processor or has_bridge:
261
+ return wrapped(*args, **kwargs)
262
+
263
+ # Create the bridge and add it to the list of processors.
264
+ bridge = AgentsOTELBridgeProcessor(
265
+ bt_processor,
266
+ service_name=service_name,
267
+ tracer_provider=otel_provider,
268
+ )
269
+ new_processors = list(processors) + [bridge]
270
+
271
+ # Call the original function with the augmented list.
272
+ new_args = (new_processors,) + args[1:]
273
+ logger.info(f"VERIS AI BraintrustTracingProcessor: {new_args}")
274
+ return wrapped(*new_args, **kwargs)
275
+
276
+ # ------------------ 3. Patch the provider instance -------------------
277
+ # This is more robust than patching the function, as it's independent
278
+ # of how the user imports `set_trace_processors`.
279
+ provider_instance = get_trace_provider()
280
+ wrapt.wrap_function_wrapper(provider_instance, "set_processors", _wrapper)
281
+
282
+ _PATCHED = True
@@ -0,0 +1,109 @@
1
+ # Jaeger Interface
2
+
3
+ This sub-package ships a **thin synchronous wrapper** around the
4
+ [Jaeger Query Service](https://www.jaegertracing.io/docs/) HTTP API so
5
+ that you can **search for and retrieve traces** directly from Python
6
+ with minimal boilerplate.
7
+
8
+ > The client relies on `requests` (already included in the SDK’s
9
+ > dependencies) and uses *pydantic* for full type-safety.
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ `veris-ai` already lists both `requests` and `pydantic` as hard
16
+ requirements, so **no additional dependencies are required**.
17
+
18
+ ```bash
19
+ pip install veris-ai
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Quick-start
25
+
26
+ ```python
27
+ from veris_ai.jaeger_interface import JaegerClient, SearchQuery
28
+ import json
29
+ from veris_ai.jaeger_interface.models import Trace
30
+ # Replace with the URL of your Jaeger Query Service instance
31
+ client = JaegerClient("http://localhost:16686")
32
+
33
+ # --- 1. Search traces --------------------------------------------------
34
+ resp = client.search(
35
+ SearchQuery(
36
+ service="veris-agent",
37
+ limit=3,
38
+ operation="CustomSpanData",
39
+ tags={"bt.metadata.session_id":"oRL1_IhMP3s7T7mYrixCW"}
40
+ )
41
+ )
42
+
43
+ # save to json
44
+ with open("resp.json", "w") as f:
45
+ f.write(resp.model_dump_json(indent=2))
46
+
47
+ # Guard clause
48
+ if not resp or not resp.data:
49
+ print("No data found")
50
+ exit(1)
51
+
52
+ # Print trace ids
53
+ for trace in resp.data:
54
+ if isinstance(trace, Trace):
55
+ print("TRACE ID:", trace.traceID, len(trace.spans), "spans")
56
+
57
+ # --- 2. Retrieve a specific trace -------------------------------------
58
+ if isinstance(resp.data, list):
59
+ trace_id = resp.data[0].traceID
60
+ else:
61
+ trace_id = resp.data.traceID
62
+
63
+ detailed = client.get_trace(trace_id)
64
+ # save detailed to json
65
+ with open("detailed.json", "w") as f:
66
+ f.write(detailed.model_dump_json(indent=2))
67
+ ```
68
+
69
+ ---
70
+
71
+ ## API Reference
72
+
73
+ ### `JaegerClient`
74
+
75
+ | Method | Description |
76
+ | -------- | ----------- |
77
+ | `search(query: SearchQuery) -> SearchResponse` | Search for traces matching the given parameters (wrapper around `/api/traces`). |
78
+ | `get_trace(trace_id: str) -> GetTraceResponse` | Fetch a single trace by ID (wrapper around `/api/traces/{id}`). |
79
+
80
+ ### `SearchQuery`
81
+
82
+ `SearchQuery` is a *pydantic* model for building the query-string sent to
83
+ Jaeger’s `/api/traces` endpoint.
84
+
85
+ Parameter logic:
86
+
87
+ * **service** – mandatory, sets the top-level service filter.
88
+ * **operation** – optional; a trace is kept only if *any* of its spans has
89
+ this exact `operationName`.
90
+ * **tags** – dict of key/value filters; *all* pairs must match on a single
91
+ span (logical AND). Example: `{"error": "true"}`.
92
+ * **limit** – applied after all filters and trims the result list.
93
+
94
+ Any other fields are forwarded untouched, so you can experiment with new
95
+ Jaeger parameters without waiting for an SDK update.
96
+
97
+ ---
98
+
99
+ ## Compatibility
100
+
101
+ The implementation targets **Jaeger v1.x** REST endpoints. For clusters
102
+ backed by **OpenSearch** storage the same endpoints apply. Should you
103
+ need API v3 support feel free to open an issue or contribution—thanks!
104
+
105
+ ---
106
+
107
+ ## Licence
108
+
109
+ This package is released under the **MIT license**.
@@ -0,0 +1,26 @@
1
+ """Jaeger interface for searching and retrieving traces.
2
+
3
+ This sub-package provides a thin synchronous wrapper around the Jaeger
4
+ Query Service HTTP API.
5
+
6
+ Typical usage example::
7
+
8
+ from veris_ai.jaeger_interface import JaegerClient, SearchQuery
9
+
10
+ client = JaegerClient("http://localhost:16686")
11
+
12
+ traces = client.search(
13
+ SearchQuery(service="veris-agent", limit=20)
14
+ )
15
+
16
+ trace = client.get_trace(traces.data[0].traceID)
17
+
18
+ The implementation uses *requests* under the hood and all public functions
19
+ are fully typed using *pydantic* models so that IDEs can provide proper
20
+ autocomplete and type checking.
21
+ """
22
+
23
+ from .client import JaegerClient
24
+ from .models import SearchQuery
25
+
26
+ __all__ = ["JaegerClient", "SearchQuery"]
@@ -0,0 +1,133 @@
1
+ """Synchronous Jaeger Query Service client built on **requests**.
2
+
3
+ This implementation keeps dependencies minimal while providing fully-typed
4
+ *pydantic* models for both **request** and **response** bodies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Self
10
+
11
+ import requests
12
+
13
+ from .models import GetTraceResponse, SearchQuery, SearchResponse
14
+
15
+ if TYPE_CHECKING:
16
+ from types import TracebackType
17
+
18
+ __all__ = ["JaegerClient"]
19
+
20
+
21
+ class JaegerClient: # noqa: D101
22
+ def __init__(
23
+ self,
24
+ base_url: str,
25
+ *,
26
+ timeout: float | None = 10.0,
27
+ session: requests.Session | None = None,
28
+ headers: dict[str, str] | None = None,
29
+ ) -> None:
30
+ """Create a new *JaegerClient* instance.
31
+
32
+ Args:
33
+ base_url: Base URL of the Jaeger Query Service (e.g. ``http://localhost:16686``).
34
+ timeout: Request timeout in **seconds** (applied to every call).
35
+ session: Optional pre-configured :class:`requests.Session` to reuse.
36
+ headers: Optional default headers to send with every request.
37
+ """
38
+ # Normalise to avoid trailing slash duplicates
39
+ self._base_url = base_url.rstrip("/")
40
+ self._timeout = timeout
41
+ self._external_session = session # If provided we won't close it
42
+ self._headers = headers or {}
43
+
44
+ # ---------------------------------------------------------------------
45
+ # Internal helpers
46
+ # ---------------------------------------------------------------------
47
+
48
+ def _make_session(self) -> tuple[requests.Session, bool]: # noqa: D401
49
+ """Return a *(session, should_close)* tuple.
50
+
51
+ If an external session was supplied we **must not** close it after the
52
+ request, hence the boolean flag letting callers know whether they are
53
+ responsible for closing the session.
54
+ """
55
+ if self._external_session is not None:
56
+ return self._external_session, False
57
+
58
+ # Reuse the session opened via the context manager if available
59
+ if hasattr(self, "_session_ctx"):
60
+ return self._session_ctx, False
61
+
62
+ session = requests.Session()
63
+ session.headers.update(self._headers)
64
+ return session, True
65
+
66
+ # ---------------------------------------------------------------------
67
+ # Public API
68
+ # ---------------------------------------------------------------------
69
+
70
+ def search(self, query: SearchQuery) -> SearchResponse: # noqa: D401
71
+ """Search traces using the *v1* ``/api/traces`` endpoint.
72
+
73
+ Args:
74
+ query: :class:`~veris_ai.jaeger_interface.models.SearchQuery` instance.
75
+
76
+ Returns:
77
+ Parsed :class:`~veris_ai.jaeger_interface.models.SearchResponse` model.
78
+ """
79
+ params = query.to_params()
80
+ session, should_close = self._make_session()
81
+ try:
82
+ url = f"{self._base_url}/api/traces"
83
+ response = session.get(url, params=params, timeout=self._timeout)
84
+ response.raise_for_status()
85
+ data = response.json()
86
+ finally:
87
+ if should_close:
88
+ session.close()
89
+ return SearchResponse.model_validate(data) # type: ignore[arg-type]
90
+
91
+ def get_trace(self, trace_id: str) -> GetTraceResponse: # noqa: D401
92
+ """Retrieve a single trace by *trace_id*.
93
+
94
+ Args:
95
+ trace_id: The Jaeger trace identifier.
96
+
97
+ Returns:
98
+ Parsed :class:`~veris_ai.jaeger_interface.models.GetTraceResponse` model.
99
+ """
100
+ if not trace_id:
101
+ error_msg = "trace_id must be non-empty"
102
+ raise ValueError(error_msg)
103
+
104
+ session, should_close = self._make_session()
105
+ try:
106
+ url = f"{self._base_url}/api/traces/{trace_id}"
107
+ response = session.get(url, timeout=self._timeout)
108
+ response.raise_for_status()
109
+ data = response.json()
110
+ finally:
111
+ if should_close:
112
+ session.close()
113
+ return GetTraceResponse.model_validate(data) # type: ignore[arg-type]
114
+
115
+ # ------------------------------------------------------------------
116
+ # Context-manager helpers (optional but convenient)
117
+ # ------------------------------------------------------------------
118
+
119
+ def __enter__(self) -> Self:
120
+ """Enter the context manager."""
121
+ self._session_ctx, self._should_close_ctx = self._make_session()
122
+ return self
123
+
124
+ def __exit__(
125
+ self,
126
+ exc_type: type[BaseException] | None,
127
+ exc: BaseException | None,
128
+ tb: TracebackType | None,
129
+ ) -> None:
130
+ """Exit the context manager."""
131
+ # Only close if we created the session
132
+ if getattr(self, "_should_close_ctx", False):
133
+ self._session_ctx.close()