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 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)