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
data/lib/sai/types.rb
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sai
|
|
4
|
+
# @api private
|
|
5
|
+
# Reading the API's JSON: the same coercions as the other SDKs.
|
|
6
|
+
module Parse
|
|
7
|
+
# Wire event type → SDK kind. Anything not listed is framing or internal detail.
|
|
8
|
+
KINDS = {
|
|
9
|
+
"data-progress" => "step",
|
|
10
|
+
"text-delta" => "message",
|
|
11
|
+
"data-approval-request" => "approval",
|
|
12
|
+
"tool-output-error" => "tool_error",
|
|
13
|
+
"error" => "error",
|
|
14
|
+
"finish" => "finished"
|
|
15
|
+
}.freeze
|
|
16
|
+
# The last log lines of a run sent with an evaluation; the API takes 20 KB per result.
|
|
17
|
+
EVAL_LOG_LINES = 30
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def str(v)
|
|
22
|
+
v.is_a?(String) ? v : nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def num(v, default = 0)
|
|
26
|
+
v.is_a?(Numeric) ? v : default
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def num_or_nil(v)
|
|
30
|
+
v.is_a?(Numeric) ? v : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def obj(v)
|
|
34
|
+
v.is_a?(Hash) ? v : {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def list(v)
|
|
38
|
+
v.is_a?(Array) ? v : []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# JavaScript truthiness: nil, false, 0 and "" are false.
|
|
42
|
+
def truthy(v)
|
|
43
|
+
!(v.nil? || v == false || v == 0 || v == "")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def present(v)
|
|
47
|
+
s = str(v)
|
|
48
|
+
s.nil? || s.empty? ? nil : s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def usage(d)
|
|
52
|
+
return nil unless d.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
Usage.new(
|
|
55
|
+
input_tokens: num(d["inputTokens"]),
|
|
56
|
+
output_tokens: num(d["outputTokens"]),
|
|
57
|
+
cache_read_tokens: num(d["cacheReadTokens"]),
|
|
58
|
+
cache_write_tokens: num(d["cacheWriteTokens"]),
|
|
59
|
+
cost_usd: num(d["costUsd"]),
|
|
60
|
+
model_calls: num(d["modelCalls"]),
|
|
61
|
+
by_model: obj(d["byModel"])
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The SDK's event kinds. +other+ only appears with +events(raw: true)+.
|
|
67
|
+
EVENT_KINDS = %w[step message approval tool_error error finished other].freeze
|
|
68
|
+
# Turn statuses: +running+ while polling, then one of the three others.
|
|
69
|
+
TURN_STATUSES = %w[running idle needs_approval error].freeze
|
|
70
|
+
# Turn record statuses, from +GET /v1/agents/turns/{id}+.
|
|
71
|
+
TURN_RECORD_STATUSES = %w[running needs_input finalizing succeeded failed].freeze
|
|
72
|
+
|
|
73
|
+
# Plan and spendable credit. +credits_usd+ is nil on the unlimited plan or when billing could
|
|
74
|
+
# not be reached.
|
|
75
|
+
Account = Struct.new(:user_id, :plan_id, :plan_title, :free, :unlimited, :credits_usd, :raw,
|
|
76
|
+
keyword_init: true) do
|
|
77
|
+
def self.parse(d)
|
|
78
|
+
plan = Parse.obj(d["plan"])
|
|
79
|
+
credits = Parse.obj(d["credits"])
|
|
80
|
+
new(
|
|
81
|
+
user_id: Parse.str(d["userId"]) || "",
|
|
82
|
+
plan_id: Parse.str(plan["id"]),
|
|
83
|
+
plan_title: Parse.str(plan["title"]) || "",
|
|
84
|
+
free: Parse.truthy(plan["free"]),
|
|
85
|
+
unlimited: Parse.truthy(plan["unlimited"]),
|
|
86
|
+
credits_usd: Parse.num_or_nil(credits["usd"]),
|
|
87
|
+
raw: d
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A model a session can run on; +allowed+ says whether this plan may use it.
|
|
93
|
+
Model = Struct.new(:id, :name, :provider, :tier, :cost_tier, :allowed, :raw, keyword_init: true) do
|
|
94
|
+
def self.parse(d)
|
|
95
|
+
id = Parse.str(d["id"]) || ""
|
|
96
|
+
new(
|
|
97
|
+
id: id,
|
|
98
|
+
name: Parse.str(d["name"]) || id,
|
|
99
|
+
provider: Parse.str(d["provider"]) || "",
|
|
100
|
+
tier: Parse.str(d["tier"]) || "",
|
|
101
|
+
cost_tier: Parse.str(d["costTier"]) || "",
|
|
102
|
+
allowed: d.key?("allowed") ? Parse.truthy(d["allowed"]) : true,
|
|
103
|
+
raw: d
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A computer Sai works on. +kind+ is +cloud+ or +own+; +state+ is +running+, +starting+,
|
|
109
|
+
# +asleep+, +creating+ or +unknown+ (nil on servers that do not send it). +status+ is deprecated.
|
|
110
|
+
Machine = Struct.new(:id, :name, :kind, :online, :state, :status, :can_wake, :os, :raw,
|
|
111
|
+
keyword_init: true) do
|
|
112
|
+
def self.parse(d)
|
|
113
|
+
new(
|
|
114
|
+
id: Parse.str(d["machineId"]) || "",
|
|
115
|
+
name: Parse.str(d["name"]),
|
|
116
|
+
kind: Parse.str(d["kind"]) || "",
|
|
117
|
+
online: Parse.truthy(d["online"]),
|
|
118
|
+
state: Parse.str(d["state"]),
|
|
119
|
+
status: Parse.str(d["status"]),
|
|
120
|
+
can_wake: Parse.truthy(d["canWake"]),
|
|
121
|
+
os: Parse.str(d["os"]),
|
|
122
|
+
raw: d
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A computer's live screen. +live_url+ is a page a person can open without a Simular sign-in
|
|
128
|
+
# (nil where the server does not offer it), until +expires_at+ (Unix ms); +websocket_url+ is a
|
|
129
|
+
# Guacamole tunnel for a viewer of your own. Both carry a token: keep them private.
|
|
130
|
+
LiveView = Struct.new(:machine_id, :live_url, :expires_at, :websocket_url, :width, :height,
|
|
131
|
+
keyword_init: true)
|
|
132
|
+
|
|
133
|
+
# A file uploaded with +sai.files.upload+. Pass it in +attachments:+.
|
|
134
|
+
Attachment = Struct.new(:file_id, :name, :mime, :size, :raw, keyword_init: true) do
|
|
135
|
+
def self.parse(d)
|
|
136
|
+
new(
|
|
137
|
+
file_id: Parse.str(d["fileId"]) || "",
|
|
138
|
+
name: Parse.str(d["name"]) || "",
|
|
139
|
+
mime: Parse.str(d["mime"]) || "",
|
|
140
|
+
size: Parse.num(d["size"]),
|
|
141
|
+
raw: d
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Sai is waiting for permission, or for an answer to a choice question. +type+ is +exec+,
|
|
147
|
+
# +choice+, ...; a +choice+ needs +selections+ when approved. +is_link_only+: a human must
|
|
148
|
+
# answer in a browser at +url+.
|
|
149
|
+
Approval = Struct.new(:id, :title, :description, :type, :is_link_only, :url, :command, :questions,
|
|
150
|
+
:raw, keyword_init: true) do
|
|
151
|
+
def self.parse(d)
|
|
152
|
+
new(
|
|
153
|
+
id: Parse.str(d["approvalId"]) || "",
|
|
154
|
+
title: Parse.str(d["title"]) || "",
|
|
155
|
+
description: Parse.str(d["description"]) || "",
|
|
156
|
+
type: Parse.str(d["approvalType"]) || "",
|
|
157
|
+
is_link_only: Parse.truthy(d["isLinkOnly"]),
|
|
158
|
+
url: Parse.str(d["approvalUrl"]),
|
|
159
|
+
command: Parse.str(d["command"]),
|
|
160
|
+
questions: Parse.list(d["questions"]),
|
|
161
|
+
raw: d
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def link_only?
|
|
166
|
+
is_link_only
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Tokens and cost of model calls.
|
|
171
|
+
Usage = Struct.new(:input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens,
|
|
172
|
+
:cost_usd, :model_calls, :by_model, keyword_init: true)
|
|
173
|
+
|
|
174
|
+
# One thing that happened in a turn. +kind+ is the SDK's name (see EVENT_KINDS); +type+ is the
|
|
175
|
+
# wire type. +code+ is set on errors such as +insufficient_credits+.
|
|
176
|
+
Event = Struct.new(:kind, :type, :text, :approval, :code, :raw, keyword_init: true) do
|
|
177
|
+
def self.parse(d)
|
|
178
|
+
t = Parse.str(d["type"]) || ""
|
|
179
|
+
data = Parse.obj(d["data"])
|
|
180
|
+
text = nil
|
|
181
|
+
approval = nil
|
|
182
|
+
case t
|
|
183
|
+
when "data-progress" then text = Parse.str(data["text"])
|
|
184
|
+
when "text-delta", "reasoning-delta" then text = Parse.str(d["delta"])
|
|
185
|
+
when "tool-output-error", "error" then text = Parse.str(d["errorText"])
|
|
186
|
+
when "data-approval-request"
|
|
187
|
+
if Parse.truthy(data["approvalId"])
|
|
188
|
+
approval = Approval.parse(data)
|
|
189
|
+
text = approval.title
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
new(kind: Parse::KINDS[t] || "other", type: t, text: text, approval: approval,
|
|
193
|
+
code: Parse.str(d["code"]), raw: d)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Where a turn stopped. +status+ is +idle+ (done: +text+ is the answer), +error+, or
|
|
198
|
+
# +needs_approval+ (waiting on +approval+; answer it and call +wait+ again). +record+ is the
|
|
199
|
+
# turn's record for a structured message.
|
|
200
|
+
TurnResult = Struct.new(:status, :text, :session_id, :approval, :error, :error_code, :usage,
|
|
201
|
+
:session_usage, :record, keyword_init: true) do
|
|
202
|
+
# Idle, and for a structured message, the turn succeeded.
|
|
203
|
+
def ok
|
|
204
|
+
status == "idle" && (record.nil? || record.ok)
|
|
205
|
+
end
|
|
206
|
+
alias_method :ok?, :ok
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# One yes/no constraint a turn is checked against. +id+: letters, digits, +_+ or +-+.
|
|
210
|
+
# +return_evidence+ also returns one sentence of evidence (an extra model call).
|
|
211
|
+
Check = Struct.new(:id, :check, :return_evidence, keyword_init: true) do
|
|
212
|
+
# @api private
|
|
213
|
+
def self.to_api(c)
|
|
214
|
+
return c if c.is_a?(String)
|
|
215
|
+
|
|
216
|
+
h = c.is_a?(Check) ? c.to_h : c.to_h.transform_keys(&:to_s).transform_keys(&:to_sym)
|
|
217
|
+
out = { "id" => h[:id], "check" => h[:check] }
|
|
218
|
+
evidence = h.key?(:return_evidence) ? h[:return_evidence] : h[:returnEvidence]
|
|
219
|
+
out["returnEvidence"] = evidence unless evidence.nil?
|
|
220
|
+
out
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @api private
|
|
224
|
+
def self.list_to_api(criteria)
|
|
225
|
+
criteria.is_a?(String) ? criteria : criteria.map { |c| to_api(c) }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The verdict on one success criterion. +id+ is nil for a natural-language criteria; +score+
|
|
230
|
+
# is set for checks only.
|
|
231
|
+
Evidence = Struct.new(:id, :passed, :score, :evidence, keyword_init: true)
|
|
232
|
+
|
|
233
|
+
# A turn's record. +status+ is one of TURN_RECORD_STATUSES. On +failed+, +reason_code+ says why.
|
|
234
|
+
TurnRecord = Struct.new(:id, :session_id, :machine_id, :status, :output, :reason_code, :raw_answer,
|
|
235
|
+
:error_code, :output_errors, :evidence, :usage, :record_trajectory,
|
|
236
|
+
:trajectory_recorded, :template, :inputs, :replayed, :raw,
|
|
237
|
+
keyword_init: true) do
|
|
238
|
+
def self.parse(d)
|
|
239
|
+
status = Parse.str(d["status"]) || "running"
|
|
240
|
+
trajectory = Parse.obj(d["trajectory"])
|
|
241
|
+
new(
|
|
242
|
+
id: Parse.str(d["turnId"]) || "",
|
|
243
|
+
session_id: Parse.str(d["sessionId"]),
|
|
244
|
+
machine_id: Parse.str(d["machineId"]) || "",
|
|
245
|
+
status: status,
|
|
246
|
+
output: d["output"],
|
|
247
|
+
reason_code: Parse.str(d["reason_code"]),
|
|
248
|
+
raw_answer: Parse.str(d["raw_answer"]),
|
|
249
|
+
error_code: Parse.str(d["error_code"]),
|
|
250
|
+
output_errors: Parse.list(d["output_errors"]),
|
|
251
|
+
evidence: Parse.list(d["evidence"]).map do |e|
|
|
252
|
+
e = Parse.obj(e)
|
|
253
|
+
Evidence.new(id: Parse.str(e["id"]), passed: Parse.truthy(e["passed"]),
|
|
254
|
+
score: Parse.num_or_nil(e["score"]), evidence: Parse.str(e["evidence"]))
|
|
255
|
+
end,
|
|
256
|
+
usage: Parse.usage(d["usage"]),
|
|
257
|
+
record_trajectory: Parse.truthy(d["recordTrajectory"]),
|
|
258
|
+
trajectory_recorded: Parse.truthy(trajectory["recorded"]),
|
|
259
|
+
template: Parse.str(d["template"]),
|
|
260
|
+
inputs: d["inputs"].is_a?(Hash) ? d["inputs"] : nil,
|
|
261
|
+
replayed: Parse.truthy(d["idempotent_replay"]),
|
|
262
|
+
raw: d
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# +succeeded+ or +failed+.
|
|
267
|
+
def done
|
|
268
|
+
status == "succeeded" || status == "failed"
|
|
269
|
+
end
|
|
270
|
+
alias_method :done?, :done
|
|
271
|
+
|
|
272
|
+
def ok
|
|
273
|
+
status == "succeeded"
|
|
274
|
+
end
|
|
275
|
+
alias_method :ok?, :ok
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# One step the agent took. Fetch its screen with +sai.turns.screenshot+.
|
|
279
|
+
TrajectoryStep = Struct.new(:index, :final, :timestamp, :title, :code, :rationale, :error,
|
|
280
|
+
:has_screenshot, :raw, keyword_init: true) do
|
|
281
|
+
def self.parse(d)
|
|
282
|
+
new(
|
|
283
|
+
index: Parse.num(d["index"]),
|
|
284
|
+
final: Parse.truthy(d["final"]),
|
|
285
|
+
timestamp: Parse.str(d["timestamp"]) || "",
|
|
286
|
+
title: Parse.str(d["title"]),
|
|
287
|
+
code: Parse.str(d["code"]),
|
|
288
|
+
rationale: Parse.str(d["rationale"]),
|
|
289
|
+
error: Parse.str(d["error"]),
|
|
290
|
+
has_screenshot: Parse.truthy(d["screenshot"]),
|
|
291
|
+
raw: d
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Why a block failed: +kind+ is +exception+, +timeout+ or +interrupted+.
|
|
297
|
+
ExecError = Struct.new(:kind, :message, :stack, keyword_init: true)
|
|
298
|
+
|
|
299
|
+
# What one Simulang block returned. A block that threw is still a result: check +error+.
|
|
300
|
+
# +screenshot+ is the captured image as a binary String (+screenshot_mime_type+ says which).
|
|
301
|
+
ExecResult = Struct.new(:result, :logs, :error, :screenshot, :screenshot_mime_type,
|
|
302
|
+
:screenshot_omitted, :variables, :duration_ms, :raw, keyword_init: true) do
|
|
303
|
+
def self.parse(d)
|
|
304
|
+
obs = Parse.obj(d["observations"])
|
|
305
|
+
shot = Parse.obj(obs["screenshot"])
|
|
306
|
+
err = Parse.truthy(d["error"]) ? Parse.obj(d["error"]) : nil
|
|
307
|
+
b64 = Parse.str(shot["base64"])
|
|
308
|
+
new(
|
|
309
|
+
result: d["result"],
|
|
310
|
+
logs: Parse.list(d["logs"]),
|
|
311
|
+
error: err && ExecError.new(kind: Parse.str(err["kind"]) || "",
|
|
312
|
+
message: Parse.str(err["message"]) || "",
|
|
313
|
+
stack: Parse.str(err["stack"])),
|
|
314
|
+
screenshot: b64.nil? || b64.empty? ? nil : b64.unpack1("m"),
|
|
315
|
+
screenshot_mime_type: Parse.str(shot["mimeType"]),
|
|
316
|
+
screenshot_omitted: Parse.truthy(obs["screenshotOmitted"]),
|
|
317
|
+
variables: Parse.list(d["variables"]),
|
|
318
|
+
duration_ms: Parse.num(d["durationMs"]),
|
|
319
|
+
raw: d
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def ok
|
|
324
|
+
error.nil?
|
|
325
|
+
end
|
|
326
|
+
alias_method :ok?, :ok
|
|
327
|
+
|
|
328
|
+
# @deprecated Use +screenshot+; it is not always PNG.
|
|
329
|
+
def screenshot_png
|
|
330
|
+
screenshot
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# One input of learned code: a +{{name}}+ in the template and a key of +main+'s params.
|
|
335
|
+
LearnParameter = Struct.new(:name, :default_value, :description, keyword_init: true)
|
|
336
|
+
|
|
337
|
+
# How learned code did on one run, for +sai.learn.create(initial_code:, evaluations:)+.
|
|
338
|
+
# +result+ is an ExecResult (its screenshot is not sent); +judgement+ a Judgement or its id.
|
|
339
|
+
Evaluation = Struct.new(:inputs, :result, :judgement, :passed, :note, keyword_init: true) do
|
|
340
|
+
# @api private
|
|
341
|
+
def self.to_api(e)
|
|
342
|
+
e = new(**e.to_h.transform_keys(&:to_sym)) unless e.is_a?(Evaluation)
|
|
343
|
+
out = { "inputs" => e.inputs }
|
|
344
|
+
if (r = e.result)
|
|
345
|
+
run = { "result" => r.result, "logs" => r.logs.last(Parse::EVAL_LOG_LINES) }
|
|
346
|
+
run["error"] = { "kind" => r.error.kind, "message" => r.error.message } if r.error
|
|
347
|
+
out["result"] = run
|
|
348
|
+
end
|
|
349
|
+
unless e.judgement.nil?
|
|
350
|
+
out["judgement"] = e.judgement.is_a?(String) ? e.judgement : e.judgement.id
|
|
351
|
+
end
|
|
352
|
+
out["passed"] = e.passed unless e.passed.nil?
|
|
353
|
+
out["note"] = e.note unless e.note.nil?
|
|
354
|
+
out
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# What a +reset+ did before a run or before the agent run. +kind+ is +code+ or +task+.
|
|
359
|
+
LearnReset = Struct.new(:kind, :ok, :duration_ms, :turn_id, :error, keyword_init: true) do
|
|
360
|
+
def self.parse(v)
|
|
361
|
+
return nil unless v.is_a?(Hash)
|
|
362
|
+
|
|
363
|
+
new(
|
|
364
|
+
kind: Parse.str(v["kind"]) == "task" ? "task" : "code",
|
|
365
|
+
ok: Parse.truthy(v["ok"]),
|
|
366
|
+
duration_ms: Parse.num_or_nil(v["durationMs"]),
|
|
367
|
+
turn_id: Parse.str(v["turnId"]),
|
|
368
|
+
error: Parse.str(v["error"])
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# One run of a version of the code in a learn job. +verdict+ is +passed+, +failed+, +error+,
|
|
374
|
+
# +stopped+, or nil while the run is going.
|
|
375
|
+
LearnRollout = Struct.new(:index, :verdict, :reset, :error, :result, :duration_ms, :judgement_id,
|
|
376
|
+
:summary, :continuation_turn_id, :continuation_timed_out,
|
|
377
|
+
keyword_init: true) do
|
|
378
|
+
def self.parse(r)
|
|
379
|
+
new(
|
|
380
|
+
index: Parse.num(r["index"]),
|
|
381
|
+
verdict: Parse.str(r["verdict"]),
|
|
382
|
+
reset: LearnReset.parse(r["reset"]),
|
|
383
|
+
error: Parse.str(r["error"]),
|
|
384
|
+
result: r["result"],
|
|
385
|
+
duration_ms: Parse.num_or_nil(r["durationMs"]),
|
|
386
|
+
judgement_id: Parse.str(r["judgementId"]),
|
|
387
|
+
summary: Parse.str(r["summary"]),
|
|
388
|
+
continuation_turn_id: Parse.str(r["continuationTurnId"]),
|
|
389
|
+
continuation_timed_out: Parse.truthy(r["continuationTimedOut"])
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# One version of the code in a learn job and its runs. +index+ is 1-based.
|
|
395
|
+
LearnIteration = Struct.new(:index, :learn_id, :changes, :passed, :runs, :rollouts,
|
|
396
|
+
keyword_init: true) do
|
|
397
|
+
def self.parse(d)
|
|
398
|
+
new(
|
|
399
|
+
index: Parse.num(d["index"]),
|
|
400
|
+
learn_id: Parse.str(d["learnId"]),
|
|
401
|
+
changes: Parse.str(d["changes"]),
|
|
402
|
+
passed: Parse.num(d["passed"]),
|
|
403
|
+
runs: Parse.num(d["runs"]),
|
|
404
|
+
rollouts: Parse.list(d["rollouts"]).map { |r| LearnRollout.parse(Parse.obj(r)) }
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# How the returned code did when it was run, on +inputs+ only: +not_judged+, +passed+ or +failed+.
|
|
410
|
+
LearnTestResult = Struct.new(:status, :passed_runs, :required_runs, :inputs, keyword_init: true)
|
|
411
|
+
# While a job runs: the step it is on.
|
|
412
|
+
LearnProgress = Struct.new(:step, :iteration, :rollout, keyword_init: true)
|
|
413
|
+
# With +judge_initial_agent_run+: the judgement of the agent run.
|
|
414
|
+
AgentJudgement = Struct.new(:judgement_id, :verdict, :summary, keyword_init: true)
|
|
415
|
+
# Problems found in learned code. Code with +errors+ comes back +failed+.
|
|
416
|
+
LearnValidation = Struct.new(:errors, :warnings, keyword_init: true)
|
|
417
|
+
|
|
418
|
+
# The result of +sai.learn.create+: Simulang code made from recorded turns or an agent run, a
|
|
419
|
+
# repair of code you sent, or a learn job. Run the code with +lease.run(learned, inputs)+.
|
|
420
|
+
# +status+ is +succeeded+, +failed+, +running+ or +stopped+. Research preview.
|
|
421
|
+
LearnResult = Struct.new(:id, :mode, :status, :stop_reason, :message, :test_result, :progress,
|
|
422
|
+
:agent_turn_id, :agent_run_reset, :agent_judgement, :version, :iterations,
|
|
423
|
+
:title, :template, :parameters, :code, :output_schema, :validation,
|
|
424
|
+
:changes, :source_turns, :usage, :error, :replayed, :raw,
|
|
425
|
+
keyword_init: true) do
|
|
426
|
+
def self.parse(d)
|
|
427
|
+
v = Parse.obj(d["validation"])
|
|
428
|
+
mode = Parse.str(d["mode"])
|
|
429
|
+
t = Parse.truthy(d["testResult"]) ? Parse.obj(d["testResult"]) : nil
|
|
430
|
+
p = Parse.truthy(d["progress"]) ? Parse.obj(d["progress"]) : nil
|
|
431
|
+
aj = Parse.truthy(d["agentJudgement"]) ? Parse.obj(d["agentJudgement"]) : nil
|
|
432
|
+
new(
|
|
433
|
+
id: Parse.str(d["learnId"]) || "",
|
|
434
|
+
mode: %w[heal policyIteration].include?(mode) ? mode : "convert",
|
|
435
|
+
status: Parse.str(d["status"]) || "running",
|
|
436
|
+
stop_reason: Parse.str(d["stopReason"]),
|
|
437
|
+
message: Parse.str(d["message"]),
|
|
438
|
+
test_result: t && LearnTestResult.new(
|
|
439
|
+
status: Parse.str(t["status"]) || "not_judged",
|
|
440
|
+
passed_runs: Parse.num(t["passedRuns"]),
|
|
441
|
+
required_runs: Parse.num(t["requiredRuns"]),
|
|
442
|
+
inputs: Parse.truthy(t["inputs"]) ? Parse.obj(t["inputs"]) : nil
|
|
443
|
+
),
|
|
444
|
+
progress: p && LearnProgress.new(
|
|
445
|
+
step: Parse.str(p["step"]) || "",
|
|
446
|
+
iteration: Parse.num_or_nil(p["iteration"]),
|
|
447
|
+
rollout: Parse.num_or_nil(p["rollout"])
|
|
448
|
+
),
|
|
449
|
+
agent_turn_id: Parse.str(d["agentTurnId"]),
|
|
450
|
+
agent_run_reset: LearnReset.parse(d["agentRunReset"]),
|
|
451
|
+
agent_judgement: aj && AgentJudgement.new(
|
|
452
|
+
judgement_id: Parse.str(aj["judgementId"]) || "",
|
|
453
|
+
verdict: Parse.str(aj["verdict"]) || "",
|
|
454
|
+
summary: Parse.str(aj["summary"])
|
|
455
|
+
),
|
|
456
|
+
version: Parse.num_or_nil(d["version"]),
|
|
457
|
+
iterations: Parse.list(d["iterations"]).map { |i| LearnIteration.parse(Parse.obj(i)) },
|
|
458
|
+
title: Parse.str(d["title"]),
|
|
459
|
+
template: Parse.str(d["template"]),
|
|
460
|
+
parameters: Parse.list(d["parameters"]).map do |x|
|
|
461
|
+
x = Parse.obj(x)
|
|
462
|
+
LearnParameter.new(name: Parse.str(x["name"]) || "",
|
|
463
|
+
default_value: Parse.str(x["defaultValue"]) || "",
|
|
464
|
+
description: Parse.str(x["description"]))
|
|
465
|
+
end,
|
|
466
|
+
code: Parse.str(d["code"]) || "",
|
|
467
|
+
output_schema: Parse.truthy(d["outputSchema"]) ? Parse.obj(d["outputSchema"]) : nil,
|
|
468
|
+
validation: LearnValidation.new(errors: Parse.list(v["errors"]), warnings: Parse.list(v["warnings"])),
|
|
469
|
+
changes: Parse.str(d["changes"]),
|
|
470
|
+
source_turns: Parse.list(d["sourceTurns"]),
|
|
471
|
+
usage: Parse.usage(d["usage"]),
|
|
472
|
+
error: Parse.str(d["error"]),
|
|
473
|
+
replayed: Parse.truthy(d["idempotent_replay"]),
|
|
474
|
+
raw: d
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def ok
|
|
479
|
+
status == "succeeded"
|
|
480
|
+
end
|
|
481
|
+
alias_method :ok?, :ok
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# One run of a learn job's code, from +sai.learn.rollout+: +run+ is what it returned and
|
|
485
|
+
# logged, +judgement+ the judge's record of it.
|
|
486
|
+
LearnRun = Struct.new(:learn_id, :iteration, :rollout, :run, :judgement, :raw, keyword_init: true) do
|
|
487
|
+
def self.parse(d)
|
|
488
|
+
new(
|
|
489
|
+
learn_id: Parse.str(d["learnId"]) || "",
|
|
490
|
+
iteration: Parse.num(d["iteration"]),
|
|
491
|
+
rollout: Parse.num(d["rollout"]),
|
|
492
|
+
run: Parse.obj(d["run"]),
|
|
493
|
+
judgement: Parse.truthy(d["judgement"]) ? Parse.obj(d["judgement"]) : nil,
|
|
494
|
+
raw: d
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The verdict on one criterion of a judgement: +passed+, +failed+ or +unverifiable+.
|
|
500
|
+
CriterionResult = Struct.new(:id, :check, :verdict, :score, :evidence, keyword_init: true)
|
|
501
|
+
|
|
502
|
+
# The result of +sai.judge.create+. It never changes the turns it judged. Research preview.
|
|
503
|
+
Judgement = Struct.new(:id, :verdict, :summary, :task, :criteria, :turns, :usage, :replayed, :raw,
|
|
504
|
+
keyword_init: true) do
|
|
505
|
+
def self.parse(d)
|
|
506
|
+
new(
|
|
507
|
+
id: Parse.str(d["judgementId"]) || "",
|
|
508
|
+
verdict: Parse.str(d["verdict"]),
|
|
509
|
+
summary: Parse.str(d["summary"]),
|
|
510
|
+
task: Parse.str(d["task"]),
|
|
511
|
+
criteria: Parse.list(d["criteria"]).map do |c|
|
|
512
|
+
c = Parse.obj(c)
|
|
513
|
+
CriterionResult.new(id: Parse.str(c["id"]), check: Parse.str(c["check"]) || "",
|
|
514
|
+
verdict: Parse.str(c["verdict"]) || "unverifiable",
|
|
515
|
+
score: Parse.num_or_nil(c["score"]), evidence: Parse.str(c["evidence"]))
|
|
516
|
+
end,
|
|
517
|
+
turns: Parse.list(d["turns"]),
|
|
518
|
+
usage: Parse.usage(d["usage"]),
|
|
519
|
+
replayed: Parse.truthy(d["idempotent_replay"]),
|
|
520
|
+
raw: d
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def ok
|
|
525
|
+
verdict == "passed"
|
|
526
|
+
end
|
|
527
|
+
alias_method :ok?, :ok
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Who the key or token acts as. +auth_type+ is +apiKey+ for a key, +firebase+ for a signed-in
|
|
531
|
+
# session or a Tenant token; +tenant_id+ and +org_id+ are set for a Tenant token.
|
|
532
|
+
AuthInfo = Struct.new(:user_id, :auth_type, :tenant_id, :org_id, :raw, keyword_init: true) do
|
|
533
|
+
def self.parse(d)
|
|
534
|
+
new(user_id: Parse.str(d["userId"]) || "", auth_type: Parse.str(d["authType"]) || "",
|
|
535
|
+
tenant_id: Parse.str(d["tenantId"]), org_id: Parse.str(d["orgId"]), raw: d)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# A session on a computer, from +sai.sessions.list+. +active+: the next message goes to it.
|
|
540
|
+
SessionSummary = Struct.new(:id, :title, :updated_at, :active, :raw, keyword_init: true) do
|
|
541
|
+
def self.parse(d)
|
|
542
|
+
new(id: Parse.str(d["sessionId"]) || "", title: Parse.str(d["title"]) || "",
|
|
543
|
+
updated_at: Parse.num(d["updatedAt"]), active: Parse.truthy(d["active"]), raw: d)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# One of your customers under an Organisation. Times are Unix milliseconds;
|
|
548
|
+
# +cloud_machine_limit+ nil means no limit.
|
|
549
|
+
Tenant = Struct.new(:id, :name, :external_id, :metadata, :status, :created_at, :disabled_at,
|
|
550
|
+
:machine_count, :cloud_machine_limit, :residential_proxy, :cloud_machine_count,
|
|
551
|
+
:raw, keyword_init: true) do
|
|
552
|
+
def self.parse(d)
|
|
553
|
+
new(
|
|
554
|
+
id: Parse.str(d["tenantId"]) || "",
|
|
555
|
+
name: Parse.str(d["name"]) || "",
|
|
556
|
+
external_id: Parse.str(d["externalId"]),
|
|
557
|
+
metadata: Parse.obj(d["metadata"]),
|
|
558
|
+
status: Parse.str(d["status"]) || "",
|
|
559
|
+
created_at: Parse.num(d["createdAt"]),
|
|
560
|
+
disabled_at: Parse.num_or_nil(d["disabledAt"]),
|
|
561
|
+
machine_count: Parse.num(d["machineCount"]),
|
|
562
|
+
cloud_machine_limit: Parse.num_or_nil(d["cloudMachineLimit"]),
|
|
563
|
+
residential_proxy: [true, false].include?(d["residentialProxy"]) ? d["residentialProxy"] : nil,
|
|
564
|
+
cloud_machine_count: Parse.num_or_nil(d["cloudMachineCount"]),
|
|
565
|
+
raw: d
|
|
566
|
+
)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# One page of Tenants. Pass +next_cursor+ as +cursor:+ for the next; nil on the last page.
|
|
571
|
+
TenantPage = Struct.new(:tenants, :next_cursor, keyword_init: true)
|
|
572
|
+
|
|
573
|
+
# A Tenant's computer: enrolled, or a cloud machine Sai hosts. +kind+ is +enrolled+ or +cloud+;
|
|
574
|
+
# +state+ is +online+/+offline+ (enrolled) or +provisioning+, +ready+, +starting+,
|
|
575
|
+
# +hibernated+, +deleting+ or +failed+ (cloud).
|
|
576
|
+
TenantMachine = Struct.new(:id, :name, :platform, :status, :online, :last_seen, :runtime_version,
|
|
577
|
+
:kind, :state, :machine_class, :created_at, :raw, keyword_init: true) do
|
|
578
|
+
def self.parse(d)
|
|
579
|
+
# Servers before cloud machines send online and status and no kind or state.
|
|
580
|
+
state = Parse.present(d["state"]) || (Parse.truthy(d["online"]) ? "online" : "offline")
|
|
581
|
+
new(
|
|
582
|
+
id: Parse.str(d["machineId"]) || "",
|
|
583
|
+
name: Parse.str(d["name"]),
|
|
584
|
+
platform: Parse.str(d["platform"]),
|
|
585
|
+
status: Parse.str(d["status"]) || "",
|
|
586
|
+
online: d.key?("online") ? Parse.truthy(d["online"]) : %w[online ready].include?(state),
|
|
587
|
+
last_seen: Parse.num_or_nil(d["lastSeen"]),
|
|
588
|
+
runtime_version: Parse.str(d["runtimeVersion"]),
|
|
589
|
+
kind: d["kind"] == "cloud" ? "cloud" : "enrolled",
|
|
590
|
+
state: state,
|
|
591
|
+
machine_class: Parse.str(d["machineClass"]),
|
|
592
|
+
created_at: Parse.num_or_nil(d["createdAt"]),
|
|
593
|
+
raw: d
|
|
594
|
+
)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# A single-use code that enrolls one computer into a Tenant. Treat +token+ as a secret.
|
|
599
|
+
Enrollment = Struct.new(:token, :expires_at, :raw, keyword_init: true) do
|
|
600
|
+
def self.parse(d)
|
|
601
|
+
new(token: Parse.str(d["enrollmentToken"]) || "", expires_at: Parse.num(d["expiresAt"]), raw: d)
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# A one-hour token that acts as a Tenant: pass +token+ as +Sai::Client.new(api_key:)+.
|
|
606
|
+
TenantToken = Struct.new(:token, :expires_at, :tenant_id, :raw, keyword_init: true) do
|
|
607
|
+
def self.parse(d)
|
|
608
|
+
new(token: Parse.str(d["token"]) || "", expires_at: Parse.num(d["expiresAt"]),
|
|
609
|
+
tenant_id: Parse.str(d["tenantId"]) || "", raw: d)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# One Tenant's spend in an Organisation usage report. +by_category+ maps a kind of spend
|
|
614
|
+
# (+token+, +compute+) to a CategoryUsage.
|
|
615
|
+
TenantUsage = Struct.new(:tenant_id, :name, :external_id, :cost_cents, :by_category, keyword_init: true)
|
|
616
|
+
# Spend of one category.
|
|
617
|
+
CategoryUsage = Struct.new(:cost_cents, keyword_init: true)
|
|
618
|
+
|
|
619
|
+
# One period of an Organisation usage report, or the whole range (+totals+, whose +bucket+ is
|
|
620
|
+
# nil). +by_tenant+ is keyed by Tenant id.
|
|
621
|
+
UsageBucket = Struct.new(:bucket, :cost_cents, :by_tenant, keyword_init: true) do
|
|
622
|
+
def self.parse(d)
|
|
623
|
+
by_tenant = {}
|
|
624
|
+
Parse.obj(d["byTenant"]).each do |key, v|
|
|
625
|
+
t = Parse.obj(v)
|
|
626
|
+
by_category = {}
|
|
627
|
+
Parse.obj(t["byCategory"]).each do |cat, c|
|
|
628
|
+
by_category[cat] = CategoryUsage.new(cost_cents: Parse.num(Parse.obj(c)["costCents"]))
|
|
629
|
+
end
|
|
630
|
+
by_tenant[key] = TenantUsage.new(
|
|
631
|
+
tenant_id: Parse.str(t["tenantId"]) || key,
|
|
632
|
+
name: Parse.str(t["name"]),
|
|
633
|
+
external_id: Parse.str(t["externalId"]),
|
|
634
|
+
cost_cents: Parse.num(t["costCents"]),
|
|
635
|
+
by_category: by_category
|
|
636
|
+
)
|
|
637
|
+
end
|
|
638
|
+
new(bucket: Parse.str(d["bucket"]), cost_cents: Parse.num(d["costCents"]), by_tenant: by_tenant)
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# What an Organisation is charged, per Tenant, from +sai.usage.get+.
|
|
643
|
+
OrgUsage = Struct.new(:granularity, :from, :to, :totals, :buckets, :raw, keyword_init: true) do
|
|
644
|
+
def self.parse(d)
|
|
645
|
+
new(
|
|
646
|
+
granularity: Parse.str(d["granularity"]) || "",
|
|
647
|
+
from: Parse.str(d["from"]) || "",
|
|
648
|
+
to: Parse.str(d["to"]) || "",
|
|
649
|
+
totals: UsageBucket.parse(Parse.obj(d["totals"])),
|
|
650
|
+
buckets: Parse.list(d["buckets"]).map { |b| UsageBucket.parse(Parse.obj(b)) },
|
|
651
|
+
raw: d
|
|
652
|
+
)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# What one of a Tenant's sessions spent on AI calls: in a date range, or so far when looked up
|
|
657
|
+
# by +session_ids+. +cost_usd+ is in US dollars, not rounded to cents; it and +model_calls+ are
|
|
658
|
+
# nil only from a +session_ids+ lookup, when the session can't be read in full right now (never
|
|
659
|
+
# 0 for that), and +settled+ is then false. +first_day+ and +last_day+ are the UTC days
|
|
660
|
+
# (+YYYY-MM-DD+) the session's spend was first and last counted, over its whole life; empty from
|
|
661
|
+
# a +session_ids+ lookup. +settled+, from a +session_ids+ lookup: true once the session's latest
|
|
662
|
+
# task has ended and every charge for it has come in; nil on a list page.
|
|
663
|
+
SessionSpend = Struct.new(:session_id, :title, :cost_usd, :model_calls, :first_day, :last_day, :settled,
|
|
664
|
+
keyword_init: true)
|
|
665
|
+
|
|
666
|
+
# One page of +sai.usage.sessions+. A page can be empty and still have a +next_cursor+.
|
|
667
|
+
# +data_through+ (ISO 8601): the figures are complete up to this time; nil when unknown, and from
|
|
668
|
+
# a +session_ids+ lookup. +as_of+ (ISO 8601), from a +session_ids+ lookup: when the figures were
|
|
669
|
+
# read; nil on a list page.
|
|
670
|
+
SessionSpendPage = Struct.new(:sessions, :next_cursor, :data_through, :raw, :as_of, keyword_init: true) do
|
|
671
|
+
def self.parse(d)
|
|
672
|
+
sessions = Parse.list(d["sessions"]).map do |s|
|
|
673
|
+
s = Parse.obj(s)
|
|
674
|
+
SessionSpend.new(
|
|
675
|
+
session_id: Parse.str(s["sessionId"]) || "",
|
|
676
|
+
title: Parse.present(s["title"]),
|
|
677
|
+
cost_usd: Parse.num_or_nil(s["costUsd"]),
|
|
678
|
+
model_calls: Parse.num_or_nil(s["modelCalls"]),
|
|
679
|
+
first_day: Parse.str(s["firstDay"]) || "",
|
|
680
|
+
last_day: Parse.str(s["lastDay"]) || "",
|
|
681
|
+
settled: [true, false].include?(s["settled"]) ? s["settled"] : nil
|
|
682
|
+
)
|
|
683
|
+
end
|
|
684
|
+
new(sessions: sessions, next_cursor: Parse.str(d["nextCursor"]),
|
|
685
|
+
data_through: Parse.str(d["dataThrough"]), raw: d, as_of: Parse.str(d["asOf"]))
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
end
|