sai-sdk 0.1.0__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.0/.gitignore +5 -0
- sai_sdk-0.1.0/LICENSE +21 -0
- sai_sdk-0.1.0/PKG-INFO +365 -0
- sai_sdk-0.1.0/README.md +350 -0
- sai_sdk-0.1.0/pyproject.toml +31 -0
- sai_sdk-0.1.0/src/sai/__init__.py +107 -0
- sai_sdk-0.1.0/src/sai/_client.py +131 -0
- sai_sdk-0.1.0/src/sai/_errors.py +139 -0
- sai_sdk-0.1.0/src/sai/_http.py +92 -0
- sai_sdk-0.1.0/src/sai/_version.py +1 -0
- sai_sdk-0.1.0/src/sai/control.py +141 -0
- sai_sdk-0.1.0/src/sai/learn.py +199 -0
- sai_sdk-0.1.0/src/sai/machines.py +82 -0
- sai_sdk-0.1.0/src/sai/py.typed +0 -0
- sai_sdk-0.1.0/src/sai/sessions.py +425 -0
- sai_sdk-0.1.0/src/sai/tenants.py +348 -0
- sai_sdk-0.1.0/src/sai/turns.py +59 -0
- sai_sdk-0.1.0/src/sai/types.py +711 -0
- sai_sdk-0.1.0/tests/test_sdk.py +1097 -0
- sai_sdk-0.1.0/tests/test_surface.py +54 -0
- sai_sdk-0.1.0/uv.lock +247 -0
sai_sdk-0.1.0/.gitignore
ADDED
sai_sdk-0.1.0/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.0/PKG-INFO
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sai-sdk
|
|
3
|
+
Version: 0.1.0
|
|
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 :: 4 - Beta
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.10
|
|
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` | Drive a cloud computer directly with Simulang | research preview |
|
|
28
|
+
| `sai.turns` | A turn's record: typed output, evidence | available |
|
|
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.10+. 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
|
+
For an unattended run with nobody to answer, `autonomous=True` runs the turn without approval cards,
|
|
92
|
+
as if "task" (trust for the rest of the task) had been answered up front. The grant ends with the turn.
|
|
93
|
+
Approvals that only a person can complete in a browser (`is_link_only`) still stop the turn.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
session.run("Export last month's invoices to CSV", autonomous=True)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Files, models, stopping
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
report = sai.files.upload("report.pdf")
|
|
103
|
+
session.run("Summarise the attached report", attachments=[report])
|
|
104
|
+
|
|
105
|
+
[m.id for m in sai.models.list() if m.allowed]
|
|
106
|
+
session.run("...", model="anthropic/claude-opus-5-5") # sticks to the session
|
|
107
|
+
|
|
108
|
+
turn = session.send("...")
|
|
109
|
+
turn.abort()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Get typed results
|
|
113
|
+
|
|
114
|
+
Any message can also return structured data. Pass `output_schema` and the turn's record comes back in
|
|
115
|
+
`result.record`, with `output` matching the schema:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
session = sai.sessions.create(machine=pc)
|
|
119
|
+
result = session.run(
|
|
120
|
+
"Open Notepad, type \"hello\" and save it on the Desktop as hello.txt. Return the saved file's path.",
|
|
121
|
+
output_schema={"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
|
|
122
|
+
)
|
|
123
|
+
if result.ok:
|
|
124
|
+
print(result.record.output["path"]) # C:\Users\...\Desktop\hello.txt
|
|
125
|
+
else:
|
|
126
|
+
print(result.record.reason_code) # e.g. output_invalid, with result.record.raw_answer
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
More options, all optional (the SDK sends them to the API as `options`):
|
|
130
|
+
|
|
131
|
+
| Argument | What it does |
|
|
132
|
+
| ------------------- | ---------------------------------------------------------------------------- |
|
|
133
|
+
| `inputs` | Fill `{{name}}` placeholders in the message; every placeholder needs a value |
|
|
134
|
+
| `output_schema` | JSON Schema (at most 32 KB) the output must match |
|
|
135
|
+
| `success_criteria` | A guideline for a judge, or a list of `Check` yes/no checks |
|
|
136
|
+
| `idempotency_key` | The same key again returns the earlier turn and sends nothing |
|
|
137
|
+
| `record_trajectory` | Keep a screenshot and the action for each step |
|
|
138
|
+
|
|
139
|
+
`inputs`, `output_schema` or `success_criteria` make the message _structured_. A structured message
|
|
140
|
+
behaves like any other (same session, same computer); it only adds the typed result. It is refused with
|
|
141
|
+
`ConflictError` (`code == "session_busy"`) while the session is working, because it starts a turn of its
|
|
142
|
+
own. A plain message sent then joins the running turn instead.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from sai import Check
|
|
146
|
+
|
|
147
|
+
result = session.run(
|
|
148
|
+
"Save \"Invoice {{invoice_number}}: {{amount}} USD\" on the Desktop as {{file_name}}. Return its path.",
|
|
149
|
+
inputs={"invoice_number": "INV-1001", "amount": "42.50", "file_name": "inv-1001.txt"},
|
|
150
|
+
output_schema={"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
|
|
151
|
+
success_criteria=[Check("saved", "The file is saved and contains the invoice line", return_evidence=True)],
|
|
152
|
+
idempotency_key="inv-1001", # a retry returns this turn and sends nothing
|
|
153
|
+
record_trajectory=True,
|
|
154
|
+
)
|
|
155
|
+
record = result.record
|
|
156
|
+
print(record.status) # succeeded | failed
|
|
157
|
+
print(record.evidence) # [Evidence(id="saved", passed=True, score=0.94, evidence="...")]
|
|
158
|
+
print(record.usage) # tokens of the agent's model calls in this turn
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`result.ok` is True only when the turn succeeded. On `failed`, `record.reason_code` says why
|
|
162
|
+
(`agent_error`, `output_invalid`, `criteria_not_met`, ...); the output is never a partial object.
|
|
163
|
+
|
|
164
|
+
**Reading a turn later.** Every message gets a turn id, `turn.id` (plain messages too):
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
turn = session.send("...")
|
|
168
|
+
record = sai.turns.get(turn) # never waits; `finalizing` while the result is worked out
|
|
169
|
+
record = sai.turns.wait(turn, timeout=600) # until succeeded, failed or needs_input
|
|
170
|
+
for step in sai.turns.trajectory(turn): # needs record_trajectory=True
|
|
171
|
+
print(step.index, step.title, step.code)
|
|
172
|
+
if step.has_screenshot:
|
|
173
|
+
open(f"step{step.index}.jpg", "wb").write(sai.turns.screenshot(turn, step.index))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Each takes a `Turn`, a `TurnRecord` or the id string. `record.status` is `running`, `needs_input` (an
|
|
177
|
+
approval is pending: answer it on `sai.sessions.resume(record.session_id)`), `finalizing`, `succeeded` or
|
|
178
|
+
`failed`.
|
|
179
|
+
|
|
180
|
+
## Drive a computer yourself
|
|
181
|
+
|
|
182
|
+
Direct control runs [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
|
|
183
|
+
blocks on a cloud computer. No agent, no model cost. While the lease is open, Sai's own tasks on that
|
|
184
|
+
computer wait, so always close it (a `with` block does).
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
with sai.control.open(machine=pc) as lease:
|
|
188
|
+
print(lease.shell("hostname")["stdout"])
|
|
189
|
+
|
|
190
|
+
r = lease.exec("""
|
|
191
|
+
var page = await machine.browser.newtab('https://news.ycombinator.com')
|
|
192
|
+
var snap = await page.snapshot()
|
|
193
|
+
snap.snapshot.grep('points')
|
|
194
|
+
""")
|
|
195
|
+
print(r.result)
|
|
196
|
+
ext = r.screenshot_mime_type.split("/")[1] # a page or app snapshot carries a screenshot (jpeg or png)
|
|
197
|
+
open(f"hn.{ext}", "wb").write(r.screenshot)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`lease.screenshot()` grabs the whole screen. It needs the computer's screen-capture service; where that is
|
|
201
|
+
off it raises `BlockError`, and a page or app snapshot is the way to get an image.
|
|
202
|
+
|
|
203
|
+
`exec()` returns an `ExecResult`; a block that threw is still a result, so check `r.error`
|
|
204
|
+
(`exception`, `timeout`, `interrupted`). Use `var`, not `let`/`const`, for values the next block needs.
|
|
205
|
+
|
|
206
|
+
## Learn code from turns (research preview)
|
|
207
|
+
|
|
208
|
+
`sai.learn` turns what the agent did into Simulang code you own, and repairs that code when it breaks.
|
|
209
|
+
`sai.judge` checks recorded turns against criteria you choose afterwards. The turns must be sent with
|
|
210
|
+
`record_trajectory=True`.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from sai import Check, Evaluation
|
|
214
|
+
|
|
215
|
+
turn = session.send(
|
|
216
|
+
"Open {{site}} and read the title of the top story",
|
|
217
|
+
inputs={"site": "https://news.ycombinator.com"},
|
|
218
|
+
record_trajectory=True,
|
|
219
|
+
)
|
|
220
|
+
sai.turns.wait(turn)
|
|
221
|
+
|
|
222
|
+
learned = sai.learn.create([turn]) # waits for the code; longer for a longer task
|
|
223
|
+
print(learned.template, [p.name for p in learned.parameters])
|
|
224
|
+
print(learned.code) # async function main(params = { site: "..." }) { ... }
|
|
225
|
+
|
|
226
|
+
with sai.control.open(machine=pc) as lease:
|
|
227
|
+
r = lease.run(learned, {"site": "https://lobste.rs"}) # inputs over the defaults
|
|
228
|
+
print(r.result) # what main returned
|
|
229
|
+
|
|
230
|
+
# It broke on a new site: repair it from the run (or from guidance, or a new turn that solved it)
|
|
231
|
+
fixed = sai.learn.create(
|
|
232
|
+
initial_code=learned.code,
|
|
233
|
+
evaluations=[Evaluation(inputs={"site": "https://lobste.rs"}, result=r, passed=False)],
|
|
234
|
+
)
|
|
235
|
+
print(fixed.changes)
|
|
236
|
+
|
|
237
|
+
judgement = sai.judge.create([turn], [Check("title", "The agent reported a story title")])
|
|
238
|
+
print(judgement.verdict, judgement.criteria[0].score)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`learned.status` is `failed` when the code still has `learned.validation["errors"]` after one retry.
|
|
242
|
+
Pass a judgement as `Evaluation(..., judgement=judgement)` to repair code from its evidence.
|
|
243
|
+
|
|
244
|
+
### Run, judge and repair on a machine (policy iteration)
|
|
245
|
+
|
|
246
|
+
With `max_iterations`, `learn` also runs the code on a machine, judges each run, and repairs the code
|
|
247
|
+
after a failed run, for up to that many versions. It starts from your `turns`, or, without them, the
|
|
248
|
+
agent does `task_template` first. The call answers at once with a job; `wait` reads it until it ends.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
job = sai.learn.create(
|
|
252
|
+
task_template="Open {{site}} and read the title of the top story",
|
|
253
|
+
inputs={"site": "https://news.ycombinator.com"},
|
|
254
|
+
machine=pc,
|
|
255
|
+
criteria="The answer is the title of the top story on the page",
|
|
256
|
+
max_iterations=3, # up to 3 code versions; 0 (the default) only writes the code
|
|
257
|
+
rollouts=3, # each version must pass 3 runs in a row
|
|
258
|
+
reset={"code": 'await machine.browser.newtab("https://news.ycombinator.com")'}, # or {"task": "..."}
|
|
259
|
+
continue_with_agent=True, # after a failed run, the agent finishes the task and the repair learns from it
|
|
260
|
+
)
|
|
261
|
+
done = sai.learn.wait(job) # can take an hour; `timeout=` to stop waiting
|
|
262
|
+
print(done.status, done.stop_reason) # did the call do its work
|
|
263
|
+
print(done.test_result) # status "passed", passed_runs 3, required_runs 3, inputs
|
|
264
|
+
print(done.code) # the latest version
|
|
265
|
+
for it in done.iterations:
|
|
266
|
+
print(it.index, it.changes, [r.verdict for r in it.rollouts])
|
|
267
|
+
|
|
268
|
+
run = sai.learn.rollout(job, 1, 1) # what run 1 of version 1 returned, and its judgement
|
|
269
|
+
sai.learn.cancel(job) # stops at once and keeps the last complete results
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`status` says whether the call did its work (`succeeded`, `failed` when no code was written, or
|
|
273
|
+
`stopped` with a `stop_reason` such as `cancelled`, `time_limit` or `agent_failed`); `test_result` says
|
|
274
|
+
how the returned code did on `inputs`. With `judge_initial_agent_run=True`, a job whose agent run fails
|
|
275
|
+
the judge stops before writing code; pass its `agent_turn_id` as `turns` to start again from that run.
|
|
276
|
+
The runs do the task for real, so use test inputs or a test account.
|
|
277
|
+
|
|
278
|
+
## Run Sai for your customers
|
|
279
|
+
|
|
280
|
+
With an [Organisation](https://platform.simular.ai/documentation/organisations), each customer is a
|
|
281
|
+
Tenant. Your backend manages Tenants with an Organisation Key, and acts as one with a Tenant token.
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
org = Sai(api_key="sapi_...", org_id="org_...") # an Organisation Key; or SAI_API_KEY, SAI_ORG_ID
|
|
285
|
+
|
|
286
|
+
tenant = org.tenants.create("Riverside Clinic", external_id="clinic-4821")
|
|
287
|
+
code = org.tenants.create_enrollment(tenant) # hand code.token to your installer
|
|
288
|
+
print(org.tenant_machines.list(tenant))
|
|
289
|
+
|
|
290
|
+
clinic = org.tenants.client(tenant) # acts as the Tenant; renews its token
|
|
291
|
+
clinic.sessions.create().run("Open the calendar and read today's appointments")
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- `tenants.list(status=, external_id=, limit=, cursor=)` returns one page: `page.tenants`, `page.next_cursor`.
|
|
295
|
+
`external_id=` finds your customer's Tenant without storing `tenant.id`.
|
|
296
|
+
- `tenants.update(tenant, name=, metadata=, status="disabled" | "active", cloud_machine_limit=, residential_proxy=)`.
|
|
297
|
+
`cloud_machine_limit` is 0 to 1000, and `None` removes the cap. `tenants.get` also returns
|
|
298
|
+
`cloud_machine_count` and `residential_proxy`.
|
|
299
|
+
- `tenants.delete(tenant)` disables it and revokes every enrolled machine. Its cloud machines stay
|
|
300
|
+
until you delete them.
|
|
301
|
+
- `tenants.token(tenant)` is the raw exchange: a one-hour `TenantToken` to pass as `Sai(api_key=token.token)`.
|
|
302
|
+
- `tenant_machines.list(tenant)` and `tenant_machines.get(tenant, machine_id)` return both kinds:
|
|
303
|
+
`machine.kind` is `enrolled` or `cloud`, and `machine.state` is `online` / `offline` for an enrolled
|
|
304
|
+
machine, or `provisioning`, `ready`, `starting`, `hibernated`, `deleting` or `failed` for a cloud one.
|
|
305
|
+
- `tenant_machines.delete(tenant, machine)` returns `"revoked"` for an enrolled machine and
|
|
306
|
+
`"deleting"` for a cloud machine, whose disk goes with it.
|
|
307
|
+
|
|
308
|
+
### Cloud machines
|
|
309
|
+
|
|
310
|
+
Sai can host a computer for a Tenant instead of the Tenant enrolling its own:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
import uuid
|
|
314
|
+
|
|
315
|
+
machine = org.tenant_machines.create(
|
|
316
|
+
tenant,
|
|
317
|
+
os="windows", # or "linux"
|
|
318
|
+
name="intake-desk-3",
|
|
319
|
+
request_id=str(uuid.uuid4()), # keep it, and send it again if you retry this create
|
|
320
|
+
wait=True, # read the machine until it is ready (timeout=600 seconds)
|
|
321
|
+
)
|
|
322
|
+
clinic.sessions.create(machine=machine.id).run("Open the calendar and read today's appointments")
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Without `wait=True`, `create` returns at once with `state="provisioning"`. A create can raise
|
|
326
|
+
`SaiError` (402 `insufficient_credits`), `SpendCapReachedError` (the Tenant's budget, `e.body["scope"]`),
|
|
327
|
+
`ConflictError` (`machine_limit_reached` with `e.body["limit"]` and `e.body["used"]`, or
|
|
328
|
+
`request_id_reused`), `PermissionDeniedError` (`tenant_disabled`) or `ServiceUnavailableError`
|
|
329
|
+
(`capacity_unavailable`: retry later). A 500 carries `e.body["errorId"]` to quote to support.
|
|
330
|
+
|
|
331
|
+
## Errors
|
|
332
|
+
|
|
333
|
+
Every non-2xx response raises a `SaiError` subclass with `status`, `code`, `message` and `retry_after`:
|
|
334
|
+
|
|
335
|
+
| Exception | When |
|
|
336
|
+
| ------------------------- | ------------------------------------------------------------- |
|
|
337
|
+
| `AuthenticationError` | 401: the key is missing, unknown or revoked |
|
|
338
|
+
| `PermissionDeniedError` | 403: plan or ownership (`model_not_allowed_for_plan`, ...) |
|
|
339
|
+
| `BadRequestError` | 400: e.g. several computers and no `machine=` |
|
|
340
|
+
| `ConflictError` | 409: `machine_busy`, approval no longer pending, ... |
|
|
341
|
+
| `LinkOnlyApprovalError` | the approval must be answered in a browser: `e.approval_url` |
|
|
342
|
+
| `GoneError` | 410: the control lease ended (`e.body["reason"]`); open again |
|
|
343
|
+
| `SpendCapReachedError` | 403: the key reached its monthly limit; `e.resets_at` |
|
|
344
|
+
| `RateLimitError` | 429: a rate limit, or the free plan's daily budget |
|
|
345
|
+
| `ServiceUnavailableError` | 503: the computer did not come online; honor `retry_after` |
|
|
346
|
+
|
|
347
|
+
A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
|
|
348
|
+
`insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
|
|
349
|
+
|
|
350
|
+
## Configuration
|
|
351
|
+
|
|
352
|
+
| Argument | Env | Default |
|
|
353
|
+
| ---------- | ------------- | ------------------------ |
|
|
354
|
+
| `api_key` | `SAI_API_KEY` | required |
|
|
355
|
+
| `base_url` | `SAI_API_URL` | `https://api.simular.ai` |
|
|
356
|
+
| `timeout` | | 60 s per request |
|
|
357
|
+
| `org_id` | `SAI_ORG_ID` | for `tenants.token()` |
|
|
358
|
+
|
|
359
|
+
## Development
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
cd python
|
|
363
|
+
uv sync
|
|
364
|
+
uv run pytest
|
|
365
|
+
```
|