sai-sdk 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +246 -0
- data/lib/sai/client.rb +183 -0
- data/lib/sai/control.rb +153 -0
- data/lib/sai/errors.rb +109 -0
- data/lib/sai/http.rb +103 -0
- data/lib/sai/learn.rb +137 -0
- data/lib/sai/machines.rb +48 -0
- data/lib/sai/sessions.rb +333 -0
- data/lib/sai/tenants.rb +174 -0
- data/lib/sai/turns.rb +72 -0
- data/lib/sai/types.rb +688 -0
- data/lib/sai/util.rb +33 -0
- data/lib/sai/version.rb +5 -0
- data/lib/sai.rb +25 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b354a76d6581874f8fb42a95a2c08e493e35caeff8719badd6ea1b3bcc2833f7
|
|
4
|
+
data.tar.gz: '079cf8b51f0604c50904643c772085e8a63873afa437a56286140d27273a1000'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1b7a1c2cb4783e1b4ef6a9add4d445e6dda1b6d37794c2cb23f7b95f57026deebcbf689a0dbb12772cdda9ea3427a73bf444edf1dab60b42d22c020a22859b14
|
|
7
|
+
data.tar.gz: f568d3063e62375cbc35f6f0eb2dabdcf5a173f77ab70b1f291326e39a95d640a67d8464a7a12a131e2ebc706bb7c71a8fc08366549e2752d1abc767f89effe2
|
data/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.
|
data/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# sai-sdk (Ruby)
|
|
2
|
+
|
|
3
|
+
Ruby 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
|
|
5
|
+
cloud computer yourself.
|
|
6
|
+
|
|
7
|
+
Status: **alpha (0.1), unreleased**. The surface may still change before 1.0. It matches the
|
|
8
|
+
[Python SDK](../python/README.md) and the [TypeScript SDK](../typescript/README.md) method for method.
|
|
9
|
+
|
|
10
|
+
| Layer | What it is | Status |
|
|
11
|
+
| -------------- | --------------------------------------------- | ---------------- |
|
|
12
|
+
| `sai.sessions` | A conversation with Sai on one computer | available |
|
|
13
|
+
| `sai.control` | Drive a cloud computer directly with Simulang | research preview |
|
|
14
|
+
| `sai.turns` | A turn's record: typed output, evidence | available |
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install sai-sdk # or: gem "sai-sdk" in your Gemfile
|
|
20
|
+
export SAI_API_KEY=sapi_... # create one at https://platform.simular.ai
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Ruby 3.1 or later. No runtime dependencies beyond the standard library (`net/http`, `json`).
|
|
24
|
+
|
|
25
|
+
## Give Sai a task
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "sai"
|
|
29
|
+
|
|
30
|
+
sai = Sai::Client.new
|
|
31
|
+
pc = sai.machines.list.first
|
|
32
|
+
|
|
33
|
+
session = sai.sessions.create(machine: pc)
|
|
34
|
+
result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")
|
|
35
|
+
|
|
36
|
+
puts result.status # idle | needs_approval | error
|
|
37
|
+
puts result.text # Sai's answer
|
|
38
|
+
p result.usage # tokens and cost for this task
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`machine:` can be left out when the account has one computer. More `session.run(...)` calls continue
|
|
42
|
+
the same conversation; `sai.sessions.create` starts a new one with no memory of the old.
|
|
43
|
+
`sai.sessions.list(machine: pc)` lists your sessions on the computer (desktop, CLI and API), most
|
|
44
|
+
recent first, and `sai.auth.check` says who the key acts as.
|
|
45
|
+
|
|
46
|
+
### Watch it work
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
turn = session.send("Find last month's invoices in Downloads and total them")
|
|
50
|
+
turn.events do |ev|
|
|
51
|
+
case ev.kind
|
|
52
|
+
when "step" then puts " #{ev.text}"
|
|
53
|
+
when "approval" then turn.respond(ev.approval, "yes")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
result = turn.wait
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`ev.kind` is one of `step`, `message`, `approval`, `tool_error`, `error`, `finished`.
|
|
60
|
+
`turn.events(raw: true)` adds the wire frames (reasoning, tool calls) as `other`. Without a block,
|
|
61
|
+
`events` returns an `Enumerator`. `Session#send` is the API call, as in the other SDKs; Ruby's own
|
|
62
|
+
`send` is still there as `__send__` and `public_send`.
|
|
63
|
+
|
|
64
|
+
### Approvals
|
|
65
|
+
|
|
66
|
+
`run` / `wait` return with `status == "needs_approval"` unless you pass a handler, as a block or as
|
|
67
|
+
`on_approval:` (anything that responds to `call`):
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
result = session.run("Clean up the temp folder") do |approval|
|
|
71
|
+
if approval.link_only?
|
|
72
|
+
puts "Approve in a browser: #{approval.url}"
|
|
73
|
+
next nil # a person answers it; keep waiting
|
|
74
|
+
end
|
|
75
|
+
next ["yes", approval.questions.map { |q| [q["options"][0]["value"]] }] if approval.type == "choice"
|
|
76
|
+
|
|
77
|
+
"yes"
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The handler returns `"yes"`, `"no"`, `"task"`, a `[response, selections]` pair for a choice, or
|
|
82
|
+
`nil` to leave the approval pending. For an unattended run, `autonomous: true` runs the turn without
|
|
83
|
+
approval cards, as if `"task"` had been answered up front; link-only approvals still stop it.
|
|
84
|
+
|
|
85
|
+
### Files, models, stopping
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
report = sai.files.upload("report.pdf") # or data: bytes, name: "report.pdf"
|
|
89
|
+
session.run("Summarise the attached report", attachments: [report])
|
|
90
|
+
|
|
91
|
+
allowed = sai.models.list.select(&:allowed).map(&:id)
|
|
92
|
+
session.run("...", model: "anthropic/claude-opus-5-5") # sticks to the session
|
|
93
|
+
|
|
94
|
+
turn = session.send("...")
|
|
95
|
+
turn.abort
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Let a person take over
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
view = sai.machines.live(pc)
|
|
102
|
+
puts "Sign in here, then tell me: #{view.live_url}" # valid until view.expires_at (ms)
|
|
103
|
+
# ... once they are done
|
|
104
|
+
session.run("I signed in. Carry on.")
|
|
105
|
+
sai.machines.revoke_live(pc) # every open link stops working
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Get typed results
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
result = session.run(
|
|
112
|
+
"Save \"Invoice {{invoice_number}}: {{amount}} USD\" on the Desktop as {{file_name}}. Return its path.",
|
|
113
|
+
inputs: { "invoice_number" => "INV-1001", "amount" => "42.50", "file_name" => "inv-1001.txt" },
|
|
114
|
+
output_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
115
|
+
success_criteria: [
|
|
116
|
+
Sai::Check.new(id: "saved", check: "The file is saved and contains the invoice line", return_evidence: true)
|
|
117
|
+
],
|
|
118
|
+
idempotency_key: "inv-1001", # a retry returns this turn and sends nothing
|
|
119
|
+
record_trajectory: true
|
|
120
|
+
)
|
|
121
|
+
if result.ok?
|
|
122
|
+
puts result.record.output["path"]
|
|
123
|
+
else
|
|
124
|
+
puts result.record.reason_code # e.g. output_invalid, with result.record.raw_answer
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`inputs`, `output_schema` or `success_criteria` make the message _structured_; it is rejected with
|
|
129
|
+
`Sai::ConflictError` (`code == "session_busy"`) while the session is working. `thinking_level:`
|
|
130
|
+
(`off`, `low`, `medium`, `high`, `xhigh`) is refused the same way.
|
|
131
|
+
|
|
132
|
+
**Reading a turn later.** Every message gets a turn id, `turn.id`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
record = sai.turns.get(turn) # never waits
|
|
136
|
+
record = sai.turns.wait(turn, timeout: 600) # until succeeded, failed or needs_input
|
|
137
|
+
sai.turns.trajectory(turn).each do |step| # needs record_trajectory: true
|
|
138
|
+
File.binwrite("step#{step.index}.jpg", sai.turns.screenshot(turn, step.index)) if step.has_screenshot
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Drive a computer yourself
|
|
143
|
+
|
|
144
|
+
Direct control runs [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
|
|
145
|
+
blocks on a cloud computer. With a block, `open` closes the lease when the block ends; while it is
|
|
146
|
+
open, Sai's own tasks on that computer wait.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
sai.control.open(machine: pc) do |lease|
|
|
150
|
+
puts lease.shell("hostname").stdout
|
|
151
|
+
|
|
152
|
+
r = lease.exec(<<~JS)
|
|
153
|
+
var page = await machine.browser.newtab('https://example.com')
|
|
154
|
+
var snap = await page.snapshot()
|
|
155
|
+
snap.title
|
|
156
|
+
JS
|
|
157
|
+
p r.result # Example Domain
|
|
158
|
+
File.binwrite("page.#{r.screenshot_mime_type.split('/')[1]}", r.screenshot)
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A block that threw still returns an `ExecResult`, so check `r.error` (`exception`, `timeout`,
|
|
163
|
+
`interrupted`). Bytes (screenshots) are binary `String`s.
|
|
164
|
+
|
|
165
|
+
## Learn code from turns (research preview)
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
learned = sai.learn.create([turn]) # turns sent with record_trajectory: true
|
|
169
|
+
r = sai.control.open(machine: pc) { |lease| lease.run(learned, { "site" => "https://lobste.rs" }) }
|
|
170
|
+
|
|
171
|
+
fixed = sai.learn.create(
|
|
172
|
+
initial_code: learned.code,
|
|
173
|
+
evaluations: [Sai::Evaluation.new(inputs: { "site" => "https://lobste.rs" }, result: r, passed: false)]
|
|
174
|
+
)
|
|
175
|
+
judgement = sai.judge.create([turn], [Sai::Check.new(id: "title", check: "The agent reported a story title")])
|
|
176
|
+
|
|
177
|
+
job = sai.learn.create(task_template: "Open {{site}} and read the title of the top story",
|
|
178
|
+
inputs: { "site" => "https://news.ycombinator.com" }, machine: pc, max_iterations: 3)
|
|
179
|
+
done = sai.learn.wait(job) # timeout: to stop waiting
|
|
180
|
+
run = sai.learn.rollout(job, 1, 1)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Run Sai for your customers
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
org = Sai::Client.new(api_key: "sapi_...", org_id: "org_...") # or SAI_API_KEY, SAI_ORG_ID
|
|
187
|
+
|
|
188
|
+
tenant = org.tenants.create("Riverside Clinic", external_id: "clinic-4821")
|
|
189
|
+
code = org.tenants.create_enrollment(tenant) # hand code.token to your installer
|
|
190
|
+
|
|
191
|
+
machine = org.tenant_machines.create(tenant, os: "windows", request_id: SecureRandom.uuid, wait: true)
|
|
192
|
+
clinic = org.tenants.client(tenant) # acts as the Tenant; renews its token
|
|
193
|
+
clinic.sessions.create(machine: machine.id).run("Open the calendar and read today's appointments")
|
|
194
|
+
|
|
195
|
+
org.tenants.update(tenant, cloud_machine_limit: nil) # nil removes the cap; leaving it out changes nothing
|
|
196
|
+
usage = org.usage.get(granularity: "month", from: "2026-10", to: "2026-10")
|
|
197
|
+
page = org.usage.sessions(tenant, from: "2026-10-01", to: "2026-10-31")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`usage.sessions` pages what each of a Tenant's sessions spent on AI calls, for UTC days `from:` to `to:`
|
|
201
|
+
(at most 93). A page can be empty and still have a `next_cursor`: keep paging until it is `nil`.
|
|
202
|
+
|
|
203
|
+
To charge for a task as soon as it ends, look its session up in real time instead of a date range:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
page = org.usage.sessions(tenant, session_ids: [session.id])
|
|
207
|
+
page.sessions.each do |s|
|
|
208
|
+
puts "#{s.session_id} #{s.cost_usd} #{page.as_of}" if s.settled
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
With `session_ids:` (1 to 50 of the Tenant's sessions) each session comes back with its spend so far and
|
|
213
|
+
`settled`: `true` once its latest task has ended and every charge for it has come in. Ask again every
|
|
214
|
+
10 seconds or so until it is. `cost_usd` and `model_calls` are `nil` when the session can't be read
|
|
215
|
+
in full right now. An id that is not one of the Tenant's sessions is left out. See
|
|
216
|
+
[Session spend](https://platform.simular.ai/documentation/organisations/session-spend) for the limits.
|
|
217
|
+
|
|
218
|
+
## Errors
|
|
219
|
+
|
|
220
|
+
Every non-2xx response raises a `Sai::Error` subclass with `status`, `code`, `message`, `body` and
|
|
221
|
+
`retry_after`: `AuthenticationError` (401), `BadRequestError` (400), `PermissionDeniedError` (403),
|
|
222
|
+
`NotFoundError` (404), `ConflictError` (409), `LinkOnlyApprovalError` (`approval_url`), `GoneError`
|
|
223
|
+
(410), `SpendCapReachedError` (403; `resets_at`), `RateLimitError` (429), `ServiceUnavailableError`
|
|
224
|
+
(503), `ConnectionError`, `BlockError`. A wait past its `timeout:` raises `Timeout::Error`.
|
|
225
|
+
|
|
226
|
+
A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
|
|
227
|
+
`insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
|
|
228
|
+
|
|
229
|
+
## Configuration
|
|
230
|
+
|
|
231
|
+
| Keyword | Env | Default |
|
|
232
|
+
| -------------- | ------------- | ------------------------ |
|
|
233
|
+
| `api_key:` | `SAI_API_KEY` | required |
|
|
234
|
+
| `base_url:` | `SAI_API_URL` | `https://api.simular.ai` |
|
|
235
|
+
| `timeout:` | | 60 seconds per request |
|
|
236
|
+
| `version_tag:` | | none |
|
|
237
|
+
| `org_id:` | `SAI_ORG_ID` | for `tenants.token` |
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
cd ruby
|
|
243
|
+
bundle install
|
|
244
|
+
bundle exec rake test # parity, conformance cases, docs snippets (SAI_DOCS_DIR)
|
|
245
|
+
gem build sai-sdk.gemspec
|
|
246
|
+
```
|
data/lib/sai/client.rb
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sai
|
|
4
|
+
# The Sai API client.
|
|
5
|
+
#
|
|
6
|
+
# sai = Sai::Client.new # SAI_API_KEY, and SAI_API_URL if set
|
|
7
|
+
# session = sai.sessions.create
|
|
8
|
+
# puts session.run("Open Notepad and type hello").text
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :machines, :sessions, :turns, :control, :models, :files, :account, :tenant_machines
|
|
11
|
+
# Recorded turns → Simulang code, and code repair (research preview).
|
|
12
|
+
attr_reader :learn
|
|
13
|
+
# Judge recorded turns against new criteria (research preview).
|
|
14
|
+
attr_reader :judge
|
|
15
|
+
# Who the key acts as.
|
|
16
|
+
attr_reader :auth
|
|
17
|
+
# What an Organisation is charged, per Tenant and per session. Takes an Organisation Key.
|
|
18
|
+
attr_reader :usage
|
|
19
|
+
# Your customers under an Organisation. Takes an Organisation Key.
|
|
20
|
+
attr_reader :tenants
|
|
21
|
+
|
|
22
|
+
# +api_key+ defaults to +SAI_API_KEY+, +base_url+ to +SAI_API_URL+ then
|
|
23
|
+
# +https://api.simular.ai+, +org_id+ (the Organisation +tenants.token+, +tenants.client+ and
|
|
24
|
+
# +usage+ act on; only an Organisation Key needs it) to +SAI_ORG_ID+. +timeout+ is seconds
|
|
25
|
+
# per request; +version_tag+ routes requests to a tagged API revision.
|
|
26
|
+
def initialize(api_key: nil, base_url: nil, timeout: 60, version_tag: nil, org_id: nil)
|
|
27
|
+
key = api_key || ENV.fetch("SAI_API_KEY", nil)
|
|
28
|
+
if key.nil? || key.empty?
|
|
29
|
+
raise ArgumentError, "No API key: pass api_key: or set SAI_API_KEY. Create one at https://platform.simular.ai."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
setup(key, base_url: base_url, timeout: timeout, version_tag: version_tag, org_id: org_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @api private
|
|
36
|
+
# A client whose bearer token comes from +credential+ before each request (+tenants.client+).
|
|
37
|
+
def self.with_credential(credential, **opts)
|
|
38
|
+
allocate.tap { |c| c.send(:setup, credential, **opts) }
|
|
39
|
+
end
|
|
40
|
+
private_class_method :with_credential
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<Sai::Client>"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def setup(credential, base_url:, timeout:, version_tag:, org_id:)
|
|
49
|
+
url = base_url || ENV.fetch("SAI_API_URL", nil)
|
|
50
|
+
url = DEFAULT_BASE_URL if url.nil? || url.empty?
|
|
51
|
+
org = org_id.nil? ? ENV.fetch("SAI_ORG_ID", nil) : org_id
|
|
52
|
+
http = Http.new(credential, url, timeout, version_tag)
|
|
53
|
+
opts = { base_url: base_url, timeout: timeout, version_tag: version_tag, org_id: org_id }
|
|
54
|
+
@machines = Machines.new(http)
|
|
55
|
+
@sessions = Sessions.new(http, @machines)
|
|
56
|
+
@turns = Turns.new(http)
|
|
57
|
+
@learn = Learn.new(http)
|
|
58
|
+
@judge = Judge.new(http)
|
|
59
|
+
@control = Control.new(http)
|
|
60
|
+
@models = Models.new(http)
|
|
61
|
+
@files = Files.new(http)
|
|
62
|
+
@account = AccountResource.new(http)
|
|
63
|
+
@auth = Auth.new(http)
|
|
64
|
+
@usage = UsageResource.new(http, org)
|
|
65
|
+
@tenants = Tenants.new(http, org, ->(c) { Client.send(:with_credential, c, **opts) })
|
|
66
|
+
@tenant_machines = TenantMachines.new(http)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Models a session can run on.
|
|
71
|
+
class Models
|
|
72
|
+
# @api private
|
|
73
|
+
def initialize(http)
|
|
74
|
+
@http = http
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# +allowed+ says whether this plan may use each.
|
|
78
|
+
# @return [Array<Model>]
|
|
79
|
+
def list
|
|
80
|
+
d = Parse.obj(@http.json("GET", "/v1/agents/models"))
|
|
81
|
+
Parse.list(d["models"]).map { |m| Model.parse(Parse.obj(m)) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Files to attach to a message.
|
|
86
|
+
class Files
|
|
87
|
+
# @api private
|
|
88
|
+
def initialize(http)
|
|
89
|
+
@http = http
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Upload one file (max 25 MB): a path, or the bytes as +data:+ with a +name:+. +name+ defaults
|
|
93
|
+
# to the path's file name.
|
|
94
|
+
# @return [Attachment]
|
|
95
|
+
def upload(path = nil, data: nil, name: nil)
|
|
96
|
+
raise ArgumentError, "pass a path, or data: with name:" if path.nil? == data.nil?
|
|
97
|
+
|
|
98
|
+
if path
|
|
99
|
+
data = File.binread(path.to_s)
|
|
100
|
+
name ||= File.basename(path.to_s)
|
|
101
|
+
end
|
|
102
|
+
raise ArgumentError, "name: is required with data:" if name.nil?
|
|
103
|
+
|
|
104
|
+
d = @http.json("POST", "/v1/agents/upload",
|
|
105
|
+
body: data.b,
|
|
106
|
+
# The server reads only a body that has a Content-Type; it derives the MIME
|
|
107
|
+
# type from the name.
|
|
108
|
+
headers: { "Content-Type" => "application/octet-stream", "x-filename" => Util.q(name) },
|
|
109
|
+
timeout: 120)
|
|
110
|
+
Attachment.parse(Parse.obj(d))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The account's plan and credit.
|
|
115
|
+
class AccountResource
|
|
116
|
+
# @api private
|
|
117
|
+
def initialize(http)
|
|
118
|
+
@http = http
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Plan and spendable credit. Slow-ish (billing lookup): not for a poll loop.
|
|
122
|
+
# @return [Account]
|
|
123
|
+
def get
|
|
124
|
+
Account.parse(Parse.obj(@http.json("GET", "/v1/agents/account")))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Who the key acts as.
|
|
129
|
+
class Auth
|
|
130
|
+
# @api private
|
|
131
|
+
def initialize(http)
|
|
132
|
+
@http = http
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Raises AuthenticationError for a missing or revoked key.
|
|
136
|
+
# @return [AuthInfo]
|
|
137
|
+
def check
|
|
138
|
+
AuthInfo.parse(Parse.obj(@http.json("GET", "/v1/agents/auth")))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# What an Organisation is charged. Takes an Organisation Key; +org_id+ defaults to the client's.
|
|
143
|
+
class UsageResource
|
|
144
|
+
# @api private
|
|
145
|
+
def initialize(http, org_id)
|
|
146
|
+
@http = http
|
|
147
|
+
@org_id = org_id
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# What the Organisation is charged, in total and per Tenant, per +granularity+ (+hour+, +day+
|
|
151
|
+
# or +month+; default +day+) from +from+ to +to+: +2026-10+ for a month, +2026-10-15+ for a
|
|
152
|
+
# day, +2026-10-15T09+ for an hour. Each defaults to a range that ends now.
|
|
153
|
+
# @return [OrgUsage]
|
|
154
|
+
def get(granularity: nil, from: nil, to: nil, org_id: nil)
|
|
155
|
+
params = { "granularity" => granularity, "from" => from, "to" => to }
|
|
156
|
+
OrgUsage.parse(Parse.obj(@http.json("GET", "/v1/orgs/#{Util.q(org(org_id))}/usage", params: params)))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One page of what each of a Tenant's sessions spent on AI calls, oldest first, for the UTC
|
|
160
|
+
# days +from+ to +to+ (+YYYY-MM-DD+, both included, at most 93 days). Keep paging with
|
|
161
|
+
# +next_cursor+ until it is nil: a page can be empty and not be the last.
|
|
162
|
+
#
|
|
163
|
+
# With +session_ids+ (1 to 50) instead of the range, the named sessions' spend right now, each
|
|
164
|
+
# with +settled+, and the page's +as_of+; no paging. Charge for a task once its session is
|
|
165
|
+
# settled.
|
|
166
|
+
# @return [SessionSpendPage]
|
|
167
|
+
def sessions(tenant, from: nil, to: nil, limit: nil, cursor: nil, session_ids: nil, org_id: nil)
|
|
168
|
+
params = { "tenantId" => Util.id_of(tenant), "from" => from, "to" => to, "limit" => limit, "cursor" => cursor,
|
|
169
|
+
"sessionIds" => session_ids.nil? || session_ids.empty? ? nil : session_ids.join(",") }
|
|
170
|
+
d = @http.json("GET", "/v1/orgs/#{Util.q(org(org_id))}/usage/sessions", params: params)
|
|
171
|
+
SessionSpendPage.parse(Parse.obj(d))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def org(org_id)
|
|
177
|
+
id = org_id || @org_id
|
|
178
|
+
raise ArgumentError, "No Organisation id: pass org_id: here or to Sai::Client.new, or set SAI_ORG_ID." if id.nil? || id.empty?
|
|
179
|
+
|
|
180
|
+
id
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/sai/control.rb
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Sai
|
|
6
|
+
# Direct control: drive a cloud computer with Simulang. No agent. A lease on the machine: at
|
|
7
|
+
# most one per machine. Research preview; model calls the code makes are billed as usual.
|
|
8
|
+
class Control
|
|
9
|
+
OPEN_TIMEOUT = 240
|
|
10
|
+
|
|
11
|
+
# @api private
|
|
12
|
+
def initialize(http)
|
|
13
|
+
@http = http
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Take a cloud computer (a Machine or its id). Returns once the lease holds it, up to about
|
|
17
|
+
# three minutes when a Sai task holds it or it has to start. Sai's tasks on that computer wait
|
|
18
|
+
# until the lease is closed. With a block, yields the lease, closes it after, and returns the
|
|
19
|
+
# block's value.
|
|
20
|
+
# @yieldparam lease [ControlLease]
|
|
21
|
+
# @return [ControlLease]
|
|
22
|
+
def open(machine:)
|
|
23
|
+
machine_id = Util.id_of(machine)
|
|
24
|
+
d = Parse.obj(@http.json("POST", ControlLease.path(machine_id), timeout: OPEN_TIMEOUT))
|
|
25
|
+
lease = ControlLease.new(@http, machine_id, Parse.num(d["expiresAfterIdleSec"]))
|
|
26
|
+
return lease unless block_given?
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
yield lease
|
|
30
|
+
ensure
|
|
31
|
+
lease.close
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The result of ControlLease#shell.
|
|
37
|
+
ShellResult = Struct.new(:stdout, :stderr, :exit_code, :timed_out, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
# A lease on a cloud computer. Close it when done: Sai's tasks on that computer wait until then.
|
|
40
|
+
class ControlLease
|
|
41
|
+
attr_reader :machine_id, :idle_timeout_sec
|
|
42
|
+
|
|
43
|
+
# @api private
|
|
44
|
+
def self.path(machine_id)
|
|
45
|
+
"/v1/machines/#{Util.q(machine_id)}/control"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @api private
|
|
49
|
+
def initialize(http, machine_id, idle_timeout_sec)
|
|
50
|
+
@http = http
|
|
51
|
+
@machine_id = machine_id
|
|
52
|
+
@idle_timeout_sec = idle_timeout_sec
|
|
53
|
+
@path = self.class.path(machine_id)
|
|
54
|
+
@closed = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Run one Simulang (JavaScript) block. Use +var+ for anything the next block needs. A block
|
|
58
|
+
# that throws, times out or is interrupted still returns: check +result.error+. Raises
|
|
59
|
+
# GoneError once the lease has ended: open again.
|
|
60
|
+
# @return [ExecResult]
|
|
61
|
+
def exec(code, timeout_ms: nil)
|
|
62
|
+
body = { "code" => code }
|
|
63
|
+
body["timeoutMs"] = timeout_ms unless timeout_ms.nil?
|
|
64
|
+
d = @http.json("POST", "#{@path}/exec", json: body, timeout: ((timeout_ms || 120_000) + 45_000) / 1000.0)
|
|
65
|
+
ExecResult.parse(Parse.obj(d))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Run code from +sai.learn.create+: its +main+ with +inputs+ over the parameters' defaults.
|
|
69
|
+
# +main+'s return value is +result.result+. A string runs as code with +inputs+ as is.
|
|
70
|
+
# @return [ExecResult]
|
|
71
|
+
def run(learned, inputs = {}, timeout_ms: nil)
|
|
72
|
+
code = learned.is_a?(String) ? learned : learned.code
|
|
73
|
+
args = learned.is_a?(String) ? {} : learned.parameters.to_h { |p| [p.name, p.default_value] }
|
|
74
|
+
inputs.each { |k, v| args[k.to_s] = v }
|
|
75
|
+
exec("#{code}\nawait main(#{JSON.generate(args)})", timeout_ms: timeout_ms)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Run a shell command.
|
|
79
|
+
# @return [ShellResult]
|
|
80
|
+
def shell(command, cwd: nil, timeout_ms: nil)
|
|
81
|
+
args = { "command" => command }
|
|
82
|
+
args["cwd"] = cwd if cwd && !cwd.empty?
|
|
83
|
+
r = exec("await machine.exec(#{JSON.generate(args)})", timeout_ms: timeout_ms)
|
|
84
|
+
raise_on_block_error(r)
|
|
85
|
+
d = Parse.obj(r.result)
|
|
86
|
+
ShellResult.new(stdout: d["stdout"], stderr: d["stderr"], exit_code: d["exitCode"], timed_out: d["timedOut"])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String]
|
|
90
|
+
def read_file(path)
|
|
91
|
+
r = exec("await machine.readFile(#{JSON.generate({ 'path' => path })})")
|
|
92
|
+
raise_on_block_error(r)
|
|
93
|
+
r.result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [nil]
|
|
97
|
+
def write_file(path, text)
|
|
98
|
+
r = exec("await machine.writeToFile(#{JSON.generate({ 'path' => path, 'text' => text, 'overwrite' => true })})")
|
|
99
|
+
raise_on_block_error(r)
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The whole screen as image bytes (usually JPEG; use +exec+ for the MIME type). Needs the
|
|
104
|
+
# computer's screen-capture service; raises BlockError where it is off.
|
|
105
|
+
# @return [String] a binary String
|
|
106
|
+
def screenshot
|
|
107
|
+
r = exec("var __sai_page = await machine.pageContent(); null")
|
|
108
|
+
raise_on_block_error(r)
|
|
109
|
+
unless r.screenshot
|
|
110
|
+
raise BlockError.new("exception", r.screenshot_omitted ? "screenshot too large to return" : "no screenshot")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
r.screenshot
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Stop the running block at its next step.
|
|
117
|
+
# @return [Boolean] false if nothing was running
|
|
118
|
+
def interrupt
|
|
119
|
+
d = @http.json("POST", "#{@path}/interrupt")
|
|
120
|
+
Parse.truthy(Parse.obj(d)["interrupted"])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The lease's state: +{ "status" => "open"|"closed", "closedReason" => ..., ... }+.
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def get
|
|
126
|
+
@http.json("GET", @path)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Clear the REPL and release the computer. Safe to call twice.
|
|
130
|
+
# @return [nil]
|
|
131
|
+
def close
|
|
132
|
+
return if @closed
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
@http.request("DELETE", @path)
|
|
136
|
+
rescue GoneError
|
|
137
|
+
# already ended
|
|
138
|
+
end
|
|
139
|
+
@closed = true
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def inspect
|
|
144
|
+
"#<Sai::ControlLease machine_id=#{machine_id.inspect} idle_timeout_sec=#{idle_timeout_sec}>"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def raise_on_block_error(r)
|
|
150
|
+
raise BlockError.new(r.error.kind, r.error.message) if r.error
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|