smooth-py 0.2.8.dev20251009__py3-none-any.whl → 0.3.5.dev20251110__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 smooth-py might be problematic. Click here for more details.

smooth/__init__.py CHANGED
@@ -1,6 +1,8 @@
1
+ # pyright: reportPrivateUsage=false
1
2
  """Smooth python SDK."""
2
3
 
3
4
  import asyncio
5
+ import base64
4
6
  import io
5
7
  import logging
6
8
  import os
@@ -8,7 +10,7 @@ import time
8
10
  import urllib.parse
9
11
  import warnings
10
12
  from pathlib import Path
11
- from typing import Any, Literal, Type
13
+ from typing import Any, Literal, NotRequired, Type, TypedDict
12
14
 
13
15
  import httpx
14
16
  import requests
@@ -26,932 +28,1494 @@ BASE_URL = "https://api.smooth.sh/api/"
26
28
 
27
29
 
28
30
  def _encode_url(url: str, interactive: bool = True, embed: bool = False) -> str:
29
- parsed_url = urllib.parse.urlparse(url)
30
- params = urllib.parse.parse_qs(parsed_url.query)
31
- params.update({"interactive": "true" if interactive else "false", "embed": "true" if embed else "false"})
32
- return urllib.parse.urlunparse(parsed_url._replace(query=urllib.parse.urlencode(params)))
31
+ parsed_url = urllib.parse.urlparse(url)
32
+ params = urllib.parse.parse_qs(parsed_url.query)
33
+ params.update(
34
+ {
35
+ "interactive": "true" if interactive else "false",
36
+ "embed": "true" if embed else "false",
37
+ }
38
+ )
39
+ return urllib.parse.urlunparse(
40
+ parsed_url._replace(query=urllib.parse.urlencode(params))
41
+ )
33
42
 
34
43
 
35
44
  # --- Models ---
36
- # These models define the data structures for API requests and responses.
45
+
46
+
47
+ class Certificate(TypedDict):
48
+ """Client certificate for accessing secure websites.
49
+
50
+ Attributes:
51
+ file: p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
52
+ password: Password to decrypt the certificate file. Optional.
53
+ """
54
+
55
+ file: str | io.IOBase # Required - base64 string or binary IO
56
+ password: NotRequired[str] # Optional
57
+ filters: NotRequired[
58
+ list[str]
59
+ ] # Optional - TODO: Reserved for future use to specify URL patterns where the certificate should be applied.
60
+
61
+
62
+ def _process_certificates(
63
+ certificates: list[Certificate] | None,
64
+ ) -> list[dict[str, Any]] | None:
65
+ """Process certificates, converting binary IO to base64-encoded strings.
66
+
67
+ Args:
68
+ certificates: List of certificates with file field as string or binary IO.
69
+
70
+ Returns:
71
+ List of certificates with file field as base64-encoded string, or None if input is None.
72
+ """
73
+ if certificates is None:
74
+ return None
75
+
76
+ processed_certs: list[dict[str, Any]] = []
77
+ for cert in certificates:
78
+ processed_cert = dict(cert) # Create a copy
79
+
80
+ file_content = processed_cert["file"]
81
+ if isinstance(file_content, io.IOBase):
82
+ # Read the binary content and encode to base64
83
+ binary_data = file_content.read()
84
+ processed_cert["file"] = base64.b64encode(binary_data).decode("utf-8")
85
+ elif not isinstance(file_content, str):
86
+ raise TypeError(
87
+ f"Certificate file must be a string or binary IO, got {type(file_content)}"
88
+ )
89
+
90
+ processed_certs.append(processed_cert)
91
+
92
+ return processed_certs
37
93
 
38
94
 
39
95
  class TaskResponse(BaseModel):
40
- """Task response model."""
96
+ """Task response model."""
41
97
 
42
- model_config = ConfigDict(extra="allow")
98
+ model_config = ConfigDict(extra="allow")
43
99
 
44
- id: str = Field(description="The ID of the task.")
45
- status: Literal["waiting", "running", "done", "failed", "cancelled"] = Field(description="The status of the task.")
46
- output: Any | None = Field(default=None, description="The output of the task.")
47
- credits_used: int | None = Field(default=None, description="The amount of credits used to perform the task.")
48
- device: Literal["desktop", "mobile"] | None = Field(default=None, description="The device type used for the task.")
49
- live_url: str | None = Field(default=None, description="The URL to view and interact with the task execution.")
50
- recording_url: str | None = Field(default=None, description="The URL to view the task recording.")
100
+ id: str = Field(description="The ID of the task.")
101
+ status: Literal["waiting", "running", "done", "failed", "cancelled"] = Field(
102
+ description="The status of the task."
103
+ )
104
+ output: Any | None = Field(default=None, description="The output of the task.")
105
+ credits_used: int | None = Field(
106
+ default=None, description="The amount of credits used to perform the task."
107
+ )
108
+ device: Literal["desktop", "mobile"] | None = Field(
109
+ default=None, description="The device type used for the task."
110
+ )
111
+ live_url: str | None = Field(
112
+ default=None,
113
+ description="The URL to view and interact with the task execution.",
114
+ )
115
+ recording_url: str | None = Field(
116
+ default=None, description="The URL to view the task recording."
117
+ )
118
+ downloads_url: str | None = Field(
119
+ default=None,
120
+ description="The URL of the archive containing the downloaded files.",
121
+ )
122
+ created_at: int | None = Field(
123
+ default=None, description="The timestamp when the task was created."
124
+ )
51
125
 
52
126
 
53
127
  class TaskRequest(BaseModel):
54
- """Run task request model."""
55
-
56
- model_config = ConfigDict(extra="allow")
57
-
58
- task: str = Field(description="The task to run.")
59
- response_model: dict[str, Any] | None = Field(
60
- default=None, description="If provided, the JSON schema describing the desired output structure. Default is None"
61
- )
62
- url: str | None = Field(
63
- default=None,
64
- description="The starting URL for the task. If not provided, the agent will infer it from the task.",
65
- )
66
- metadata: dict[str, str | int | float | bool] | None = Field(
67
- default=None, description="A dictionary containing variables or parameters that will be passed to the agent."
68
- )
69
- files: list[str] | None = Field(default=None, description="A list of file ids to pass to the agent.")
70
- agent: Literal["smooth", "smooth-lite"] = Field(default="smooth", description="The agent to use for the task.")
71
- max_steps: int = Field(default=32, ge=2, le=128, description="Maximum number of steps the agent can take (min 2, max 128).")
72
- device: Literal["desktop", "mobile"] = Field(default="mobile", description="Device type for the task. Default is mobile.")
73
- allowed_urls: list[str] | None = Field(
74
- default=None,
75
- description=(
76
- "List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*). If None, all URLs are allowed."
77
- ),
78
- )
79
- enable_recording: bool = Field(default=True, description="Enable video recording of the task execution. Default is True")
80
- profile_id: str | None = Field(
81
- default=None,
82
- description=("Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials."),
83
- )
84
- profile_read_only: bool = Field(
85
- default=False, description="If true, the profile specified by `profile_id` will be loaded in read-only mode."
86
- )
87
- stealth_mode: bool = Field(default=False, description="Run the browser in stealth mode.")
88
- proxy_server: str | None = Field(
89
- default=None,
90
- description=(
91
- "Proxy server url to route browser traffic through. Must include the protocol to use (e.g. http:// or https://)"
92
- ),
93
- )
94
- proxy_username: str | None = Field(default=None, description="Proxy server username.")
95
- proxy_password: str | None = Field(default=None, description="Proxy server password.")
96
- experimental_features: dict[str, Any] | None = Field(
97
- default=None, description="Experimental features to enable for the task."
98
- )
99
-
100
- @model_validator(mode="before")
101
- @classmethod
102
- def _handle_deprecated_session_id(cls, data: Any) -> Any:
103
- if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
104
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
105
- data["profile_id"] = data.pop("session_id")
106
- return data
107
-
108
- @computed_field(return_type=str | None)
109
- @property
110
- def session_id(self):
111
- """(Deprecated) Returns the session ID."""
112
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
113
- return self.profile_id
114
-
115
- @session_id.setter
116
- def session_id(self, value):
117
- """(Deprecated) Sets the session ID."""
118
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
119
- self.profile_id = value
120
-
121
- def model_dump(self, **kwargs) -> dict[str, Any]:
122
- """Dump model to dict, including deprecated session_id for retrocompatibility."""
123
- data = super().model_dump(**kwargs)
124
- # Add deprecated session_id field for retrocompatibility
125
- if "profile_id" in data:
126
- data["session_id"] = data["profile_id"]
127
- return data
128
+ """Run task request model."""
128
129
 
130
+ model_config = ConfigDict(extra="allow")
129
131
 
130
- class BrowserSessionRequest(BaseModel):
131
- """Request model for creating a browser session."""
132
-
133
- profile_id: str | None = Field(
134
- default=None, description=("The profile ID to use for the browser session. If None, a new profile will be created.")
135
- )
136
- live_view: bool | None = Field(default=True, description="Request a live URL to interact with the browser session.")
137
-
138
- @model_validator(mode="before")
139
- @classmethod
140
- def _handle_deprecated_session_id(cls, data: Any) -> Any:
141
- if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
142
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
143
- data["profile_id"] = data.pop("session_id")
144
- return data
145
-
146
- @computed_field(return_type=str | None)
147
- @property
148
- def session_id(self):
149
- """(Deprecated) Returns the session ID."""
150
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
151
- return self.profile_id
152
-
153
- @session_id.setter
154
- def session_id(self, value):
155
- """(Deprecated) Sets the session ID."""
156
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
157
- self.profile_id = value
158
-
159
- def model_dump(self, **kwargs) -> dict[str, Any]:
160
- """Dump model to dict, including deprecated session_id for retrocompatibility."""
161
- data = super().model_dump(**kwargs)
162
- # Add deprecated session_id field for retrocompatibility
163
- if "profile_id" in data:
164
- data["session_id"] = data["profile_id"]
165
- return data
132
+ task: str = Field(description="The task to run.")
133
+ response_model: dict[str, Any] | None = Field(
134
+ default=None,
135
+ description="If provided, the JSON schema describing the desired output structure. Default is None",
136
+ )
137
+ url: str | None = Field(
138
+ default=None,
139
+ description="The starting URL for the task. If not provided, the agent will infer it from the task.",
140
+ )
141
+ metadata: dict[str, str | int | float | bool] | None = Field(
142
+ default=None,
143
+ description="A dictionary containing variables or parameters that will be passed to the agent.",
144
+ )
145
+ files: list[str] | None = Field(
146
+ default=None, description="A list of file ids to pass to the agent."
147
+ )
148
+ agent: Literal["smooth", "smooth-lite"] = Field(
149
+ default="smooth", description="The agent to use for the task."
150
+ )
151
+ max_steps: int = Field(
152
+ default=32,
153
+ ge=2,
154
+ le=128,
155
+ description="Maximum number of steps the agent can take (min 2, max 128).",
156
+ )
157
+ device: Literal["desktop", "mobile"] = Field(
158
+ default="desktop", description="Device type for the task. Default is desktop."
159
+ )
160
+ allowed_urls: list[str] | None = Field(
161
+ default=None,
162
+ description=(
163
+ "List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*). If None, all URLs are allowed."
164
+ ),
165
+ )
166
+ enable_recording: bool = Field(
167
+ default=True,
168
+ description="Enable video recording of the task execution. Default is True",
169
+ )
170
+ profile_id: str | None = Field(
171
+ default=None,
172
+ description=(
173
+ "Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials."
174
+ ),
175
+ )
176
+ profile_read_only: bool = Field(
177
+ default=False,
178
+ description=(
179
+ "If true, the profile specified by `profile_id` will be loaded in read-only mode. "
180
+ "Changes made during the task will not be saved back to the profile."
181
+ ),
182
+ )
183
+ stealth_mode: bool = Field(
184
+ default=False, description="Run the browser in stealth mode."
185
+ )
186
+ proxy_server: str | None = Field(
187
+ default=None,
188
+ description=(
189
+ "Proxy server url to route browser traffic through."
190
+ ),
191
+ )
192
+ proxy_username: str | None = Field(
193
+ default=None, description="Proxy server username."
194
+ )
195
+ proxy_password: str | None = Field(
196
+ default=None, description="Proxy server password."
197
+ )
198
+ certificates: list[dict[str, Any]] | None = Field(
199
+ default=None,
200
+ description=(
201
+ "List of client certificates to use when accessing secure websites. "
202
+ "Each certificate is a dictionary with the following fields:\n"
203
+ " - `file`: p12 file object to be uploaded (e.g., open('cert.p12', 'rb')).\n"
204
+ " - `password` (optional): Password to decrypt the certificate file."
205
+ ),
206
+ )
207
+ use_adblock: bool | None = Field(
208
+ default=True,
209
+ description="Enable adblock for the browser session. Default is True.",
210
+ )
211
+ additional_tools: dict[str, dict[str, Any] | None] | None = Field(
212
+ default=None, description="Additional tools to enable for the task."
213
+ )
214
+ experimental_features: dict[str, Any] | None = Field(
215
+ default=None, description="Experimental features to enable for the task."
216
+ )
217
+ extensions: list[str] | None = Field(
218
+ default=None, description="List of extensions to install for the task."
219
+ )
166
220
 
221
+ @model_validator(mode="before")
222
+ @classmethod
223
+ def _handle_deprecated_session_id(cls, data: Any) -> Any:
224
+ if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
225
+ warnings.warn(
226
+ "'session_id' is deprecated, use 'profile_id' instead",
227
+ DeprecationWarning,
228
+ stacklevel=2,
229
+ )
230
+ data["profile_id"] = data.pop("session_id")
231
+ return data
232
+
233
+ @computed_field(return_type=str | None)
234
+ @property
235
+ def session_id(self):
236
+ """(Deprecated) Returns the session ID."""
237
+ warnings.warn(
238
+ "'session_id' is deprecated, use 'profile_id' instead",
239
+ DeprecationWarning,
240
+ stacklevel=2,
241
+ )
242
+ return self.profile_id
243
+
244
+ @session_id.setter
245
+ def session_id(self, value: str | None):
246
+ """(Deprecated) Sets the session ID."""
247
+ warnings.warn(
248
+ "'session_id' is deprecated, use 'profile_id' instead",
249
+ DeprecationWarning,
250
+ stacklevel=2,
251
+ )
252
+ self.profile_id = value
253
+
254
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
255
+ """Dump model to dict, including deprecated session_id for retrocompatibility."""
256
+ data = super().model_dump(**kwargs)
257
+ # Add deprecated session_id field for retrocompatibility
258
+ if "profile_id" in data:
259
+ data["session_id"] = data["profile_id"]
260
+ return data
167
261
 
168
- class BrowserSessionResponse(BaseModel):
169
- """Browser session response model."""
170
-
171
- profile_id: str = Field(description="The ID of the browser profile associated with the opened browser instance.")
172
- live_id: str | None = Field(description="The ID of the live browser session.")
173
- live_url: str | None = Field(default=None, description="The live URL to interact with the browser session.")
174
-
175
- @model_validator(mode="before")
176
- @classmethod
177
- def _handle_deprecated_session_id(cls, data: Any) -> Any:
178
- if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
179
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
180
- data["profile_id"] = data.pop("session_id")
181
- return data
182
-
183
- @computed_field(return_type=str | None)
184
- @property
185
- def session_id(self):
186
- """(Deprecated) Returns the session ID."""
187
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
188
- return self.profile_id
189
-
190
- @session_id.setter
191
- def session_id(self, value):
192
- """(Deprecated) Sets the session ID."""
193
- warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
194
- self.profile_id = value
195
262
 
263
+ class BrowserSessionRequest(BaseModel):
264
+ """Request model for creating a browser session."""
196
265
 
197
- class BrowserProfilesResponse(BaseModel):
198
- """Response model for listing browser profiles."""
199
-
200
- profile_ids: list[str] = Field(description="The IDs of the browser profiles.")
201
-
202
- @model_validator(mode="before")
203
- @classmethod
204
- def _handle_deprecated_session_ids(cls, data: Any) -> Any:
205
- if isinstance(data, dict) and "session_ids" in data and "profile_ids" not in data:
206
- warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
207
- data["profile_ids"] = data.pop("session_ids")
208
- return data
209
-
210
- @computed_field(return_type=list[str])
211
- @property
212
- def session_ids(self):
213
- """(Deprecated) Returns the session IDs."""
214
- warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
215
- return self.profile_ids
216
-
217
- @session_ids.setter
218
- def session_ids(self, value):
219
- """(Deprecated) Sets the session IDs."""
220
- warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
221
- self.profile_ids = value
222
-
223
- def model_dump(self, **kwargs) -> dict[str, Any]:
224
- """Dump model to dict, including deprecated session_ids for retrocompatibility."""
225
- data = super().model_dump(**kwargs)
226
- # Add deprecated session_ids field for retrocompatibility
227
- if "profile_ids" in data:
228
- data["session_ids"] = data["profile_ids"]
229
- return data
266
+ model_config = ConfigDict(extra="allow")
230
267
 
268
+ profile_id: str | None = Field(
269
+ default=None,
270
+ description=(
271
+ "The profile ID to use for the browser session. If None, a new profile will be created."
272
+ ),
273
+ )
274
+ live_view: bool | None = Field(
275
+ default=True,
276
+ description="Request a live URL to interact with the browser session.",
277
+ )
278
+ device: Literal["desktop", "mobile"] | None = Field(
279
+ default="desktop", description="The device type to use."
280
+ )
281
+ url: str | None = Field(
282
+ default=None, description="The URL to open in the browser session."
283
+ )
284
+ proxy_server: str | None = Field(
285
+ default=None,
286
+ description=(
287
+ "Proxy server address to route browser traffic through."
288
+ ),
289
+ )
290
+ proxy_username: str | None = Field(
291
+ default=None, description="Proxy server username."
292
+ )
293
+ proxy_password: str | None = Field(
294
+ default=None, description="Proxy server password."
295
+ )
231
296
 
232
- class BrowserSessionsResponse(BrowserProfilesResponse):
233
- """Response model for listing browser profiles."""
297
+ @model_validator(mode="before")
298
+ @classmethod
299
+ def _handle_deprecated_session_id(cls, data: Any) -> Any:
300
+ if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
301
+ warnings.warn(
302
+ "'session_id' is deprecated, use 'profile_id' instead",
303
+ DeprecationWarning,
304
+ stacklevel=2,
305
+ )
306
+ data["profile_id"] = data.pop("session_id")
307
+ return data
308
+
309
+ @computed_field(return_type=str | None)
310
+ @property
311
+ def session_id(self):
312
+ """(Deprecated) Returns the session ID."""
313
+ warnings.warn(
314
+ "'session_id' is deprecated, use 'profile_id' instead",
315
+ DeprecationWarning,
316
+ stacklevel=2,
317
+ )
318
+ return self.profile_id
319
+
320
+ @session_id.setter
321
+ def session_id(self, value: str | None):
322
+ """(Deprecated) Sets the session ID."""
323
+ warnings.warn(
324
+ "'session_id' is deprecated, use 'profile_id' instead",
325
+ DeprecationWarning,
326
+ stacklevel=2,
327
+ )
328
+ self.profile_id = value
329
+
330
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
331
+ """Dump model to dict, including deprecated session_id for retrocompatibility."""
332
+ data = super().model_dump(**kwargs)
333
+ # Add deprecated session_id field for retrocompatibility
334
+ if "profile_id" in data:
335
+ data["session_id"] = data["profile_id"]
336
+ return data
234
337
 
235
- pass
236
338
 
339
+ class BrowserSessionResponse(BaseModel):
340
+ """Browser session response model."""
237
341
 
238
- class UploadFileResponse(BaseModel):
239
- """Response model for uploading a file."""
342
+ model_config = ConfigDict(extra="allow")
240
343
 
241
- id: str = Field(description="The ID assigned to the uploaded file.")
344
+ profile_id: str = Field(
345
+ description="The ID of the browser profile associated with the opened browser instance."
346
+ )
347
+ live_id: str | None = Field(default=None, description="The ID of the live browser session.")
348
+ live_url: str | None = Field(
349
+ default=None, description="The live URL to interact with the browser session."
350
+ )
242
351
 
352
+ @model_validator(mode="before")
353
+ @classmethod
354
+ def _handle_deprecated_session_id(cls, data: Any) -> Any:
355
+ if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
356
+ warnings.warn(
357
+ "'session_id' is deprecated, use 'profile_id' instead",
358
+ DeprecationWarning,
359
+ stacklevel=2,
360
+ )
361
+ data["profile_id"] = data.pop("session_id")
362
+ return data
363
+
364
+ @computed_field(return_type=str | None)
365
+ @property
366
+ def session_id(self):
367
+ """(Deprecated) Returns the session ID."""
368
+ warnings.warn(
369
+ "'session_id' is deprecated, use 'profile_id' instead",
370
+ DeprecationWarning,
371
+ stacklevel=2,
372
+ )
373
+ return self.profile_id
374
+
375
+ @session_id.setter
376
+ def session_id(self, value: str):
377
+ """(Deprecated) Sets the session ID."""
378
+ warnings.warn(
379
+ "'session_id' is deprecated, use 'profile_id' instead",
380
+ DeprecationWarning,
381
+ stacklevel=2,
382
+ )
383
+ self.profile_id = value
243
384
 
244
- # --- Exception Handling ---
245
385
 
386
+ class BrowserProfilesResponse(BaseModel):
387
+ """Response model for listing browser profiles."""
388
+
389
+ model_config = ConfigDict(extra="allow")
390
+
391
+ profile_ids: list[str] = Field(description="The IDs of the browser profiles.")
392
+
393
+ @model_validator(mode="before")
394
+ @classmethod
395
+ def _handle_deprecated_session_ids(cls, data: Any) -> Any:
396
+ if (
397
+ isinstance(data, dict)
398
+ and "session_ids" in data
399
+ and "profile_ids" not in data
400
+ ):
401
+ warnings.warn(
402
+ "'session_ids' is deprecated, use 'profile_ids' instead",
403
+ DeprecationWarning,
404
+ stacklevel=2,
405
+ )
406
+ data["profile_ids"] = data.pop("session_ids")
407
+ return data
408
+
409
+ @computed_field(return_type=list[str])
410
+ @property
411
+ def session_ids(self):
412
+ """(Deprecated) Returns the session IDs."""
413
+ warnings.warn(
414
+ "'session_ids' is deprecated, use 'profile_ids' instead",
415
+ DeprecationWarning,
416
+ stacklevel=2,
417
+ )
418
+ return self.profile_ids
419
+
420
+ @session_ids.setter
421
+ def session_ids(self, value: list[str]):
422
+ """(Deprecated) Sets the session IDs."""
423
+ warnings.warn(
424
+ "'session_ids' is deprecated, use 'profile_ids' instead",
425
+ DeprecationWarning,
426
+ stacklevel=2,
427
+ )
428
+ self.profile_ids = value
429
+
430
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
431
+ """Dump model to dict, including deprecated session_ids for retrocompatibility."""
432
+ data = super().model_dump(**kwargs)
433
+ # Add deprecated session_ids field for retrocompatibility
434
+ if "profile_ids" in data:
435
+ data["session_ids"] = data["profile_ids"]
436
+ return data
246
437
 
247
- class ApiError(Exception):
248
- """Custom exception for API errors."""
249
438
 
250
- def __init__(self, status_code: int, detail: str, response_data: dict[str, Any] | None = None):
251
- """Initializes the API error."""
252
- self.status_code = status_code
253
- self.detail = detail
254
- self.response_data = response_data
255
- super().__init__(f"API Error {status_code}: {detail}")
439
+ class BrowserSessionsResponse(BrowserProfilesResponse):
440
+ """Response model for listing browser profiles."""
256
441
 
442
+ pass
257
443
 
258
- class TimeoutError(Exception):
259
- """Custom exception for task timeouts."""
260
444
 
261
- pass
445
+ class UploadFileResponse(BaseModel):
446
+ """Response model for uploading a file."""
262
447
 
448
+ model_config = ConfigDict(extra="allow")
263
449
 
264
- # --- Base Client ---
450
+ id: str = Field(description="The ID assigned to the uploaded file.")
265
451
 
266
452
 
267
- class BaseClient:
268
- """Base client for handling common API interactions."""
269
-
270
- def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, api_version: str = "v1"):
271
- """Initializes the base client."""
272
- # Try to get API key from environment if not provided
273
- if not api_key:
274
- api_key = os.getenv("CIRCLEMIND_API_KEY")
275
-
276
- if not api_key:
277
- raise ValueError("API key is required. Provide it directly or set CIRCLEMIND_API_KEY environment variable.")
278
-
279
- if not base_url:
280
- raise ValueError("Base URL cannot be empty.")
281
-
282
- self.api_key = api_key
283
- self.base_url = f"{base_url.rstrip('/')}/{api_version}"
284
- self.headers = {
285
- "apikey": self.api_key,
286
- "User-Agent": "smooth-python-sdk/0.2.5",
287
- }
288
-
289
- def _handle_response(self, response: requests.Response | httpx.Response) -> dict[str, Any]:
290
- """Handles HTTP responses and raises exceptions for errors."""
291
- if 200 <= response.status_code < 300:
292
- try:
293
- return response.json()
294
- except ValueError as e:
295
- logger.error(f"Failed to parse JSON response: {e}")
296
- raise ApiError(status_code=response.status_code, detail="Invalid JSON response from server") from None
297
-
298
- # Handle error responses
299
- error_data = None
300
- try:
301
- error_data = response.json()
302
- detail = error_data.get("detail", response.text)
303
- except ValueError:
304
- detail = response.text or f"HTTP {response.status_code} error"
305
-
306
- logger.error(f"API error: {response.status_code} - {detail}")
307
- raise ApiError(status_code=response.status_code, detail=detail, response_data=error_data)
453
+ class UploadExtensionResponse(BaseModel):
454
+ """Response model for uploading an extension."""
308
455
 
456
+ model_config = ConfigDict(extra="allow")
309
457
 
310
- # --- Synchronous Client ---
458
+ id: str = Field(description="The uploaded extension ID.")
311
459
 
312
460
 
313
- class BrowserSessionHandle(BaseModel):
314
- """Browser session handle model."""
461
+ class Extension(BaseModel):
462
+ """Extension model."""
315
463
 
316
- browser_session: BrowserSessionResponse = Field(description="The browser session associated with this handle.")
464
+ model_config = ConfigDict(extra="allow")
317
465
 
318
- @deprecated("session_id is deprecated, use profile_id instead")
319
- def session_id(self):
320
- """Returns the session ID for the browser session."""
321
- return self.profile_id()
466
+ id: str = Field(description="The ID of the extension.")
467
+ file_name: str = Field(description="The name of the extension.")
468
+ creation_time: int = Field(description="The creation timestamp.")
322
469
 
323
- def profile_id(self):
324
- """Returns the profile ID for the browser session."""
325
- return self.browser_session.profile_id
326
470
 
327
- def live_url(self, interactive: bool = True, embed: bool = False):
328
- """Returns the live URL for the browser session."""
329
- if self.browser_session.live_url:
330
- return _encode_url(self.browser_session.live_url, interactive=interactive, embed=embed)
331
- return None
471
+ class ListExtensionsResponse(BaseModel):
472
+ """Response model for listing extensions."""
332
473
 
474
+ model_config = ConfigDict(extra="allow")
333
475
 
334
- class TaskHandle:
335
- """A handle to a running task."""
336
-
337
- def __init__(self, task_id: str, client: "SmoothClient"):
338
- """Initializes the task handle."""
339
- self._client = client
340
- self._task_response: TaskResponse | None = None
341
-
342
- self._id = task_id
343
-
344
- def id(self):
345
- """Returns the task ID."""
346
- return self._id
347
-
348
- def stop(self):
349
- """Stops the task."""
350
- try:
351
- response = self._client._client.delete(f"{self._client.base_url}/task/{self._id}")
352
- self._handle_response(response)
353
- except requests.exceptions.RequestException as e:
354
- logger.error(f"Request failed: {e}")
355
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
356
-
357
- def result(self, timeout: int | None = None, poll_interval: float = 1) -> TaskResponse:
358
- """Waits for the task to complete and returns the result."""
359
- if self._task_response and self._task_response.status not in ["running", "waiting"]:
360
- return self._task_response
361
-
362
- if timeout is not None and timeout < 1:
363
- raise ValueError("Timeout must be at least 1 second.")
364
- if poll_interval < 0.1:
365
- raise ValueError("Poll interval must be at least 100 milliseconds.")
366
-
367
- start_time = time.time()
368
- while timeout is None or (time.time() - start_time) < timeout:
369
- task_response = self._client._get_task(self.id())
370
- self._task_response = task_response
371
- if task_response.status not in ["running", "waiting"]:
372
- return task_response
373
- time.sleep(poll_interval)
374
- raise TimeoutError(f"Task {self.id()} did not complete within {timeout} seconds.")
375
-
376
- def live_url(self, interactive: bool = False, embed: bool = False, timeout: int | None = None):
377
- """Returns the live URL for the task."""
378
- if self._task_response and self._task_response.live_url:
379
- return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
380
-
381
- start_time = time.time()
382
- while timeout is None or (time.time() - start_time) < timeout:
383
- task_response = self._client._get_task(self.id())
384
- self._task_response = task_response
385
- if self._task_response.live_url:
386
- return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
387
- time.sleep(1)
388
-
389
- raise TimeoutError(f"Live URL not available for task {self.id()}.")
390
-
391
- def recording_url(self, timeout: int | None = None) -> str:
392
- """Returns the recording URL for the task."""
393
- if self._task_response and self._task_response.recording_url is not None:
394
- return self._task_response.recording_url
395
-
396
- start_time = time.time()
397
- while timeout is None or (time.time() - start_time) < timeout:
398
- task_response = self._client._get_task(self.id())
399
- self._task_response = task_response
400
- if task_response.recording_url is not None:
401
- return task_response.recording_url
402
- time.sleep(1)
403
- raise TimeoutError(f"Recording URL not available for task {self.id()}.")
476
+ extensions: list[Extension] = Field(description="The list of extensions.")
404
477
 
405
478
 
406
- class SmoothClient(BaseClient):
407
- """A synchronous client for the API."""
408
-
409
- def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, api_version: str = "v1"):
410
- """Initializes the synchronous client."""
411
- super().__init__(api_key, base_url, api_version)
412
- self._session = requests.Session()
413
- self._session.headers.update(self.headers)
414
-
415
- def __enter__(self):
416
- """Enters the synchronous context manager."""
417
- return self
418
-
419
- def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
420
- """Exits the synchronous context manager."""
421
- self.close()
422
-
423
- def close(self):
424
- """Close the session."""
425
- if hasattr(self, "_session"):
426
- self._session.close()
427
-
428
- def _submit_task(self, payload: TaskRequest) -> TaskResponse:
429
- """Submits a task to be run."""
430
- try:
431
- response = self._session.post(f"{self.base_url}/task", json=payload.model_dump(exclude_none=True))
432
- data = self._handle_response(response)
433
- return TaskResponse(**data["r"])
434
- except requests.exceptions.RequestException as e:
435
- logger.error(f"Request failed: {e}")
436
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
437
-
438
- def _get_task(self, task_id: str) -> TaskResponse:
439
- """Retrieves the status and result of a task."""
440
- if not task_id:
441
- raise ValueError("Task ID cannot be empty.")
442
-
443
- try:
444
- response = self._session.get(f"{self.base_url}/task/{task_id}")
445
- data = self._handle_response(response)
446
- return TaskResponse(**data["r"])
447
- except requests.exceptions.RequestException as e:
448
- logger.error(f"Request failed: {e}")
449
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
450
-
451
- def run(
452
- self,
453
- task: str,
454
- response_model: dict[str, Any] | Type[BaseModel] | None = None,
455
- url: str | None = None,
456
- metadata: dict[str, str | int | float | bool] | None = None,
457
- files: list[str] | None = None,
458
- agent: Literal["smooth"] = "smooth",
459
- max_steps: int = 32,
460
- device: Literal["desktop", "mobile"] = "mobile",
461
- allowed_urls: list[str] | None = None,
462
- enable_recording: bool = False,
463
- session_id: str | None = None,
464
- profile_id: str | None = None,
465
- profile_read_only: bool = False,
466
- stealth_mode: bool = False,
467
- proxy_server: str | None = None,
468
- proxy_username: str | None = None,
469
- proxy_password: str | None = None,
470
- experimental_features: dict[str, Any] | None = None,
471
- ) -> TaskHandle:
472
- """Runs a task and returns a handle to the task.
473
-
474
- This method submits a task and returns a `TaskHandle` object
475
- that can be used to get the result of the task.
479
+ # --- Exception Handling ---
476
480
 
477
- Args:
478
- task: The task to run.
479
- response_model: If provided, the schema describing the desired output structure.
480
- url: The starting URL for the task. If not provided, the agent will infer it from the task.
481
- metadata: A dictionary containing variables or parameters that will be passed to the agent.
482
- files: A dictionary of file names to their ids. These files will be passed to the agent.
483
- agent: The agent to use for the task.
484
- max_steps: Maximum number of steps the agent can take (max 64).
485
- device: Device type for the task. Default is mobile.
486
- allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
487
- If None, all URLs are allowed.
488
- enable_recording: Enable video recording of the task execution.
489
- session_id: (Deprecated, now `profile_id`) Browser session ID to use.
490
- profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
491
- profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
492
- stealth_mode: Run the browser in stealth mode.
493
- proxy_server: Proxy server url to route browser traffic through.
494
- proxy_username: Proxy server username.
495
- proxy_password: Proxy server password.
496
- experimental_features: Experimental features to enable for the task.
497
481
 
498
- Returns:
499
- A handle to the running task.
482
+ class ApiError(Exception):
483
+ """Custom exception for API errors."""
500
484
 
501
- Raises:
502
- ApiException: If the API request fails.
503
- """
504
- payload = TaskRequest(
505
- task=task,
506
- response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
507
- url=url,
508
- metadata=metadata,
509
- files=files,
510
- agent=agent,
511
- max_steps=max_steps,
512
- device=device,
513
- allowed_urls=allowed_urls,
514
- enable_recording=enable_recording,
515
- profile_id=profile_id or session_id,
516
- profile_read_only=profile_read_only,
517
- stealth_mode=stealth_mode,
518
- proxy_server=proxy_server,
519
- proxy_username=proxy_username,
520
- proxy_password=proxy_password,
521
- experimental_features=experimental_features,
522
- )
523
- initial_response = self._submit_task(payload)
524
-
525
- return TaskHandle(initial_response.id, self)
526
-
527
- def open_session(
528
- self, profile_id: str | None = None, session_id: str | None = None, live_view: bool = True
529
- ) -> BrowserSessionHandle:
530
- """Opens an interactive browser instance to interact with a specific browser profile.
485
+ def __init__(
486
+ self, status_code: int, detail: str, response_data: dict[str, Any] | None = None
487
+ ):
488
+ """Initializes the API error."""
489
+ self.status_code = status_code
490
+ self.detail = detail
491
+ self.response_data = response_data
492
+ super().__init__(f"API Error {status_code}: {detail}")
531
493
 
532
- Args:
533
- profile_id: The profile ID to use for the session. If None, a new profile will be created.
534
- session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
535
- live_view: Whether to enable live view for the session.
536
494
 
537
- Returns:
538
- The browser session details, including the live URL.
495
+ class TimeoutError(Exception):
496
+ """Custom exception for task timeouts."""
539
497
 
540
- Raises:
541
- ApiException: If the API request fails.
542
- """
543
- try:
544
- response = self._session.post(
545
- f"{self.base_url}/browser/session",
546
- json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(exclude_none=True),
547
- )
548
- data = self._handle_response(response)
549
- return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
550
- except requests.exceptions.RequestException as e:
551
- logger.error(f"Request failed: {e}")
552
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
553
-
554
- def close_session(self, live_id: str):
555
- """Closes a browser session."""
556
- try:
557
- response = self._session.delete(f"{self.base_url}/browser/session/{live_id}")
558
- self._handle_response(response)
559
- except requests.exceptions.RequestException as e:
560
- logger.error(f"Request failed: {e}")
561
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
562
-
563
- def list_profiles(self):
564
- """Lists all browser profiles for the user.
498
+ pass
565
499
 
566
- Returns:
567
- A list of existing browser profiles.
568
500
 
569
- Raises:
570
- ApiException: If the API request fails.
571
- """
572
- try:
573
- response = self._session.get(f"{self.base_url}/browser/session")
574
- data = self._handle_response(response)
575
- return BrowserProfilesResponse(**data["r"])
576
- except requests.exceptions.RequestException as e:
577
- logger.error(f"Request failed: {e}")
578
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
579
-
580
- @deprecated("list_sessions is deprecated, use list_profiles instead")
581
- def list_sessions(self):
582
- """Lists all browser profiles for the user."""
583
- return self.list_profiles()
584
-
585
- def delete_profile(self, profile_id: str):
586
- """Delete a browser profile."""
587
- try:
588
- response = self._session.delete(f"{self.base_url}/browser/session/{profile_id}")
589
- self._handle_response(response)
590
- except requests.exceptions.RequestException as e:
591
- logger.error(f"Request failed: {e}")
592
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
593
-
594
- @deprecated("delete_session is deprecated, use delete_profile instead")
595
- def delete_session(self, session_id: str):
596
- """Delete a browser profile."""
597
- self.delete_profile(session_id)
598
-
599
- def upload_file(self, file: io.IOBase, name: str | None = None, purpose: str | None = None) -> UploadFileResponse:
600
- """Upload a file and return the file ID.
501
+ # --- Base Client ---
601
502
 
602
- Args:
603
- file: File object to be uploaded.
604
- name: Optional custom name for the file. If not provided, the original file name will be used.
605
- purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
606
503
 
607
- Returns:
608
- The file ID assigned to the uploaded file.
504
+ class BaseClient:
505
+ """Base client for handling common API interactions."""
506
+
507
+ def __init__(
508
+ self,
509
+ api_key: str | None = None,
510
+ base_url: str = BASE_URL,
511
+ api_version: str = "v1",
512
+ ):
513
+ """Initializes the base client."""
514
+ # Try to get API key from environment if not provided
515
+ if not api_key:
516
+ api_key = os.getenv("CIRCLEMIND_API_KEY")
517
+
518
+ if not api_key:
519
+ raise ValueError(
520
+ "API key is required. Provide it directly or set CIRCLEMIND_API_KEY environment variable."
521
+ )
522
+
523
+ if not base_url:
524
+ raise ValueError("Base URL cannot be empty.")
525
+
526
+ self.api_key = api_key
527
+ self.base_url = f"{base_url.rstrip('/')}/{api_version}"
528
+ self.headers = {
529
+ "apikey": self.api_key,
530
+ "User-Agent": "smooth-python-sdk/0.2.5",
531
+ }
532
+
533
+ def _handle_response(
534
+ self, response: requests.Response | httpx.Response
535
+ ) -> dict[str, Any]:
536
+ """Handles HTTP responses and raises exceptions for errors."""
537
+ if 200 <= response.status_code < 300:
538
+ try:
539
+ return response.json()
540
+ except ValueError as e:
541
+ logger.error(f"Failed to parse JSON response: {e}")
542
+ raise ApiError(
543
+ status_code=response.status_code,
544
+ detail="Invalid JSON response from server",
545
+ ) from None
546
+
547
+ # Handle error responses
548
+ error_data = None
549
+ try:
550
+ error_data = response.json()
551
+ detail = error_data.get("detail", response.text)
552
+ except ValueError:
553
+ detail = response.text or f"HTTP {response.status_code} error"
554
+
555
+ logger.error(f"API error: {response.status_code} - {detail}")
556
+ raise ApiError(
557
+ status_code=response.status_code, detail=detail, response_data=error_data
558
+ )
609
559
 
610
- Raises:
611
- ValueError: If the file doesn't exist or can't be read.
612
- ApiError: If the API request fails.
613
- """
614
- try:
615
- name = name or getattr(file, "name", None)
616
- if name is None:
617
- raise ValueError("File name must be provided or the file object must have a 'name' attribute.")
618
-
619
- if purpose:
620
- data = {"file_purpose": purpose}
621
- else:
622
- data = None
623
-
624
- files = {"file": (Path(name).name, file)}
625
- response = self._session.post(f"{self.base_url}/file", files=files, data=data)
626
- data = self._handle_response(response)
627
- return UploadFileResponse(**data["r"])
628
- except requests.exceptions.RequestException as e:
629
- logger.error(f"Request failed: {e}")
630
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
631
-
632
- def delete_file(self, file_id: str):
633
- """Delete a file by its ID."""
634
- try:
635
- response = self._session.delete(f"{self.base_url}/file/{file_id}")
636
- self._handle_response(response)
637
- except requests.exceptions.RequestException as e:
638
- logger.error(f"Request failed: {e}")
639
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
640
560
 
561
+ # --- Synchronous Client ---
641
562
 
642
- # --- Asynchronous Client ---
643
563
 
564
+ class BrowserSessionHandle(BaseModel):
565
+ """Browser session handle model."""
644
566
 
645
- class AsyncTaskHandle:
646
- """An asynchronous handle to a running task."""
647
-
648
- def __init__(self, task_id: str, client: "SmoothAsyncClient"):
649
- """Initializes the asynchronous task handle."""
650
- self._client = client
651
- self._task_response: TaskResponse | None = None
652
-
653
- self._id = task_id
654
-
655
- def id(self):
656
- """Returns the task ID."""
657
- return self._id
658
-
659
- async def result(self, timeout: int | None = None, poll_interval: float = 1) -> TaskResponse:
660
- """Waits for the task to complete and returns the result."""
661
- if self._task_response and self._task_response.status not in ["running", "waiting"]:
662
- return self._task_response
663
-
664
- if timeout is not None and timeout < 1:
665
- raise ValueError("Timeout must be at least 1 second.")
666
- if poll_interval < 0.1:
667
- raise ValueError("Poll interval must be at least 100 milliseconds.")
668
-
669
- start_time = time.time()
670
- while timeout is None or (time.time() - start_time) < timeout:
671
- task_response = await self._client._get_task(self.id())
672
- self._task_response = task_response
673
- if task_response.status not in ["running", "waiting"]:
674
- return task_response
675
- await asyncio.sleep(poll_interval)
676
- raise TimeoutError(f"Task {self.id()} did not complete within {timeout} seconds.")
677
-
678
- async def live_url(self, interactive: bool = True, embed: bool = False, timeout: int | None = None):
679
- """Returns the live URL for the task."""
680
- if self._task_response and self._task_response.live_url:
681
- return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
682
-
683
- start_time = time.time()
684
- while timeout is None or (time.time() - start_time) < timeout:
685
- task_response = await self._client._get_task(self.id())
686
- self._task_response = task_response
687
- if task_response.live_url is not None:
688
- return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
689
- await asyncio.sleep(1)
690
-
691
- raise TimeoutError(f"Live URL not available for task {self.id()}.")
692
-
693
- async def recording_url(self, timeout: int | None = None):
694
- """Returns the recording URL for the task."""
695
- if self._task_response and self._task_response.recording_url is not None:
696
- return self._task_response.recording_url
697
-
698
- start_time = time.time()
699
- while timeout is None or (time.time() - start_time) < timeout:
700
- task_response = await self._client._get_task(self.id())
701
- self._task_response = task_response
702
- if task_response.recording_url is not None:
703
- return task_response.recording_url
704
- await asyncio.sleep(1)
705
-
706
- raise TimeoutError(f"Recording URL not available for task {self.id()}.")
567
+ browser_session: BrowserSessionResponse = Field(
568
+ description="The browser session associated with this handle."
569
+ )
707
570
 
571
+ @deprecated("session_id is deprecated, use profile_id instead")
572
+ def session_id(self):
573
+ """Returns the session ID for the browser session."""
574
+ return self.profile_id()
708
575
 
709
- class SmoothAsyncClient(BaseClient):
710
- """An asynchronous client for the API."""
711
-
712
- def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, api_version: str = "v1", timeout: int = 30):
713
- """Initializes the asynchronous client."""
714
- super().__init__(api_key, base_url, api_version)
715
- self._client = httpx.AsyncClient(headers=self.headers, timeout=timeout)
716
-
717
- async def __aenter__(self):
718
- """Enters the asynchronous context manager."""
719
- return self
720
-
721
- async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
722
- """Exits the asynchronous context manager."""
723
- await self.close()
724
-
725
- async def _submit_task(self, payload: TaskRequest) -> TaskResponse:
726
- """Submits a task to be run asynchronously."""
727
- try:
728
- response = await self._client.post(f"{self.base_url}/task", json=payload.model_dump(exclude_none=True))
729
- data = self._handle_response(response)
730
- return TaskResponse(**data["r"])
731
- except httpx.RequestError as e:
732
- logger.error(f"Request failed: {e}")
733
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
734
-
735
- async def _get_task(self, task_id: str) -> TaskResponse:
736
- """Retrieves the status and result of a task asynchronously."""
737
- if not task_id:
738
- raise ValueError("Task ID cannot be empty.")
739
-
740
- try:
741
- response = await self._client.get(f"{self.base_url}/task/{task_id}")
742
- data = self._handle_response(response)
743
- return TaskResponse(**data["r"])
744
- except httpx.RequestError as e:
745
- logger.error(f"Request failed: {e}")
746
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
747
-
748
- async def run(
749
- self,
750
- task: str,
751
- response_model: dict[str, Any] | Type[BaseModel] | None = None,
752
- url: str | None = None,
753
- metadata: dict[str, str | int | float | bool] | None = None,
754
- files: list[str] | None = None,
755
- agent: Literal["smooth"] = "smooth",
756
- max_steps: int = 32,
757
- device: Literal["desktop", "mobile"] = "mobile",
758
- allowed_urls: list[str] | None = None,
759
- enable_recording: bool = False,
760
- session_id: str | None = None,
761
- profile_id: str | None = None,
762
- profile_read_only: bool = False,
763
- stealth_mode: bool = False,
764
- proxy_server: str | None = None,
765
- proxy_username: str | None = None,
766
- proxy_password: str | None = None,
767
- experimental_features: dict[str, Any] | None = None,
768
- ) -> AsyncTaskHandle:
769
- """Runs a task and returns a handle to the task asynchronously.
770
-
771
- This method submits a task and returns an `AsyncTaskHandle` object
772
- that can be used to get the result of the task.
576
+ def profile_id(self):
577
+ """Returns the profile ID for the browser session."""
578
+ return self.browser_session.profile_id
773
579
 
774
- Args:
775
- task: The task to run.
776
- response_model: If provided, the schema describing the desired output structure.
777
- url: The starting URL for the task. If not provided, the agent will infer it from the task.
778
- metadata: A dictionary containing variables or parameters that will be passed to the agent.
779
- files: A dictionary of file names to their url or base64-encoded content to be used by the agent.
780
- agent: The agent to use for the task.
781
- max_steps: Maximum number of steps the agent can take (max 64).
782
- device: Device type for the task. Default is mobile.
783
- allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
784
- If None, all URLs are allowed.
785
- enable_recording: Enable video recording of the task execution.
786
- session_id: (Deprecated, now `profile_id`) Browser session ID to use.
787
- profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
788
- profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
789
- stealth_mode: Run the browser in stealth mode.
790
- proxy_server: Proxy server url to route browser traffic through.
791
- proxy_username: Proxy server username.
792
- proxy_password: Proxy server password.
793
- experimental_features: Experimental features to enable for the task.
580
+ def live_url(self, interactive: bool = True, embed: bool = False):
581
+ """Returns the live URL for the browser session."""
582
+ if self.browser_session.live_url:
583
+ return _encode_url(
584
+ self.browser_session.live_url, interactive=interactive, embed=embed
585
+ )
586
+ return None
794
587
 
795
- Returns:
796
- A handle to the running task.
588
+ def live_id(self):
589
+ """Returns the live ID for the browser session."""
590
+ return self.browser_session.live_id
797
591
 
798
- Raises:
799
- ApiException: If the API request fails.
800
- """
801
- payload = TaskRequest(
802
- task=task,
803
- response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
804
- url=url,
805
- metadata=metadata,
806
- files=files,
807
- agent=agent,
808
- max_steps=max_steps,
809
- device=device,
810
- allowed_urls=allowed_urls,
811
- enable_recording=enable_recording,
812
- profile_id=profile_id or session_id,
813
- profile_read_only=profile_read_only,
814
- stealth_mode=stealth_mode,
815
- proxy_server=proxy_server,
816
- proxy_username=proxy_username,
817
- proxy_password=proxy_password,
818
- experimental_features=experimental_features,
819
- )
820
-
821
- initial_response = await self._submit_task(payload)
822
- return AsyncTaskHandle(initial_response.id, self)
823
-
824
- async def open_session(
825
- self, profile_id: str | None = None, session_id: str | None = None, live_view: bool = True
826
- ) -> BrowserSessionHandle:
827
- """Opens an interactive browser instance asynchronously.
828
592
 
829
- Args:
830
- session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
831
- profile_id: The profile ID to associate with the browser.
832
- live_view: Whether to enable live view for the session.
593
+ class TaskHandle:
594
+ """A handle to a running task."""
595
+
596
+ def __init__(self, task_id: str, client: "SmoothClient"):
597
+ """Initializes the task handle."""
598
+ self._client = client
599
+ self._task_response: TaskResponse | None = None
600
+
601
+ self._id = task_id
602
+
603
+ def id(self):
604
+ """Returns the task ID."""
605
+ return self._id
606
+
607
+ def stop(self):
608
+ """Stops the task."""
609
+ self._client._delete_task(self._id)
610
+
611
+ def result(
612
+ self, timeout: int | None = None, poll_interval: float = 1
613
+ ) -> TaskResponse:
614
+ """Waits for the task to complete and returns the result."""
615
+ if self._task_response and self._task_response.status not in [
616
+ "running",
617
+ "waiting",
618
+ ]:
619
+ return self._task_response
620
+
621
+ if timeout is not None and timeout < 1:
622
+ raise ValueError("Timeout must be at least 1 second.")
623
+ if poll_interval < 0.1:
624
+ raise ValueError("Poll interval must be at least 100 milliseconds.")
625
+
626
+ start_time = time.time()
627
+ while timeout is None or (time.time() - start_time) < timeout:
628
+ task_response = self._client._get_task(self.id())
629
+ self._task_response = task_response
630
+ if task_response.status not in ["running", "waiting"]:
631
+ return task_response
632
+ time.sleep(poll_interval)
633
+ raise TimeoutError(
634
+ f"Task {self.id()} did not complete within {timeout} seconds."
635
+ )
636
+
637
+ def live_url(
638
+ self, interactive: bool = False, embed: bool = False, timeout: int | None = None
639
+ ):
640
+ """Returns the live URL for the task."""
641
+ if self._task_response and self._task_response.live_url:
642
+ return _encode_url(
643
+ self._task_response.live_url, interactive=interactive, embed=embed
644
+ )
645
+
646
+ start_time = time.time()
647
+ while timeout is None or (time.time() - start_time) < timeout:
648
+ task_response = self._client._get_task(self.id())
649
+ self._task_response = task_response
650
+ if self._task_response.live_url:
651
+ return _encode_url(
652
+ self._task_response.live_url, interactive=interactive, embed=embed
653
+ )
654
+ time.sleep(1)
655
+
656
+ raise TimeoutError(f"Live URL not available for task {self.id()}.")
657
+
658
+ def recording_url(self, timeout: int | None = None) -> str:
659
+ """Returns the recording URL for the task."""
660
+ if self._task_response and self._task_response.recording_url is not None:
661
+ return self._task_response.recording_url
662
+
663
+ start_time = time.time()
664
+ while timeout is None or (time.time() - start_time) < timeout:
665
+ task_response = self._client._get_task(self.id())
666
+ self._task_response = task_response
667
+ if task_response.recording_url is not None:
668
+ if not task_response.recording_url:
669
+ raise ApiError(
670
+ status_code=404,
671
+ detail=(
672
+ f"Recording URL not available for task {self.id()}."
673
+ " Set `enable_recording=True` when creating the task to enable it."
674
+ ),
675
+ )
676
+ return task_response.recording_url
677
+ time.sleep(1)
678
+ raise TimeoutError(f"Recording URL not available for task {self.id()}.")
679
+
680
+ def downloads_url(self, timeout: int | None = None) -> str:
681
+ """Returns the downloads URL for the task."""
682
+ if self._task_response and self._task_response.downloads_url is not None:
683
+ return self._task_response.downloads_url
684
+
685
+ start_time = time.time()
686
+ while timeout is None or (time.time() - start_time) < timeout:
687
+ task_response = self._client._get_task(
688
+ self.id(), query_params={"downloads": "true"}
689
+ )
690
+ self._task_response = task_response
691
+ if task_response.downloads_url is not None:
692
+ if not task_response.downloads_url:
693
+ raise ApiError(
694
+ status_code=404,
695
+ detail=(
696
+ f"Downloads URL not available for task {self.id()}."
697
+ " Make sure the task downloaded files during its execution."
698
+ ),
699
+ )
700
+ return task_response.downloads_url
701
+ time.sleep(1)
702
+ raise TimeoutError(f"Downloads URL not available for task {self.id()}.")
833
703
 
834
- Returns:
835
- The browser session details, including the live URL.
836
704
 
837
- Raises:
838
- ApiException: If the API request fails.
839
- """
840
- try:
841
- response = await self._client.post(
842
- f"{self.base_url}/browser/session",
843
- json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(exclude_none=True),
844
- )
845
- data = self._handle_response(response)
846
- return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
847
- except httpx.RequestError as e:
848
- logger.error(f"Request failed: {e}")
849
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
850
-
851
- async def close_session(self, live_id: str):
852
- """Closes a browser session."""
853
- try:
854
- response = await self._client.delete(f"{self.base_url}/browser/session/{live_id}")
855
- self._handle_response(response)
856
- except httpx.RequestError as e:
857
- logger.error(f"Request failed: {e}")
858
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
859
-
860
- async def list_profiles(self):
861
- """Lists all browser profiles for the user.
705
+ class SmoothClient(BaseClient):
706
+ """A synchronous client for the API."""
707
+
708
+ def __init__(
709
+ self,
710
+ api_key: str | None = None,
711
+ base_url: str = BASE_URL,
712
+ api_version: str = "v1",
713
+ ):
714
+ """Initializes the synchronous client."""
715
+ super().__init__(api_key, base_url, api_version)
716
+ self._session = requests.Session()
717
+ self._session.headers.update(self.headers)
718
+
719
+ def __enter__(self):
720
+ """Enters the synchronous context manager."""
721
+ return self
722
+
723
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
724
+ """Exits the synchronous context manager."""
725
+ self.close()
726
+
727
+ def close(self):
728
+ """Close the session."""
729
+ if hasattr(self, "_session"):
730
+ self._session.close()
731
+
732
+ def _submit_task(self, payload: TaskRequest) -> TaskResponse:
733
+ """Submits a task to be run."""
734
+ try:
735
+ response = self._session.post(
736
+ f"{self.base_url}/task", json=payload.model_dump()
737
+ )
738
+ data = self._handle_response(response)
739
+ return TaskResponse(**data["r"])
740
+ except requests.exceptions.RequestException as e:
741
+ logger.error(f"Request failed: {e}")
742
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
743
+
744
+ def _get_task(
745
+ self, task_id: str, query_params: dict[str, Any] | None = None
746
+ ) -> TaskResponse:
747
+ """Retrieves the status and result of a task."""
748
+ if not task_id:
749
+ raise ValueError("Task ID cannot be empty.")
750
+
751
+ try:
752
+ url = f"{self.base_url}/task/{task_id}"
753
+ response = self._session.get(url, params=query_params)
754
+ data = self._handle_response(response)
755
+ return TaskResponse(**data["r"])
756
+ except requests.exceptions.RequestException as e:
757
+ logger.error(f"Request failed: {e}")
758
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
759
+
760
+ def _delete_task(self, task_id: str):
761
+ """Deletes a task."""
762
+ if not task_id:
763
+ raise ValueError("Task ID cannot be empty.")
764
+
765
+ try:
766
+ response = self._session.delete(f"{self.base_url}/task/{task_id}")
767
+ self._handle_response(response)
768
+ except requests.exceptions.RequestException as e:
769
+ logger.error(f"Request failed: {e}")
770
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
771
+
772
+ def run(
773
+ self,
774
+ task: str,
775
+ response_model: dict[str, Any] | Type[BaseModel] | None = None,
776
+ url: str | None = None,
777
+ metadata: dict[str, str | int | float | bool] | None = None,
778
+ files: list[str] | None = None,
779
+ agent: Literal["smooth"] = "smooth",
780
+ max_steps: int = 32,
781
+ device: Literal["desktop", "mobile"] = "mobile",
782
+ allowed_urls: list[str] | None = None,
783
+ enable_recording: bool = True,
784
+ session_id: str | None = None,
785
+ profile_id: str | None = None,
786
+ profile_read_only: bool = False,
787
+ stealth_mode: bool = False,
788
+ proxy_server: str | None = None,
789
+ proxy_username: str | None = None,
790
+ proxy_password: str | None = None,
791
+ certificates: list[Certificate] | None = None,
792
+ use_adblock: bool | None = True,
793
+ additional_tools: dict[str, dict[str, Any] | None] | None = None,
794
+ experimental_features: dict[str, Any] | None = None,
795
+ extensions: list[str] | None = None,
796
+ ) -> TaskHandle:
797
+ """Runs a task and returns a handle to the task.
798
+
799
+ This method submits a task and returns a `TaskHandle` object
800
+ that can be used to get the result of the task.
801
+
802
+ Args:
803
+ task: The task to run.
804
+ response_model: If provided, the schema describing the desired output structure.
805
+ url: The starting URL for the task. If not provided, the agent will infer it from the task.
806
+ metadata: A dictionary containing variables or parameters that will be passed to the agent.
807
+ files: A list of file ids to pass to the agent.
808
+ agent: The agent to use for the task.
809
+ max_steps: Maximum number of steps the agent can take (max 64).
810
+ device: Device type for the task. Default is mobile.
811
+ allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
812
+ If None, all URLs are allowed.
813
+ enable_recording: Enable video recording of the task execution.
814
+ session_id: (Deprecated, now `profile_id`) Browser session ID to use.
815
+ profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
816
+ profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
817
+ stealth_mode: Run the browser in stealth mode.
818
+ proxy_server: Proxy server address to route browser traffic through.
819
+ proxy_username: Proxy server username.
820
+ proxy_password: Proxy server password.
821
+ certificates: List of client certificates to use when accessing secure websites.
822
+ Each certificate is a dictionary with the following fields:
823
+ - `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
824
+ - `password` (optional): Password to decrypt the certificate file, if password-protected.
825
+ use_adblock: Enable adblock for the browser session. Default is True.
826
+ additional_tools: Additional tools to enable for the task.
827
+ experimental_features: Experimental features to enable for the task.
828
+
829
+ Returns:
830
+ A handle to the running task.
831
+
832
+ Raises:
833
+ ApiException: If the API request fails.
834
+ """
835
+ payload = TaskRequest(
836
+ task=task,
837
+ response_model=response_model
838
+ if isinstance(response_model, dict | None)
839
+ else response_model.model_json_schema(),
840
+ url=url,
841
+ metadata=metadata,
842
+ files=files,
843
+ agent=agent,
844
+ max_steps=max_steps,
845
+ device=device,
846
+ allowed_urls=allowed_urls,
847
+ enable_recording=enable_recording,
848
+ profile_id=profile_id or session_id,
849
+ profile_read_only=profile_read_only,
850
+ stealth_mode=stealth_mode,
851
+ proxy_server=proxy_server,
852
+ proxy_username=proxy_username,
853
+ proxy_password=proxy_password,
854
+ certificates=_process_certificates(certificates),
855
+ use_adblock=use_adblock,
856
+ additional_tools=additional_tools,
857
+ experimental_features=experimental_features,
858
+ extensions=extensions,
859
+ )
860
+ initial_response = self._submit_task(payload)
861
+
862
+ return TaskHandle(initial_response.id, self)
863
+
864
+ def open_session(
865
+ self,
866
+ profile_id: str | None = None,
867
+ session_id: str | None = None,
868
+ live_view: bool = True,
869
+ device: Literal["desktop", "mobile"] = "desktop",
870
+ url: str | None = None,
871
+ proxy_server: str | None = None,
872
+ proxy_username: str | None = None,
873
+ proxy_password: str | None = None,
874
+ ) -> BrowserSessionHandle:
875
+ """Opens an interactive browser instance to interact with a specific browser profile.
876
+
877
+ Args:
878
+ profile_id: The profile ID to use for the session. If None, a new profile will be created.
879
+ session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
880
+ live_view: Whether to enable live view for the session.
881
+ device: The device type to use for the browser session.
882
+ url: The URL to open in the browser session.
883
+ proxy_server: Proxy server address to route browser traffic through.
884
+ proxy_username: Proxy server username.
885
+ proxy_password: Proxy server password.
886
+
887
+ Returns:
888
+ The browser session details, including the live URL.
889
+
890
+ Raises:
891
+ ApiException: If the API request fails.
892
+ """
893
+ try:
894
+ response = self._session.post(
895
+ f"{self.base_url}/browser/session",
896
+ json=BrowserSessionRequest(
897
+ profile_id=profile_id or session_id,
898
+ live_view=live_view,
899
+ device=device,
900
+ url=url,
901
+ proxy_server=proxy_server,
902
+ proxy_username=proxy_username,
903
+ proxy_password=proxy_password,
904
+ ).model_dump(),
905
+ )
906
+ data = self._handle_response(response)
907
+ return BrowserSessionHandle(
908
+ browser_session=BrowserSessionResponse(**data["r"])
909
+ )
910
+ except requests.exceptions.RequestException as e:
911
+ logger.error(f"Request failed: {e}")
912
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
913
+
914
+ def close_session(self, live_id: str):
915
+ """Closes a browser session."""
916
+ try:
917
+ response = self._session.delete(
918
+ f"{self.base_url}/browser/session/{live_id}"
919
+ )
920
+ self._handle_response(response)
921
+ except requests.exceptions.RequestException as e:
922
+ logger.error(f"Request failed: {e}")
923
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
924
+
925
+ def list_profiles(self):
926
+ """Lists all browser profiles for the user.
927
+
928
+ Returns:
929
+ A list of existing browser profiles.
930
+
931
+ Raises:
932
+ ApiException: If the API request fails.
933
+ """
934
+ try:
935
+ response = self._session.get(f"{self.base_url}/browser/profile")
936
+ data = self._handle_response(response)
937
+ return BrowserProfilesResponse(**data["r"])
938
+ except requests.exceptions.RequestException as e:
939
+ logger.error(f"Request failed: {e}")
940
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
941
+
942
+ @deprecated("list_sessions is deprecated, use list_profiles instead")
943
+ def list_sessions(self):
944
+ """Lists all browser profiles for the user."""
945
+ return self.list_profiles()
946
+
947
+ def delete_profile(self, profile_id: str):
948
+ """Delete a browser profile."""
949
+ try:
950
+ response = self._session.delete(
951
+ f"{self.base_url}/browser/profile/{profile_id}"
952
+ )
953
+ self._handle_response(response)
954
+ except requests.exceptions.RequestException as e:
955
+ logger.error(f"Request failed: {e}")
956
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
957
+
958
+ @deprecated("delete_session is deprecated, use delete_profile instead")
959
+ def delete_session(self, session_id: str):
960
+ """Delete a browser profile."""
961
+ self.delete_profile(session_id)
962
+
963
+ def upload_file(
964
+ self, file: io.IOBase, name: str | None = None, purpose: str | None = None
965
+ ) -> UploadFileResponse:
966
+ """Upload a file and return the file ID.
967
+
968
+ Args:
969
+ file: File object to be uploaded.
970
+ name: Optional custom name for the file. If not provided, the original file name will be used.
971
+ purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
972
+
973
+ Returns:
974
+ The file ID assigned to the uploaded file.
975
+
976
+ Raises:
977
+ ValueError: If the file doesn't exist or can't be read.
978
+ ApiError: If the API request fails.
979
+ """
980
+ try:
981
+ name = name or getattr(file, "name", None)
982
+ if name is None:
983
+ raise ValueError(
984
+ "File name must be provided or the file object must have a 'name' attribute."
985
+ )
986
+
987
+ if purpose:
988
+ data = {"file_purpose": purpose}
989
+ else:
990
+ data = None
991
+
992
+ files = {"file": (Path(name).name, file)}
993
+ response = self._session.post(
994
+ f"{self.base_url}/file", files=files, data=data
995
+ )
996
+ data = self._handle_response(response)
997
+ return UploadFileResponse(**data["r"])
998
+ except requests.exceptions.RequestException as e:
999
+ logger.error(f"Request failed: {e}")
1000
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1001
+
1002
+ def delete_file(self, file_id: str):
1003
+ """Delete a file by its ID."""
1004
+ try:
1005
+ response = self._session.delete(f"{self.base_url}/file/{file_id}")
1006
+ self._handle_response(response)
1007
+ except requests.exceptions.RequestException as e:
1008
+ logger.error(f"Request failed: {e}")
1009
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1010
+
1011
+ def upload_extension(self, file: io.IOBase, name: str | None = None) -> UploadExtensionResponse:
1012
+ """Upload an extension and return the extension ID."""
1013
+ try:
1014
+ name = name or getattr(file, "name", None)
1015
+ if name is None:
1016
+ raise ValueError(
1017
+ "Extension name must be provided or the extension object must have a 'name' attribute."
1018
+ )
1019
+ files = {"file": (Path(name).name, file)}
1020
+ response = self._session.post(f"{self.base_url}/browser/extension", files=files)
1021
+ data = self._handle_response(response)
1022
+ return UploadExtensionResponse(**data["r"])
1023
+ except requests.exceptions.RequestException as e:
1024
+ logger.error(f"Request failed: {e}")
1025
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1026
+
1027
+ def list_extensions(self) -> ListExtensionsResponse:
1028
+ """List all extensions."""
1029
+ try:
1030
+ response = self._session.get(f"{self.base_url}/browser/extension")
1031
+ data = self._handle_response(response)
1032
+ return ListExtensionsResponse(**data["r"])
1033
+ except requests.exceptions.RequestException as e:
1034
+ logger.error(f"Request failed: {e}")
1035
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1036
+
1037
+ def delete_extension(self, extension_id: str):
1038
+ """Delete an extension by its ID."""
1039
+ try:
1040
+ response = self._session.delete(f"{self.base_url}/browser/extension/{extension_id}")
1041
+ self._handle_response(response)
1042
+ except requests.exceptions.RequestException as e:
1043
+ logger.error(f"Request failed: {e}")
1044
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
862
1045
 
863
- Returns:
864
- A list of existing browser profiles.
1046
+ # --- Asynchronous Client ---
865
1047
 
866
- Raises:
867
- ApiException: If the API request fails.
868
- """
869
- try:
870
- response = await self._client.get(f"{self.base_url}/browser/session")
871
- data = self._handle_response(response)
872
- return BrowserProfilesResponse(**data["r"])
873
- except httpx.RequestError as e:
874
- logger.error(f"Request failed: {e}")
875
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
876
-
877
- @deprecated("list_sessions is deprecated, use list_profiles instead")
878
- async def list_sessions(self):
879
- """Lists all browser profiles for the user."""
880
- return await self.list_profiles()
881
-
882
- async def delete_profile(self, profile_id: str):
883
- """Delete a browser profile."""
884
- try:
885
- response = await self._client.delete(f"{self.base_url}/browser/session/{profile_id}")
886
- self._handle_response(response)
887
- except httpx.RequestError as e:
888
- logger.error(f"Request failed: {e}")
889
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
890
-
891
- @deprecated("delete_session is deprecated, use delete_profile instead")
892
- async def delete_session(self, session_id: str):
893
- """Delete a browser profile."""
894
- await self.delete_profile(session_id)
895
-
896
- async def upload_file(self, file: io.IOBase, name: str | None = None, purpose: str | None = None) -> UploadFileResponse:
897
- """Upload a file and return the file ID.
898
1048
 
899
- Args:
900
- file: File object to be uploaded.
901
- name: Optional custom name for the file. If not provided, the original file name will be used.
902
- purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
1049
+ class AsyncTaskHandle:
1050
+ """An asynchronous handle to a running task."""
1051
+
1052
+ def __init__(self, task_id: str, client: "SmoothAsyncClient"):
1053
+ """Initializes the asynchronous task handle."""
1054
+ self._client = client
1055
+ self._task_response: TaskResponse | None = None
1056
+
1057
+ self._id = task_id
1058
+
1059
+ def id(self):
1060
+ """Returns the task ID."""
1061
+ return self._id
1062
+
1063
+ async def stop(self):
1064
+ """Stops the task."""
1065
+ await self._client._delete_task(self._id)
1066
+
1067
+ async def result(
1068
+ self, timeout: int | None = None, poll_interval: float = 1
1069
+ ) -> TaskResponse:
1070
+ """Waits for the task to complete and returns the result."""
1071
+ if self._task_response and self._task_response.status not in [
1072
+ "running",
1073
+ "waiting",
1074
+ ]:
1075
+ return self._task_response
1076
+
1077
+ if timeout is not None and timeout < 1:
1078
+ raise ValueError("Timeout must be at least 1 second.")
1079
+ if poll_interval < 0.1:
1080
+ raise ValueError("Poll interval must be at least 100 milliseconds.")
1081
+
1082
+ start_time = time.time()
1083
+ while timeout is None or (time.time() - start_time) < timeout:
1084
+ task_response = await self._client._get_task(self.id())
1085
+ self._task_response = task_response
1086
+ if task_response.status not in ["running", "waiting"]:
1087
+ return task_response
1088
+ await asyncio.sleep(poll_interval)
1089
+ raise TimeoutError(
1090
+ f"Task {self.id()} did not complete within {timeout} seconds."
1091
+ )
1092
+
1093
+ async def live_url(
1094
+ self, interactive: bool = False, embed: bool = False, timeout: int | None = None
1095
+ ):
1096
+ """Returns the live URL for the task."""
1097
+ if self._task_response and self._task_response.live_url:
1098
+ return _encode_url(
1099
+ self._task_response.live_url, interactive=interactive, embed=embed
1100
+ )
1101
+
1102
+ start_time = time.time()
1103
+ while timeout is None or (time.time() - start_time) < timeout:
1104
+ task_response = await self._client._get_task(self.id())
1105
+ self._task_response = task_response
1106
+ if self._task_response.live_url:
1107
+ return _encode_url(
1108
+ self._task_response.live_url, interactive=interactive, embed=embed
1109
+ )
1110
+ await asyncio.sleep(1)
1111
+
1112
+ raise TimeoutError(f"Live URL not available for task {self.id()}.")
1113
+
1114
+ async def recording_url(self, timeout: int | None = None) -> str:
1115
+ """Returns the recording URL for the task."""
1116
+ if self._task_response and self._task_response.recording_url is not None:
1117
+ return self._task_response.recording_url
1118
+
1119
+ start_time = time.time()
1120
+ while timeout is None or (time.time() - start_time) < timeout:
1121
+ task_response = await self._client._get_task(self.id())
1122
+ self._task_response = task_response
1123
+ if task_response.recording_url is not None:
1124
+ if not task_response.recording_url:
1125
+ raise ApiError(
1126
+ status_code=404,
1127
+ detail=(
1128
+ f"Recording URL not available for task {self.id()}."
1129
+ " Set `enable_recording=True` when creating the task to enable it."
1130
+ ),
1131
+ )
1132
+ return task_response.recording_url
1133
+ await asyncio.sleep(1)
1134
+
1135
+ raise TimeoutError(f"Recording URL not available for task {self.id()}.")
1136
+
1137
+ async def downloads_url(self, timeout: int | None = None) -> str:
1138
+ """Returns the downloads URL for the task."""
1139
+ if self._task_response and self._task_response.downloads_url is not None:
1140
+ return self._task_response.downloads_url
1141
+
1142
+ start_time = time.time()
1143
+ while timeout is None or (time.time() - start_time) < timeout:
1144
+ task_response = await self._client._get_task(
1145
+ self.id(), query_params={"downloads": "true"}
1146
+ )
1147
+ self._task_response = task_response
1148
+ if task_response.downloads_url is not None:
1149
+ if not task_response.downloads_url:
1150
+ raise ApiError(
1151
+ status_code=404,
1152
+ detail=(
1153
+ f"Downloads URL not available for task {self.id()}."
1154
+ " Make sure the task downloaded files during its execution."
1155
+ ),
1156
+ )
1157
+ return task_response.downloads_url
1158
+ await asyncio.sleep(1)
1159
+
1160
+ raise TimeoutError(f"Downloads URL not available for task {self.id()}.")
903
1161
 
904
- Returns:
905
- The file ID assigned to the uploaded file.
906
1162
 
907
- Raises:
908
- ValueError: If the file doesn't exist or can't be read.
909
- ApiError: If the API request fails.
910
- """
911
- try:
912
- name = name or getattr(file, "name", None)
913
- if name is None:
914
- raise ValueError("File name must be provided or the file object must have a 'name' attribute.")
915
-
916
- files = {"file": (Path(name).name, file)}
917
- if purpose:
918
- data = {"file_purpose": purpose}
919
- else:
920
- data = None
921
- response = await self._client.post(f"{self.base_url}/file", files=files, data=data)
922
- data = self._handle_response(response)
923
- return UploadFileResponse(**data["r"])
924
- except httpx.RequestError as e:
925
- logger.error(f"Request failed: {e}")
926
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
927
-
928
- async def delete_file(self, file_id: str):
929
- """Delete a file by its ID."""
930
- try:
931
- response = await self._client.delete(f"{self.base_url}/file/{file_id}")
932
- self._handle_response(response)
933
- except httpx.RequestError as e:
934
- logger.error(f"Request failed: {e}")
935
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
936
-
937
- async def close(self):
938
- """Closes the async client session."""
939
- await self._client.aclose()
1163
+ class SmoothAsyncClient(BaseClient):
1164
+ """An asynchronous client for the API."""
1165
+
1166
+ def __init__(
1167
+ self,
1168
+ api_key: str | None = None,
1169
+ base_url: str = BASE_URL,
1170
+ api_version: str = "v1",
1171
+ timeout: int = 30,
1172
+ ):
1173
+ """Initializes the asynchronous client."""
1174
+ super().__init__(api_key, base_url, api_version)
1175
+ self._client = httpx.AsyncClient(headers=self.headers, timeout=timeout)
1176
+
1177
+ async def __aenter__(self):
1178
+ """Enters the asynchronous context manager."""
1179
+ return self
1180
+
1181
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
1182
+ """Exits the asynchronous context manager."""
1183
+ await self.close()
1184
+
1185
+ async def _submit_task(self, payload: TaskRequest) -> TaskResponse:
1186
+ """Submits a task to be run asynchronously."""
1187
+ try:
1188
+ response = await self._client.post(
1189
+ f"{self.base_url}/task", json=payload.model_dump()
1190
+ )
1191
+ data = self._handle_response(response)
1192
+ return TaskResponse(**data["r"])
1193
+ except httpx.RequestError as e:
1194
+ logger.error(f"Request failed: {e}")
1195
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1196
+
1197
+ async def _get_task(
1198
+ self, task_id: str, query_params: dict[str, Any] | None = None
1199
+ ) -> TaskResponse:
1200
+ """Retrieves the status and result of a task asynchronously."""
1201
+ if not task_id:
1202
+ raise ValueError("Task ID cannot be empty.")
1203
+
1204
+ try:
1205
+ url = f"{self.base_url}/task/{task_id}"
1206
+ response = await self._client.get(url, params=query_params)
1207
+ data = self._handle_response(response)
1208
+ return TaskResponse(**data["r"])
1209
+ except httpx.RequestError as e:
1210
+ logger.error(f"Request failed: {e}")
1211
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1212
+
1213
+ async def _delete_task(self, task_id: str):
1214
+ """Deletes a task asynchronously."""
1215
+ if not task_id:
1216
+ raise ValueError("Task ID cannot be empty.")
1217
+
1218
+ try:
1219
+ response = await self._client.delete(f"{self.base_url}/task/{task_id}")
1220
+ self._handle_response(response)
1221
+ except httpx.RequestError as e:
1222
+ logger.error(f"Request failed: {e}")
1223
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1224
+
1225
+ async def run(
1226
+ self,
1227
+ task: str,
1228
+ response_model: dict[str, Any] | Type[BaseModel] | None = None,
1229
+ url: str | None = None,
1230
+ metadata: dict[str, str | int | float | bool] | None = None,
1231
+ files: list[str] | None = None,
1232
+ agent: Literal["smooth"] = "smooth",
1233
+ max_steps: int = 32,
1234
+ device: Literal["desktop", "mobile"] = "mobile",
1235
+ allowed_urls: list[str] | None = None,
1236
+ enable_recording: bool = True,
1237
+ session_id: str | None = None,
1238
+ profile_id: str | None = None,
1239
+ profile_read_only: bool = False,
1240
+ stealth_mode: bool = False,
1241
+ proxy_server: str | None = None,
1242
+ proxy_username: str | None = None,
1243
+ proxy_password: str | None = None,
1244
+ certificates: list[Certificate] | None = None,
1245
+ use_adblock: bool | None = True,
1246
+ additional_tools: dict[str, dict[str, Any] | None] | None = None,
1247
+ experimental_features: dict[str, Any] | None = None,
1248
+ ) -> AsyncTaskHandle:
1249
+ """Runs a task and returns a handle to the task asynchronously.
1250
+
1251
+ This method submits a task and returns an `AsyncTaskHandle` object
1252
+ that can be used to get the result of the task.
1253
+
1254
+ Args:
1255
+ task: The task to run.
1256
+ response_model: If provided, the schema describing the desired output structure.
1257
+ url: The starting URL for the task. If not provided, the agent will infer it from the task.
1258
+ metadata: A dictionary containing variables or parameters that will be passed to the agent.
1259
+ files: A list of file ids to pass to the agent.
1260
+ agent: The agent to use for the task.
1261
+ max_steps: Maximum number of steps the agent can take (max 64).
1262
+ device: Device type for the task. Default is mobile.
1263
+ allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
1264
+ If None, all URLs are allowed.
1265
+ enable_recording: Enable video recording of the task execution.
1266
+ session_id: (Deprecated, now `profile_id`) Browser session ID to use.
1267
+ profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
1268
+ profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
1269
+ stealth_mode: Run the browser in stealth mode.
1270
+ proxy_server: Proxy server address to route browser traffic through.
1271
+ proxy_username: Proxy server username.
1272
+ proxy_password: Proxy server password.
1273
+ certificates: List of client certificates to use when accessing secure websites.
1274
+ Each certificate is a dictionary with the following fields:
1275
+ - `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
1276
+ - `password` (optional): Password to decrypt the certificate file.
1277
+ use_adblock: Enable adblock for the browser session. Default is True.
1278
+ additional_tools: Additional tools to enable for the task.
1279
+ experimental_features: Experimental features to enable for the task.
1280
+
1281
+ Returns:
1282
+ A handle to the running task.
1283
+
1284
+ Raises:
1285
+ ApiException: If the API request fails.
1286
+ """
1287
+ payload = TaskRequest(
1288
+ task=task,
1289
+ response_model=response_model
1290
+ if isinstance(response_model, dict | None)
1291
+ else response_model.model_json_schema(),
1292
+ url=url,
1293
+ metadata=metadata,
1294
+ files=files,
1295
+ agent=agent,
1296
+ max_steps=max_steps,
1297
+ device=device,
1298
+ allowed_urls=allowed_urls,
1299
+ enable_recording=enable_recording,
1300
+ profile_id=profile_id or session_id,
1301
+ profile_read_only=profile_read_only,
1302
+ stealth_mode=stealth_mode,
1303
+ proxy_server=proxy_server,
1304
+ proxy_username=proxy_username,
1305
+ proxy_password=proxy_password,
1306
+ certificates=_process_certificates(certificates),
1307
+ use_adblock=use_adblock,
1308
+ additional_tools=additional_tools,
1309
+ experimental_features=experimental_features,
1310
+ )
1311
+
1312
+ initial_response = await self._submit_task(payload)
1313
+ return AsyncTaskHandle(initial_response.id, self)
1314
+
1315
+ async def open_session(
1316
+ self,
1317
+ profile_id: str | None = None,
1318
+ session_id: str | None = None,
1319
+ live_view: bool = True,
1320
+ device: Literal["desktop", "mobile"] = "desktop",
1321
+ url: str | None = None,
1322
+ proxy_server: str | None = None,
1323
+ proxy_username: str | None = None,
1324
+ proxy_password: str | None = None,
1325
+ ) -> BrowserSessionHandle:
1326
+ """Opens an interactive browser instance asynchronously.
1327
+
1328
+ Args:
1329
+ profile_id: The profile ID to use for the session. If None, a new profile will be created.
1330
+ session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
1331
+ live_view: Whether to enable live view for the session.
1332
+ device: The device type to use for the session. Defaults to "desktop".
1333
+ url: The URL to open in the browser session.
1334
+ proxy_server: Proxy server address to route browser traffic through.
1335
+ proxy_username: Proxy server username.
1336
+ proxy_password: Proxy server password.
1337
+
1338
+ Returns:
1339
+ The browser session details, including the live URL.
1340
+
1341
+ Raises:
1342
+ ApiException: If the API request fails.
1343
+ """
1344
+ try:
1345
+ response = await self._client.post(
1346
+ f"{self.base_url}/browser/session",
1347
+ json=BrowserSessionRequest(
1348
+ profile_id=profile_id or session_id,
1349
+ live_view=live_view,
1350
+ device=device,
1351
+ url=url,
1352
+ proxy_server=proxy_server,
1353
+ proxy_username=proxy_username,
1354
+ proxy_password=proxy_password,
1355
+ ).model_dump(),
1356
+ )
1357
+ data = self._handle_response(response)
1358
+ return BrowserSessionHandle(
1359
+ browser_session=BrowserSessionResponse(**data["r"])
1360
+ )
1361
+ except httpx.RequestError as e:
1362
+ logger.error(f"Request failed: {e}")
1363
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1364
+
1365
+ async def close_session(self, live_id: str):
1366
+ """Closes a browser session."""
1367
+ try:
1368
+ response = await self._client.delete(
1369
+ f"{self.base_url}/browser/session/{live_id}"
1370
+ )
1371
+ self._handle_response(response)
1372
+ except httpx.RequestError as e:
1373
+ logger.error(f"Request failed: {e}")
1374
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1375
+
1376
+ async def list_profiles(self):
1377
+ """Lists all browser profiles for the user.
1378
+
1379
+ Returns:
1380
+ A list of existing browser profiles.
1381
+
1382
+ Raises:
1383
+ ApiException: If the API request fails.
1384
+ """
1385
+ try:
1386
+ response = await self._client.get(f"{self.base_url}/browser/profile")
1387
+ data = self._handle_response(response)
1388
+ return BrowserProfilesResponse(**data["r"])
1389
+ except httpx.RequestError as e:
1390
+ logger.error(f"Request failed: {e}")
1391
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1392
+
1393
+ @deprecated("list_sessions is deprecated, use list_profiles instead")
1394
+ async def list_sessions(self):
1395
+ """Lists all browser profiles for the user."""
1396
+ return await self.list_profiles()
1397
+
1398
+ async def delete_profile(self, profile_id: str):
1399
+ """Delete a browser profile."""
1400
+ try:
1401
+ response = await self._client.delete(
1402
+ f"{self.base_url}/browser/profile/{profile_id}"
1403
+ )
1404
+ self._handle_response(response)
1405
+ except httpx.RequestError as e:
1406
+ logger.error(f"Request failed: {e}")
1407
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1408
+
1409
+ @deprecated("delete_session is deprecated, use delete_profile instead")
1410
+ async def delete_session(self, session_id: str):
1411
+ """Delete a browser profile."""
1412
+ await self.delete_profile(session_id)
1413
+
1414
+ async def upload_file(
1415
+ self, file: io.IOBase, name: str | None = None, purpose: str | None = None
1416
+ ) -> UploadFileResponse:
1417
+ """Upload a file and return the file ID.
1418
+
1419
+ Args:
1420
+ file: File object to be uploaded.
1421
+ name: Optional custom name for the file. If not provided, the original file name will be used.
1422
+ purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
1423
+
1424
+ Returns:
1425
+ The file ID assigned to the uploaded file.
1426
+
1427
+ Raises:
1428
+ ValueError: If the file doesn't exist or can't be read.
1429
+ ApiError: If the API request fails.
1430
+ """
1431
+ try:
1432
+ name = name or getattr(file, "name", None)
1433
+ if name is None:
1434
+ raise ValueError(
1435
+ "File name must be provided or the file object must have a 'name' attribute."
1436
+ )
1437
+
1438
+ if purpose:
1439
+ data = {"file_purpose": purpose}
1440
+ else:
1441
+ data = None
1442
+
1443
+ files = {"file": (Path(name).name, file)}
1444
+ response = await self._client.post(
1445
+ f"{self.base_url}/file", files=files, data=data
1446
+ )
1447
+ data = self._handle_response(response)
1448
+ return UploadFileResponse(**data["r"])
1449
+ except httpx.RequestError as e:
1450
+ logger.error(f"Request failed: {e}")
1451
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1452
+
1453
+ async def delete_file(self, file_id: str):
1454
+ """Delete a file by its ID."""
1455
+ try:
1456
+ response = await self._client.delete(f"{self.base_url}/file/{file_id}")
1457
+ self._handle_response(response)
1458
+ except httpx.RequestError as e:
1459
+ logger.error(f"Request failed: {e}")
1460
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1461
+
1462
+ async def upload_extension(self, file: io.IOBase, name: str | None = None) -> UploadExtensionResponse:
1463
+ """Upload an extension and return the extension ID."""
1464
+ try:
1465
+ name = name or getattr(file, "name", None)
1466
+ if name is None:
1467
+ raise ValueError(
1468
+ "File name must be provided or the file object must have a 'name' attribute."
1469
+ )
1470
+ files = {"file": (Path(name).name, file)}
1471
+ response = await self._client.post(f"{self.base_url}/browser/extension", files=files)
1472
+ data = self._handle_response(response)
1473
+ return UploadExtensionResponse(**data["r"])
1474
+ except httpx.RequestError as e:
1475
+ logger.error(f"Request failed: {e}")
1476
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1477
+
1478
+ async def list_extensions(self) -> ListExtensionsResponse:
1479
+ """List all extensions."""
1480
+ try:
1481
+ response = await self._client.get(f"{self.base_url}/browser/extension")
1482
+ data = self._handle_response(response)
1483
+ return ListExtensionsResponse(**data["r"])
1484
+ except httpx.RequestError as e:
1485
+ logger.error(f"Request failed: {e}")
1486
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1487
+
1488
+ async def delete_extension(self, extension_id: str):
1489
+ """Delete an extension by its ID."""
1490
+ try:
1491
+ response = await self._client.delete(f"{self.base_url}/browser/extension/{extension_id}")
1492
+ self._handle_response(response)
1493
+ except httpx.RequestError as e:
1494
+ logger.error(f"Request failed: {e}")
1495
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
1496
+
1497
+ async def close(self):
1498
+ """Closes the async client session."""
1499
+ await self._client.aclose()
940
1500
 
941
1501
 
942
1502
  # Export public API
943
1503
  __all__ = [
944
- "SmoothClient",
945
- "SmoothAsyncClient",
946
- "TaskHandle",
947
- "AsyncTaskHandle",
948
- "BrowserSessionHandle",
949
- "TaskRequest",
950
- "TaskResponse",
951
- "BrowserSessionRequest",
952
- "BrowserSessionResponse",
953
- "BrowserSessionsResponse",
954
- "UploadFileResponse",
955
- "ApiError",
956
- "TimeoutError",
1504
+ "SmoothClient",
1505
+ "SmoothAsyncClient",
1506
+ "TaskHandle",
1507
+ "AsyncTaskHandle",
1508
+ "BrowserSessionHandle",
1509
+ "TaskRequest",
1510
+ "TaskResponse",
1511
+ "BrowserSessionRequest",
1512
+ "BrowserSessionResponse",
1513
+ "BrowserSessionsResponse",
1514
+ "UploadFileResponse",
1515
+ "UploadExtensionResponse",
1516
+ "ListExtensionsResponse",
1517
+ "Extension",
1518
+ "Certificate",
1519
+ "ApiError",
1520
+ "TimeoutError",
957
1521
  ]