steerpane-sdk 0.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.
steerpane/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Steerpane: remote browsers for AI agents, with human takeover."""
2
+ from ._async_client import AsyncSteerpane
3
+ from ._client import DEFAULT_BASE_URL, Steerpane
4
+ from ._version import __version__
5
+ from .errors import APIError, HumanInControlError, SteerpaneError, ToolError
6
+ from .webhooks import WebhookSignatureError, construct_event, verify_signature
7
+
8
+ __all__ = [
9
+ "APIError",
10
+ "AsyncSteerpane",
11
+ "DEFAULT_BASE_URL",
12
+ "HumanInControlError",
13
+ "Steerpane",
14
+ "SteerpaneError",
15
+ "ToolError",
16
+ "WebhookSignatureError",
17
+ "__version__",
18
+ "construct_event",
19
+ "verify_signature",
20
+ ]
@@ -0,0 +1,506 @@
1
+ """GENERATED by scripts/gen_async.py from _client.py. Do not edit."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import time
7
+ import uuid
8
+ from collections.abc import AsyncIterator, Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ._client import DictList, ENDED_CRAWL_STATUSES, ENDED_RUN_STATUSES, ENDED_SESSION_STATUSES, MCP_PROTOCOL, RETRY_STATUSES, StrList, _clean, _parse, _rate_limited, _resolve_config, _retry_after, _seg
15
+ from ._version import __version__
16
+ from .errors import SteerpaneError, ToolError, from_response
17
+ from .types import AgentRun, Capture, Credential, CrawlJob, EmbedLink, FetchResult, HumanResult, Page, Profile, SearchResponse, Session, StoredFile, UploadUrl, Webhook
18
+
19
+
20
+ class _AsyncActions:
21
+ """Browser actions, `POST /v1/sessions/{id}/actions`. While a human holds control, acting calls raise `HumanInControlError` and queue."""
22
+
23
+ def __init__(self, client: AsyncSteerpane) -> None:
24
+ self._c = client
25
+
26
+ async def run(self, session_id: str, type: str, *, wait_s: float | None = None, **args: Any) -> dict[str, Any]:
27
+ """Run any action type. `wait_s` holds the call until a takeover ends instead of raising, up to 600 seconds."""
28
+ headers = {"Steerpane-Wait": str(wait_s)} if wait_s is not None else None
29
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/actions", json={"type": type, **_clean(args)}, headers=headers,
30
+ timeout=max(self._c.timeout, (wait_s or 0) + 30))
31
+
32
+ async def navigate(self, session_id: str, url: str, **args: Any) -> dict[str, Any]:
33
+ return await self.run(session_id, "navigate", url=url, **args)
34
+
35
+ async def click(self, session_id: str, selector: str, **args: Any) -> dict[str, Any]:
36
+ return await self.run(session_id, "click", selector=selector, **args)
37
+
38
+ async def type(self, session_id: str, selector: str, text: str, **args: Any) -> dict[str, Any]:
39
+ return await self.run(session_id, "type", selector=selector, text=text, **args)
40
+
41
+ async def press_key(self, session_id: str, key: str, **args: Any) -> dict[str, Any]:
42
+ return await self.run(session_id, "press_key", key=key, **args)
43
+
44
+ async def select_option(self, session_id: str, selector: str, option: str, **args: Any) -> dict[str, Any]:
45
+ return await self.run(session_id, "select_option", selector=selector, option=option, **args)
46
+
47
+ async def scroll(self, session_id: str, **args: Any) -> dict[str, Any]:
48
+ return await self.run(session_id, "scroll", **args)
49
+
50
+ async def wait_for(self, session_id: str, **args: Any) -> dict[str, Any]:
51
+ """Exactly one of `selector` (+ `state`), `text`, `url`, `load_state` or `ms`. `timeout_ms` defaults to 10000."""
52
+ return await self.run(session_id, "wait_for", **args)
53
+
54
+ async def snapshot(self, session_id: str, **args: Any) -> dict[str, Any]:
55
+ return await self.run(session_id, "snapshot", **args)
56
+
57
+ async def get_page_text(self, session_id: str, **args: Any) -> dict[str, Any]:
58
+ return await self.run(session_id, "get_page_text", **args)
59
+
60
+ async def element_state(self, session_id: str, selector: str, **args: Any) -> dict[str, Any]:
61
+ return await self.run(session_id, "element_state", selector=selector, **args)
62
+
63
+ async def form_fields(self, session_id: str, **args: Any) -> dict[str, Any]:
64
+ return await self.run(session_id, "form_fields", **args)
65
+
66
+ async def screenshot(self, session_id: str, **args: Any) -> dict[str, Any]:
67
+ return await self.run(session_id, "screenshot", **args)
68
+
69
+ async def execute(self, session_id: str, code: str, **args: Any) -> dict[str, Any]:
70
+ """Run JavaScript in the page. `code` is a function body, so use `return` to get a value back as `result`."""
71
+ return await self.run(session_id, "execute", code=code, **args)
72
+
73
+ async def extract(self, session_id: str, instruction: str, **args: Any) -> dict[str, Any]:
74
+ """Needs a model key on the organization."""
75
+ return await self.run(session_id, "extract", instruction=instruction, **args)
76
+
77
+ async def download(self, session_id: str, **args: Any) -> dict[str, Any]:
78
+ return await self.run(session_id, "download", **args)
79
+
80
+ async def upload_file(self, session_id: str, **args: Any) -> dict[str, Any]:
81
+ return await self.run(session_id, "upload_file", **args)
82
+
83
+ async def vault_fill(self, session_id: str, credential_id: str, **args: Any) -> dict[str, Any]:
84
+ """Type a stored credential into the page. The secret never comes back. Refused off the credential's site."""
85
+ return await self.run(session_id, "vault_fill", credential_id=credential_id, **args)
86
+
87
+
88
+ class _AsyncSessions:
89
+ def __init__(self, client: AsyncSteerpane) -> None:
90
+ self._c = client
91
+
92
+ async def list(self, *, status: str | None = None, profile_id: str | None = None, q: str | None = None, project_id: str | None = None,
93
+ limit: int | None = None, cursor: str | None = None) -> Page[Session]:
94
+ return await self._c.request("GET", "/v1/sessions", params=_clean({"status": status, "profile_id": profile_id, "q": q, "project_id": project_id, "limit": limit, "cursor": cursor}))
95
+
96
+ async def create(self, *, idempotency_key: str | None = None, **config: Any) -> Session:
97
+ """Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `auto_close`, `timeout_min`."""
98
+ return await self._c.request("POST", "/v1/sessions", json=_clean(config), headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())})
99
+
100
+ async def get(self, session_id: str) -> Session:
101
+ return await self._c.request("GET", f"/v1/sessions/{_seg(session_id)}")
102
+
103
+ async def wait_until_ready(self, session_id: str, *, timeout_s: float = 90, poll_s: float = 0.5) -> Session:
104
+ """Wait for the browser to leave `starting`. Raises if it ends instead, or doesn't start in time."""
105
+ deadline = time.monotonic() + timeout_s
106
+ while True:
107
+ session = await self.get(session_id)
108
+ status = session.get("status")
109
+ if status in ENDED_SESSION_STATUSES:
110
+ raise SteerpaneError(f"Session {session_id} ended before it was ready: {session.get('error_message') or status}")
111
+ if status != "starting":
112
+ return session
113
+ if time.monotonic() >= deadline:
114
+ raise SteerpaneError(f"Session {session_id} was still starting after {timeout_s:.0f} s")
115
+ await asyncio.sleep(poll_s)
116
+
117
+ async def stop(self, session_id: str) -> Session:
118
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/stop")
119
+
120
+ async def delete(self, session_id: str) -> None:
121
+ await self._c.request("DELETE", f"/v1/sessions/{_seg(session_id)}")
122
+
123
+ async def keepalive(self, session_id: str) -> Session:
124
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/keepalive")
125
+
126
+ async def takeover(self, session_id: str) -> Session:
127
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/takeover")
128
+
129
+ async def handback(self, session_id: str, *, outcome: str | None = None, note: str | None = None) -> Session:
130
+ """Give control back. `outcome` is `done` or `not_done`, and the waiting agent receives it with the note."""
131
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/handback", json=_clean({"outcome": outcome, "note": note}))
132
+
133
+ async def request_help(self, session_id: str, *, reason: str, message: str = "") -> dict[str, Any]:
134
+ """Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires."""
135
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/help-requests", json={"reason": reason, "message": message})
136
+
137
+ async def wait_for_human(self, session_id: str, request_id: str = "latest", *, timeout_s: float = 30) -> HumanResult:
138
+ """Long-poll until the human hands back. `status` is `handed_back`, `timeout` (call again) or `stopped`."""
139
+ return await self._c.request("GET", f"/v1/sessions/{_seg(session_id)}/help-requests/{_seg(request_id)}/wait", params={"timeout_s": timeout_s},
140
+ timeout=max(self._c.timeout, timeout_s + 30))
141
+
142
+ async def events(self, session_id: str, *, limit: int | None = None, cursor: str | None = None) -> Page[dict[str, Any]]:
143
+ return await self._c.request("GET", f"/v1/sessions/{_seg(session_id)}/events", params=_clean({"limit": limit, "cursor": cursor}))
144
+
145
+ async def files(self, session_id: str) -> Any:
146
+ return await self._c.request("GET", f"/v1/sessions/{_seg(session_id)}/files")
147
+
148
+ async def zip_files(self, session_id: str) -> dict[str, Any]:
149
+ """Pack what the session downloaded into one ZIP in the file store."""
150
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/files/zip")
151
+
152
+
153
+ class _AsyncProfiles:
154
+ def __init__(self, client: AsyncSteerpane) -> None:
155
+ self._c = client
156
+
157
+ async def list(self, *, tag: str | None = None, q: str | None = None, limit: int | None = None, cursor: str | None = None) -> Page[Profile]:
158
+ return await self._c.request("GET", "/v1/profiles", params=_clean({"tag": tag, "q": q, "limit": limit, "cursor": cursor}))
159
+
160
+ async def get(self, profile_id: str) -> Profile:
161
+ return await self._c.request("GET", f"/v1/profiles/{_seg(profile_id)}")
162
+
163
+ async def create(self, name: str, **fields: Any) -> Profile:
164
+ return await self._c.request("POST", "/v1/profiles", json={"name": name, **_clean(fields)})
165
+
166
+ async def update(self, profile_id: str, **fields: Any) -> Profile:
167
+ return await self._c.request("PATCH", f"/v1/profiles/{_seg(profile_id)}", json=fields)
168
+
169
+ async def delete(self, profile_id: str) -> None:
170
+ await self._c.request("DELETE", f"/v1/profiles/{_seg(profile_id)}")
171
+
172
+ async def clone(self, profile_id: str) -> Profile:
173
+ return await self._c.request("POST", f"/v1/profiles/{_seg(profile_id)}/clone")
174
+
175
+ async def verify(self, profile_id: str, *, domains: StrList | None = None) -> dict[str, Any]:
176
+ """Really load each signed-in site with the profile's state, and report whether it is still signed in."""
177
+ return await self._c.request("POST", f"/v1/profiles/{_seg(profile_id)}/verify", json=_clean({"domains": domains}), timeout=max(self._c.timeout, 90))
178
+
179
+ async def lock(self, profile_id: str) -> dict[str, Any]:
180
+ return await self._c.request("GET", f"/v1/profiles/{_seg(profile_id)}/lock")
181
+
182
+ async def import_cookies(self, profile_id: str, cookies: DictList) -> dict[str, Any]:
183
+ return await self._c.request("POST", f"/v1/profiles/{_seg(profile_id)}/cookies/import", json={"cookies": cookies})
184
+
185
+
186
+ class _AsyncRuns:
187
+ """Hosted agent runs. These use your organization's own model key."""
188
+
189
+ def __init__(self, client: AsyncSteerpane) -> None:
190
+ self._c = client
191
+
192
+ async def create(self, task: str, *, idempotency_key: str | None = None, **fields: Any) -> AgentRun:
193
+ """`fields`: `profile_id`, `agent_id`, `model`, `max_steps`, `max_cost_usd`, `max_duration_s`, `result_schema`, `variables`, `project_id`."""
194
+ return await self._c.request("POST", "/v1/agents/runs", json={"task": task, **_clean(fields)}, headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())})
195
+
196
+ async def get(self, run_id: str) -> AgentRun:
197
+ return await self._c.request("GET", f"/v1/agents/runs/{_seg(run_id)}")
198
+
199
+ async def list(self, *, status: str | None = None, agent_id: str | None = None, project_id: str | None = None, limit: int | None = None, cursor: str | None = None) -> Page[AgentRun]:
200
+ return await self._c.request("GET", "/v1/agents/runs", params=_clean({"status": status, "agent_id": agent_id, "project_id": project_id, "limit": limit, "cursor": cursor}))
201
+
202
+ async def messages(self, run_id: str) -> Page[dict[str, Any]]:
203
+ return await self._c.request("GET", f"/v1/agents/runs/{_seg(run_id)}/messages")
204
+
205
+ async def cancel(self, run_id: str) -> AgentRun:
206
+ return await self._c.request("POST", f"/v1/agents/runs/{_seg(run_id)}/cancel")
207
+
208
+ async def wait(self, run_id: str, *, timeout_s: float = 600, poll_s: float = 2) -> AgentRun:
209
+ """Poll until the run finishes. Returns the run whatever its outcome, so check `status`."""
210
+ deadline = time.monotonic() + timeout_s
211
+ while True:
212
+ run = await self.get(run_id)
213
+ if run.get("status") in ENDED_RUN_STATUSES:
214
+ return run
215
+ if time.monotonic() >= deadline:
216
+ raise SteerpaneError(f"Run {run_id} had not finished after {timeout_s:.0f} s (status {run.get('status')})")
217
+ await asyncio.sleep(poll_s)
218
+
219
+
220
+ class _AsyncFiles:
221
+ def __init__(self, client: AsyncSteerpane) -> None:
222
+ self._c = client
223
+
224
+ async def upload(self, file: str | Path | bytes, *, name: str | None = None, mime: str | None = None, ttl_hours: int | None = None) -> StoredFile:
225
+ """Store a file. The result's `id` goes straight into `actions.upload_file(session_id, file_id=...)`."""
226
+ if isinstance(file, bytes):
227
+ content, filename = file, name or "file"
228
+ else:
229
+ content, filename = Path(file).read_bytes(), name or Path(file).name
230
+ form = _clean({"name": name, "ttl_hours": ttl_hours})
231
+ return await self._c.request("POST", "/v1/files", data={k: str(v) for k, v in form.items()},
232
+ files={"file": (filename, content, mime or "application/octet-stream")})
233
+
234
+ async def create_upload_url(self, name: str, *, mime: str | None = None, size_bytes: int | None = None, ttl_hours: int | None = None) -> UploadUrl:
235
+ return await self._c.request("POST", "/v1/files/uploads", json=_clean({"name": name, "mime": mime, "size_bytes": size_bytes, "ttl_hours": ttl_hours}))
236
+
237
+ async def list(self, *, source: str | None = None, session_id: str | None = None, q: str | None = None, limit: int | None = None, cursor: str | None = None) -> Page[StoredFile]:
238
+ return await self._c.request("GET", "/v1/files", params=_clean({"source": source, "session_id": session_id, "q": q, "limit": limit, "cursor": cursor}))
239
+
240
+ async def get(self, file_id: str) -> StoredFile:
241
+ return await self._c.request("GET", f"/v1/files/{_seg(file_id)}")
242
+
243
+ async def download(self, file_id: str) -> bytes:
244
+ """The file's bytes. On S3 storage the API redirects to a presigned URL, which is fetched without your token."""
245
+ return await self._c.request_bytes(f"/v1/files/{_seg(file_id)}/content")
246
+
247
+ async def delete(self, file_id: str) -> None:
248
+ await self._c.request("DELETE", f"/v1/files/{_seg(file_id)}")
249
+
250
+
251
+ class _AsyncEmbeds:
252
+ """Links that show a session inside your own product. Scale and Enterprise plans. The URL is a credential: keep it out of logs."""
253
+
254
+ def __init__(self, client: AsyncSteerpane) -> None:
255
+ self._c = client
256
+
257
+ async def create(self, session_id: str, *, allow_takeover: bool = False, allowed_origins: StrList | None = None, expires_in_min: int | None = 30,
258
+ hide_toolbar: bool = False, show_control: bool = False, theme: str = "auto", end_user: dict[str, Any] | None = None, mode: str | None = None) -> EmbedLink:
259
+ return await self._c.request("POST", f"/v1/sessions/{_seg(session_id)}/embed", json=_clean({
260
+ "allow_takeover": allow_takeover, "allowed_origins": allowed_origins, "expires_in_min": expires_in_min, "hide_toolbar": hide_toolbar,
261
+ "show_control": show_control, "theme": theme, "end_user": end_user, "mode": mode}))
262
+
263
+ async def list(self, *, session_id: str | None = None, limit: int | None = None, cursor: str | None = None) -> Page[EmbedLink]:
264
+ return await self._c.request("GET", "/v1/embed-links", params=_clean({"session_id": session_id, "limit": limit, "cursor": cursor}))
265
+
266
+ async def revoke(self, link_id: str) -> EmbedLink:
267
+ """Closes open iframes at once, and returns control to the agent if this link held it."""
268
+ return await self._c.request("POST", f"/v1/embed-links/{_seg(link_id)}/revoke")
269
+
270
+
271
+ class _AsyncWebhooks:
272
+ """Endpoint management. To check a delivery's signature use `steerpane.webhooks.verify_signature`, which needs no client."""
273
+
274
+ def __init__(self, client: AsyncSteerpane) -> None:
275
+ self._c = client
276
+
277
+ async def list(self) -> Page[Webhook]:
278
+ return await self._c.request("GET", "/v1/webhooks")
279
+
280
+ async def create(self, url: str, events: StrList, **fields: Any) -> Webhook:
281
+ return await self._c.request("POST", "/v1/webhooks", json={"url": url, "events": events, **_clean(fields)})
282
+
283
+ async def get(self, webhook_id: str) -> Webhook:
284
+ return await self._c.request("GET", f"/v1/webhooks/{_seg(webhook_id)}")
285
+
286
+ async def delete(self, webhook_id: str) -> None:
287
+ await self._c.request("DELETE", f"/v1/webhooks/{_seg(webhook_id)}")
288
+
289
+ async def send_test(self, webhook_id: str, *, event: str | None = None) -> dict[str, Any]:
290
+ return await self._c.request("POST", f"/v1/webhooks/{_seg(webhook_id)}/test", json=_clean({"event": event}))
291
+
292
+
293
+ class _AsyncVault:
294
+ """Stored logins. Passwords and TOTP secrets go in and never come back to an API token."""
295
+
296
+ def __init__(self, client: AsyncSteerpane) -> None:
297
+ self._c = client
298
+
299
+ async def list(self) -> Page[Credential]:
300
+ return await self._c.request("GET", "/v1/vault/credentials")
301
+
302
+ async def create(self, *, domain: str, username: str, password: str, name: str | None = None, totp_secret: str | None = None, **fields: Any) -> Credential:
303
+ return await self._c.request("POST", "/v1/vault/credentials", json=_clean({"domain": domain, "username": username, "password": password, "name": name, "totp_secret": totp_secret, **fields}))
304
+
305
+ async def delete(self, credential_id: str) -> None:
306
+ await self._c.request("DELETE", f"/v1/vault/credentials/{_seg(credential_id)}")
307
+
308
+
309
+ class _AsyncCrawls:
310
+ def __init__(self, client: AsyncSteerpane) -> None:
311
+ self._c = client
312
+
313
+ async def start(self, url: str, **options: Any) -> CrawlJob:
314
+ """`options`: `limit`, `max_depth`, `mode`, `formats`, `include_paths`, `exclude_paths`, `check_links`, `same_site`, ..."""
315
+ return await self._c.request("POST", "/v1/crawl", json={"url": url, **_clean(options)})
316
+
317
+ async def get(self, job_id: str, *, cursor: str | None = None, limit: int | None = None, include_content: bool | None = None) -> CrawlJob:
318
+ params = _clean({"cursor": cursor, "limit": limit, "include_content": None if include_content is None else str(include_content).lower()})
319
+ return await self._c.request("GET", f"/v1/crawl/{_seg(job_id)}", params=params)
320
+
321
+ async def cancel(self, job_id: str) -> CrawlJob:
322
+ return await self._c.request("DELETE", f"/v1/crawl/{_seg(job_id)}")
323
+
324
+ async def wait(self, job_id: str, *, timeout_s: float = 300, poll_s: float = 2) -> CrawlJob:
325
+ deadline = time.monotonic() + timeout_s
326
+ while True:
327
+ job = await self.get(job_id, include_content=False)
328
+ if job.get("status") in ENDED_CRAWL_STATUSES:
329
+ return job
330
+ if time.monotonic() >= deadline:
331
+ raise SteerpaneError(f"Crawl {job_id} had not finished after {timeout_s:.0f} s")
332
+ await asyncio.sleep(poll_s)
333
+
334
+
335
+ class _AsyncMcp:
336
+ """Call MCP tools over the API's Streamable HTTP endpoint. A few tools are MCP only, such as `act`, `observe` and `wait_for_code`."""
337
+
338
+ def __init__(self, client: AsyncSteerpane) -> None:
339
+ self._c = client
340
+ self._session_id: str | None = None
341
+ self._next_id = 0
342
+
343
+ async def _post(self, method: str, params: dict[str, Any], *, notify: bool = False, timeout: float | None = None) -> Any:
344
+ message: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params}
345
+ if not notify:
346
+ self._next_id += 1
347
+ message["id"] = self._next_id
348
+ headers = {"Accept": "application/json, text/event-stream", "Mcp-Protocol-Version": MCP_PROTOCOL}
349
+ if self._session_id:
350
+ headers["Mcp-Session-Id"] = self._session_id
351
+ response = await self._c.raw_request("POST", "/mcp", json=message, headers=headers, timeout=timeout)
352
+ if response.status_code >= 400:
353
+ raise from_response(response.status_code, _parse(response), _retry_after(response))
354
+ self._session_id = response.headers.get("mcp-session-id") or self._session_id
355
+ if notify:
356
+ return None
357
+ body = response.text
358
+ if "event-stream" in response.headers.get("content-type", ""):
359
+ frames = [line[5:].strip() for line in body.splitlines() if line.startswith("data:")]
360
+ replies = [json.loads(f) for f in frames if f]
361
+ reply = next((m for m in replies if m.get("id") == message["id"]), None)
362
+ else:
363
+ reply = json.loads(body)
364
+ if not reply:
365
+ raise SteerpaneError(f"MCP {method}: the server sent no reply")
366
+ if reply.get("error"):
367
+ raise SteerpaneError(f"MCP {method}: {reply['error'].get('message')}")
368
+ return reply.get("result")
369
+
370
+ async def _ensure_session(self) -> None:
371
+ if self._session_id:
372
+ return
373
+ await self._post("initialize", {"protocolVersion": MCP_PROTOCOL, "capabilities": {}, "clientInfo": {"name": "steerpane-sdk-python-async", "version": __version__}})
374
+ await self._post("notifications/initialized", {}, notify=True)
375
+
376
+ async def list_tools(self) -> DictList:
377
+ """The default list: core tools plus `search_tools` and `call_tool`. Any tool still runs by name with `call`."""
378
+ await self._ensure_session()
379
+ return (await self._post("tools/list", {}))["tools"]
380
+
381
+ async def call(self, tool: str, *, timeout_s: float = 120, **arguments: Any) -> Any:
382
+ """Run a tool and return its JSON result. A tool failure raises `ToolError` with the API's error code."""
383
+ await self._ensure_session()
384
+ result = await self._post("tools/call", {"name": tool, "arguments": _clean(arguments)}, timeout=timeout_s)
385
+ text = next((c.get("text") for c in result.get("content", []) if c.get("type") == "text"), None)
386
+ try:
387
+ payload = result.get("structuredContent") or (json.loads(text) if text else None)
388
+ except ValueError:
389
+ payload = text
390
+ if result.get("isError"):
391
+ err = payload.get("error", {}) if isinstance(payload, dict) else {}
392
+ raise ToolError(tool, str(err.get("code") or "tool_error"), str(err.get("message") or text or "Tool failed"), err.get("details"))
393
+ return payload
394
+
395
+
396
+ class AsyncSteerpane:
397
+ """Client for the AsyncSteerpane API.
398
+
399
+ `token` falls back to `STEERPANE_API_TOKEN`, and `base_url` to `STEERPANE_API_URL`. Results are plain dicts, typed for your editor.
400
+ """
401
+
402
+ def __init__(self, token: str | None = None, *, base_url: str | None = None, timeout: float = 60, max_retries: int = 2,
403
+ allow_insecure_http: bool = False, http_client: httpx.AsyncClient | None = None) -> None:
404
+ self.token, self.base_url = _resolve_config(token, base_url, allow_insecure_http)
405
+ self.timeout = timeout
406
+ self.max_retries = max_retries
407
+ self._http = http_client or httpx.AsyncClient(follow_redirects=False)
408
+ self.sessions = _AsyncSessions(self)
409
+ self.actions = _AsyncActions(self)
410
+ self.profiles = _AsyncProfiles(self)
411
+ self.runs = _AsyncRuns(self)
412
+ self.files = _AsyncFiles(self)
413
+ self.embeds = _AsyncEmbeds(self)
414
+ self.webhooks = _AsyncWebhooks(self)
415
+ self.vault = _AsyncVault(self)
416
+ self.crawls = _AsyncCrawls(self)
417
+ self.mcp = _AsyncMcp(self)
418
+
419
+ def __repr__(self) -> str: # the token stays out of logs and tracebacks
420
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
421
+
422
+ async def aclose(self) -> None:
423
+ await self._http.aclose()
424
+
425
+ async def __aenter__(self) -> AsyncSteerpane:
426
+ return self
427
+
428
+ async def __aexit__(self, *exc: object) -> None:
429
+ await self.aclose()
430
+
431
+ # ---- transport
432
+
433
+ async def raw_request(self, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None,
434
+ data: dict[str, str] | None = None, files: Any = None, timeout: float | None = None) -> httpx.Response:
435
+ """One HTTP call with your token and the retry rules. Doesn't raise on an error status."""
436
+ merged = {"Authorization": f"Bearer {self.token}", "User-Agent": f"steerpane-sdk-python-async/{__version__}", **(headers or {})}
437
+ idempotent = method in ("GET", "HEAD", "DELETE") or "Idempotency-Key" in merged
438
+ attempt = 0
439
+ while True:
440
+ try:
441
+ response = await self._http.request(method, self.base_url + path, json=json, params=params, headers=merged, data=data, files=files, timeout=timeout or self.timeout)
442
+ except httpx.TransportError:
443
+ if not idempotent or attempt >= self.max_retries:
444
+ raise
445
+ delay = 0.5 * 2**attempt
446
+ else:
447
+ rate_limited = response.status_code == 429 and _rate_limited(response)
448
+ # A stored 429 replays under an Idempotency-Key, so a keyed POST isn't retried on it. A rate-limited request never ran.
449
+ retryable = (idempotent and response.status_code in RETRY_STATUSES) or (rate_limited and "Idempotency-Key" not in merged)
450
+ if not retryable or attempt >= self.max_retries:
451
+ return response
452
+ delay = _retry_after(response) or 0.5 * 2**attempt
453
+ attempt += 1
454
+ await asyncio.sleep(min(delay, 30))
455
+
456
+ async def request(self, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None,
457
+ data: dict[str, str] | None = None, files: Any = None, timeout: float | None = None) -> Any:
458
+ """Call any endpoint, for what this client has no method for. Returns the parsed body, or raises `APIError`."""
459
+ response = await self.raw_request(method, path, json=json, params=params, headers=headers, data=data, files=files, timeout=timeout)
460
+ if response.status_code >= 300:
461
+ raise from_response(response.status_code, _parse(response), _retry_after(response))
462
+ return _parse(response)
463
+
464
+ async def request_bytes(self, path: str) -> bytes:
465
+ response = await self.raw_request("GET", path)
466
+ if response.is_redirect and response.headers.get("location"):
467
+ response = await self._http.request("GET", response.headers["location"], timeout=self.timeout) # presigned: no token
468
+ if response.status_code >= 300:
469
+ raise from_response(response.status_code, _parse(response), _retry_after(response))
470
+ return response.content
471
+
472
+ async def paginate(self, list_call: Callable[..., Any], **params: Any) -> AsyncIterator[Any]:
473
+ """Every item across pages: `async for s in client.paginate(client.sessions.list, status="live")`."""
474
+ cursor = params.pop("cursor", None)
475
+ while True:
476
+ page = await list_call(cursor=cursor, **params)
477
+ for item in page.get("data", []):
478
+ yield item
479
+ cursor = page.get("next_cursor")
480
+ if not cursor:
481
+ return
482
+
483
+ # ---- one-call tools: no session to manage
484
+
485
+ async def fetch(self, url: str, *, format: str | None = None, mode: str | None = None, formats: StrList | None = None, **fields: Any) -> FetchResult:
486
+ """Fetch a page without a browser session. `mode` is `fast`, `deep` or `auto`. The site's own status is in the result's `status`."""
487
+ return await self.request("POST", "/v1/fetch", json=_clean({"url": url, "format": format, "mode": mode, "formats": formats, **fields}))
488
+
489
+ async def fetch_batch(self, urls: StrList, **fields: Any) -> dict[str, Any]:
490
+ return await self.request("POST", "/v1/fetch/batch", json={"urls": urls, **_clean(fields)}, timeout=max(self.timeout, 180))
491
+
492
+ async def search(self, query: str, *, limit: int | None = None) -> SearchResponse:
493
+ return await self.request("POST", "/v1/search", json=_clean({"query": query, "limit": limit}))
494
+
495
+ async def screenshot(self, url: str, **options: Any) -> Capture:
496
+ return await self.request("POST", "/v1/screenshot", json={"url": url, **_clean(options)}, timeout=max(self.timeout, 120))
497
+
498
+ async def pdf(self, url: str, **options: Any) -> Capture:
499
+ return await self.request("POST", "/v1/pdf", json={"url": url, **_clean(options)}, timeout=max(self.timeout, 120))
500
+
501
+ async def map(self, url: str, **options: Any) -> dict[str, Any]:
502
+ return await self.request("POST", "/v1/map", json={"url": url, **_clean(options)}, timeout=max(self.timeout, 120))
503
+
504
+ async def extract(self, *, instruction: str, url: str | None = None, urls: StrList | None = None, schema: dict[str, Any] | None = None, **options: Any) -> dict[str, Any]:
505
+ """Structured data from up to 10 URLs. Needs a model key on the organization."""
506
+ return await self.request("POST", "/v1/extract", json=_clean({"instruction": instruction, "url": url, "urls": urls, "schema": schema, **options}), timeout=max(self.timeout, 180))