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.
- morphsdk/__init__.py +54 -0
- morphsdk/_agent/__init__.py +64 -0
- morphsdk/_agent/config.py +52 -0
- morphsdk/_agent/explore.py +276 -0
- morphsdk/_agent/github.py +57 -0
- morphsdk/_agent/helpers.py +133 -0
- morphsdk/_agent/parser.py +163 -0
- morphsdk/_agent/runner.py +524 -0
- morphsdk/_agent/tools.py +171 -0
- morphsdk/_agent/types.py +126 -0
- morphsdk/_base.py +309 -0
- morphsdk/_client.py +245 -0
- morphsdk/_config.py +37 -0
- morphsdk/_constants.py +53 -0
- morphsdk/_errors.py +111 -0
- morphsdk/_providers/__init__.py +36 -0
- morphsdk/_providers/_filter.py +92 -0
- morphsdk/_providers/base.py +94 -0
- morphsdk/_providers/code_storage_http.py +104 -0
- morphsdk/_providers/local.py +270 -0
- morphsdk/_providers/remote.py +161 -0
- morphsdk/_version.py +1 -0
- morphsdk/adapters/__init__.py +1 -0
- morphsdk/adapters/anthropic.py +360 -0
- morphsdk/adapters/langchain.py +120 -0
- morphsdk/adapters/openai.py +500 -0
- morphsdk/py.typed +0 -0
- morphsdk/resources/__init__.py +0 -0
- morphsdk/resources/browser.py +919 -0
- morphsdk/resources/compact.py +133 -0
- morphsdk/resources/edit.py +506 -0
- morphsdk/resources/explore.py +333 -0
- morphsdk/resources/git.py +861 -0
- morphsdk/resources/github.py +1214 -0
- morphsdk/resources/grep.py +583 -0
- morphsdk/resources/mobile.py +134 -0
- morphsdk/resources/reflex.py +414 -0
- morphsdk/resources/router.py +124 -0
- morphsdk/resources/search.py +110 -0
- morphsdk/tracing/__init__.py +70 -0
- morphsdk/tracing/_otel.py +101 -0
- morphsdk/tracing/core.py +249 -0
- morphsdk/tracing/interaction.py +284 -0
- morphsdk/tracing/otel.py +75 -0
- morphsdk/tracing/reflex.py +58 -0
- morphsdk/tracing/types.py +163 -0
- morphsdk/types/__init__.py +140 -0
- morphsdk/types/browser.py +118 -0
- morphsdk/types/compact.py +41 -0
- morphsdk/types/edit.py +31 -0
- morphsdk/types/explore.py +42 -0
- morphsdk/types/git.py +25 -0
- morphsdk/types/github.py +111 -0
- morphsdk/types/grep.py +41 -0
- morphsdk/types/mobile.py +25 -0
- morphsdk/types/reflex.py +137 -0
- morphsdk/types/router.py +21 -0
- morphsdk/types/search.py +33 -0
- morphsdk-0.2.5.dist-info/METADATA +226 -0
- morphsdk-0.2.5.dist-info/RECORD +61 -0
- 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)
|