stacklink-sdk 0.2.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.
@@ -0,0 +1,432 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import uuid
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from typing import Any, Callable
8
+
9
+ from .errors import StacklinkError
10
+
11
+
12
+ class StacklinkMemoryLifecycleError(RuntimeError):
13
+ def __init__(self, message: str, *, phase: str, cause: Exception | None = None):
14
+ super().__init__(message)
15
+ self.phase = phase
16
+ self.__cause__ = cause
17
+
18
+
19
+ def with_stacklink_memory(
20
+ openai_client: Any,
21
+ *,
22
+ session: Any,
23
+ application_key: str = "openai",
24
+ capture: str = "completed_turns",
25
+ availability_mode: str = "continue",
26
+ context_options: dict[str, Any] | None = None,
27
+ consent: dict[str, Any] | None = None,
28
+ metadata: dict[str, Any] | None = None,
29
+ on_diagnostic: Callable[[dict[str, Any]], None] | None = None,
30
+ ):
31
+ """Add automatic Stacklink Memory recall and completed-turn capture to OpenAI.
32
+
33
+ The returned object preserves the normal ``responses.create`` and
34
+ ``chat.completions.create`` call shapes. Both sync and async OpenAI clients
35
+ are supported. Streaming turns are captured only after the stream reaches a
36
+ successful terminal event.
37
+ """
38
+ lifecycle = _MemoryLifecycle(
39
+ session=session,
40
+ application_key=application_key,
41
+ capture=capture,
42
+ availability_mode=availability_mode,
43
+ context_options=context_options or {},
44
+ consent=consent,
45
+ metadata=metadata or {},
46
+ on_diagnostic=on_diagnostic,
47
+ )
48
+ responses = _CreateResourceProxy(
49
+ openai_client.responses,
50
+ lambda request, original: _responses_create(lifecycle, request, original),
51
+ )
52
+ completions = _CreateResourceProxy(
53
+ openai_client.chat.completions,
54
+ lambda request, original: _chat_create(lifecycle, request, original),
55
+ )
56
+ chat = _AttributeProxy(openai_client.chat, {"completions": completions})
57
+ return _AttributeProxy(openai_client, {"responses": responses, "chat": chat})
58
+
59
+
60
+ class _MemoryLifecycle:
61
+ def __init__(
62
+ self,
63
+ *,
64
+ session: Any,
65
+ application_key: str,
66
+ capture: str,
67
+ availability_mode: str,
68
+ context_options: dict[str, Any],
69
+ consent: dict[str, Any] | None,
70
+ metadata: dict[str, Any],
71
+ on_diagnostic: Callable[[dict[str, Any]], None] | None,
72
+ ):
73
+ if capture not in {"completed_turns", "off"}:
74
+ raise ValueError("capture must be 'completed_turns' or 'off'.")
75
+ if availability_mode not in {"continue", "throw"}:
76
+ raise ValueError("availability_mode must be 'continue' or 'throw'.")
77
+ self.session = session
78
+ self.application_key = application_key
79
+ self.capture = capture
80
+ self.availability_mode = availability_mode
81
+ self.context_options = context_options
82
+ self.consent = consent
83
+ self.metadata = metadata
84
+ self.on_diagnostic = on_diagnostic
85
+
86
+ def recall(self, query: str) -> str:
87
+ if not query.strip():
88
+ return ""
89
+ try:
90
+ response = self.session.memory.context({**self.context_options, "query": query})
91
+ prompt_text = response.get("promptText") if isinstance(response, dict) else None
92
+ if not isinstance(prompt_text, str):
93
+ raise StacklinkMemoryLifecycleError(
94
+ "Stacklink Memory context did not include promptText.",
95
+ phase="recall",
96
+ )
97
+ return prompt_text
98
+ except Exception as error:
99
+ self._handle_failure("recall", error)
100
+ return ""
101
+
102
+ def capture_turn(
103
+ self,
104
+ *,
105
+ user_text: str,
106
+ assistant_text: str,
107
+ framework: str,
108
+ response_id: str | None,
109
+ ) -> None:
110
+ if self.capture == "off" or not user_text.strip() or not assistant_text.strip():
111
+ return
112
+ content = json.dumps(
113
+ {
114
+ "schemaVersion": "stacklink.conversation.turn.v1",
115
+ "user": {"role": "user", "content": user_text},
116
+ "assistant": {"role": "assistant", "content": assistant_text},
117
+ },
118
+ separators=(",", ":"),
119
+ ensure_ascii=False,
120
+ )
121
+ body: dict[str, Any] = {
122
+ "applicationKey": self.application_key,
123
+ "turnId": response_id or str(uuid.uuid4()),
124
+ "content": content,
125
+ "metadata": {
126
+ **self.metadata,
127
+ "framework": framework,
128
+ "responseId": response_id,
129
+ "captureMode": "completed_turns",
130
+ },
131
+ }
132
+ if self.consent is not None:
133
+ body["consent"] = self.consent
134
+ try:
135
+ self.session.memory.record_conversation_turn(body)
136
+ except Exception as error:
137
+ self._handle_failure("capture", error)
138
+
139
+ def _handle_failure(self, phase: str, error: Exception) -> None:
140
+ if self.availability_mode == "continue" and _is_availability_failure(error):
141
+ if self.on_diagnostic is not None:
142
+ self.on_diagnostic(
143
+ {
144
+ "phase": phase,
145
+ "code": "memory_unavailable",
146
+ "message": (
147
+ "The model call continued without Stacklink Memory context."
148
+ if phase == "recall"
149
+ else "The model response completed, but Stacklink Memory capture is unavailable."
150
+ ),
151
+ "error": error,
152
+ }
153
+ )
154
+ return
155
+ if isinstance(error, StacklinkMemoryLifecycleError):
156
+ raise error
157
+ raise StacklinkMemoryLifecycleError(
158
+ (
159
+ "Stacklink Memory context could not be assembled."
160
+ if phase == "recall"
161
+ else "The completed turn could not be accepted by Stacklink Memory."
162
+ ),
163
+ phase=phase,
164
+ cause=error,
165
+ ) from error
166
+
167
+
168
+ def _responses_create(lifecycle: _MemoryLifecycle, request: dict[str, Any], original: Callable[..., Any]):
169
+ user_text = _responses_user_text(request.get("input"))
170
+ prompt_text = lifecycle.recall(user_text)
171
+ next_request = dict(request)
172
+ if prompt_text:
173
+ memory_instructions = f"Stacklink Memory context\n\n{prompt_text}"
174
+ existing = next_request.get("instructions")
175
+ next_request["instructions"] = (
176
+ f"{existing}\n\n{memory_instructions}"
177
+ if isinstance(existing, str) and existing
178
+ else memory_instructions
179
+ )
180
+ response = original(**next_request)
181
+
182
+ def transform(value: Any):
183
+ if request.get("stream") is True:
184
+ observer = _ResponsesStreamObserver(lifecycle, user_text)
185
+ return _wrap_stream(value, observer)
186
+ if _value(value, "status") == "completed":
187
+ lifecycle.capture_turn(
188
+ user_text=user_text,
189
+ assistant_text=_string(_value(value, "output_text")),
190
+ framework="openai-python-responses",
191
+ response_id=_optional_string(_value(value, "id")),
192
+ )
193
+ return value
194
+
195
+ return _transform_possible_awaitable(response, transform)
196
+
197
+
198
+ def _chat_create(lifecycle: _MemoryLifecycle, request: dict[str, Any], original: Callable[..., Any]):
199
+ user_text = _chat_user_text(request.get("messages"))
200
+ prompt_text = lifecycle.recall(user_text)
201
+ next_request = dict(request)
202
+ if prompt_text:
203
+ messages = list(next_request.get("messages") or [])
204
+ insertion = len(messages)
205
+ for index, message in enumerate(messages):
206
+ role = _value(message, "role")
207
+ if role not in {"system", "developer"}:
208
+ insertion = index
209
+ break
210
+ messages.insert(insertion, {"role": "system", "content": f"Stacklink Memory context\n\n{prompt_text}"})
211
+ next_request["messages"] = messages
212
+ response = original(**next_request)
213
+
214
+ def transform(value: Any):
215
+ if request.get("stream") is True:
216
+ observer = _ChatStreamObserver(lifecycle, user_text)
217
+ return _wrap_stream(value, observer)
218
+ choices = _value(value, "choices")
219
+ choice = choices[0] if isinstance(choices, list) and choices else None
220
+ finish_reason = _value(choice, "finish_reason")
221
+ if finish_reason in {"stop", "length"}:
222
+ lifecycle.capture_turn(
223
+ user_text=user_text,
224
+ assistant_text=_string(_value(_value(choice, "message"), "content")),
225
+ framework="openai-python-chat-completions",
226
+ response_id=_optional_string(_value(value, "id")),
227
+ )
228
+ return value
229
+
230
+ return _transform_possible_awaitable(response, transform)
231
+
232
+
233
+ async def _await_and_transform(awaitable: Any, transform: Callable[[Any], Any]):
234
+ return transform(await awaitable)
235
+
236
+
237
+ def _transform_possible_awaitable(value: Any, transform: Callable[[Any], Any]):
238
+ if inspect.isawaitable(value):
239
+ return _await_and_transform(value, transform)
240
+ return transform(value)
241
+
242
+
243
+ def _wrap_stream(source: Any, observer: "_StreamObserver"):
244
+ if hasattr(source, "__aiter__"):
245
+ return _ObservedAsyncIterator(source, observer)
246
+ if hasattr(source, "__iter__"):
247
+ return _ObservedIterator(source, observer)
248
+ raise TypeError("OpenAI returned a stream without a sync or async iterator contract.")
249
+
250
+
251
+ class _StreamObserver:
252
+ def item(self, value: Any) -> None:
253
+ raise NotImplementedError
254
+
255
+ def complete(self) -> None:
256
+ raise NotImplementedError
257
+
258
+
259
+ class _ResponsesStreamObserver(_StreamObserver):
260
+ def __init__(self, lifecycle: _MemoryLifecycle, user_text: str):
261
+ self.lifecycle = lifecycle
262
+ self.user_text = user_text
263
+ self.assistant_text = ""
264
+ self.response_id: str | None = None
265
+ self.completed = False
266
+
267
+ def item(self, event: Any) -> None:
268
+ event_type = _value(event, "type")
269
+ if event_type == "response.output_text.delta":
270
+ self.assistant_text += _string(_value(event, "delta"))
271
+ elif event_type == "response.completed":
272
+ self.completed = True
273
+ response = _value(event, "response")
274
+ self.response_id = _optional_string(_value(response, "id"))
275
+ if not self.assistant_text:
276
+ self.assistant_text = _string(_value(response, "output_text"))
277
+
278
+ def complete(self) -> None:
279
+ if self.completed:
280
+ self.lifecycle.capture_turn(
281
+ user_text=self.user_text,
282
+ assistant_text=self.assistant_text,
283
+ framework="openai-python-responses",
284
+ response_id=self.response_id,
285
+ )
286
+
287
+
288
+ class _ChatStreamObserver(_StreamObserver):
289
+ def __init__(self, lifecycle: _MemoryLifecycle, user_text: str):
290
+ self.lifecycle = lifecycle
291
+ self.user_text = user_text
292
+ self.assistant_text = ""
293
+ self.response_id: str | None = None
294
+ self.finish_reason: str | None = None
295
+
296
+ def item(self, chunk: Any) -> None:
297
+ self.response_id = self.response_id or _optional_string(_value(chunk, "id"))
298
+ choices = _value(chunk, "choices")
299
+ choice = choices[0] if isinstance(choices, list) and choices else None
300
+ self.assistant_text += _string(_value(_value(choice, "delta"), "content"))
301
+ self.finish_reason = _optional_string(_value(choice, "finish_reason")) or self.finish_reason
302
+
303
+ def complete(self) -> None:
304
+ if self.finish_reason in {"stop", "length"}:
305
+ self.lifecycle.capture_turn(
306
+ user_text=self.user_text,
307
+ assistant_text=self.assistant_text,
308
+ framework="openai-python-chat-completions",
309
+ response_id=self.response_id,
310
+ )
311
+
312
+
313
+ class _ObservedIterator(Iterator[Any]):
314
+ def __init__(self, source: Any, observer: _StreamObserver):
315
+ self.source = source
316
+ self.iterator = iter(source)
317
+ self.observer = observer
318
+ self.finished = False
319
+
320
+ def __next__(self):
321
+ try:
322
+ item = next(self.iterator)
323
+ except StopIteration:
324
+ if not self.finished:
325
+ self.finished = True
326
+ self.observer.complete()
327
+ raise
328
+ self.observer.item(item)
329
+ return item
330
+
331
+ def __getattr__(self, name: str):
332
+ return getattr(self.source, name)
333
+
334
+
335
+ class _ObservedAsyncIterator(AsyncIterator[Any]):
336
+ def __init__(self, source: Any, observer: _StreamObserver):
337
+ self.source = source
338
+ self.iterator = source.__aiter__()
339
+ self.observer = observer
340
+ self.finished = False
341
+
342
+ async def __anext__(self):
343
+ try:
344
+ item = await self.iterator.__anext__()
345
+ except StopAsyncIteration:
346
+ if not self.finished:
347
+ self.finished = True
348
+ self.observer.complete()
349
+ raise
350
+ self.observer.item(item)
351
+ return item
352
+
353
+ def __getattr__(self, name: str):
354
+ return getattr(self.source, name)
355
+
356
+
357
+ class _AttributeProxy:
358
+ def __init__(self, target: Any, overrides: dict[str, Any]):
359
+ self._target = target
360
+ self._overrides = overrides
361
+
362
+ def __getattr__(self, name: str):
363
+ if name in self._overrides:
364
+ return self._overrides[name]
365
+ return getattr(self._target, name)
366
+
367
+
368
+ class _CreateResourceProxy(_AttributeProxy):
369
+ def __init__(self, target: Any, create: Callable[[dict[str, Any], Callable[..., Any]], Any]):
370
+ super().__init__(target, {})
371
+ self._create = create
372
+
373
+ def create(self, **request: Any):
374
+ return self._create(request, self._target.create)
375
+
376
+
377
+ def _responses_user_text(value: Any) -> str:
378
+ if isinstance(value, str):
379
+ return value
380
+ if not isinstance(value, list):
381
+ return ""
382
+ for item in reversed(value):
383
+ if _value(item, "role") != "user":
384
+ continue
385
+ content = _value(item, "content")
386
+ if isinstance(content, str):
387
+ return content
388
+ if isinstance(content, list):
389
+ return "\n".join(
390
+ _string(_value(part, "text"))
391
+ for part in content
392
+ if _value(part, "type") == "input_text"
393
+ )
394
+ return ""
395
+
396
+
397
+ def _chat_user_text(value: Any) -> str:
398
+ if not isinstance(value, list):
399
+ return ""
400
+ for message in reversed(value):
401
+ if _value(message, "role") != "user":
402
+ continue
403
+ content = _value(message, "content")
404
+ if isinstance(content, str):
405
+ return content
406
+ if isinstance(content, list):
407
+ return "\n".join(
408
+ _string(_value(part, "text"))
409
+ for part in content
410
+ if _value(part, "type") == "text"
411
+ )
412
+ return ""
413
+
414
+
415
+ def _is_availability_failure(error: Exception) -> bool:
416
+ if isinstance(error, StacklinkError) and error.status_code is not None:
417
+ return error.status_code == 408 or error.status_code >= 500
418
+ return isinstance(error, (ConnectionError, TimeoutError, OSError))
419
+
420
+
421
+ def _value(value: Any, name: str):
422
+ if isinstance(value, dict):
423
+ return value.get(name)
424
+ return getattr(value, name, None)
425
+
426
+
427
+ def _string(value: Any) -> str:
428
+ return value if isinstance(value, str) else ""
429
+
430
+
431
+ def _optional_string(value: Any) -> str | None:
432
+ return value if isinstance(value, str) and value else None
stacklink/projects.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ProjectsResource:
7
+ def __init__(self, client):
8
+ self.client = client
9
+
10
+ def list(self, **query):
11
+ return self.client.request("GET", "/v1/projects", query=query)
12
+
13
+ def get(self, project_id: str):
14
+ return self.client.request("GET", f"/v1/projects/{project_id}")
15
+
16
+ def create(self, body: dict[str, Any]):
17
+ return self.client.request("POST", "/v1/projects", json_body=body, registration=True)
18
+
19
+ def reset_auth_configs(self, project_id: str):
20
+ return self.client.request("POST", f"/v1/projects/{project_id}/auth-configs/reset", registration=True)
stacklink/sessions.py ADDED
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, TypedDict
4
+
5
+ from .memory import SessionMemoryResource
6
+ from .types import ProjectProvider
7
+
8
+
9
+ class SessionToolDefinition(TypedDict):
10
+ name: str
11
+ description: str
12
+ inputSchema: dict[str, Any]
13
+
14
+
15
+ class SessionTool:
16
+ def __init__(self, session: "Session", definition: SessionToolDefinition):
17
+ self.session = session
18
+ self.name = definition["name"]
19
+ self.description = definition.get("description", "")
20
+ self.parameters = definition.get("inputSchema", {})
21
+
22
+ def call(self, arguments: Mapping[str, Any] | None = None):
23
+ return self.session.client.mcp.call_session_tool_result(self.session.id, self.name, dict(arguments or {}))
24
+
25
+ def __call__(self, **arguments: Any):
26
+ return self.call(arguments)
27
+
28
+
29
+ class Session:
30
+ def __init__(
31
+ self,
32
+ client,
33
+ session_id: str,
34
+ *,
35
+ session_data: dict[str, Any] | None = None,
36
+ tool_projection: dict[str, Any] | None = None,
37
+ ):
38
+ self.client = client
39
+ self.id = session_id
40
+ self.data = dict(session_data or {})
41
+ self._tool_projection = dict(tool_projection or {})
42
+ self.memory = SessionMemoryResource(client.memory, session_id)
43
+ self.sandbox = client.workbench.bind(session_id)
44
+
45
+ def get(self):
46
+ return self.client.sessions.get(self.id)
47
+
48
+ def update(self, body: dict[str, Any]):
49
+ response = self.client.sessions.update(self.id, body)
50
+ self.data = dict(response.get("session", self.data)) if isinstance(response, dict) else self.data
51
+ self.refresh_tools()
52
+ return response
53
+
54
+ def set_provider_mutation_mode(
55
+ self,
56
+ mode: str,
57
+ *,
58
+ expected_config_version: int,
59
+ ):
60
+ if mode not in {"read_only", "ask", "full_access"}:
61
+ raise ValueError("mode must be read_only, ask, or full_access")
62
+ return self.update({
63
+ "expectedConfigVersion": expected_config_version,
64
+ "providerMutationMode": mode,
65
+ })
66
+
67
+ def close(self):
68
+ return self.client.sessions.close(self.id)
69
+
70
+ def fail(self, body: dict[str, Any] | None = None):
71
+ return self.client.sessions.fail(self.id, body)
72
+
73
+ def trace(self, **query):
74
+ return self.client.sessions.trace(self.id, **query)
75
+
76
+ def config_history(self, **query):
77
+ return self.client.sessions.config_history(self.id, **query)
78
+
79
+ def refresh_tools(self) -> dict[str, Any]:
80
+ self._tool_projection = self.client.sessions.meta_tools(self.id)
81
+ return self._tool_projection
82
+
83
+ @property
84
+ def tool_contract_version(self) -> str:
85
+ self._ensure_tools()
86
+ return str(self._tool_projection.get("contractVersion", "unknown"))
87
+
88
+ def tool_registry(self) -> list[SessionToolDefinition]:
89
+ self._ensure_tools()
90
+ tools = self._tool_projection.get("tools", [])
91
+ return [dict(tool) for tool in tools if isinstance(tool, dict) and isinstance(tool.get("name"), str)]
92
+
93
+ def tools(self) -> dict[str, SessionTool]:
94
+ return {definition["name"]: SessionTool(self, definition) for definition in self.tool_registry()}
95
+
96
+ def mcp_connection(self) -> dict[str, Any]:
97
+ descriptor = self.data.get("mcp")
98
+ if not isinstance(descriptor, dict) or not isinstance(descriptor.get("url"), str):
99
+ response = self.get()
100
+ session_data = response.get("session", {}) if isinstance(response, dict) else {}
101
+ self.data = dict(session_data)
102
+ descriptor = self.data.get("mcp")
103
+ if not isinstance(descriptor, dict) or not isinstance(descriptor.get("url"), str):
104
+ raise ValueError("This Stacklink session does not include an MCP connection descriptor.")
105
+ headers = descriptor.get("headers")
106
+ return {
107
+ "url": descriptor["url"],
108
+ "headers": {
109
+ **(headers if isinstance(headers, dict) else {}),
110
+ "Authorization": f"Bearer {self.client.api_key}",
111
+ },
112
+ }
113
+
114
+ def langchain_tools(self) -> list[dict[str, Any]]:
115
+ return [
116
+ {
117
+ "name": tool.name,
118
+ "description": tool.description,
119
+ "schema": tool.parameters,
120
+ "invoke": tool.call,
121
+ "func": tool.call,
122
+ }
123
+ for tool in self.tools().values()
124
+ ]
125
+
126
+ def _ensure_tools(self) -> None:
127
+ if not isinstance(self._tool_projection.get("tools"), list):
128
+ self.refresh_tools()
129
+
130
+
131
+ class SessionsResource:
132
+ def __init__(self, client):
133
+ self.client = client
134
+
135
+ def create(self, body: dict[str, Any]):
136
+ return self.client.request("POST", "/v1/sessions", json_body=body)
137
+
138
+ def create_bound(self, body: dict[str, Any]) -> Session:
139
+ response = self.create(body)
140
+ session_data = response.get("session", {}) if isinstance(response, dict) else {}
141
+ session_id = str(session_data["id"])
142
+ return Session(
143
+ self.client,
144
+ session_id,
145
+ session_data=session_data,
146
+ tool_projection=self.meta_tools(session_id),
147
+ )
148
+
149
+ def bind(self, session_id: str) -> Session:
150
+ return Session(self.client, session_id)
151
+
152
+ def use(self, session_id: str) -> Session:
153
+ response = self.get(session_id)
154
+ session_data = response.get("session", {}) if isinstance(response, dict) else {}
155
+ return Session(
156
+ self.client,
157
+ session_id,
158
+ session_data=session_data,
159
+ tool_projection=self.meta_tools(session_id),
160
+ )
161
+
162
+ def list(self, **query):
163
+ return self.client.request("GET", "/v1/sessions", query=query)
164
+
165
+ def get(self, session_id: str):
166
+ return self.client.request("GET", f"/v1/sessions/{session_id}")
167
+
168
+ def update(self, session_id: str, body: dict[str, Any]):
169
+ response = self.client.request("PATCH", f"/v1/sessions/{session_id}", json_body=body)
170
+ self.client.clear_cache(f"session:{session_id}:")
171
+ return response
172
+
173
+ def meta_tools(self, session_id: str):
174
+ return self.client.request("GET", f"/v1/sessions/{session_id}/meta-tools")
175
+
176
+ def close(self, session_id: str):
177
+ return self.client.request("POST", f"/v1/sessions/{session_id}/close")
178
+
179
+ def fail(self, session_id: str, body: dict[str, Any] | None = None):
180
+ return self.client.request("POST", f"/v1/sessions/{session_id}/fail", json_body=body or {})
181
+
182
+ def router_facts(self, session_id: str, **query):
183
+ return self.client.request("GET", f"/v1/sessions/{session_id}/router-facts", query=query)
184
+
185
+ def workbench(self, session_id: str):
186
+ return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
187
+
188
+ def trace(self, session_id: str, **query):
189
+ return self.client.request("GET", f"/v1/sessions/{session_id}/trace", query=query)
190
+
191
+ def config_history(self, session_id: str, **query):
192
+ return self.client.request("GET", f"/v1/sessions/{session_id}/config-history", query=query)
193
+
194
+ def toolkits(self, session_id: str) -> dict[str, list[ProjectProvider]]:
195
+ return self.client.cached_request(
196
+ f"session:{session_id}:toolkits",
197
+ "GET",
198
+ f"/v1/sessions/{session_id}/toolkits",
199
+ ttl_seconds=300,
200
+ )
201
+
202
+ def search_tools(self, session_id: str, body: dict[str, Any], *, cache: str = "default"):
203
+ return self.client.tools.search_for_session(session_id, body, cache=cache)
204
+
205
+ def get_tool_schemas(self, session_id: str, body: dict[str, Any], *, cache: str = "default"):
206
+ return self.client.tools.schemas_for_session(session_id, body, cache=cache)
207
+
208
+ def prepare_tools(self, session_id: str, **kwargs):
209
+ return self.client.tools.prepare_for_session(session_id, **kwargs)
210
+
211
+ def prepare_runtime_tools(self, session_id: str, **kwargs):
212
+ return self.client.tools.prepare_runtime_for_session(session_id, **kwargs)
213
+
214
+ def stream_events(self, session_id: str, **query):
215
+ return self.client.request_sse("GET", f"/v1/sessions/{session_id}/events/stream", query=query)
216
+
217
+ def authorize_connection(self, session_id: str, body: dict[str, Any]):
218
+ return self.client.request("POST", f"/v1/sessions/{session_id}/connections/authorize", json_body=body)
219
+
220
+ def mcp_url(self, session: dict[str, Any] | Session) -> str | None:
221
+ value = session.data if isinstance(session, Session) else session
222
+ mcp = value.get("mcp")
223
+ return mcp.get("url") if isinstance(mcp, dict) else None