sai-sdk 0.1.0__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.
- sai/__init__.py +107 -0
- sai/_client.py +131 -0
- sai/_errors.py +139 -0
- sai/_http.py +92 -0
- sai/_version.py +1 -0
- sai/control.py +141 -0
- sai/learn.py +199 -0
- sai/machines.py +82 -0
- sai/py.typed +0 -0
- sai/sessions.py +425 -0
- sai/tenants.py +348 -0
- sai/turns.py +59 -0
- sai/types.py +711 -0
- sai_sdk-0.1.0.dist-info/METADATA +365 -0
- sai_sdk-0.1.0.dist-info/RECORD +17 -0
- sai_sdk-0.1.0.dist-info/WHEEL +4 -0
- sai_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
sai/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Python SDK for the Sai API.
|
|
2
|
+
|
|
3
|
+
- `sai.sessions`: give Sai a task on a computer and get the answer back.
|
|
4
|
+
- `sai.learn`, `sai.judge`: turn recorded turns into Simulang code, repair it, judge turns.
|
|
5
|
+
- `sai.control`: drive a cloud computer yourself with Simulang.
|
|
6
|
+
- `sai.machines`, `sai.models`, `sai.files`, `sai.account`.
|
|
7
|
+
- `sai.tenants`, `sai.tenant_machines`: your customers under an Organisation (an Organisation Key).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._client import Sai
|
|
11
|
+
from ._errors import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
BadRequestError,
|
|
14
|
+
BlockError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
ConnectionError,
|
|
17
|
+
GoneError,
|
|
18
|
+
LinkOnlyApprovalError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionDeniedError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
SaiError,
|
|
23
|
+
ServiceUnavailableError,
|
|
24
|
+
SpendCapReachedError,
|
|
25
|
+
)
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .control import ControlLease
|
|
28
|
+
from .machines import Machine
|
|
29
|
+
from .sessions import Session, Turn
|
|
30
|
+
from .tenants import Enrollment, Tenant, TenantMachine, TenantPage, TenantToken
|
|
31
|
+
from .types import (
|
|
32
|
+
Account,
|
|
33
|
+
Approval,
|
|
34
|
+
Attachment,
|
|
35
|
+
Check,
|
|
36
|
+
CriterionResult,
|
|
37
|
+
Evaluation,
|
|
38
|
+
Event,
|
|
39
|
+
Evidence,
|
|
40
|
+
ExecError,
|
|
41
|
+
ExecResult,
|
|
42
|
+
Judgement,
|
|
43
|
+
LearnIteration,
|
|
44
|
+
LearnParameter,
|
|
45
|
+
LearnReset,
|
|
46
|
+
LearnResult,
|
|
47
|
+
LearnRollout,
|
|
48
|
+
LearnRun,
|
|
49
|
+
LearnTestResult,
|
|
50
|
+
LiveView,
|
|
51
|
+
Model,
|
|
52
|
+
TurnRecord,
|
|
53
|
+
TrajectoryStep,
|
|
54
|
+
TurnResult,
|
|
55
|
+
Usage,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"Sai",
|
|
60
|
+
"Session",
|
|
61
|
+
"Turn",
|
|
62
|
+
"ControlLease",
|
|
63
|
+
"Machine",
|
|
64
|
+
"Tenant",
|
|
65
|
+
"TenantPage",
|
|
66
|
+
"TenantMachine",
|
|
67
|
+
"Enrollment",
|
|
68
|
+
"TenantToken",
|
|
69
|
+
"Account",
|
|
70
|
+
"Approval",
|
|
71
|
+
"Attachment",
|
|
72
|
+
"Check",
|
|
73
|
+
"CriterionResult",
|
|
74
|
+
"Evaluation",
|
|
75
|
+
"Event",
|
|
76
|
+
"Evidence",
|
|
77
|
+
"ExecError",
|
|
78
|
+
"ExecResult",
|
|
79
|
+
"Judgement",
|
|
80
|
+
"LearnIteration",
|
|
81
|
+
"LearnParameter",
|
|
82
|
+
"LearnReset",
|
|
83
|
+
"LearnResult",
|
|
84
|
+
"LearnRollout",
|
|
85
|
+
"LearnRun",
|
|
86
|
+
"LearnTestResult",
|
|
87
|
+
"LiveView",
|
|
88
|
+
"Model",
|
|
89
|
+
"TurnRecord",
|
|
90
|
+
"TrajectoryStep",
|
|
91
|
+
"TurnResult",
|
|
92
|
+
"Usage",
|
|
93
|
+
"SaiError",
|
|
94
|
+
"AuthenticationError",
|
|
95
|
+
"BadRequestError",
|
|
96
|
+
"BlockError",
|
|
97
|
+
"ConflictError",
|
|
98
|
+
"ConnectionError",
|
|
99
|
+
"GoneError",
|
|
100
|
+
"LinkOnlyApprovalError",
|
|
101
|
+
"NotFoundError",
|
|
102
|
+
"PermissionDeniedError",
|
|
103
|
+
"RateLimitError",
|
|
104
|
+
"ServiceUnavailableError",
|
|
105
|
+
"SpendCapReachedError",
|
|
106
|
+
"__version__",
|
|
107
|
+
]
|
sai/_client.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._http import DEFAULT_BASE_URL, HTTP
|
|
11
|
+
from .control import Control
|
|
12
|
+
from .machines import Machines
|
|
13
|
+
from .sessions import Sessions
|
|
14
|
+
from .turns import Turns
|
|
15
|
+
from .learn import Judge, Learn
|
|
16
|
+
from .tenants import TenantMachines, Tenants
|
|
17
|
+
from .types import Account, Attachment, Model
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Sai:
|
|
21
|
+
"""The Sai API client.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
sai = Sai() # SAI_API_KEY, and SAI_API_URL if set
|
|
25
|
+
session = sai.sessions.create()
|
|
26
|
+
print(session.run("Open Notepad and type hello").text)
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: Optional[str] = None,
|
|
33
|
+
*,
|
|
34
|
+
base_url: Optional[str] = None,
|
|
35
|
+
timeout: float = 60.0,
|
|
36
|
+
version_tag: Optional[str] = None,
|
|
37
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
38
|
+
org_id: Optional[str] = None,
|
|
39
|
+
_token: Optional[Callable[[], str]] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""`org_id` (default `SAI_ORG_ID`) is the Organisation that `tenants.token()` and
|
|
42
|
+
`tenants.client()` exchange for; only an Organisation Key needs it."""
|
|
43
|
+
key = _token or api_key or os.environ.get("SAI_API_KEY")
|
|
44
|
+
if not key:
|
|
45
|
+
raise ValueError("No API key: pass api_key= or set SAI_API_KEY. Create one at https://platform.simular.ai.")
|
|
46
|
+
self._base_url = base_url or os.environ.get("SAI_API_URL") or DEFAULT_BASE_URL
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
self._version_tag = version_tag
|
|
49
|
+
self._transport = transport
|
|
50
|
+
self._org_id = org_id or os.environ.get("SAI_ORG_ID")
|
|
51
|
+
self._http = HTTP(
|
|
52
|
+
key,
|
|
53
|
+
self._base_url,
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
version_tag=version_tag,
|
|
56
|
+
transport=transport,
|
|
57
|
+
)
|
|
58
|
+
self.machines = Machines(self)
|
|
59
|
+
self.sessions = Sessions(self)
|
|
60
|
+
self.turns = Turns(self)
|
|
61
|
+
self.learn = Learn(self)
|
|
62
|
+
"""Recorded turns → Simulang code, and code repair (research preview)."""
|
|
63
|
+
self.judge = Judge(self)
|
|
64
|
+
"""Judge recorded turns against new criteria (research preview)."""
|
|
65
|
+
self.control = Control(self)
|
|
66
|
+
self.models = _Models(self)
|
|
67
|
+
self.files = _Files(self)
|
|
68
|
+
self.account = _Account(self)
|
|
69
|
+
self.tenants = Tenants(self)
|
|
70
|
+
"""Your customers under an Organisation. Takes an Organisation Key."""
|
|
71
|
+
self.tenant_machines = TenantMachines(self)
|
|
72
|
+
"""A Tenant's computers: enrolled, and cloud machines Sai hosts. Takes an Organisation Key."""
|
|
73
|
+
|
|
74
|
+
def _with_token(self, token: Callable[[], str]) -> Sai:
|
|
75
|
+
return Sai(
|
|
76
|
+
base_url=self._base_url,
|
|
77
|
+
timeout=self._timeout,
|
|
78
|
+
version_tag=self._version_tag,
|
|
79
|
+
transport=self._transport,
|
|
80
|
+
org_id=self._org_id,
|
|
81
|
+
_token=token,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
self._http.close()
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> Sai:
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *exc: object) -> None:
|
|
91
|
+
self.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Models:
|
|
95
|
+
def __init__(self, sai: Sai) -> None:
|
|
96
|
+
self._sai = sai
|
|
97
|
+
|
|
98
|
+
def list(self) -> list[Model]:
|
|
99
|
+
"""Models a session can run on; `allowed` says whether this plan may use each."""
|
|
100
|
+
d = self._sai._http.json("GET", "/v1/agents/models")
|
|
101
|
+
return [Model.parse(m) for m in d.get("models") or []]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Files:
|
|
105
|
+
def __init__(self, sai: Sai) -> None:
|
|
106
|
+
self._sai = sai
|
|
107
|
+
|
|
108
|
+
def upload(self, file: Union[str, Path], *, name: Optional[str] = None) -> Attachment:
|
|
109
|
+
"""Upload one file (max 25 MB) to attach to a message."""
|
|
110
|
+
path = Path(file)
|
|
111
|
+
d = self._sai._http.json(
|
|
112
|
+
"POST",
|
|
113
|
+
"/v1/agents/upload",
|
|
114
|
+
content=path.read_bytes(),
|
|
115
|
+
# The server reads only a body that has a Content-Type; it derives the MIME type from the name.
|
|
116
|
+
headers={
|
|
117
|
+
"Content-Type": "application/octet-stream",
|
|
118
|
+
"x-filename": urllib.parse.quote(name or path.name),
|
|
119
|
+
},
|
|
120
|
+
timeout=120,
|
|
121
|
+
)
|
|
122
|
+
return Attachment.parse(d)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Account:
|
|
126
|
+
def __init__(self, sai: Sai) -> None:
|
|
127
|
+
self._sai = sai
|
|
128
|
+
|
|
129
|
+
def get(self) -> Account:
|
|
130
|
+
"""Plan and spendable credit. Slow-ish (billing lookup): not for a poll loop."""
|
|
131
|
+
return Account.parse(self._sai._http.json("GET", "/v1/agents/account"))
|
sai/_errors.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SaiError(Exception):
|
|
7
|
+
"""A non-2xx response from the Sai API.
|
|
8
|
+
|
|
9
|
+
`code` is the machine-readable `error` string when the server sent one
|
|
10
|
+
(`machine_busy`, `link_only`, `insufficient_credits`, ...); `message` is the
|
|
11
|
+
human text. `body` is the parsed JSON body, unchanged.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
status: int = 0,
|
|
19
|
+
code: Optional[str] = None,
|
|
20
|
+
body: Optional[dict[str, Any]] = None,
|
|
21
|
+
retry_after: Optional[float] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.code = code
|
|
27
|
+
self.body = body or {}
|
|
28
|
+
self.retry_after = retry_after
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"{type(self).__name__}(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConnectionError(SaiError):
|
|
35
|
+
"""The API could not be reached (DNS, TLS, timeout)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BadRequestError(SaiError):
|
|
39
|
+
"""400: the request was invalid, e.g. several computers and no `machine`."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthenticationError(SaiError):
|
|
43
|
+
"""401: no key, or the key is unknown or revoked."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PermissionDeniedError(SaiError):
|
|
47
|
+
"""403: the key is valid but the account may not do this (plan, ownership)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(SaiError):
|
|
51
|
+
"""404."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConflictError(SaiError):
|
|
55
|
+
"""409: e.g. `machine_busy`, `link_only`, approval no longer pending."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GoneError(SaiError):
|
|
59
|
+
"""410 `control_closed`: the control lease has ended. Open a new one.
|
|
60
|
+
|
|
61
|
+
`body["reason"]` says why: `closed`, `idle_timeout`, `lock_lost` (a Sai task
|
|
62
|
+
took the computer) or `unresponsive` (the computer stopped answering).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RateLimitError(SaiError):
|
|
67
|
+
"""429: a rate limit, or a free-plan budget that resets daily."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ServiceUnavailableError(SaiError):
|
|
71
|
+
"""503: the computer did not come online, or no capacity. Honor `retry_after`."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BlockError(SaiError):
|
|
75
|
+
"""A control helper's Simulang block failed on the computer. `kind` is
|
|
76
|
+
`exception`, `timeout` or `interrupted`."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, kind: str, message: str) -> None:
|
|
79
|
+
super().__init__(f"{kind}: {message}", code=kind)
|
|
80
|
+
self.kind = kind
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LinkOnlyApprovalError(ConflictError):
|
|
84
|
+
"""The approval must be answered by a human in a browser: open `approval_url`."""
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def approval_url(self) -> Optional[str]:
|
|
88
|
+
return self.body.get("approvalUrl")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SpendCapReachedError(PermissionDeniedError):
|
|
92
|
+
"""403 `spend_cap_reached`: the key reached its monthly spend limit, or (on a cloud machine
|
|
93
|
+
create, `body["scope"] == "tenant"`) the Tenant reached its budget.
|
|
94
|
+
|
|
95
|
+
Not retryable: the limit resets at `resets_at` (ISO 8601, 00:00 UTC on the
|
|
96
|
+
1st), or raise it for the key in the portal.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def resets_at(self) -> Optional[str]:
|
|
101
|
+
at = self.body.get("resetsAt")
|
|
102
|
+
return at if isinstance(at, str) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_BY_CODE: dict[str, type[SaiError]] = {
|
|
106
|
+
"link_only": LinkOnlyApprovalError,
|
|
107
|
+
"spend_cap_reached": SpendCapReachedError,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_BY_STATUS = {
|
|
111
|
+
400: BadRequestError,
|
|
112
|
+
401: AuthenticationError,
|
|
113
|
+
403: PermissionDeniedError,
|
|
114
|
+
404: NotFoundError,
|
|
115
|
+
409: ConflictError,
|
|
116
|
+
410: GoneError,
|
|
117
|
+
429: RateLimitError,
|
|
118
|
+
503: ServiceUnavailableError,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def error_for(status: int, body: dict[str, Any], retry_after: Optional[float]) -> SaiError:
|
|
123
|
+
err = body.get("error")
|
|
124
|
+
detail = body.get("message")
|
|
125
|
+
# Two body shapes: `{ error: "<human text>" }` (/v1/agents) and
|
|
126
|
+
# `{ error: "<code>", message: "<human text>" }` (/v1/machines/{id}/control, newer errors).
|
|
127
|
+
code = err if isinstance(err, str) and (detail is not None or _looks_like_code(err)) else None
|
|
128
|
+
message = detail if isinstance(detail, str) else (err if isinstance(err, str) else f"HTTP {status}")
|
|
129
|
+
if status == 401:
|
|
130
|
+
message += " (SAI_API_KEY was rejected: it may be revoked.)"
|
|
131
|
+
cls = _BY_CODE.get(code or "") or _BY_STATUS.get(status, SaiError)
|
|
132
|
+
# The `Retry-After` header wins; `retryAfterSeconds` is the same value in the body (`rate_limited`).
|
|
133
|
+
if retry_after is None and isinstance(body.get("retryAfterSeconds"), (int, float)):
|
|
134
|
+
retry_after = float(body["retryAfterSeconds"])
|
|
135
|
+
return cls(message, status=status, code=code, body=body, retry_after=retry_after)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _looks_like_code(s: str) -> bool:
|
|
139
|
+
return bool(s) and " " not in s and s == s.lower()
|
sai/_http.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional, Union
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._errors import ConnectionError, SaiError, error_for
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.simular.ai"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HTTP:
|
|
14
|
+
"""The one place requests are made: auth header, error mapping, timeouts."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: Union[str, Callable[[], str]],
|
|
19
|
+
base_url: str,
|
|
20
|
+
*,
|
|
21
|
+
timeout: float,
|
|
22
|
+
version_tag: Optional[str],
|
|
23
|
+
transport: Optional[httpx.BaseTransport],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""`api_key` is the bearer token, or a function that returns it before each request."""
|
|
26
|
+
self._token = api_key if callable(api_key) else None
|
|
27
|
+
headers = {"User-Agent": f"sai-python/{__version__}"}
|
|
28
|
+
if not callable(api_key):
|
|
29
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
30
|
+
if version_tag:
|
|
31
|
+
# routes the request to a tagged API revision
|
|
32
|
+
headers["x-sai-version"] = version_tag
|
|
33
|
+
self._client = httpx.Client(
|
|
34
|
+
base_url=base_url.rstrip("/"), headers=headers, timeout=timeout, transport=transport
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
self._client.close()
|
|
39
|
+
|
|
40
|
+
def request(
|
|
41
|
+
self,
|
|
42
|
+
method: str,
|
|
43
|
+
path: str,
|
|
44
|
+
*,
|
|
45
|
+
json: Any = None,
|
|
46
|
+
params: Optional[dict[str, Any]] = None,
|
|
47
|
+
content: Optional[bytes] = None,
|
|
48
|
+
headers: Optional[dict[str, str]] = None,
|
|
49
|
+
timeout: Optional[float] = None,
|
|
50
|
+
) -> httpx.Response:
|
|
51
|
+
kwargs: dict[str, Any] = {}
|
|
52
|
+
if timeout is not None:
|
|
53
|
+
kwargs["timeout"] = timeout
|
|
54
|
+
if self._token is not None:
|
|
55
|
+
headers = {**(headers or {}), "Authorization": f"Bearer {self._token()}"}
|
|
56
|
+
try:
|
|
57
|
+
res = self._client.request(
|
|
58
|
+
method, path, json=json, params=params, content=content, headers=headers, **kwargs
|
|
59
|
+
)
|
|
60
|
+
except httpx.TimeoutException as e:
|
|
61
|
+
raise ConnectionError(f"Request to {self._client.base_url.host} timed out.") from e
|
|
62
|
+
except httpx.TransportError as e:
|
|
63
|
+
raise ConnectionError(
|
|
64
|
+
f"Could not reach the Sai API at {self._client.base_url.host}: {e}. "
|
|
65
|
+
"Check the network and SAI_API_URL."
|
|
66
|
+
) from e
|
|
67
|
+
if res.status_code >= 400:
|
|
68
|
+
raise _to_error(res)
|
|
69
|
+
return res
|
|
70
|
+
|
|
71
|
+
def json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
72
|
+
res = self.request(method, path, **kwargs)
|
|
73
|
+
if res.status_code == 204 or not res.content:
|
|
74
|
+
return None
|
|
75
|
+
return res.json()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _to_error(res: httpx.Response) -> SaiError:
|
|
79
|
+
try:
|
|
80
|
+
body = res.json()
|
|
81
|
+
if not isinstance(body, dict):
|
|
82
|
+
body = {"error": str(body)}
|
|
83
|
+
except ValueError:
|
|
84
|
+
body = {"error": res.text[:500] or res.reason_phrase}
|
|
85
|
+
retry_after: Optional[float] = None
|
|
86
|
+
raw = res.headers.get("retry-after")
|
|
87
|
+
if raw:
|
|
88
|
+
try:
|
|
89
|
+
retry_after = float(raw)
|
|
90
|
+
except ValueError:
|
|
91
|
+
retry_after = None
|
|
92
|
+
return error_for(res.status_code, body, retry_after)
|
sai/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sai/control.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Direct control: drive a cloud computer with Simulang. No agent.
|
|
2
|
+
|
|
3
|
+
Maps to `/v1/machines/{id}/control`, a lease on the machine: at most one per
|
|
4
|
+
machine, so the machine id is the handle. Research preview, open to every account;
|
|
5
|
+
model calls the code makes (`callLLM`, `ground`, `stateSatisfies`) are billed as usual.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Optional, Union
|
|
13
|
+
|
|
14
|
+
from ._errors import BlockError, GoneError
|
|
15
|
+
from .types import ExecResult, LearnResult
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ._client import Sai
|
|
19
|
+
from .machines import Machine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _control_path(machine_id: str) -> str:
|
|
24
|
+
return f"/v1/machines/{quote(machine_id, safe='')}/control"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Control:
|
|
28
|
+
def __init__(self, sai: Sai) -> None:
|
|
29
|
+
self._sai = sai
|
|
30
|
+
|
|
31
|
+
def open(self, machine: Union[Machine, str]) -> ControlLease:
|
|
32
|
+
"""Take a cloud computer. Returns once the lease holds it, up to ~3 minutes
|
|
33
|
+
when a Sai task holds it or it has to start; at once if the lease is already
|
|
34
|
+
open. Use as a context manager so it is always released: Sai's tasks on that
|
|
35
|
+
computer wait until then."""
|
|
36
|
+
machine_id = machine if isinstance(machine, str) else machine.id
|
|
37
|
+
d = self._sai._http.json("POST", _control_path(machine_id), timeout=240)
|
|
38
|
+
return ControlLease(self._sai, machine_id, int(d.get("expiresAfterIdleSec", 0)))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ControlLease:
|
|
42
|
+
def __init__(self, sai: Sai, machine_id: str, idle_timeout_sec: int) -> None:
|
|
43
|
+
self._sai = sai
|
|
44
|
+
self.machine_id = machine_id
|
|
45
|
+
self._path = _control_path(machine_id)
|
|
46
|
+
self.idle_timeout_sec = idle_timeout_sec
|
|
47
|
+
self._closed = False
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"ControlLease(machine_id={self.machine_id!r})"
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> ControlLease:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, *exc: object) -> None:
|
|
56
|
+
self.close()
|
|
57
|
+
|
|
58
|
+
def exec(self, code: str, *, timeout_ms: Optional[int] = None) -> ExecResult:
|
|
59
|
+
"""Run one Simulang (JavaScript) block. Use `var` for anything the next block needs.
|
|
60
|
+
|
|
61
|
+
A block that throws, times out or is interrupted still returns: check
|
|
62
|
+
`result.error`. Raises `GoneError` once the lease has ended (closed, idle,
|
|
63
|
+
taken by a Sai task, or the computer stopped answering): open again.
|
|
64
|
+
"""
|
|
65
|
+
body: dict[str, Any] = {"code": code}
|
|
66
|
+
if timeout_ms is not None:
|
|
67
|
+
body["timeoutMs"] = timeout_ms
|
|
68
|
+
d = self._sai._http.json(
|
|
69
|
+
"POST", f"{self._path}/exec", json=body, timeout=(timeout_ms or 120_000) / 1000 + 45
|
|
70
|
+
)
|
|
71
|
+
return ExecResult.parse(d)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run(
|
|
75
|
+
self,
|
|
76
|
+
learned: Union[LearnResult, str],
|
|
77
|
+
inputs: Optional[dict[str, Any]] = None,
|
|
78
|
+
*,
|
|
79
|
+
timeout_ms: Optional[int] = None,
|
|
80
|
+
) -> ExecResult:
|
|
81
|
+
"""Run code from `sai.learn.create()`: its `main` with `inputs` over the parameters'
|
|
82
|
+
defaults (an argument to `main` replaces its whole default object, so missing inputs
|
|
83
|
+
keep their defaults). `main`'s return value is `result.result`. A string runs as
|
|
84
|
+
code with `inputs` as is."""
|
|
85
|
+
if isinstance(learned, str):
|
|
86
|
+
code, params = learned, dict(inputs or {})
|
|
87
|
+
else:
|
|
88
|
+
code = learned.code
|
|
89
|
+
params = {p.name: p.default_value for p in learned.parameters} | (inputs or {})
|
|
90
|
+
return self.exec(f"{code}\nawait main({json.dumps(params)})", timeout_ms=timeout_ms)
|
|
91
|
+
def shell(self, command: str, *, cwd: Optional[str] = None, timeout_ms: Optional[int] = None) -> dict[str, Any]:
|
|
92
|
+
"""Run a shell command: `{stdout, stderr, exitCode, timedOut}`."""
|
|
93
|
+
args: dict[str, Any] = {"command": command}
|
|
94
|
+
if cwd:
|
|
95
|
+
args["cwd"] = cwd
|
|
96
|
+
r = self.exec(f"await machine.exec({json.dumps(args)})", timeout_ms=timeout_ms)
|
|
97
|
+
_raise_on_block_error(r)
|
|
98
|
+
return r.result
|
|
99
|
+
|
|
100
|
+
def read_file(self, path: str) -> str:
|
|
101
|
+
r = self.exec(f"await machine.readFile({json.dumps({'path': path})})")
|
|
102
|
+
_raise_on_block_error(r)
|
|
103
|
+
return r.result
|
|
104
|
+
|
|
105
|
+
def write_file(self, path: str, text: str) -> None:
|
|
106
|
+
r = self.exec(f"await machine.writeToFile({json.dumps({'path': path, 'text': text, 'overwrite': True})})")
|
|
107
|
+
_raise_on_block_error(r)
|
|
108
|
+
|
|
109
|
+
def screenshot(self) -> bytes:
|
|
110
|
+
"""The whole screen as image bytes (usually JPEG; use `exec` for the MIME type). Needs the
|
|
111
|
+
computer's screen-capture service; raises `BlockError` where it is off (a page or app
|
|
112
|
+
snapshot still carries an image)."""
|
|
113
|
+
r = self.exec("var __sai_page = await machine.pageContent(); null")
|
|
114
|
+
_raise_on_block_error(r)
|
|
115
|
+
if r.screenshot is None:
|
|
116
|
+
raise BlockError("exception", "screenshot too large to return" if r.screenshot_omitted else "no screenshot")
|
|
117
|
+
return r.screenshot
|
|
118
|
+
|
|
119
|
+
def interrupt(self) -> bool:
|
|
120
|
+
"""Stop the running block at its next step. False if nothing was running."""
|
|
121
|
+
d = self._sai._http.json("POST", f"{self._path}/interrupt")
|
|
122
|
+
return bool(d and d.get("interrupted"))
|
|
123
|
+
|
|
124
|
+
def get(self) -> dict[str, Any]:
|
|
125
|
+
"""`{status: open|closed, closedReason?, lastExecAt, ...}`."""
|
|
126
|
+
return self._sai._http.json("GET", self._path)
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
"""Clear the REPL and release the computer. Safe to call twice."""
|
|
130
|
+
if self._closed:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
self._sai._http.request("DELETE", self._path)
|
|
134
|
+
except GoneError:
|
|
135
|
+
pass
|
|
136
|
+
self._closed = True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _raise_on_block_error(r: ExecResult) -> None:
|
|
140
|
+
if r.error:
|
|
141
|
+
raise BlockError(r.error.kind, r.error.message)
|