morphsdk 0.2.5__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.
Files changed (61) hide show
  1. morphsdk/__init__.py +54 -0
  2. morphsdk/_agent/__init__.py +64 -0
  3. morphsdk/_agent/config.py +52 -0
  4. morphsdk/_agent/explore.py +276 -0
  5. morphsdk/_agent/github.py +57 -0
  6. morphsdk/_agent/helpers.py +133 -0
  7. morphsdk/_agent/parser.py +163 -0
  8. morphsdk/_agent/runner.py +524 -0
  9. morphsdk/_agent/tools.py +171 -0
  10. morphsdk/_agent/types.py +126 -0
  11. morphsdk/_base.py +309 -0
  12. morphsdk/_client.py +245 -0
  13. morphsdk/_config.py +37 -0
  14. morphsdk/_constants.py +53 -0
  15. morphsdk/_errors.py +111 -0
  16. morphsdk/_providers/__init__.py +36 -0
  17. morphsdk/_providers/_filter.py +92 -0
  18. morphsdk/_providers/base.py +94 -0
  19. morphsdk/_providers/code_storage_http.py +104 -0
  20. morphsdk/_providers/local.py +270 -0
  21. morphsdk/_providers/remote.py +161 -0
  22. morphsdk/_version.py +1 -0
  23. morphsdk/adapters/__init__.py +1 -0
  24. morphsdk/adapters/anthropic.py +360 -0
  25. morphsdk/adapters/langchain.py +120 -0
  26. morphsdk/adapters/openai.py +500 -0
  27. morphsdk/py.typed +0 -0
  28. morphsdk/resources/__init__.py +0 -0
  29. morphsdk/resources/browser.py +919 -0
  30. morphsdk/resources/compact.py +133 -0
  31. morphsdk/resources/edit.py +506 -0
  32. morphsdk/resources/explore.py +333 -0
  33. morphsdk/resources/git.py +861 -0
  34. morphsdk/resources/github.py +1214 -0
  35. morphsdk/resources/grep.py +583 -0
  36. morphsdk/resources/mobile.py +134 -0
  37. morphsdk/resources/reflex.py +414 -0
  38. morphsdk/resources/router.py +124 -0
  39. morphsdk/resources/search.py +110 -0
  40. morphsdk/tracing/__init__.py +70 -0
  41. morphsdk/tracing/_otel.py +101 -0
  42. morphsdk/tracing/core.py +249 -0
  43. morphsdk/tracing/interaction.py +284 -0
  44. morphsdk/tracing/otel.py +75 -0
  45. morphsdk/tracing/reflex.py +58 -0
  46. morphsdk/tracing/types.py +163 -0
  47. morphsdk/types/__init__.py +140 -0
  48. morphsdk/types/browser.py +118 -0
  49. morphsdk/types/compact.py +41 -0
  50. morphsdk/types/edit.py +31 -0
  51. morphsdk/types/explore.py +42 -0
  52. morphsdk/types/git.py +25 -0
  53. morphsdk/types/github.py +111 -0
  54. morphsdk/types/grep.py +41 -0
  55. morphsdk/types/mobile.py +25 -0
  56. morphsdk/types/reflex.py +137 -0
  57. morphsdk/types/router.py +21 -0
  58. morphsdk/types/search.py +33 -0
  59. morphsdk-0.2.5.dist-info/METADATA +226 -0
  60. morphsdk-0.2.5.dist-info/RECORD +61 -0
  61. morphsdk-0.2.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,919 @@
1
+ """Browser automation resource -- AI-powered browser tasks and recordings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, Any
7
+ from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
8
+
9
+ from morphsdk._constants import (
10
+ BROWSER_BASE_URL,
11
+ BROWSER_TIMEOUT,
12
+ DEFAULT_BROWSER_MODEL,
13
+ )
14
+ from morphsdk.types.browser import (
15
+ BrowserTask,
16
+ BrowserTaskResult,
17
+ ErrorsResponse,
18
+ IframeOptions,
19
+ LiveSessionOptions,
20
+ RecordingStatus,
21
+ WebpResponse,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from morphsdk._base import AsyncBaseClient, BaseClient
26
+
27
+
28
+ # Base URL for the browser-use live viewer (mirrors TS live.ts).
29
+ LIVE_BASE_URL = "https://live.browser-use.com"
30
+
31
+
32
+ # Preset configurations for common live-session use cases (mirrors TS LIVE_PRESETS).
33
+ LIVE_PRESETS: dict[str, LiveSessionOptions] = {
34
+ "readonly": LiveSessionOptions(interactive=False),
35
+ "interactive": LiveSessionOptions(interactive=True),
36
+ "monitoring": LiveSessionOptions(interactive=False, show_controls=False),
37
+ }
38
+
39
+
40
+ def _serialize_structured_output(schema: dict[str, Any] | str) -> str:
41
+ """Serialize a structured-output schema to the wire ``structured_output`` string.
42
+
43
+ The backend expects a JSON string (matching the TS SDK, which serializes
44
+ its Zod schema to JSON). A ``dict`` schema is JSON-encoded; a ``str`` is
45
+ assumed already-serialized and passed through unchanged.
46
+ """
47
+ if isinstance(schema, str):
48
+ return schema
49
+ import json
50
+
51
+ return json.dumps(schema)
52
+
53
+
54
+ def get_structured_output(result: BrowserTaskResult) -> Any | None:
55
+ """Parse the structured result from a completed browser task.
56
+
57
+ Mirrors the TS ``parseStructuredTaskOutput`` JSON-parse step: returns the
58
+ decoded ``result.output`` (the structured payload as a string of JSON), or
59
+ ``None`` if there is no output or it is not valid JSON.
60
+ """
61
+ if not result.output:
62
+ return None
63
+ import json
64
+
65
+ try:
66
+ return json.loads(result.output)
67
+ except (ValueError, TypeError):
68
+ return None
69
+
70
+
71
+ def _cdp_to_https(ws_url: str) -> str:
72
+ """Swap a CDP WebSocket scheme to HTTP(S). Mirrors TS ``cdpToHttps``."""
73
+ if ws_url.startswith("wss://"):
74
+ return "https://" + ws_url[len("wss://") :]
75
+ if ws_url.startswith("ws://"):
76
+ return "http://" + ws_url[len("ws://") :]
77
+ return ws_url
78
+
79
+
80
+ def _normalize_live_url(debug_url: str) -> str:
81
+ """Normalize any debug URL into a valid browser-use live-viewer URL.
82
+
83
+ Mirrors TS ``normalizeLiveUrl`` and handles three input formats:
84
+ 1. Already-correct ``https://live.browser-use.com?wss=https://...`` -- pass through.
85
+ 2. Live viewer URL with wrong scheme in the ``wss`` param -- fix the scheme.
86
+ 3. Raw CDP URL ``wss://UUID.cdpN.browser-use.com`` -- wrap into a live-viewer URL.
87
+ """
88
+ trimmed = debug_url.strip()
89
+ if not trimmed:
90
+ return trimmed
91
+
92
+ # Case 3: raw CDP WebSocket URL -> wrap into live viewer.
93
+ if trimmed.startswith("wss://") or trimmed.startswith("ws://"):
94
+ # encodeURIComponent equivalent: percent-encode everything unsafe.
95
+ encoded = quote(_cdp_to_https(trimmed), safe="")
96
+ return f"{LIVE_BASE_URL}?wss={encoded}"
97
+
98
+ try:
99
+ parts = urlsplit(trimmed)
100
+ if not parts.scheme:
101
+ raise ValueError("relative URL")
102
+ except ValueError:
103
+ return trimmed
104
+
105
+ # Case 2: live viewer URL with wrong scheme in the wss param -> fix it.
106
+ query = parse_qsl(parts.query, keep_blank_values=True)
107
+ wss = next((v for k, v in query if k == "wss"), None)
108
+ if wss and (wss.startswith("wss://") or wss.startswith("ws://")):
109
+ query = [(k, _cdp_to_https(v) if k == "wss" else v) for k, v in query]
110
+ new_query = urlencode(query)
111
+ return urlunsplit(
112
+ (parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)
113
+ )
114
+
115
+ return trimmed
116
+
117
+
118
+ def _resolve_preset(
119
+ options_or_preset: str | LiveSessionOptions | IframeOptions | None,
120
+ ) -> IframeOptions:
121
+ """Resolve a preset name or options object into :class:`IframeOptions`.
122
+
123
+ Mirrors TS ``resolvePreset``.
124
+ """
125
+ if options_or_preset is None:
126
+ return IframeOptions()
127
+ if isinstance(options_or_preset, str):
128
+ preset = LIVE_PRESETS.get(options_or_preset)
129
+ if preset is None:
130
+ available = ", ".join(LIVE_PRESETS)
131
+ raise ValueError(
132
+ f"Unknown preset: {options_or_preset}. Available presets: {available}"
133
+ )
134
+ return IframeOptions(**preset.model_dump(exclude_none=True))
135
+ if isinstance(options_or_preset, IframeOptions):
136
+ return options_or_preset
137
+ return IframeOptions(**options_or_preset.model_dump(exclude_none=True))
138
+
139
+
140
+ def build_live_url(debug_url: str, options: LiveSessionOptions | None = None) -> str:
141
+ """Build a live-session URL with query parameters.
142
+
143
+ Mirrors TS ``buildLiveUrl``. *debug_url* is the live-session debug URL
144
+ (e.g. ``BrowserTask.debug_url``). Query-parameter names match TS exactly
145
+ (``interactive``, ``theme``, ``showControls``, ``pageId``, ``pageIndex``).
146
+ """
147
+ if not debug_url:
148
+ raise ValueError(
149
+ "debug_url is required. Ensure your backend returns debug_url in the "
150
+ "task response. Contact support@morphllm.com if you need help."
151
+ )
152
+
153
+ opts = options or LiveSessionOptions()
154
+ normalized = _normalize_live_url(debug_url)
155
+ parts = urlsplit(normalized)
156
+ query = parse_qsl(parts.query, keep_blank_values=True)
157
+
158
+ if opts.interactive is not None:
159
+ query.append(("interactive", "true" if opts.interactive else "false"))
160
+ if opts.theme is not None:
161
+ query.append(("theme", opts.theme))
162
+ if opts.show_controls is not None:
163
+ query.append(("showControls", "true" if opts.show_controls else "false"))
164
+ if opts.page_id is not None:
165
+ query.append(("pageId", opts.page_id))
166
+ if opts.page_index is not None:
167
+ query.append(("pageIndex", opts.page_index))
168
+
169
+ new_query = urlencode(query)
170
+ return urlunsplit(
171
+ (parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)
172
+ )
173
+
174
+
175
+ def build_live_iframe(
176
+ debug_url: str, options: str | IframeOptions | None = None
177
+ ) -> str:
178
+ """Build iframe HTML for embedding a live session. Mirrors TS ``buildLiveIframe``."""
179
+ opts = _resolve_preset(options)
180
+
181
+ session_opts = LiveSessionOptions(
182
+ interactive=opts.interactive,
183
+ theme=opts.theme,
184
+ show_controls=opts.show_controls,
185
+ page_id=opts.page_id,
186
+ page_index=opts.page_index,
187
+ )
188
+ src = build_live_url(debug_url, session_opts)
189
+
190
+ width_str = f"{opts.width}px" if isinstance(opts.width, int) else opts.width
191
+ height_str = f"{opts.height}px" if isinstance(opts.height, int) else opts.height
192
+
193
+ base_style = f"width: {width_str}; height: {height_str}; border: none;"
194
+ full_style = f"{base_style} {opts.style}" if opts.style else base_style
195
+
196
+ attributes = [f'src="{src}"', f'style="{full_style}"']
197
+ if opts.class_name:
198
+ attributes.append(f'class="{opts.class_name}"')
199
+
200
+ return f"<iframe {' '.join(attributes)}></iframe>"
201
+
202
+
203
+ def build_embed_code(
204
+ debug_url: str, options: str | IframeOptions | None = None
205
+ ) -> str:
206
+ """Build a copy-paste embed snippet for a live session. Mirrors TS ``buildEmbedCode``."""
207
+ iframe = build_live_iframe(debug_url, options)
208
+ return f"<!-- Embed Morph Live Session -->\n{iframe}"
209
+
210
+
211
+ def _map_task_result(data: dict[str, Any]) -> BrowserTaskResult:
212
+ """Map snake_case API response to BrowserTaskResult."""
213
+ return BrowserTaskResult(
214
+ result=data.get("result"),
215
+ error=data.get("error"),
216
+ steps_taken=data.get("steps_taken"),
217
+ execution_time_ms=data.get("execution_time_ms"),
218
+ urls=data.get("urls"),
219
+ action_names=data.get("action_names"),
220
+ errors=data.get("errors"),
221
+ model_actions=data.get("model_actions"),
222
+ is_done=data.get("is_done"),
223
+ action_history=data.get("action_history"),
224
+ action_results=data.get("action_results"),
225
+ has_errors=data.get("has_errors"),
226
+ number_of_steps=data.get("number_of_steps"),
227
+ judgement=data.get("judgement"),
228
+ is_validated=data.get("is_validated"),
229
+ replay_id=data.get("replay_id"),
230
+ replay_url=data.get("replay_url"),
231
+ recording_id=data.get("recording_id"),
232
+ recording_status=data.get("recording_status"),
233
+ task_id=data.get("task_id"),
234
+ status=data.get("status"),
235
+ output=data.get("output"),
236
+ debug_url=data.get("debug_url"),
237
+ )
238
+
239
+
240
+ def _map_recording_status(data: dict[str, Any]) -> RecordingStatus:
241
+ return RecordingStatus(
242
+ id=data["id"],
243
+ status=data["status"],
244
+ replay_url=data.get("replay_url"),
245
+ network_url=data.get("network_url"),
246
+ console_url=data.get("console_url"),
247
+ video_url=data.get("video_url"),
248
+ result=data.get("result"),
249
+ total_events=data.get("total_events"),
250
+ file_size=data.get("file_size"),
251
+ duration=data.get("duration"),
252
+ error=data.get("error"),
253
+ created_at=data.get("created_at", ""),
254
+ )
255
+
256
+
257
+ def _build_run_body(
258
+ *,
259
+ task: str,
260
+ url: str | None,
261
+ max_steps: int,
262
+ model: str,
263
+ region: str | None,
264
+ stealth: bool,
265
+ viewport_width: int,
266
+ viewport_height: int,
267
+ record_video: bool,
268
+ structured_output: dict[str, Any] | str | None,
269
+ auth: dict[str, Any] | None,
270
+ profile_id: str | None,
271
+ ) -> dict[str, Any]:
272
+ """Build the synchronous ``/browser-task`` request body."""
273
+ body: dict[str, Any] = {
274
+ "task": task,
275
+ "max_steps": max_steps,
276
+ "model": model,
277
+ "viewport_width": viewport_width,
278
+ "viewport_height": viewport_height,
279
+ "record_video": record_video,
280
+ }
281
+ if url is not None:
282
+ body["url"] = url
283
+ if region is not None:
284
+ body["region"] = region
285
+ if not stealth:
286
+ body["stealth"] = False
287
+ if structured_output is not None:
288
+ body["structured_output"] = _serialize_structured_output(structured_output)
289
+ if auth is not None:
290
+ body["auth"] = auth
291
+ if profile_id is not None:
292
+ body["profile_id"] = profile_id
293
+ return body
294
+
295
+
296
+ def _build_create_task_body(
297
+ *,
298
+ task: str,
299
+ url: str | None,
300
+ max_steps: int,
301
+ model: str,
302
+ record_video: bool,
303
+ viewport_width: int,
304
+ viewport_height: int,
305
+ structured_output: dict[str, Any] | str | None,
306
+ auth: dict[str, Any] | None,
307
+ profile_id: str | None,
308
+ extra: dict[str, Any],
309
+ ) -> dict[str, Any]:
310
+ """Build the async ``/browser-task/async`` request body."""
311
+ body: dict[str, Any] = {
312
+ "task": task,
313
+ "max_steps": max_steps,
314
+ "model": model,
315
+ "viewport_width": viewport_width,
316
+ "viewport_height": viewport_height,
317
+ "record_video": record_video,
318
+ }
319
+ if url is not None:
320
+ body["url"] = url
321
+ if structured_output is not None:
322
+ body["structured_output"] = _serialize_structured_output(structured_output)
323
+ if auth is not None:
324
+ body["auth"] = auth
325
+ if profile_id is not None:
326
+ body["profile_id"] = profile_id
327
+ body.update(extra)
328
+ return body
329
+
330
+
331
+ def _task_to_browser_task(data: dict[str, Any]) -> BrowserTask:
332
+ """Map a ``/browser-task/async`` response into a :class:`BrowserTask`."""
333
+ result = _map_task_result(data)
334
+ return BrowserTask(
335
+ task_id=result.task_id or "",
336
+ live_url=result.debug_url or "",
337
+ debug_url=result.debug_url or "",
338
+ )
339
+
340
+
341
+ def _build_webp_params(
342
+ *,
343
+ width: int | None,
344
+ fps: int | None,
345
+ quality: int | None,
346
+ max_duration: float | None,
347
+ max_size_mb: float | None,
348
+ ) -> dict[str, str]:
349
+ """Build query params for the recordings WebP endpoint."""
350
+ params: dict[str, str] = {}
351
+ if width is not None:
352
+ params["width"] = str(width)
353
+ if fps is not None:
354
+ params["fps"] = str(fps)
355
+ if quality is not None:
356
+ params["quality"] = str(quality)
357
+ if max_duration is not None:
358
+ params["max_duration"] = str(max_duration)
359
+ if max_size_mb is not None:
360
+ params["max_size_mb"] = str(max_size_mb)
361
+ return params
362
+
363
+
364
+ def _parse_webp(data: dict[str, Any]) -> WebpResponse:
365
+ """Parse a recordings WebP response."""
366
+ return WebpResponse(
367
+ webp_url=data["webp_url"],
368
+ cached=data["cached"],
369
+ width=data["width"],
370
+ fps=data["fps"],
371
+ max_duration=data.get("max_duration"),
372
+ file_size=data.get("file_size"),
373
+ max_size_mb=data.get("max_size_mb"),
374
+ budget_met=data.get("budget_met"),
375
+ quality_used=data.get("quality_used"),
376
+ attempts=data.get("attempts"),
377
+ )
378
+
379
+
380
+ def _health_payload(data: dict[str, Any]) -> dict[str, Any]:
381
+ """Map a healthy ``/health`` response into the public dict shape."""
382
+ return {
383
+ "ok": True,
384
+ "google_configured": data.get("google_configured", False),
385
+ "database_configured": data.get("database_configured", False),
386
+ "s3_configured": data.get("s3_configured", False),
387
+ }
388
+
389
+
390
+ def _health_error(exc: Exception) -> dict[str, Any]:
391
+ """Map a failed health check into the public dict shape."""
392
+ return {
393
+ "ok": False,
394
+ "google_configured": False,
395
+ "database_configured": False,
396
+ "s3_configured": False,
397
+ "error": str(exc),
398
+ }
399
+
400
+
401
+ class ProfilesResource:
402
+ """Browser profile management (login state persistence)."""
403
+
404
+ def __init__(self, client: BaseClient) -> None:
405
+ self._client = client
406
+
407
+ def create(
408
+ self,
409
+ *,
410
+ name: str,
411
+ repo_id: str,
412
+ timeout: float | None = None,
413
+ ) -> dict[str, Any]:
414
+ """Create a new browser profile and return its metadata."""
415
+ response = self._client._request(
416
+ "POST",
417
+ f"{BROWSER_BASE_URL}/profiles",
418
+ json={"name": name, "repo_id": repo_id},
419
+ timeout=timeout,
420
+ )
421
+ return response.json() # type: ignore[no-any-return]
422
+
423
+ def list(
424
+ self,
425
+ *,
426
+ repo_id: str | None = None,
427
+ timeout: float | None = None,
428
+ ) -> list[dict[str, Any]]:
429
+ """List all browser profiles."""
430
+ params: dict[str, str] = {}
431
+ if repo_id is not None:
432
+ params["repo_id"] = repo_id
433
+
434
+ response = self._client._request(
435
+ "GET",
436
+ f"{BROWSER_BASE_URL}/profiles",
437
+ params=params or None,
438
+ timeout=timeout,
439
+ )
440
+ data = response.json()
441
+ return data.get("profiles", []) # type: ignore[no-any-return]
442
+
443
+ def get(self, profile_id: str, *, timeout: float | None = None) -> dict[str, Any]:
444
+ """Get a single profile by ID."""
445
+ response = self._client._request(
446
+ "GET",
447
+ f"{BROWSER_BASE_URL}/profiles/{profile_id}",
448
+ timeout=timeout,
449
+ )
450
+ return response.json() # type: ignore[no-any-return]
451
+
452
+ def delete(self, profile_id: str, *, timeout: float | None = None) -> None:
453
+ """Delete a browser profile."""
454
+ self._client._request(
455
+ "DELETE",
456
+ f"{BROWSER_BASE_URL}/profiles/{profile_id}",
457
+ timeout=timeout,
458
+ )
459
+
460
+
461
+ class BrowserResource:
462
+ """AI-powered browser automation."""
463
+
464
+ def __init__(self, client: BaseClient) -> None:
465
+ self._client = client
466
+ self.profiles = ProfilesResource(client)
467
+
468
+ def run(
469
+ self,
470
+ *,
471
+ task: str,
472
+ url: str | None = None,
473
+ max_steps: int = 10,
474
+ model: str = DEFAULT_BROWSER_MODEL,
475
+ region: str | None = None,
476
+ stealth: bool = True,
477
+ viewport_width: int = 1280,
478
+ viewport_height: int = 720,
479
+ record_video: bool = False,
480
+ structured_output: dict[str, Any] | str | None = None,
481
+ auth: dict[str, Any] | None = None,
482
+ profile_id: str | None = None,
483
+ timeout: float | None = None,
484
+ ) -> BrowserTaskResult:
485
+ """Execute a synchronous browser automation task.
486
+
487
+ Blocks until the task completes and returns the full result
488
+ including agent history, errors, and optional recording info.
489
+
490
+ Pass *structured_output* (a JSON-schema ``dict`` or pre-serialized
491
+ ``str``) to request a structured result; retrieve it from the
492
+ completed result via :func:`get_structured_output`.
493
+ """
494
+ body = _build_run_body(
495
+ task=task,
496
+ url=url,
497
+ max_steps=max_steps,
498
+ model=model,
499
+ region=region,
500
+ stealth=stealth,
501
+ viewport_width=viewport_width,
502
+ viewport_height=viewport_height,
503
+ record_video=record_video,
504
+ structured_output=structured_output,
505
+ auth=auth,
506
+ profile_id=profile_id,
507
+ )
508
+
509
+ response = self._client._request(
510
+ "POST",
511
+ f"{BROWSER_BASE_URL}/browser-task",
512
+ json=body,
513
+ timeout=timeout or BROWSER_TIMEOUT,
514
+ )
515
+
516
+ return _map_task_result(response.json())
517
+
518
+ def create_task(
519
+ self,
520
+ *,
521
+ task: str,
522
+ url: str | None = None,
523
+ max_steps: int = 10,
524
+ model: str = DEFAULT_BROWSER_MODEL,
525
+ record_video: bool = False,
526
+ viewport_width: int = 1280,
527
+ viewport_height: int = 720,
528
+ structured_output: dict[str, Any] | str | None = None,
529
+ auth: dict[str, Any] | None = None,
530
+ profile_id: str | None = None,
531
+ timeout: float | None = None,
532
+ **kwargs: Any,
533
+ ) -> BrowserTask:
534
+ """Create an async browser task and return immediately.
535
+
536
+ Returns a ``BrowserTask`` with ``task_id`` and ``debug_url``
537
+ for live viewing. Poll via ``get_recording`` or use
538
+ ``wait_for_recording`` to block until completion.
539
+
540
+ Pass *structured_output* (a JSON-schema ``dict`` or pre-serialized
541
+ ``str``) to request a structured result; once the task completes,
542
+ retrieve it via :func:`get_structured_output`.
543
+ """
544
+ body = _build_create_task_body(
545
+ task=task,
546
+ url=url,
547
+ max_steps=max_steps,
548
+ model=model,
549
+ record_video=record_video,
550
+ viewport_width=viewport_width,
551
+ viewport_height=viewport_height,
552
+ structured_output=structured_output,
553
+ auth=auth,
554
+ profile_id=profile_id,
555
+ extra=kwargs,
556
+ )
557
+
558
+ response = self._client._request(
559
+ "POST",
560
+ f"{BROWSER_BASE_URL}/browser-task/async",
561
+ json=body,
562
+ timeout=timeout or BROWSER_TIMEOUT,
563
+ )
564
+
565
+ return _task_to_browser_task(response.json())
566
+
567
+ def get_recording(
568
+ self,
569
+ recording_id: str,
570
+ *,
571
+ timeout: float | None = None,
572
+ ) -> RecordingStatus:
573
+ """Get recording status and URLs."""
574
+ response = self._client._request(
575
+ "GET",
576
+ f"{BROWSER_BASE_URL}/recordings/{recording_id}",
577
+ timeout=timeout,
578
+ )
579
+ return _map_recording_status(response.json())
580
+
581
+ def wait_for_recording(
582
+ self,
583
+ recording_id: str,
584
+ *,
585
+ timeout: float = 60.0,
586
+ poll_interval: float = 2.0,
587
+ ) -> RecordingStatus:
588
+ """Poll ``get_recording`` until COMPLETED or ERROR."""
589
+ start = time.monotonic()
590
+ while time.monotonic() - start < timeout:
591
+ status = self.get_recording(recording_id)
592
+ if status.status in ("COMPLETED", "ERROR"):
593
+ return status
594
+ time.sleep(poll_interval)
595
+
596
+ from morphsdk._errors import APITimeoutError
597
+
598
+ raise APITimeoutError(
599
+ f"Recording {recording_id} did not complete within {timeout}s"
600
+ )
601
+
602
+ def get_webp(
603
+ self,
604
+ recording_id: str,
605
+ *,
606
+ width: int | None = None,
607
+ fps: int | None = None,
608
+ quality: int | None = None,
609
+ max_duration: float | None = None,
610
+ max_size_mb: float | None = None,
611
+ timeout: float | None = None,
612
+ ) -> WebpResponse:
613
+ """Get an animated WebP preview of a recording."""
614
+ params = _build_webp_params(
615
+ width=width,
616
+ fps=fps,
617
+ quality=quality,
618
+ max_duration=max_duration,
619
+ max_size_mb=max_size_mb,
620
+ )
621
+
622
+ response = self._client._request(
623
+ "GET",
624
+ f"{BROWSER_BASE_URL}/recordings/{recording_id}/webp",
625
+ params=params or None,
626
+ timeout=timeout,
627
+ )
628
+
629
+ return _parse_webp(response.json())
630
+
631
+ def get_errors(
632
+ self,
633
+ recording_id: str,
634
+ *,
635
+ timeout: float | None = None,
636
+ ) -> ErrorsResponse:
637
+ """Get errors from a recording with screenshots."""
638
+ response = self._client._request(
639
+ "GET",
640
+ f"{BROWSER_BASE_URL}/recordings/{recording_id}/errors",
641
+ timeout=timeout,
642
+ )
643
+
644
+ data = response.json()
645
+ return ErrorsResponse.model_validate(data)
646
+
647
+ def check_health(self, *, timeout: float | None = None) -> dict[str, Any]:
648
+ """Check if the browser worker service is healthy."""
649
+ try:
650
+ response = self._client._request(
651
+ "GET",
652
+ f"{BROWSER_BASE_URL}/health",
653
+ timeout=timeout or 5.0,
654
+ )
655
+ return _health_payload(response.json())
656
+ except Exception as exc:
657
+ return _health_error(exc)
658
+
659
+
660
+ class AsyncProfilesResource:
661
+ """Async browser profile management (login state persistence)."""
662
+
663
+ def __init__(self, client: AsyncBaseClient) -> None:
664
+ self._client = client
665
+
666
+ async def create(
667
+ self,
668
+ *,
669
+ name: str,
670
+ repo_id: str,
671
+ timeout: float | None = None,
672
+ ) -> dict[str, Any]:
673
+ """Create a new browser profile and return its metadata."""
674
+ response = await self._client._request(
675
+ "POST",
676
+ f"{BROWSER_BASE_URL}/profiles",
677
+ json={"name": name, "repo_id": repo_id},
678
+ timeout=timeout,
679
+ )
680
+ return response.json() # type: ignore[no-any-return]
681
+
682
+ async def list(
683
+ self,
684
+ *,
685
+ repo_id: str | None = None,
686
+ timeout: float | None = None,
687
+ ) -> list[dict[str, Any]]:
688
+ """List all browser profiles."""
689
+ params: dict[str, str] = {}
690
+ if repo_id is not None:
691
+ params["repo_id"] = repo_id
692
+
693
+ response = await self._client._request(
694
+ "GET",
695
+ f"{BROWSER_BASE_URL}/profiles",
696
+ params=params or None,
697
+ timeout=timeout,
698
+ )
699
+ data = response.json()
700
+ return data.get("profiles", []) # type: ignore[no-any-return]
701
+
702
+ async def get(
703
+ self, profile_id: str, *, timeout: float | None = None
704
+ ) -> dict[str, Any]:
705
+ """Get a single profile by ID."""
706
+ response = await self._client._request(
707
+ "GET",
708
+ f"{BROWSER_BASE_URL}/profiles/{profile_id}",
709
+ timeout=timeout,
710
+ )
711
+ return response.json() # type: ignore[no-any-return]
712
+
713
+ async def delete(self, profile_id: str, *, timeout: float | None = None) -> None:
714
+ """Delete a browser profile."""
715
+ await self._client._request(
716
+ "DELETE",
717
+ f"{BROWSER_BASE_URL}/profiles/{profile_id}",
718
+ timeout=timeout,
719
+ )
720
+
721
+
722
+ class AsyncBrowserResource:
723
+ """Async AI-powered browser automation."""
724
+
725
+ def __init__(self, client: AsyncBaseClient) -> None:
726
+ self._client = client
727
+ self.profiles = AsyncProfilesResource(client)
728
+
729
+ async def run(
730
+ self,
731
+ *,
732
+ task: str,
733
+ url: str | None = None,
734
+ max_steps: int = 10,
735
+ model: str = DEFAULT_BROWSER_MODEL,
736
+ region: str | None = None,
737
+ stealth: bool = True,
738
+ viewport_width: int = 1280,
739
+ viewport_height: int = 720,
740
+ record_video: bool = False,
741
+ structured_output: dict[str, Any] | str | None = None,
742
+ auth: dict[str, Any] | None = None,
743
+ profile_id: str | None = None,
744
+ timeout: float | None = None,
745
+ ) -> BrowserTaskResult:
746
+ """Execute a synchronous browser automation task.
747
+
748
+ Blocks until the task completes and returns the full result
749
+ including agent history, errors, and optional recording info.
750
+
751
+ Pass *structured_output* (a JSON-schema ``dict`` or pre-serialized
752
+ ``str``) to request a structured result; retrieve it from the
753
+ completed result via :func:`get_structured_output`.
754
+ """
755
+ body = _build_run_body(
756
+ task=task,
757
+ url=url,
758
+ max_steps=max_steps,
759
+ model=model,
760
+ region=region,
761
+ stealth=stealth,
762
+ viewport_width=viewport_width,
763
+ viewport_height=viewport_height,
764
+ record_video=record_video,
765
+ structured_output=structured_output,
766
+ auth=auth,
767
+ profile_id=profile_id,
768
+ )
769
+
770
+ response = await self._client._request(
771
+ "POST",
772
+ f"{BROWSER_BASE_URL}/browser-task",
773
+ json=body,
774
+ timeout=timeout or BROWSER_TIMEOUT,
775
+ )
776
+
777
+ return _map_task_result(response.json())
778
+
779
+ async def create_task(
780
+ self,
781
+ *,
782
+ task: str,
783
+ url: str | None = None,
784
+ max_steps: int = 10,
785
+ model: str = DEFAULT_BROWSER_MODEL,
786
+ record_video: bool = False,
787
+ viewport_width: int = 1280,
788
+ viewport_height: int = 720,
789
+ structured_output: dict[str, Any] | str | None = None,
790
+ auth: dict[str, Any] | None = None,
791
+ profile_id: str | None = None,
792
+ timeout: float | None = None,
793
+ **kwargs: Any,
794
+ ) -> BrowserTask:
795
+ """Create an async browser task and return immediately.
796
+
797
+ Returns a ``BrowserTask`` with ``task_id`` and ``debug_url``
798
+ for live viewing. Poll via ``get_recording`` or use
799
+ ``wait_for_recording`` to block until completion.
800
+
801
+ Pass *structured_output* (a JSON-schema ``dict`` or pre-serialized
802
+ ``str``) to request a structured result; once the task completes,
803
+ retrieve it via :func:`get_structured_output`.
804
+ """
805
+ body = _build_create_task_body(
806
+ task=task,
807
+ url=url,
808
+ max_steps=max_steps,
809
+ model=model,
810
+ record_video=record_video,
811
+ viewport_width=viewport_width,
812
+ viewport_height=viewport_height,
813
+ structured_output=structured_output,
814
+ auth=auth,
815
+ profile_id=profile_id,
816
+ extra=kwargs,
817
+ )
818
+
819
+ response = await self._client._request(
820
+ "POST",
821
+ f"{BROWSER_BASE_URL}/browser-task/async",
822
+ json=body,
823
+ timeout=timeout or BROWSER_TIMEOUT,
824
+ )
825
+
826
+ return _task_to_browser_task(response.json())
827
+
828
+ async def get_recording(
829
+ self,
830
+ recording_id: str,
831
+ *,
832
+ timeout: float | None = None,
833
+ ) -> RecordingStatus:
834
+ """Get recording status and URLs."""
835
+ response = await self._client._request(
836
+ "GET",
837
+ f"{BROWSER_BASE_URL}/recordings/{recording_id}",
838
+ timeout=timeout,
839
+ )
840
+ return _map_recording_status(response.json())
841
+
842
+ async def wait_for_recording(
843
+ self,
844
+ recording_id: str,
845
+ *,
846
+ timeout: float = 60.0,
847
+ poll_interval: float = 2.0,
848
+ ) -> RecordingStatus:
849
+ """Poll ``get_recording`` until COMPLETED or ERROR."""
850
+ import asyncio
851
+
852
+ start = time.monotonic()
853
+ while time.monotonic() - start < timeout:
854
+ status = await self.get_recording(recording_id)
855
+ if status.status in ("COMPLETED", "ERROR"):
856
+ return status
857
+ await asyncio.sleep(poll_interval)
858
+
859
+ from morphsdk._errors import APITimeoutError
860
+
861
+ raise APITimeoutError(
862
+ f"Recording {recording_id} did not complete within {timeout}s"
863
+ )
864
+
865
+ async def get_webp(
866
+ self,
867
+ recording_id: str,
868
+ *,
869
+ width: int | None = None,
870
+ fps: int | None = None,
871
+ quality: int | None = None,
872
+ max_duration: float | None = None,
873
+ max_size_mb: float | None = None,
874
+ timeout: float | None = None,
875
+ ) -> WebpResponse:
876
+ """Get an animated WebP preview of a recording."""
877
+ params = _build_webp_params(
878
+ width=width,
879
+ fps=fps,
880
+ quality=quality,
881
+ max_duration=max_duration,
882
+ max_size_mb=max_size_mb,
883
+ )
884
+
885
+ response = await self._client._request(
886
+ "GET",
887
+ f"{BROWSER_BASE_URL}/recordings/{recording_id}/webp",
888
+ params=params or None,
889
+ timeout=timeout,
890
+ )
891
+
892
+ return _parse_webp(response.json())
893
+
894
+ async def get_errors(
895
+ self,
896
+ recording_id: str,
897
+ *,
898
+ timeout: float | None = None,
899
+ ) -> ErrorsResponse:
900
+ """Get errors from a recording with screenshots."""
901
+ response = await self._client._request(
902
+ "GET",
903
+ f"{BROWSER_BASE_URL}/recordings/{recording_id}/errors",
904
+ timeout=timeout,
905
+ )
906
+
907
+ return ErrorsResponse.model_validate(response.json())
908
+
909
+ async def check_health(self, *, timeout: float | None = None) -> dict[str, Any]:
910
+ """Check if the browser worker service is healthy."""
911
+ try:
912
+ response = await self._client._request(
913
+ "GET",
914
+ f"{BROWSER_BASE_URL}/health",
915
+ timeout=timeout or 5.0,
916
+ )
917
+ return _health_payload(response.json())
918
+ except Exception as exc:
919
+ return _health_error(exc)