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