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