gl-browser-use-binary 0.0.0b5__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.
- gl_browser_use/__init__.py +60 -0
- gl_browser_use/__init__.pyi +12 -0
- gl_browser_use/client.py +639 -0
- gl_browser_use/client.pyi +57 -0
- gl_browser_use/config.py +169 -0
- gl_browser_use/config.pyi +42 -0
- gl_browser_use/errors.py +119 -0
- gl_browser_use/errors.pyi +40 -0
- gl_browser_use/events.py +147 -0
- gl_browser_use/events.pyi +29 -0
- gl_browser_use/infrastructure/__init__.py +39 -0
- gl_browser_use/infrastructure/__init__.pyi +6 -0
- gl_browser_use/infrastructure/base.py +103 -0
- gl_browser_use/infrastructure/base.pyi +56 -0
- gl_browser_use/infrastructure/steel.py +296 -0
- gl_browser_use/infrastructure/steel.pyi +56 -0
- gl_browser_use/models.py +45 -0
- gl_browser_use/models.pyi +32 -0
- gl_browser_use/parsing.py +264 -0
- gl_browser_use/parsing.pyi +41 -0
- gl_browser_use/parsing_recovery.py +204 -0
- gl_browser_use/parsing_recovery.pyi +35 -0
- gl_browser_use/session_errors.py +72 -0
- gl_browser_use/session_errors.pyi +16 -0
- gl_browser_use/storage/__init__.py +39 -0
- gl_browser_use/storage/__init__.pyi +6 -0
- gl_browser_use/storage/base.py +125 -0
- gl_browser_use/storage/base.pyi +46 -0
- gl_browser_use/storage/minio_compatible.py +84 -0
- gl_browser_use/storage/minio_compatible.pyi +15 -0
- gl_browser_use_binary-0.0.0b5.dist-info/METADATA +236 -0
- gl_browser_use_binary-0.0.0b5.dist-info/RECORD +34 -0
- gl_browser_use_binary-0.0.0b5.dist-info/WHEEL +5 -0
- gl_browser_use_binary-0.0.0b5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Browser infrastructure abstractions for GL Browser Use SDK.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Reinhart Linanda (reinhart.linanda@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
from gl_browser_use.storage.base import ObjectStorage
|
|
14
|
+
|
|
15
|
+
VIDEO_FILE_NAME_PREFIX = "session_"
|
|
16
|
+
|
|
17
|
+
ClientType = TypeVar("ClientType")
|
|
18
|
+
SessionType = TypeVar("SessionType")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BrowserInfrastructureContext(Generic[ClientType, SessionType]):
|
|
23
|
+
"""Runtime context for an active browser infrastructure session."""
|
|
24
|
+
|
|
25
|
+
client: ClientType
|
|
26
|
+
session: SessionType
|
|
27
|
+
session_id: str
|
|
28
|
+
streaming_url: str
|
|
29
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BrowserInfrastructure(ABC, Generic[ClientType, SessionType]):
|
|
33
|
+
"""Abstract contract for browser infrastructure providers.
|
|
34
|
+
|
|
35
|
+
Provider-specific configuration (API keys, endpoints, timeouts) belongs to
|
|
36
|
+
the concrete subclass; this base only defines the behavioral surface.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def provider(self) -> str:
|
|
42
|
+
"""Return the human-readable infrastructure provider name."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def supports_recording(self) -> bool:
|
|
46
|
+
"""Whether this infrastructure can produce session recordings."""
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def create_session(self) -> BrowserInfrastructureContext[ClientType, SessionType]:
|
|
51
|
+
"""Create a new browser infrastructure session and return the active context."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def release_session(self, context: BrowserInfrastructureContext[ClientType, SessionType]) -> None:
|
|
55
|
+
"""Release or dispose browser infrastructure session resources."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def cdp_url(self, session_id: str) -> str:
|
|
59
|
+
"""Build a CDP endpoint URL for the provided browser infrastructure session ID."""
|
|
60
|
+
|
|
61
|
+
def recording_filename(self, session_id: str) -> str:
|
|
62
|
+
"""Return the preferred recording object name for the provided browser infrastructure session ID."""
|
|
63
|
+
return f"{VIDEO_FILE_NAME_PREFIX}{session_id}.mp4"
|
|
64
|
+
|
|
65
|
+
def resolve_recording_url(self, session_id: str, storage: ObjectStorage | None) -> str | None:
|
|
66
|
+
"""Resolve a recording URL from storage without scheduling any new work."""
|
|
67
|
+
if storage is None or not storage.is_available or not session_id:
|
|
68
|
+
return None
|
|
69
|
+
return storage.get_file_url(self.recording_filename(session_id))
|
|
70
|
+
|
|
71
|
+
def can_record(
|
|
72
|
+
self,
|
|
73
|
+
context: BrowserInfrastructureContext[ClientType, SessionType] | None,
|
|
74
|
+
storage: ObjectStorage | None,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""Return True when an active recording can be produced for ``context``.
|
|
77
|
+
|
|
78
|
+
This encapsulates the "recording is supported, a live context exists,
|
|
79
|
+
and storage is available" check that clients use before scheduling a
|
|
80
|
+
recording or resolving a recording URL.
|
|
81
|
+
"""
|
|
82
|
+
return (
|
|
83
|
+
self.supports_recording
|
|
84
|
+
and context is not None
|
|
85
|
+
and bool(getattr(context, "session_id", ""))
|
|
86
|
+
and storage is not None
|
|
87
|
+
and storage.is_available
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def schedule_recording(
|
|
92
|
+
self,
|
|
93
|
+
context: BrowserInfrastructureContext[ClientType, SessionType],
|
|
94
|
+
storage: ObjectStorage | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Optionally schedule background recording for the provided context."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"BrowserInfrastructure",
|
|
101
|
+
"BrowserInfrastructureContext",
|
|
102
|
+
"VIDEO_FILE_NAME_PREFIX",
|
|
103
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from gl_browser_use.storage.base import ObjectStorage
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
__all__ = ['BrowserInfrastructure', 'BrowserInfrastructureContext', 'VIDEO_FILE_NAME_PREFIX']
|
|
7
|
+
|
|
8
|
+
VIDEO_FILE_NAME_PREFIX: str
|
|
9
|
+
ClientType = TypeVar('ClientType')
|
|
10
|
+
SessionType = TypeVar('SessionType')
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BrowserInfrastructureContext(Generic[ClientType, SessionType]):
|
|
14
|
+
"""Runtime context for an active browser infrastructure session."""
|
|
15
|
+
client: ClientType
|
|
16
|
+
session: SessionType
|
|
17
|
+
session_id: str
|
|
18
|
+
streaming_url: str
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
class BrowserInfrastructure(ABC, Generic[ClientType, SessionType]):
|
|
22
|
+
"""Abstract contract for browser infrastructure providers.
|
|
23
|
+
|
|
24
|
+
Provider-specific configuration (API keys, endpoints, timeouts) belongs to
|
|
25
|
+
the concrete subclass; this base only defines the behavioral surface.
|
|
26
|
+
"""
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def provider(self) -> str:
|
|
30
|
+
"""Return the human-readable infrastructure provider name."""
|
|
31
|
+
@property
|
|
32
|
+
def supports_recording(self) -> bool:
|
|
33
|
+
"""Whether this infrastructure can produce session recordings."""
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def create_session(self) -> BrowserInfrastructureContext[ClientType, SessionType]:
|
|
36
|
+
"""Create a new browser infrastructure session and return the active context."""
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def release_session(self, context: BrowserInfrastructureContext[ClientType, SessionType]) -> None:
|
|
39
|
+
"""Release or dispose browser infrastructure session resources."""
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def cdp_url(self, session_id: str) -> str:
|
|
42
|
+
"""Build a CDP endpoint URL for the provided browser infrastructure session ID."""
|
|
43
|
+
def recording_filename(self, session_id: str) -> str:
|
|
44
|
+
"""Return the preferred recording object name for the provided browser infrastructure session ID."""
|
|
45
|
+
def resolve_recording_url(self, session_id: str, storage: ObjectStorage | None) -> str | None:
|
|
46
|
+
"""Resolve a recording URL from storage without scheduling any new work."""
|
|
47
|
+
def can_record(self, context: BrowserInfrastructureContext[ClientType, SessionType] | None, storage: ObjectStorage | None) -> bool:
|
|
48
|
+
'''Return True when an active recording can be produced for ``context``.
|
|
49
|
+
|
|
50
|
+
This encapsulates the "recording is supported, a live context exists,
|
|
51
|
+
and storage is available" check that clients use before scheduling a
|
|
52
|
+
recording or resolving a recording URL.
|
|
53
|
+
'''
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def schedule_recording(self, context: BrowserInfrastructureContext[ClientType, SessionType], storage: ObjectStorage | None) -> None:
|
|
56
|
+
"""Optionally schedule background recording for the provided context."""
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Steel-backed browser infrastructure for GL Browser Use SDK.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Reinhart Linanda (reinhart.linanda@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from io import BufferedWriter
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import urljoin
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
from steel import Steel
|
|
23
|
+
from steel.types.session import Session
|
|
24
|
+
|
|
25
|
+
from gl_browser_use.errors import BrowserUseConfigurationError
|
|
26
|
+
from gl_browser_use.infrastructure.base import (
|
|
27
|
+
VIDEO_FILE_NAME_PREFIX,
|
|
28
|
+
BrowserInfrastructure,
|
|
29
|
+
BrowserInfrastructureContext,
|
|
30
|
+
)
|
|
31
|
+
from gl_browser_use.storage.base import ObjectStorage
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_STEEL_BASE_URL = "https://api.steel.dev"
|
|
36
|
+
DEFAULT_STEEL_WS_URL = "wss://connect.steel.dev"
|
|
37
|
+
DEFAULT_STEEL_TIMEOUT_IN_MS = 600_000
|
|
38
|
+
|
|
39
|
+
MANIFEST_TEMP_SUFFIX = ".m3u8"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _SteelSessionRecorder:
|
|
43
|
+
"""Capture Steel HLS sessions and upload recordings to object storage."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, base_url: str, api_key: str, *, storage: ObjectStorage | None = None) -> None:
|
|
46
|
+
self.base_url = base_url
|
|
47
|
+
self.api_key = api_key
|
|
48
|
+
self.storage = storage
|
|
49
|
+
self._manifest_poll_timeout_minutes = 1
|
|
50
|
+
self._manifest_poll_interval_seconds = 2
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def safe_session_id(session_id: str) -> str:
|
|
54
|
+
return "".join(char for char in session_id if char.isalnum() or char in {"-", "_"})
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def video_filename(cls, session_id: str, extension: str = ".mp4") -> str:
|
|
58
|
+
safe_session_id = cls.safe_session_id(session_id)
|
|
59
|
+
normalized_extension = extension if extension.startswith(".") else f".{extension}"
|
|
60
|
+
return f"{VIDEO_FILE_NAME_PREFIX}{safe_session_id}{normalized_extension}"
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _parse_manifest_entries(manifest_text: str, manifest_url: str) -> tuple[str | None, list[str]]:
|
|
64
|
+
init_url = None
|
|
65
|
+
segments: list[str] = []
|
|
66
|
+
|
|
67
|
+
for line in manifest_text.splitlines():
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if not line:
|
|
70
|
+
continue
|
|
71
|
+
if line.startswith("#EXT-X-MAP"):
|
|
72
|
+
match = re.search(r'URI="([^"]+)"', line)
|
|
73
|
+
if match:
|
|
74
|
+
init_url = match.group(1)
|
|
75
|
+
continue
|
|
76
|
+
if line.startswith("#"):
|
|
77
|
+
continue
|
|
78
|
+
segments.append(urljoin(manifest_url, line))
|
|
79
|
+
|
|
80
|
+
if init_url:
|
|
81
|
+
init_url = urljoin(manifest_url, init_url)
|
|
82
|
+
|
|
83
|
+
return init_url, segments
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _stream_url(session: requests.Session, url: str, headers: dict[str, str], output_file: BufferedWriter) -> None:
|
|
87
|
+
with session.get(url, headers=headers, stream=True, timeout=120) as response:
|
|
88
|
+
response.raise_for_status()
|
|
89
|
+
for chunk in response.iter_content(chunk_size=64 * 1024):
|
|
90
|
+
if chunk:
|
|
91
|
+
output_file.write(chunk)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _cleanup_temp_directory(video_dir: str) -> None:
|
|
95
|
+
try:
|
|
96
|
+
shutil.rmtree(video_dir)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def _download_manifest(self, session_id: str, dest: Path) -> str | None:
|
|
101
|
+
if not self.base_url or not self.api_key:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
url = f"{self.base_url.rstrip('/')}/v1/sessions/{session_id}/hls"
|
|
105
|
+
headers = {"steel-api-key": self.api_key}
|
|
106
|
+
deadline = time.monotonic() + (self._manifest_poll_timeout_minutes * 60)
|
|
107
|
+
|
|
108
|
+
while True:
|
|
109
|
+
response = requests.get(url, headers=headers, timeout=60)
|
|
110
|
+
if response.ok:
|
|
111
|
+
dest.write_text(response.text)
|
|
112
|
+
return response.url
|
|
113
|
+
|
|
114
|
+
pollable = response.status_code in {404, 408, 429, 503}
|
|
115
|
+
if not pollable or time.monotonic() >= deadline:
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
|
|
118
|
+
time.sleep(self._manifest_poll_interval_seconds)
|
|
119
|
+
|
|
120
|
+
def _merge_segments(self, manifest_path: Path, manifest_url: str, output_path: Path) -> bool:
|
|
121
|
+
if not self.api_key:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
headers = {"steel-api-key": self.api_key}
|
|
125
|
+
manifest_text = manifest_path.read_text()
|
|
126
|
+
init_url, segments = self._parse_manifest_entries(manifest_text, manifest_url)
|
|
127
|
+
if not segments:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
with requests.Session() as session, open(output_path, "wb") as output_file:
|
|
132
|
+
if init_url:
|
|
133
|
+
self._stream_url(session, init_url, headers, output_file)
|
|
134
|
+
for segment_url in segments:
|
|
135
|
+
self._stream_url(session, segment_url, headers, output_file)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
async def _download_and_merge_manifest(self, session_id: str, output_path: Path) -> bool:
|
|
139
|
+
manifest_path = Path(tempfile.NamedTemporaryFile(suffix=MANIFEST_TEMP_SUFFIX, delete=False).name)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
manifest_url = await asyncio.to_thread(self._download_manifest, session_id, manifest_path)
|
|
143
|
+
if not manifest_url:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
return await asyncio.to_thread(self._merge_segments, manifest_path, manifest_url, output_path)
|
|
147
|
+
except Exception:
|
|
148
|
+
logger.debug(
|
|
149
|
+
"Failed to download/merge Steel HLS manifest for session %s",
|
|
150
|
+
session_id,
|
|
151
|
+
exc_info=True,
|
|
152
|
+
)
|
|
153
|
+
return False
|
|
154
|
+
finally:
|
|
155
|
+
manifest_path.unlink(missing_ok=True)
|
|
156
|
+
|
|
157
|
+
async def _upload_to_storage(self, video_path: Path, video_filename: str) -> bool:
|
|
158
|
+
if self.storage is None or not self.storage.is_available:
|
|
159
|
+
return False
|
|
160
|
+
if not video_path.exists():
|
|
161
|
+
return False
|
|
162
|
+
return self.storage.upload_file(str(video_path), video_filename) is not None
|
|
163
|
+
|
|
164
|
+
async def record_session_to_video(self, session_id: str) -> bool:
|
|
165
|
+
if not self.storage or not self.storage.is_available or not session_id:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
video_dir = tempfile.mkdtemp()
|
|
169
|
+
try:
|
|
170
|
+
video_filename = self.video_filename(session_id)
|
|
171
|
+
output_path = Path(video_dir) / video_filename
|
|
172
|
+
merged = await self._download_and_merge_manifest(session_id, output_path)
|
|
173
|
+
if not merged:
|
|
174
|
+
return False
|
|
175
|
+
return await self._upload_to_storage(output_path, video_filename)
|
|
176
|
+
finally:
|
|
177
|
+
self._cleanup_temp_directory(video_dir)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def schedule_recording_in_background(
|
|
181
|
+
cls,
|
|
182
|
+
session_id: str,
|
|
183
|
+
base_url: str,
|
|
184
|
+
api_key: str,
|
|
185
|
+
storage: ObjectStorage | None,
|
|
186
|
+
) -> threading.Thread | None:
|
|
187
|
+
if not session_id or not storage or not storage.is_available:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
recorder = cls(base_url=base_url, api_key=api_key, storage=storage)
|
|
191
|
+
|
|
192
|
+
def _run() -> None:
|
|
193
|
+
try:
|
|
194
|
+
asyncio.run(recorder.record_session_to_video(session_id=session_id))
|
|
195
|
+
except Exception as error: # pragma: no cover - defensive logging
|
|
196
|
+
logger.warning("Background recording failed for session %s: %s", session_id, error, exc_info=True)
|
|
197
|
+
|
|
198
|
+
thread_name = f"steel-recording-{cls.safe_session_id(session_id)}"
|
|
199
|
+
thread = threading.Thread(target=_run, daemon=True, name=thread_name)
|
|
200
|
+
thread.start()
|
|
201
|
+
return thread
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SteelBrowserInfrastructure(BrowserInfrastructure[Steel, Session]):
|
|
205
|
+
"""Concrete Steel implementation for browser infrastructure sessions."""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
api_key: str | None = None,
|
|
210
|
+
base_url: str = DEFAULT_STEEL_BASE_URL,
|
|
211
|
+
ws_url: str = DEFAULT_STEEL_WS_URL,
|
|
212
|
+
timeout_in_ms: int = DEFAULT_STEEL_TIMEOUT_IN_MS,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Initialize the Steel browser infrastructure.
|
|
215
|
+
|
|
216
|
+
``api_key`` defaults to the ``STEEL_API_KEY`` environment variable
|
|
217
|
+
resolved at construction time (not import time) when not provided.
|
|
218
|
+
"""
|
|
219
|
+
resolved_api_key = api_key if api_key is not None else os.getenv("STEEL_API_KEY")
|
|
220
|
+
if not resolved_api_key:
|
|
221
|
+
raise BrowserUseConfigurationError("api_key is required.")
|
|
222
|
+
if timeout_in_ms <= 0:
|
|
223
|
+
raise BrowserUseConfigurationError("timeout_in_ms must be > 0.")
|
|
224
|
+
|
|
225
|
+
self.api_key = resolved_api_key
|
|
226
|
+
self.base_url = base_url
|
|
227
|
+
self.ws_url = ws_url
|
|
228
|
+
self.timeout_in_ms = timeout_in_ms
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def provider(self) -> str:
|
|
232
|
+
"""Return the human-readable infrastructure provider name."""
|
|
233
|
+
return "steel"
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def supports_recording(self) -> bool:
|
|
237
|
+
"""Whether this infrastructure can produce session recordings.
|
|
238
|
+
|
|
239
|
+
``True`` indicates Steel supports recording. Recording is only
|
|
240
|
+
actually started when an available :class:`ObjectStorage` is also
|
|
241
|
+
provided to the :class:`BrowserUseClient`.
|
|
242
|
+
"""
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
async def create_session(self) -> BrowserInfrastructureContext[Steel, Session]:
|
|
246
|
+
"""Create a new Steel session and return the active context."""
|
|
247
|
+
client = Steel(steel_api_key=self.api_key, base_url=self.base_url)
|
|
248
|
+
session = client.sessions.create(use_proxy=False, api_timeout=self.timeout_in_ms)
|
|
249
|
+
return BrowserInfrastructureContext(
|
|
250
|
+
client=client,
|
|
251
|
+
session=session,
|
|
252
|
+
session_id=str(getattr(session, "id", "")),
|
|
253
|
+
streaming_url=str(getattr(session, "debug_url", "")),
|
|
254
|
+
metadata={"provider": self.provider},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def release_session(self, context: BrowserInfrastructureContext[Steel, Session]) -> None:
|
|
258
|
+
"""Release or dispose Steel session resources."""
|
|
259
|
+
if not context.client or not context.session:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
for attempt in range(3):
|
|
263
|
+
try:
|
|
264
|
+
context.client.sessions.release(context.session.id)
|
|
265
|
+
return
|
|
266
|
+
except Exception:
|
|
267
|
+
if attempt == 2:
|
|
268
|
+
raise
|
|
269
|
+
await asyncio.sleep(0.5 * (attempt + 1))
|
|
270
|
+
|
|
271
|
+
def cdp_url(self, session_id: str) -> str:
|
|
272
|
+
"""Build a CDP endpoint URL for the provided Steel session ID."""
|
|
273
|
+
return f"{self.ws_url}?apiKey={self.api_key}&sessionId={session_id}"
|
|
274
|
+
|
|
275
|
+
def recording_filename(self, session_id: str) -> str:
|
|
276
|
+
"""Return the preferred recording object name for the provided Steel session ID."""
|
|
277
|
+
return _SteelSessionRecorder.video_filename(session_id)
|
|
278
|
+
|
|
279
|
+
def schedule_recording(
|
|
280
|
+
self, context: BrowserInfrastructureContext[Steel, Session], storage: ObjectStorage | None
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Schedule background recording for the provided context."""
|
|
283
|
+
if not self.supports_recording or not context.session_id:
|
|
284
|
+
return
|
|
285
|
+
try:
|
|
286
|
+
_SteelSessionRecorder.schedule_recording_in_background(
|
|
287
|
+
session_id=context.session_id,
|
|
288
|
+
base_url=self.base_url,
|
|
289
|
+
api_key=self.api_key,
|
|
290
|
+
storage=storage,
|
|
291
|
+
)
|
|
292
|
+
except Exception as error:
|
|
293
|
+
logger.debug("Failed to start background recording for Steel session %s: %s", context.session_id, error)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
__all__ = ["SteelBrowserInfrastructure"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from _typeshed import Incomplete
|
|
3
|
+
from gl_browser_use.infrastructure.base import BrowserInfrastructure, BrowserInfrastructureContext
|
|
4
|
+
from gl_browser_use.storage.base import ObjectStorage
|
|
5
|
+
from steel import Steel
|
|
6
|
+
from steel.types.session import Session
|
|
7
|
+
|
|
8
|
+
__all__ = ['SteelBrowserInfrastructure']
|
|
9
|
+
|
|
10
|
+
class _SteelSessionRecorder:
|
|
11
|
+
"""Capture Steel HLS sessions and upload recordings to object storage."""
|
|
12
|
+
base_url: Incomplete
|
|
13
|
+
api_key: Incomplete
|
|
14
|
+
storage: Incomplete
|
|
15
|
+
def __init__(self, base_url: str, api_key: str, *, storage: ObjectStorage | None = None) -> None: ...
|
|
16
|
+
@staticmethod
|
|
17
|
+
def safe_session_id(session_id: str) -> str: ...
|
|
18
|
+
@classmethod
|
|
19
|
+
def video_filename(cls, session_id: str, extension: str = '.mp4') -> str: ...
|
|
20
|
+
async def record_session_to_video(self, session_id: str) -> bool: ...
|
|
21
|
+
@classmethod
|
|
22
|
+
def schedule_recording_in_background(cls, session_id: str, base_url: str, api_key: str, storage: ObjectStorage | None) -> threading.Thread | None: ...
|
|
23
|
+
|
|
24
|
+
class SteelBrowserInfrastructure(BrowserInfrastructure[Steel, Session]):
|
|
25
|
+
"""Concrete Steel implementation for browser infrastructure sessions."""
|
|
26
|
+
api_key: Incomplete
|
|
27
|
+
base_url: Incomplete
|
|
28
|
+
ws_url: Incomplete
|
|
29
|
+
timeout_in_ms: Incomplete
|
|
30
|
+
def __init__(self, api_key: str | None = None, base_url: str = ..., ws_url: str = ..., timeout_in_ms: int = ...) -> None:
|
|
31
|
+
"""Initialize the Steel browser infrastructure.
|
|
32
|
+
|
|
33
|
+
``api_key`` defaults to the ``STEEL_API_KEY`` environment variable
|
|
34
|
+
resolved at construction time (not import time) when not provided.
|
|
35
|
+
"""
|
|
36
|
+
@property
|
|
37
|
+
def provider(self) -> str:
|
|
38
|
+
"""Return the human-readable infrastructure provider name."""
|
|
39
|
+
@property
|
|
40
|
+
def supports_recording(self) -> bool:
|
|
41
|
+
"""Whether this infrastructure can produce session recordings.
|
|
42
|
+
|
|
43
|
+
``True`` indicates Steel supports recording. Recording is only
|
|
44
|
+
actually started when an available :class:`ObjectStorage` is also
|
|
45
|
+
provided to the :class:`BrowserUseClient`.
|
|
46
|
+
"""
|
|
47
|
+
async def create_session(self) -> BrowserInfrastructureContext[Steel, Session]:
|
|
48
|
+
"""Create a new Steel session and return the active context."""
|
|
49
|
+
async def release_session(self, context: BrowserInfrastructureContext[Steel, Session]) -> None:
|
|
50
|
+
"""Release or dispose Steel session resources."""
|
|
51
|
+
def cdp_url(self, session_id: str) -> str:
|
|
52
|
+
"""Build a CDP endpoint URL for the provided Steel session ID."""
|
|
53
|
+
def recording_filename(self, session_id: str) -> str:
|
|
54
|
+
"""Return the preferred recording object name for the provided Steel session ID."""
|
|
55
|
+
def schedule_recording(self, context: BrowserInfrastructureContext[Steel, Session], storage: ObjectStorage | None) -> None:
|
|
56
|
+
"""Schedule background recording for the provided context."""
|
gl_browser_use/models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Result and event models for GL Browser Use SDK.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Reinhart Linanda (reinhart.linanda@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
BrowserUseRunStatus = Literal["success", "error", "cancelled"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class BrowserUseStreamEvent:
|
|
17
|
+
"""Structured stream event emitted during ``BrowserUseClient`` execution."""
|
|
18
|
+
|
|
19
|
+
event_type: str
|
|
20
|
+
content: str
|
|
21
|
+
thinking_and_activity_info: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
is_final: bool = False
|
|
23
|
+
tool_info: dict[str, Any] | None = None
|
|
24
|
+
metadata: dict[str, Any] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class BrowserUseRunResult:
|
|
29
|
+
"""Final result returned for a completed run."""
|
|
30
|
+
|
|
31
|
+
status: BrowserUseRunStatus
|
|
32
|
+
task: str
|
|
33
|
+
final_output: str
|
|
34
|
+
session_id: str | None = None
|
|
35
|
+
streaming_url: str | None = None
|
|
36
|
+
recording_url: str | None = None
|
|
37
|
+
steps: int = 0
|
|
38
|
+
error: str | None = None
|
|
39
|
+
events: list[BrowserUseStreamEvent] = field(default_factory=list)
|
|
40
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_success(self) -> bool:
|
|
44
|
+
"""Return True if the run was successful."""
|
|
45
|
+
return self.status == "success"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from _typeshed import Incomplete
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
BrowserUseRunStatus: Incomplete
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class BrowserUseStreamEvent:
|
|
9
|
+
"""Structured stream event emitted during ``BrowserUseClient`` execution."""
|
|
10
|
+
event_type: str
|
|
11
|
+
content: str
|
|
12
|
+
thinking_and_activity_info: dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
is_final: bool = ...
|
|
14
|
+
tool_info: dict[str, Any] | None = ...
|
|
15
|
+
metadata: dict[str, Any] | None = ...
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class BrowserUseRunResult:
|
|
19
|
+
"""Final result returned for a completed run."""
|
|
20
|
+
status: BrowserUseRunStatus
|
|
21
|
+
task: str
|
|
22
|
+
final_output: str
|
|
23
|
+
session_id: str | None = ...
|
|
24
|
+
streaming_url: str | None = ...
|
|
25
|
+
recording_url: str | None = ...
|
|
26
|
+
steps: int = ...
|
|
27
|
+
error: str | None = ...
|
|
28
|
+
events: list[BrowserUseStreamEvent] = field(default_factory=list)
|
|
29
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
@property
|
|
31
|
+
def is_success(self) -> bool:
|
|
32
|
+
"""Return True if the run was successful."""
|