tinyfish 0.2.2__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.
- tinyfish/__init__.py +104 -0
- tinyfish/_utils/__init__.py +26 -0
- tinyfish/_utils/client/__init__.py +4 -0
- tinyfish/_utils/client/_base.py +137 -0
- tinyfish/_utils/client/async_.py +192 -0
- tinyfish/_utils/client/sync.py +191 -0
- tinyfish/_utils/exceptions.py +159 -0
- tinyfish/_utils/resource.py +23 -0
- tinyfish/_utils/sse_parser.py +62 -0
- tinyfish/agent/__init__.py +368 -0
- tinyfish/agent/types.py +182 -0
- tinyfish/client.py +76 -0
- tinyfish/py.typed +11 -0
- tinyfish/runs/__init__.py +147 -0
- tinyfish/runs/types.py +107 -0
- tinyfish-0.2.2.dist-info/METADATA +8 -0
- tinyfish-0.2.2.dist-info/RECORD +18 -0
- tinyfish-0.2.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Exception hierarchy for SDK errors.
|
|
2
|
+
|
|
3
|
+
All exceptions inherit from SDKError for easy catching.
|
|
4
|
+
|
|
5
|
+
Hierarchy:
|
|
6
|
+
SDKError
|
|
7
|
+
├─ SSEParseError
|
|
8
|
+
└─ APIError
|
|
9
|
+
├─ APIConnectionError
|
|
10
|
+
│ └─ APITimeoutError
|
|
11
|
+
└─ APIStatusError
|
|
12
|
+
├─ BadRequestError (400)
|
|
13
|
+
├─ AuthenticationError (401)
|
|
14
|
+
├─ PermissionDeniedError (403)
|
|
15
|
+
├─ NotFoundError (404)
|
|
16
|
+
├─ RequestTimeoutError (408)
|
|
17
|
+
├─ ConflictError (409)
|
|
18
|
+
├─ UnprocessableEntityError (422)
|
|
19
|
+
├─ RateLimitError (429)
|
|
20
|
+
└─ InternalServerError (500+)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SDKError(Exception):
|
|
29
|
+
"""Base exception for all SDK errors."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SSEParseError(SDKError):
|
|
35
|
+
"""Raised when a malformed SSE event cannot be parsed.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
line: The raw SSE data line that failed to parse.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, line: str) -> None:
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.line = line
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"SSEParseError(message={str(self)!r}, line={self.line!r})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class APIError(SDKError):
|
|
50
|
+
"""Base exception for all API-related errors."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str,
|
|
55
|
+
*,
|
|
56
|
+
request: httpx.Request | None = None,
|
|
57
|
+
response: httpx.Response | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.message = message
|
|
61
|
+
self.request = request
|
|
62
|
+
self.response = response
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APIConnectionError(APIError):
|
|
66
|
+
"""Network or connection failure."""
|
|
67
|
+
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class APITimeoutError(APIConnectionError):
|
|
72
|
+
"""Request timeout."""
|
|
73
|
+
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class APIStatusError(APIError):
|
|
78
|
+
"""API returned error status code (4xx or 5xx)."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
message: str,
|
|
83
|
+
*,
|
|
84
|
+
response: httpx.Response,
|
|
85
|
+
status_code: int,
|
|
86
|
+
request: httpx.Request | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(message, request=request, response=response)
|
|
89
|
+
self.status_code = status_code
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
return f"{self.__class__.__name__}(status_code={self.status_code}, message={self.message!r})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BadRequestError(APIStatusError):
|
|
96
|
+
"""400 Bad Request."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
99
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.BAD_REQUEST), request=request)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AuthenticationError(APIStatusError):
|
|
103
|
+
"""401 Unauthorized."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
106
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.UNAUTHORIZED), request=request)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PermissionDeniedError(APIStatusError):
|
|
110
|
+
"""403 Forbidden."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
113
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.FORBIDDEN), request=request)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class NotFoundError(APIStatusError):
|
|
117
|
+
"""404 Not Found."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
120
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.NOT_FOUND), request=request)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RequestTimeoutError(APIStatusError):
|
|
124
|
+
"""408 Request Timeout."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
127
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.REQUEST_TIMEOUT), request=request)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ConflictError(APIStatusError):
|
|
131
|
+
"""409 Conflict."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
134
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.CONFLICT), request=request)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class UnprocessableEntityError(APIStatusError):
|
|
138
|
+
"""422 Unprocessable Entity."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
141
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.UNPROCESSABLE_ENTITY), request=request)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class RateLimitError(APIStatusError):
|
|
145
|
+
"""429 Too Many Requests."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
148
|
+
super().__init__(message, response=response, status_code=int(httpx.codes.TOO_MANY_REQUESTS), request=request)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class InternalServerError(APIStatusError):
|
|
152
|
+
"""500+ Server Error.
|
|
153
|
+
|
|
154
|
+
Catches all 5xx status codes. The actual status code (500, 502, 503, etc.)
|
|
155
|
+
is preserved in the status_code attribute for accurate monitoring/alerting.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
|
|
159
|
+
super().__init__(message, response=response, status_code=response.status_code, request=request)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Base classes for API resources."""
|
|
2
|
+
|
|
3
|
+
from .client import BaseAsyncAPIClient, BaseSyncAPIClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseSyncAPIResource:
|
|
7
|
+
"""Base class for synchronous API resources."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, client: BaseSyncAPIClient) -> None:
|
|
10
|
+
self._client = client
|
|
11
|
+
self._get = client._get
|
|
12
|
+
self._post = client._post
|
|
13
|
+
self._post_stream = client._post_stream
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseAsyncAPIResource:
|
|
17
|
+
"""Base class for asynchronous API resources."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client: BaseAsyncAPIClient) -> None:
|
|
20
|
+
self._client = client
|
|
21
|
+
self._get = client._get
|
|
22
|
+
self._post = client._post
|
|
23
|
+
self._post_stream = client._post_stream
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Simple Server-Sent Events (SSE) parser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator, Iterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import SSEParseError
|
|
8
|
+
|
|
9
|
+
_DATA_PREFIX = "data:"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_sse_line_stream(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
|
|
13
|
+
"""
|
|
14
|
+
Parse SSE stream and yield JSON event data.
|
|
15
|
+
|
|
16
|
+
SSE Format:
|
|
17
|
+
event: STARTED
|
|
18
|
+
data: {"type":"STARTED","run_id":"123",...}
|
|
19
|
+
|
|
20
|
+
event: PROGRESS
|
|
21
|
+
data: {"type":"PROGRESS",...}
|
|
22
|
+
|
|
23
|
+
Yields:
|
|
24
|
+
Parsed JSON objects from 'data:' lines
|
|
25
|
+
"""
|
|
26
|
+
for line in lines:
|
|
27
|
+
line = line.strip()
|
|
28
|
+
|
|
29
|
+
# Skip empty lines and comments
|
|
30
|
+
if not line or line.startswith(":"):
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# Parse data lines (the actual event payload)
|
|
34
|
+
if line.startswith("data:"):
|
|
35
|
+
data_str = line[len(_DATA_PREFIX) :].strip() # Remove 'data:' prefix
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
event_data = json.loads(data_str)
|
|
39
|
+
yield event_data
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
raise SSEParseError(f"Malformed JSON in SSE event: {e}", line=line) from e
|
|
42
|
+
|
|
43
|
+
# Ignore 'event:', 'id:', 'retry:' lines for now
|
|
44
|
+
# (we get event type from the JSON data itself)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def async_parse_sse_line_stream(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]:
|
|
48
|
+
"""Async version of parse_sse_line_stream."""
|
|
49
|
+
async for line in lines:
|
|
50
|
+
line = line.strip()
|
|
51
|
+
|
|
52
|
+
if not line or line.startswith(":"):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if line.startswith("data:"):
|
|
56
|
+
data_str = line[len(_DATA_PREFIX) :].strip()
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
event_data = json.loads(data_str)
|
|
60
|
+
yield event_data
|
|
61
|
+
except json.JSONDecodeError as e:
|
|
62
|
+
raise SSEParseError(f"Malformed JSON in SSE event: {e}", line=line) from e
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Browser automation resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tinyfish._utils.resource import BaseAsyncAPIResource, BaseSyncAPIResource
|
|
9
|
+
from tinyfish._utils.sse_parser import async_parse_sse_line_stream, parse_sse_line_stream
|
|
10
|
+
|
|
11
|
+
from .types import (
|
|
12
|
+
AgentRunAsyncResponse,
|
|
13
|
+
AgentRunResponse,
|
|
14
|
+
AgentRunWithStreamingResponse,
|
|
15
|
+
BrowserProfile,
|
|
16
|
+
CompleteEvent,
|
|
17
|
+
HeartbeatEvent,
|
|
18
|
+
ProgressEvent,
|
|
19
|
+
ProxyConfig,
|
|
20
|
+
StartedEvent,
|
|
21
|
+
StreamingUrlEvent,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_run_body(
|
|
26
|
+
goal: str,
|
|
27
|
+
url: str,
|
|
28
|
+
browser_profile: BrowserProfile | None,
|
|
29
|
+
proxy_config: ProxyConfig | None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
body: dict[str, Any] = {"goal": goal, "url": url}
|
|
32
|
+
if browser_profile is not None:
|
|
33
|
+
body["browser_profile"] = browser_profile
|
|
34
|
+
if proxy_config is not None:
|
|
35
|
+
body["proxy_config"] = proxy_config.model_dump(exclude_none=True)
|
|
36
|
+
return body
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentStream:
|
|
40
|
+
"""Context manager for a synchronous streaming agent run.
|
|
41
|
+
|
|
42
|
+
Use as::
|
|
43
|
+
|
|
44
|
+
with client.agent.stream(goal=..., url=...) as stream:
|
|
45
|
+
for event in stream:
|
|
46
|
+
...
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, iterator: Iterator[AgentRunWithStreamingResponse]) -> None:
|
|
50
|
+
self._iterator = iterator
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> AgentStream:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, *args: object) -> None:
|
|
56
|
+
self._iterator.close()
|
|
57
|
+
|
|
58
|
+
def __iter__(self) -> Iterator[AgentRunWithStreamingResponse]:
|
|
59
|
+
return self._iterator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AsyncAgentStream:
|
|
63
|
+
"""Context manager for an asynchronous streaming agent run.
|
|
64
|
+
|
|
65
|
+
Use as::
|
|
66
|
+
|
|
67
|
+
async with client.agent.stream(goal=..., url=...) as stream:
|
|
68
|
+
async for event in stream:
|
|
69
|
+
...
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, iterator: AsyncIterator[AgentRunWithStreamingResponse]) -> None:
|
|
73
|
+
self._iterator = iterator
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> AsyncAgentStream:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, *args: object) -> None:
|
|
79
|
+
await self._iterator.aclose()
|
|
80
|
+
|
|
81
|
+
def __aiter__(self) -> AsyncIterator[AgentRunWithStreamingResponse]:
|
|
82
|
+
return self._iterator
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AgentResource(BaseSyncAPIResource):
|
|
86
|
+
"""Browser automation methods."""
|
|
87
|
+
|
|
88
|
+
def run(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
goal: str,
|
|
92
|
+
url: str,
|
|
93
|
+
browser_profile: BrowserProfile | None = None,
|
|
94
|
+
proxy_config: ProxyConfig | None = None,
|
|
95
|
+
) -> AgentRunResponse:
|
|
96
|
+
"""Run a browser automation and wait for it to finish.
|
|
97
|
+
|
|
98
|
+
Blocks until the automation completes or fails. Use `queue()` instead
|
|
99
|
+
if you want to kick off the run and check back later.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
goal: Natural language description of what to do on the page.
|
|
103
|
+
url: The URL to open the browser on.
|
|
104
|
+
browser_profile: "lite" (default) or "stealth" (anti-detection).
|
|
105
|
+
proxy_config: Optional proxy settings (enabled, country_code).
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
AgentRunResponse with status, result, and timing info.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
AuthenticationError: Invalid API key.
|
|
112
|
+
RateLimitError: Too many requests.
|
|
113
|
+
InternalServerError: Something went wrong on the server.
|
|
114
|
+
"""
|
|
115
|
+
body = _build_run_body(goal, url, browser_profile, proxy_config)
|
|
116
|
+
return self._post("/v1/automation/run", json=body, cast_to=AgentRunResponse)
|
|
117
|
+
|
|
118
|
+
def queue(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
goal: str,
|
|
122
|
+
url: str,
|
|
123
|
+
browser_profile: BrowserProfile | None = None,
|
|
124
|
+
proxy_config: ProxyConfig | None = None,
|
|
125
|
+
) -> AgentRunAsyncResponse:
|
|
126
|
+
"""Queue a browser automation and return immediately.
|
|
127
|
+
|
|
128
|
+
Does not wait for the run to complete — returns a run_id straight away.
|
|
129
|
+
Use `client.runs.get(run_id)` to poll for the result.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
goal: Natural language description of what to do on the page.
|
|
133
|
+
url: The URL to open the browser on.
|
|
134
|
+
browser_profile: "lite" (default) or "stealth" (anti-detection).
|
|
135
|
+
proxy_config: Optional proxy settings (enabled, country_code).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
AgentRunAsyncResponse with the run_id to poll later.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
AuthenticationError: Invalid API key.
|
|
142
|
+
RateLimitError: Too many requests.
|
|
143
|
+
InternalServerError: Something went wrong on the server.
|
|
144
|
+
"""
|
|
145
|
+
body = _build_run_body(goal, url, browser_profile, proxy_config)
|
|
146
|
+
return self._post("/v1/automation/run-async", json=body, cast_to=AgentRunAsyncResponse)
|
|
147
|
+
|
|
148
|
+
def stream(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
goal: str,
|
|
152
|
+
url: str,
|
|
153
|
+
browser_profile: BrowserProfile | None = None,
|
|
154
|
+
proxy_config: ProxyConfig | None = None,
|
|
155
|
+
on_started: Callable[[StartedEvent], None] | None = None,
|
|
156
|
+
on_streaming_url: Callable[[StreamingUrlEvent], None] | None = None,
|
|
157
|
+
on_progress: Callable[[ProgressEvent], None] | None = None,
|
|
158
|
+
on_heartbeat: Callable[[HeartbeatEvent], None] | None = None,
|
|
159
|
+
on_complete: Callable[[CompleteEvent], None] | None = None,
|
|
160
|
+
) -> AgentStream:
|
|
161
|
+
"""Stream live events from a browser automation run.
|
|
162
|
+
|
|
163
|
+
Returns a context manager that yields SSE events in real time:
|
|
164
|
+
STARTED → STREAMING_URL → PROGRESS (repeated) → COMPLETE.
|
|
165
|
+
|
|
166
|
+
Use the on_* callbacks for a reactive style, or iterate over
|
|
167
|
+
the stream for a sequential style::
|
|
168
|
+
|
|
169
|
+
with client.agent.stream(goal=..., url=...) as stream:
|
|
170
|
+
for event in stream:
|
|
171
|
+
if isinstance(event, ProgressEvent):
|
|
172
|
+
print(event.purpose)
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
goal: Natural language description of what to do on the page.
|
|
176
|
+
url: The URL to open the browser on.
|
|
177
|
+
browser_profile: "lite" (default) or "stealth" (anti-detection).
|
|
178
|
+
proxy_config: Optional proxy settings (enabled, country_code).
|
|
179
|
+
on_started: Called when the run starts (receives StartedEvent).
|
|
180
|
+
on_streaming_url: Called with the live browser stream URL (receives StreamingUrlEvent).
|
|
181
|
+
on_progress: Called on each automation step (receives ProgressEvent).
|
|
182
|
+
on_heartbeat: Called on keepalive pings (receives HeartbeatEvent).
|
|
183
|
+
on_complete: Called when the run finishes (receives CompleteEvent).
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
AgentStream context manager — iterate over it to receive events.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
AuthenticationError: Invalid API key.
|
|
190
|
+
RateLimitError: Too many requests.
|
|
191
|
+
InternalServerError: Something went wrong on the server.
|
|
192
|
+
"""
|
|
193
|
+
body = _build_run_body(goal, url, browser_profile, proxy_config)
|
|
194
|
+
|
|
195
|
+
def _generate() -> Iterator[AgentRunWithStreamingResponse]:
|
|
196
|
+
lines = self._post_stream("/v1/automation/run-sse", json=body)
|
|
197
|
+
for event_data in parse_sse_line_stream(lines):
|
|
198
|
+
event_type = event_data.get("type")
|
|
199
|
+
if event_type == "STARTED":
|
|
200
|
+
event = StartedEvent.model_validate(event_data)
|
|
201
|
+
if on_started:
|
|
202
|
+
on_started(event)
|
|
203
|
+
yield event
|
|
204
|
+
elif event_type == "STREAMING_URL":
|
|
205
|
+
event = StreamingUrlEvent.model_validate(event_data)
|
|
206
|
+
if on_streaming_url:
|
|
207
|
+
on_streaming_url(event)
|
|
208
|
+
yield event
|
|
209
|
+
elif event_type == "PROGRESS":
|
|
210
|
+
event = ProgressEvent.model_validate(event_data)
|
|
211
|
+
if on_progress:
|
|
212
|
+
on_progress(event)
|
|
213
|
+
yield event
|
|
214
|
+
elif event_type == "HEARTBEAT":
|
|
215
|
+
event = HeartbeatEvent.model_validate(event_data)
|
|
216
|
+
if on_heartbeat:
|
|
217
|
+
on_heartbeat(event)
|
|
218
|
+
yield event
|
|
219
|
+
elif event_type == "COMPLETE":
|
|
220
|
+
event = CompleteEvent.model_validate(event_data)
|
|
221
|
+
if on_complete:
|
|
222
|
+
on_complete(event)
|
|
223
|
+
yield event
|
|
224
|
+
|
|
225
|
+
return AgentStream(_generate())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AsyncAgentResource(BaseAsyncAPIResource):
|
|
229
|
+
"""Async browser automation methods."""
|
|
230
|
+
|
|
231
|
+
async def run(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
goal: str,
|
|
235
|
+
url: str,
|
|
236
|
+
browser_profile: BrowserProfile | None = None,
|
|
237
|
+
proxy_config: ProxyConfig | None = None,
|
|
238
|
+
) -> AgentRunResponse:
|
|
239
|
+
"""Run a browser automation and wait for it to finish.
|
|
240
|
+
|
|
241
|
+
Async version of `AgentResource.run()`. Awaits until the automation
|
|
242
|
+
completes or fails. Use `queue()` instead if you want to fire and poll.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
goal: Natural language description of what to do on the page.
|
|
246
|
+
url: The URL to open the browser on.
|
|
247
|
+
browser_profile: "lite" (default) or "stealth" (anti-detection).
|
|
248
|
+
proxy_config: Optional proxy settings (enabled, country_code).
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
AgentRunResponse with status, result, and timing info.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
AuthenticationError: Invalid API key.
|
|
255
|
+
RateLimitError: Too many requests.
|
|
256
|
+
InternalServerError: Something went wrong on the server.
|
|
257
|
+
"""
|
|
258
|
+
body = _build_run_body(goal, url, browser_profile, proxy_config)
|
|
259
|
+
return await self._post("/v1/automation/run", json=body, cast_to=AgentRunResponse)
|
|
260
|
+
|
|
261
|
+
async def queue(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
goal: str,
|
|
265
|
+
url: str,
|
|
266
|
+
browser_profile: BrowserProfile | None = None,
|
|
267
|
+
proxy_config: ProxyConfig | None = None,
|
|
268
|
+
) -> AgentRunAsyncResponse:
|
|
269
|
+
"""Queue a browser automation and return immediately.
|
|
270
|
+
|
|
271
|
+
Async version of `AgentResource.queue()`. Returns a run_id without
|
|
272
|
+
waiting for completion. Use `client.runs.get(run_id)` to poll.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
goal: Natural language description of what to do on the page.
|
|
276
|
+
url: The URL to open the browser on.
|
|
277
|
+
browser_profile: "lite" (default) or "stealth" (anti-detection).
|
|
278
|
+
proxy_config: Optional proxy settings (enabled, country_code).
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
AgentRunAsyncResponse with the run_id to poll later.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
AuthenticationError: Invalid API key.
|
|
285
|
+
RateLimitError: Too many requests.
|
|
286
|
+
InternalServerError: Something went wrong on the server.
|
|
287
|
+
"""
|
|
288
|
+
body = _build_run_body(goal, url, browser_profile, proxy_config)
|
|
289
|
+
return await self._post("/v1/automation/run-async", json=body, cast_to=AgentRunAsyncResponse)
|
|
290
|
+
|
|
291
|
+
def stream(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
goal: str,
|
|
295
|
+
url: str,
|
|
296
|
+
browser_profile: BrowserProfile | None = None,
|
|
297
|
+
proxy_config: ProxyConfig | None = None,
|
|
298
|
+
on_started: Callable[[StartedEvent], None] | None = None,
|
|
299
|
+
on_streaming_url: Callable[[StreamingUrlEvent], None] | None = None,
|
|
300
|
+
on_progress: Callable[[ProgressEvent], None] | None = None,
|
|
301
|
+
on_heartbeat: Callable[[HeartbeatEvent], None] | None = None,
|
|
302
|
+
on_complete: Callable[[CompleteEvent], None] | None = None,
|
|
303
|
+
) -> AsyncAgentStream:
|
|
304
|
+
"""Stream live events from a browser automation run.
|
|
305
|
+
|
|
306
|
+
Returns an async context manager that yields SSE events in real time:
|
|
307
|
+
STARTED → STREAMING_URL → PROGRESS (repeated) → COMPLETE.
|
|
308
|
+
|
|
309
|
+
Use the on_* callbacks for a reactive style, or iterate over
|
|
310
|
+
the stream for a sequential style::
|
|
311
|
+
|
|
312
|
+
async with client.agent.stream(goal=..., url=...) as stream:
|
|
313
|
+
async for event in stream:
|
|
314
|
+
if isinstance(event, ProgressEvent):
|
|
315
|
+
print(event.purpose)
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
goal: Natural language description of what to do on the page.
|
|
319
|
+
url: The URL to open the browser on.
|
|
320
|
+
browser_profile: "lite" (default) or "stealth" (anti-detection).
|
|
321
|
+
proxy_config: Optional proxy settings (enabled, country_code).
|
|
322
|
+
on_started: Called when the run starts (receives StartedEvent).
|
|
323
|
+
on_streaming_url: Called with the live browser stream URL (receives StreamingUrlEvent).
|
|
324
|
+
on_progress: Called on each automation step (receives ProgressEvent).
|
|
325
|
+
on_heartbeat: Called on keepalive pings (receives HeartbeatEvent).
|
|
326
|
+
on_complete: Called when the run finishes (receives CompleteEvent).
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
AsyncAgentStream context manager — async-iterate over it to receive events.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
AuthenticationError: Invalid API key.
|
|
333
|
+
RateLimitError: Too many requests.
|
|
334
|
+
InternalServerError: Something went wrong on the server.
|
|
335
|
+
"""
|
|
336
|
+
body = _build_run_body(goal, url, browser_profile, proxy_config)
|
|
337
|
+
|
|
338
|
+
async def _generate() -> AsyncIterator[AgentRunWithStreamingResponse]:
|
|
339
|
+
lines = self._post_stream("/v1/automation/run-sse", json=body)
|
|
340
|
+
async for event_data in async_parse_sse_line_stream(lines):
|
|
341
|
+
event_type = event_data.get("type")
|
|
342
|
+
if event_type == "STARTED":
|
|
343
|
+
event = StartedEvent.model_validate(event_data)
|
|
344
|
+
if on_started:
|
|
345
|
+
on_started(event)
|
|
346
|
+
yield event
|
|
347
|
+
elif event_type == "STREAMING_URL":
|
|
348
|
+
event = StreamingUrlEvent.model_validate(event_data)
|
|
349
|
+
if on_streaming_url:
|
|
350
|
+
on_streaming_url(event)
|
|
351
|
+
yield event
|
|
352
|
+
elif event_type == "PROGRESS":
|
|
353
|
+
event = ProgressEvent.model_validate(event_data)
|
|
354
|
+
if on_progress:
|
|
355
|
+
on_progress(event)
|
|
356
|
+
yield event
|
|
357
|
+
elif event_type == "HEARTBEAT":
|
|
358
|
+
event = HeartbeatEvent.model_validate(event_data)
|
|
359
|
+
if on_heartbeat:
|
|
360
|
+
on_heartbeat(event)
|
|
361
|
+
yield event
|
|
362
|
+
elif event_type == "COMPLETE":
|
|
363
|
+
event = CompleteEvent.model_validate(event_data)
|
|
364
|
+
if on_complete:
|
|
365
|
+
on_complete(event)
|
|
366
|
+
yield event
|
|
367
|
+
|
|
368
|
+
return AsyncAgentStream(_generate())
|