sai-sdk 0.1.0a1__tar.gz
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_sdk-0.1.0a1/.gitignore +5 -0
- sai_sdk-0.1.0a1/LICENSE +21 -0
- sai_sdk-0.1.0a1/PKG-INFO +161 -0
- sai_sdk-0.1.0a1/README.md +146 -0
- sai_sdk-0.1.0a1/pyproject.toml +31 -0
- sai_sdk-0.1.0a1/src/sai/__init__.py +69 -0
- sai_sdk-0.1.0a1/src/sai/_client.py +96 -0
- sai_sdk-0.1.0a1/src/sai/_errors.py +113 -0
- sai_sdk-0.1.0a1/src/sai/_http.py +89 -0
- sai_sdk-0.1.0a1/src/sai/_version.py +1 -0
- sai_sdk-0.1.0a1/src/sai/control.py +119 -0
- sai_sdk-0.1.0a1/src/sai/machines.py +78 -0
- sai_sdk-0.1.0a1/src/sai/py.typed +0 -0
- sai_sdk-0.1.0a1/src/sai/sessions.py +278 -0
- sai_sdk-0.1.0a1/src/sai/types.py +263 -0
- sai_sdk-0.1.0a1/tests/test_sdk.py +254 -0
- sai_sdk-0.1.0a1/tests/test_surface.py +50 -0
- sai_sdk-0.1.0a1/uv.lock +312 -0
sai_sdk-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simular, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sai_sdk-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sai-sdk
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Python SDK for the Sai API: delegate desktop work to Sai, or drive a cloud computer directly.
|
|
5
|
+
Project-URL: Documentation, https://platform.simular.ai/documentation
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: httpx<1,>=0.25
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# sai-sdk (Python)
|
|
17
|
+
|
|
18
|
+
Python SDK for the [Sai API](https://platform.simular.ai/documentation). Hand desktop work to Sai on a
|
|
19
|
+
computer (a Sai cloud computer, or your own PC with the Sai app) and get the answer back, or drive a cloud
|
|
20
|
+
computer yourself.
|
|
21
|
+
|
|
22
|
+
Status: **alpha (0.1)**. The surface may still change before 1.0.
|
|
23
|
+
|
|
24
|
+
| Layer | What it is | Status |
|
|
25
|
+
| ---------------------- | ------------------------------------------------- | --------------- |
|
|
26
|
+
| `sai.sessions` | A conversation with Sai on one computer | available |
|
|
27
|
+
| `sai.control_sessions` | Drive a cloud computer directly with Simulang | research preview |
|
|
28
|
+
| `sai.runs` | Typed tasks: inputs, output schema, evidence | not yet |
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install sai-sdk # import name: sai
|
|
34
|
+
export SAI_API_KEY=sapi_... # create one at https://platform.simular.ai
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Python 3.9+. One dependency, `httpx`.
|
|
38
|
+
|
|
39
|
+
## Give Sai a task
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from sai import Sai
|
|
43
|
+
|
|
44
|
+
sai = Sai()
|
|
45
|
+
pc = sai.machines.list()[0]
|
|
46
|
+
|
|
47
|
+
session = sai.sessions.create(machine=pc) # a fresh conversation
|
|
48
|
+
result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")
|
|
49
|
+
|
|
50
|
+
print(result.status) # idle | needs_approval | error
|
|
51
|
+
print(result.text) # Sai's answer
|
|
52
|
+
print(result.usage) # tokens and cost for this task
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`machine` can be left out when the account has one computer. Further `session.run(...)` calls continue
|
|
56
|
+
the same conversation; `sai.sessions.create()` starts a new one with no memory of the old.
|
|
57
|
+
|
|
58
|
+
### Watch it work
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
turn = session.send("Find last month's invoices in Downloads and total them")
|
|
62
|
+
for ev in turn.events():
|
|
63
|
+
if ev.kind == "step":
|
|
64
|
+
print(" ", ev.text)
|
|
65
|
+
elif ev.kind == "approval":
|
|
66
|
+
print("Sai asks:", ev.approval.title, ev.approval.command)
|
|
67
|
+
turn.respond(ev.approval, "yes") # or "no", or "task" for the rest of the task
|
|
68
|
+
result = turn.wait()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`ev.kind` is one of `step`, `message`, `approval`, `tool_error`, `error`, `finished`.
|
|
72
|
+
`turn.events(raw=True)` adds the wire frames (reasoning, tool calls) as `other`.
|
|
73
|
+
|
|
74
|
+
### Approvals
|
|
75
|
+
|
|
76
|
+
Sai asks before risky actions. `run()` / `wait()` return with `status == "needs_approval"` unless you pass
|
|
77
|
+
a handler:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
def decide(approval):
|
|
81
|
+
if approval.is_link_only:
|
|
82
|
+
print("Approve in a browser:", approval.url) # a human answers; keep waiting
|
|
83
|
+
return None
|
|
84
|
+
if approval.type == "choice":
|
|
85
|
+
return ("yes", [[q["options"][0]["value"]] for q in approval.questions])
|
|
86
|
+
return "yes" if approval.command and "del " not in approval.command else "no"
|
|
87
|
+
|
|
88
|
+
result = session.run("Clean up the temp folder", on_approval=decide)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Files, models, stopping
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
report = sai.files.upload("report.pdf")
|
|
95
|
+
session.run("Summarise the attached report", attachments=[report])
|
|
96
|
+
|
|
97
|
+
[m.id for m in sai.models.list() if m.allowed]
|
|
98
|
+
session.run("...", model="anthropic/claude-opus-5-5") # sticks to the session
|
|
99
|
+
|
|
100
|
+
turn = session.send("...")
|
|
101
|
+
turn.abort()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Drive a computer yourself
|
|
105
|
+
|
|
106
|
+
Control sessions run [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
|
|
107
|
+
blocks on a cloud computer. No agent, no model cost. While one is open, Sai's own tasks on that
|
|
108
|
+
computer wait, so always close it (a `with` block does).
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
with sai.control_sessions.open(machine=pc) as cs:
|
|
112
|
+
print(cs.shell("hostname")["stdout"])
|
|
113
|
+
|
|
114
|
+
r = cs.exec("""
|
|
115
|
+
var page = await machine.browser.newtab('https://news.ycombinator.com')
|
|
116
|
+
var snap = await page.snapshot()
|
|
117
|
+
snap.snapshot.grep('points')
|
|
118
|
+
""")
|
|
119
|
+
print(r.result)
|
|
120
|
+
open("hn.png", "wb").write(r.screenshot_png) # a page or app snapshot carries a screenshot
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`cs.screenshot()` grabs the whole screen. It needs the computer's screen-capture service; where that is
|
|
124
|
+
off it raises `BlockError`, and a page or app snapshot is the way to get an image.
|
|
125
|
+
|
|
126
|
+
`exec()` returns an `ExecResult`; a block that threw is still a result, so check `r.error`
|
|
127
|
+
(`exception`, `timeout`, `interrupted`). Use `var`, not `let`/`const`, for values the next block needs.
|
|
128
|
+
|
|
129
|
+
## Errors
|
|
130
|
+
|
|
131
|
+
Every non-2xx response raises a `SaiError` subclass with `status`, `code`, `message` and `retry_after`:
|
|
132
|
+
|
|
133
|
+
| Exception | When |
|
|
134
|
+
| ------------------------- | --------------------------------------------------------------------- |
|
|
135
|
+
| `AuthenticationError` | 401: the key is missing, unknown or revoked |
|
|
136
|
+
| `PermissionDeniedError` | 403: plan or ownership (`model_not_allowed_for_plan`, ...) |
|
|
137
|
+
| `BadRequestError` | 400: e.g. several computers and no `machine=` |
|
|
138
|
+
| `ConflictError` | 409: `machine_busy`, approval no longer pending, ... |
|
|
139
|
+
| `LinkOnlyApprovalError` | the approval must be answered in a browser: `e.approval_url` |
|
|
140
|
+
| `GoneError` | 410: the control session lost its computer; open a new one |
|
|
141
|
+
| `RateLimitError` | 429: a rate limit, or the free plan's daily budget |
|
|
142
|
+
| `ServiceUnavailableError` | 503: the computer did not come online; honor `retry_after` |
|
|
143
|
+
|
|
144
|
+
A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
|
|
145
|
+
`insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
| Argument | Env | Default |
|
|
150
|
+
| ------------- | ------------- | ------------------------ |
|
|
151
|
+
| `api_key` | `SAI_API_KEY` | required |
|
|
152
|
+
| `base_url` | `SAI_API_URL` | `https://api.simular.ai` |
|
|
153
|
+
| `timeout` | | 60 s per request |
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
cd python
|
|
159
|
+
uv sync
|
|
160
|
+
uv run pytest
|
|
161
|
+
```
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# sai-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Sai API](https://platform.simular.ai/documentation). Hand desktop work to Sai on a
|
|
4
|
+
computer (a Sai cloud computer, or your own PC with the Sai app) and get the answer back, or drive a cloud
|
|
5
|
+
computer yourself.
|
|
6
|
+
|
|
7
|
+
Status: **alpha (0.1)**. The surface may still change before 1.0.
|
|
8
|
+
|
|
9
|
+
| Layer | What it is | Status |
|
|
10
|
+
| ---------------------- | ------------------------------------------------- | --------------- |
|
|
11
|
+
| `sai.sessions` | A conversation with Sai on one computer | available |
|
|
12
|
+
| `sai.control_sessions` | Drive a cloud computer directly with Simulang | research preview |
|
|
13
|
+
| `sai.runs` | Typed tasks: inputs, output schema, evidence | not yet |
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install sai-sdk # import name: sai
|
|
19
|
+
export SAI_API_KEY=sapi_... # create one at https://platform.simular.ai
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.9+. One dependency, `httpx`.
|
|
23
|
+
|
|
24
|
+
## Give Sai a task
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from sai import Sai
|
|
28
|
+
|
|
29
|
+
sai = Sai()
|
|
30
|
+
pc = sai.machines.list()[0]
|
|
31
|
+
|
|
32
|
+
session = sai.sessions.create(machine=pc) # a fresh conversation
|
|
33
|
+
result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")
|
|
34
|
+
|
|
35
|
+
print(result.status) # idle | needs_approval | error
|
|
36
|
+
print(result.text) # Sai's answer
|
|
37
|
+
print(result.usage) # tokens and cost for this task
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`machine` can be left out when the account has one computer. Further `session.run(...)` calls continue
|
|
41
|
+
the same conversation; `sai.sessions.create()` starts a new one with no memory of the old.
|
|
42
|
+
|
|
43
|
+
### Watch it work
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
turn = session.send("Find last month's invoices in Downloads and total them")
|
|
47
|
+
for ev in turn.events():
|
|
48
|
+
if ev.kind == "step":
|
|
49
|
+
print(" ", ev.text)
|
|
50
|
+
elif ev.kind == "approval":
|
|
51
|
+
print("Sai asks:", ev.approval.title, ev.approval.command)
|
|
52
|
+
turn.respond(ev.approval, "yes") # or "no", or "task" for the rest of the task
|
|
53
|
+
result = turn.wait()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`ev.kind` is one of `step`, `message`, `approval`, `tool_error`, `error`, `finished`.
|
|
57
|
+
`turn.events(raw=True)` adds the wire frames (reasoning, tool calls) as `other`.
|
|
58
|
+
|
|
59
|
+
### Approvals
|
|
60
|
+
|
|
61
|
+
Sai asks before risky actions. `run()` / `wait()` return with `status == "needs_approval"` unless you pass
|
|
62
|
+
a handler:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
def decide(approval):
|
|
66
|
+
if approval.is_link_only:
|
|
67
|
+
print("Approve in a browser:", approval.url) # a human answers; keep waiting
|
|
68
|
+
return None
|
|
69
|
+
if approval.type == "choice":
|
|
70
|
+
return ("yes", [[q["options"][0]["value"]] for q in approval.questions])
|
|
71
|
+
return "yes" if approval.command and "del " not in approval.command else "no"
|
|
72
|
+
|
|
73
|
+
result = session.run("Clean up the temp folder", on_approval=decide)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Files, models, stopping
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
report = sai.files.upload("report.pdf")
|
|
80
|
+
session.run("Summarise the attached report", attachments=[report])
|
|
81
|
+
|
|
82
|
+
[m.id for m in sai.models.list() if m.allowed]
|
|
83
|
+
session.run("...", model="anthropic/claude-opus-5-5") # sticks to the session
|
|
84
|
+
|
|
85
|
+
turn = session.send("...")
|
|
86
|
+
turn.abort()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Drive a computer yourself
|
|
90
|
+
|
|
91
|
+
Control sessions run [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
|
|
92
|
+
blocks on a cloud computer. No agent, no model cost. While one is open, Sai's own tasks on that
|
|
93
|
+
computer wait, so always close it (a `with` block does).
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
with sai.control_sessions.open(machine=pc) as cs:
|
|
97
|
+
print(cs.shell("hostname")["stdout"])
|
|
98
|
+
|
|
99
|
+
r = cs.exec("""
|
|
100
|
+
var page = await machine.browser.newtab('https://news.ycombinator.com')
|
|
101
|
+
var snap = await page.snapshot()
|
|
102
|
+
snap.snapshot.grep('points')
|
|
103
|
+
""")
|
|
104
|
+
print(r.result)
|
|
105
|
+
open("hn.png", "wb").write(r.screenshot_png) # a page or app snapshot carries a screenshot
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`cs.screenshot()` grabs the whole screen. It needs the computer's screen-capture service; where that is
|
|
109
|
+
off it raises `BlockError`, and a page or app snapshot is the way to get an image.
|
|
110
|
+
|
|
111
|
+
`exec()` returns an `ExecResult`; a block that threw is still a result, so check `r.error`
|
|
112
|
+
(`exception`, `timeout`, `interrupted`). Use `var`, not `let`/`const`, for values the next block needs.
|
|
113
|
+
|
|
114
|
+
## Errors
|
|
115
|
+
|
|
116
|
+
Every non-2xx response raises a `SaiError` subclass with `status`, `code`, `message` and `retry_after`:
|
|
117
|
+
|
|
118
|
+
| Exception | When |
|
|
119
|
+
| ------------------------- | --------------------------------------------------------------------- |
|
|
120
|
+
| `AuthenticationError` | 401: the key is missing, unknown or revoked |
|
|
121
|
+
| `PermissionDeniedError` | 403: plan or ownership (`model_not_allowed_for_plan`, ...) |
|
|
122
|
+
| `BadRequestError` | 400: e.g. several computers and no `machine=` |
|
|
123
|
+
| `ConflictError` | 409: `machine_busy`, approval no longer pending, ... |
|
|
124
|
+
| `LinkOnlyApprovalError` | the approval must be answered in a browser: `e.approval_url` |
|
|
125
|
+
| `GoneError` | 410: the control session lost its computer; open a new one |
|
|
126
|
+
| `RateLimitError` | 429: a rate limit, or the free plan's daily budget |
|
|
127
|
+
| `ServiceUnavailableError` | 503: the computer did not come online; honor `retry_after` |
|
|
128
|
+
|
|
129
|
+
A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
|
|
130
|
+
`insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
| Argument | Env | Default |
|
|
135
|
+
| ------------- | ------------- | ------------------------ |
|
|
136
|
+
| `api_key` | `SAI_API_KEY` | required |
|
|
137
|
+
| `base_url` | `SAI_API_URL` | `https://api.simular.ai` |
|
|
138
|
+
| `timeout` | | 60 s per request |
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
cd python
|
|
144
|
+
uv sync
|
|
145
|
+
uv run pytest
|
|
146
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sai-sdk"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Python SDK for the Sai API: delegate desktop work to Sai, or drive a cloud computer directly."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://platform.simular.ai/documentation"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling>=1.24"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/sai"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,69 @@
|
|
|
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.control_sessions`: drive a cloud computer yourself with Simulang.
|
|
5
|
+
- `sai.machines`, `sai.models`, `sai.files`, `sai.account`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._client import Sai
|
|
9
|
+
from ._errors import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
BadRequestError,
|
|
12
|
+
BlockError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
ConnectionError,
|
|
15
|
+
GoneError,
|
|
16
|
+
LinkOnlyApprovalError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
PermissionDeniedError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
SaiError,
|
|
21
|
+
ServiceUnavailableError,
|
|
22
|
+
)
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .control import ControlSession
|
|
25
|
+
from .machines import Machine
|
|
26
|
+
from .sessions import Session, Turn
|
|
27
|
+
from .types import (
|
|
28
|
+
Account,
|
|
29
|
+
Approval,
|
|
30
|
+
Attachment,
|
|
31
|
+
Event,
|
|
32
|
+
ExecError,
|
|
33
|
+
ExecResult,
|
|
34
|
+
LiveView,
|
|
35
|
+
Model,
|
|
36
|
+
TurnResult,
|
|
37
|
+
Usage,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Sai",
|
|
42
|
+
"Session",
|
|
43
|
+
"Turn",
|
|
44
|
+
"ControlSession",
|
|
45
|
+
"Machine",
|
|
46
|
+
"Account",
|
|
47
|
+
"Approval",
|
|
48
|
+
"Attachment",
|
|
49
|
+
"Event",
|
|
50
|
+
"ExecError",
|
|
51
|
+
"ExecResult",
|
|
52
|
+
"LiveView",
|
|
53
|
+
"Model",
|
|
54
|
+
"TurnResult",
|
|
55
|
+
"Usage",
|
|
56
|
+
"SaiError",
|
|
57
|
+
"AuthenticationError",
|
|
58
|
+
"BadRequestError",
|
|
59
|
+
"BlockError",
|
|
60
|
+
"ConflictError",
|
|
61
|
+
"ConnectionError",
|
|
62
|
+
"GoneError",
|
|
63
|
+
"LinkOnlyApprovalError",
|
|
64
|
+
"NotFoundError",
|
|
65
|
+
"PermissionDeniedError",
|
|
66
|
+
"RateLimitError",
|
|
67
|
+
"ServiceUnavailableError",
|
|
68
|
+
"__version__",
|
|
69
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._http import DEFAULT_BASE_URL, HTTP
|
|
11
|
+
from .control import ControlSessions
|
|
12
|
+
from .machines import Machines
|
|
13
|
+
from .sessions import Sessions
|
|
14
|
+
from .types import Account, Attachment, Model
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Sai:
|
|
18
|
+
"""The Sai API client.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
sai = Sai() # SAI_API_KEY, and SAI_API_URL if set
|
|
22
|
+
session = sai.sessions.create()
|
|
23
|
+
print(session.run("Open Notepad and type hello").text)
|
|
24
|
+
```
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
*,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
timeout: float = 60.0,
|
|
33
|
+
version_tag: Optional[str] = None,
|
|
34
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
key = api_key or os.environ.get("SAI_API_KEY")
|
|
37
|
+
if not key:
|
|
38
|
+
raise ValueError("No API key: pass api_key= or set SAI_API_KEY. Create one at https://platform.simular.ai.")
|
|
39
|
+
self._http = HTTP(
|
|
40
|
+
key,
|
|
41
|
+
base_url or os.environ.get("SAI_API_URL") or DEFAULT_BASE_URL,
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
version_tag=version_tag,
|
|
44
|
+
transport=transport,
|
|
45
|
+
)
|
|
46
|
+
self.machines = Machines(self)
|
|
47
|
+
self.sessions = Sessions(self)
|
|
48
|
+
self.control_sessions = ControlSessions(self)
|
|
49
|
+
self.models = _Models(self)
|
|
50
|
+
self.files = _Files(self)
|
|
51
|
+
self.account = _Account(self)
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self._http.close()
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> Sai:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *exc: object) -> None:
|
|
60
|
+
self.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Models:
|
|
64
|
+
def __init__(self, sai: Sai) -> None:
|
|
65
|
+
self._sai = sai
|
|
66
|
+
|
|
67
|
+
def list(self) -> list[Model]:
|
|
68
|
+
"""Models a session can run on; `allowed` says whether this plan may use each."""
|
|
69
|
+
d = self._sai._http.json("GET", "/v1/agents/models")
|
|
70
|
+
return [Model.parse(m) for m in d.get("models") or []]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Files:
|
|
74
|
+
def __init__(self, sai: Sai) -> None:
|
|
75
|
+
self._sai = sai
|
|
76
|
+
|
|
77
|
+
def upload(self, file: Union[str, Path], *, name: Optional[str] = None) -> Attachment:
|
|
78
|
+
"""Upload one file (max 25 MB) to attach to a message."""
|
|
79
|
+
path = Path(file)
|
|
80
|
+
d = self._sai._http.json(
|
|
81
|
+
"POST",
|
|
82
|
+
"/v1/agents/upload",
|
|
83
|
+
content=path.read_bytes(),
|
|
84
|
+
headers={"x-filename": urllib.parse.quote(name or path.name)},
|
|
85
|
+
timeout=120,
|
|
86
|
+
)
|
|
87
|
+
return Attachment.parse(d)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _Account:
|
|
91
|
+
def __init__(self, sai: Sai) -> None:
|
|
92
|
+
self._sai = sai
|
|
93
|
+
|
|
94
|
+
def get(self) -> Account:
|
|
95
|
+
"""Plan and spendable credit. Slow-ish (billing lookup): not for a poll loop."""
|
|
96
|
+
return Account.parse(self._sai._http.json("GET", "/v1/agents/account"))
|
|
@@ -0,0 +1,113 @@
|
|
|
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: the control session was closed or sat idle. Open a new one."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitError(SaiError):
|
|
63
|
+
"""429: a rate limit, or a free-plan budget that resets daily."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ServiceUnavailableError(SaiError):
|
|
67
|
+
"""503: the computer did not come online, or no capacity. Honor `retry_after`."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BlockError(SaiError):
|
|
71
|
+
"""A control-session helper's Simulang block failed on the computer. `kind` is
|
|
72
|
+
`exception`, `timeout` or `interrupted`."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, kind: str, message: str) -> None:
|
|
75
|
+
super().__init__(f"{kind}: {message}", code=kind)
|
|
76
|
+
self.kind = kind
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LinkOnlyApprovalError(ConflictError):
|
|
80
|
+
"""The approval must be answered by a human in a browser: open `approval_url`."""
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def approval_url(self) -> Optional[str]:
|
|
84
|
+
return self.body.get("approvalUrl")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_BY_STATUS = {
|
|
88
|
+
400: BadRequestError,
|
|
89
|
+
401: AuthenticationError,
|
|
90
|
+
403: PermissionDeniedError,
|
|
91
|
+
404: NotFoundError,
|
|
92
|
+
409: ConflictError,
|
|
93
|
+
410: GoneError,
|
|
94
|
+
429: RateLimitError,
|
|
95
|
+
503: ServiceUnavailableError,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def error_for(status: int, body: dict[str, Any], retry_after: Optional[float]) -> SaiError:
|
|
100
|
+
err = body.get("error")
|
|
101
|
+
detail = body.get("message")
|
|
102
|
+
# Two body shapes: `{ error: "<human text>" }` (/v1/agents) and
|
|
103
|
+
# `{ error: "<code>", message: "<human text>" }` (/v1/sessions, newer errors).
|
|
104
|
+
code = err if isinstance(err, str) and (detail is not None or _looks_like_code(err)) else None
|
|
105
|
+
message = detail if isinstance(detail, str) else (err if isinstance(err, str) else f"HTTP {status}")
|
|
106
|
+
if status == 401:
|
|
107
|
+
message += " (SAI_API_KEY was rejected: it may be revoked.)"
|
|
108
|
+
cls = LinkOnlyApprovalError if code == "link_only" else _BY_STATUS.get(status, SaiError)
|
|
109
|
+
return cls(message, status=status, code=code, body=body, retry_after=retry_after)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _looks_like_code(s: str) -> bool:
|
|
113
|
+
return bool(s) and " " not in s and s == s.lower()
|