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/errors.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ # A non-2xx response from the Sai API (the surface's +SaiError+). +code+ is the machine-readable
5
+ # +error+ string when the server sent one (+machine_busy+, +link_only+, ...); +message+ is the
6
+ # human text; +body+ is the parsed JSON body, unchanged.
7
+ class Error < StandardError
8
+ attr_reader :status, :code, :body, :retry_after
9
+
10
+ def initialize(message = nil, status: 0, code: nil, body: {}, retry_after: nil)
11
+ super(message)
12
+ @status = status
13
+ @code = code
14
+ @body = body || {}
15
+ @retry_after = retry_after
16
+ end
17
+ end
18
+
19
+ # The API could not be reached (DNS, TLS, timeout).
20
+ class ConnectionError < Error; end
21
+ # 400: the request was invalid, e.g. several computers and no +machine+.
22
+ class BadRequestError < Error; end
23
+ # 401: no key, or the key is unknown or revoked.
24
+ class AuthenticationError < Error; end
25
+ # 403: the key is valid but the account may not do this (plan, ownership).
26
+ class PermissionDeniedError < Error; end
27
+ # 404.
28
+ class NotFoundError < Error; end
29
+ # 409: e.g. +machine_busy+, +link_only+, approval no longer pending.
30
+ class ConflictError < Error; end
31
+ # 410 +control_closed+: the control lease has ended; +body["reason"]+ says why. Open a new one.
32
+ class GoneError < Error; end
33
+ # 429: a rate limit, or a free-plan budget that resets daily.
34
+ class RateLimitError < Error; end
35
+ # 503: the computer did not come online, or no capacity. Honor +retry_after+.
36
+ class ServiceUnavailableError < Error; end
37
+
38
+ # A control helper's Simulang block failed on the computer. +kind+ is +exception+, +timeout+ or
39
+ # +interrupted+.
40
+ class BlockError < Error
41
+ attr_reader :kind
42
+
43
+ def initialize(kind, message)
44
+ super("#{kind}: #{message}", code: kind)
45
+ @kind = kind
46
+ end
47
+ end
48
+
49
+ # The approval must be answered by a human in a browser: open +approval_url+.
50
+ class LinkOnlyApprovalError < ConflictError
51
+ def approval_url
52
+ url = body["approvalUrl"]
53
+ url.is_a?(String) ? url : nil
54
+ end
55
+ end
56
+
57
+ # 403 +spend_cap_reached+: the key reached its monthly spend limit, or (on a cloud machine
58
+ # create, +body["scope"] == "tenant"+) the Tenant reached its budget. Not retryable: the limit
59
+ # resets at +resets_at+ (ISO 8601), or raise it for the key in the portal.
60
+ class SpendCapReachedError < PermissionDeniedError
61
+ def resets_at
62
+ at = body["resetsAt"]
63
+ at.is_a?(String) ? at : nil
64
+ end
65
+ end
66
+
67
+ # @api private
68
+ module Errors
69
+ BY_CODE = {
70
+ "link_only" => LinkOnlyApprovalError,
71
+ "spend_cap_reached" => SpendCapReachedError
72
+ }.freeze
73
+
74
+ BY_STATUS = {
75
+ 400 => BadRequestError,
76
+ 401 => AuthenticationError,
77
+ 403 => PermissionDeniedError,
78
+ 404 => NotFoundError,
79
+ 409 => ConflictError,
80
+ 410 => GoneError,
81
+ 429 => RateLimitError,
82
+ 503 => ServiceUnavailableError
83
+ }.freeze
84
+
85
+ module_function
86
+
87
+ def for(status, body, retry_after)
88
+ err = body["error"]
89
+ detail = body["message"]
90
+ # Two body shapes: { error: "<human text>" } (/v1/agents) and
91
+ # { error: "<code>", message: "<human text>" } (/v1/machines/{id}/control, newer errors).
92
+ code = err.is_a?(String) && (body.key?("message") || looks_like_code?(err)) ? err : nil
93
+ message = if detail.is_a?(String) then detail
94
+ elsif err.is_a?(String) then err
95
+ else "HTTP #{status}"
96
+ end
97
+ message += " (SAI_API_KEY was rejected: it may be revoked.)" if status == 401
98
+ cls = BY_CODE[code] || BY_STATUS[status] || Error
99
+ # The Retry-After header wins; retryAfterSeconds is the same value in the body (rate_limited).
100
+ wait = retry_after
101
+ wait = body["retryAfterSeconds"] if wait.nil? && body["retryAfterSeconds"].is_a?(Numeric)
102
+ cls.new(message, status: status, code: code, body: body, retry_after: wait)
103
+ end
104
+
105
+ def looks_like_code?(s)
106
+ !s.empty? && !s.include?(" ") && s == s.downcase
107
+ end
108
+ end
109
+ end
data/lib/sai/http.rb ADDED
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Sai
9
+ DEFAULT_BASE_URL = "https://api.simular.ai"
10
+
11
+ # @api private
12
+ # The one place requests are made: auth header, error mapping, timeouts.
13
+ class Http
14
+ Response = Struct.new(:status, :reason, :body, :headers, keyword_init: true)
15
+
16
+ # +credential+ is the bearer token, or a callable that returns it before each request.
17
+ def initialize(credential, base_url, timeout, version_tag)
18
+ @credential = credential
19
+ @base_url = base_url.sub(%r{/+\z}, "")
20
+ @timeout = timeout
21
+ @headers = { "User-Agent" => "sai-ruby/#{VERSION}" }
22
+ @headers["Authorization"] = "Bearer #{credential}" unless credential.respond_to?(:call)
23
+ # routes the request to a tagged API revision
24
+ @headers["x-sai-version"] = version_tag if version_tag && !version_tag.empty?
25
+ end
26
+
27
+ def request(method, path, json: UNSET, params: nil, body: nil, headers: nil, timeout: nil)
28
+ uri = URI(@base_url + path)
29
+ query = (params || {}).reject { |_, v| v.nil? }
30
+ uri.query = URI.encode_www_form(query.map { |k, v| [k.to_s, v.to_s] }) unless query.empty?
31
+ req = Net::HTTPGenericRequest.new(
32
+ method, !body.nil? || !json.equal?(UNSET), true, uri.request_uri
33
+ )
34
+ @headers.merge(headers || {}).each { |k, v| req[k] = v }
35
+ req["Authorization"] = "Bearer #{@credential.call}" if @credential.respond_to?(:call)
36
+ if !json.equal?(UNSET)
37
+ req["Content-Type"] = "application/json"
38
+ req.body = JSON.generate(json)
39
+ elsif body
40
+ req.body = body
41
+ end
42
+ res = perform(uri, req, timeout || @timeout)
43
+ raise to_error(res) if res.status >= 400
44
+
45
+ res
46
+ end
47
+
48
+ def json(method, path, **opts)
49
+ res = request(method, path, **opts)
50
+ text = res.body.to_s.dup.force_encoding(Encoding::UTF_8)
51
+ text.empty? ? nil : JSON.parse(text)
52
+ end
53
+
54
+ UNSET = Object.new.freeze
55
+
56
+ private
57
+
58
+ def perform(uri, req, timeout)
59
+ http = Net::HTTP.new(uri.host, uri.port)
60
+ http.use_ssl = uri.scheme == "https"
61
+ http.open_timeout = timeout
62
+ http.read_timeout = timeout
63
+ http.write_timeout = timeout
64
+ res = http.start { http.request(req) }
65
+ headers = {}
66
+ res.each_header { |k, v| headers[k] = v }
67
+ Response.new(status: res.code.to_i, reason: res.message.to_s, body: (res.body || "").b, headers: headers)
68
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
69
+ raise ConnectionError, "Request to #{host(uri)} timed out."
70
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
71
+ raise ConnectionError,
72
+ "Could not reach the Sai API at #{host(uri)}: #{e.message}. Check the network and SAI_API_URL."
73
+ end
74
+
75
+ def host(uri)
76
+ uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
77
+ end
78
+
79
+ def to_error(res)
80
+ text = res.body.dup.force_encoding(Encoding::UTF_8)
81
+ body = begin
82
+ parsed = JSON.parse(text)
83
+ if parsed.is_a?(Hash) then parsed
84
+ elsif parsed.is_a?(Array) then { "error" => parsed.join(",") }
85
+ else { "error" => parsed.nil? ? "null" : parsed.to_s }
86
+ end
87
+ rescue JSON::ParserError
88
+ short = text[0, 500]
89
+ { "error" => short.nil? || short.empty? ? res.reason : short }
90
+ end
91
+ Errors.for(res.status, body, retry_after(res.headers["retry-after"]))
92
+ end
93
+
94
+ def retry_after(raw)
95
+ return nil if raw.nil? || raw.strip.empty?
96
+
97
+ v = Float(raw)
98
+ v == v.floor ? v.to_i : v
99
+ rescue ArgumentError
100
+ nil
101
+ end
102
+ end
103
+ end
data/lib/sai/learn.rb ADDED
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Sai
6
+ # Learn (research preview): turn recorded turns, or a task the agent does first, into Simulang
7
+ # code you own; repair that code; run, judge and repair it on a machine until it passes.
8
+ class Learn
9
+ # A conversion is two model calls of up to a minute or two each, plus one retry.
10
+ TIMEOUT = 600
11
+ # How often +wait+ reads a learn job; a job's steps take tens of seconds to minutes.
12
+ POLL_INTERVAL = 10
13
+ # Options sent as given, by their API name.
14
+ PASSTHROUGH = {
15
+ history: "history",
16
+ runs_without_reset: "runsWithoutReset",
17
+ max_iterations: "maxIterations",
18
+ rollouts: "rollouts",
19
+ reset: "reset",
20
+ judge_initial_agent_run: "judgeInitialAgentRun",
21
+ continue_with_agent: "continueWithAgent",
22
+ rollout_minutes: "rolloutMinutes",
23
+ agent_run_minutes: "agentRunMinutes",
24
+ continuation_minutes: "continuationMinutes",
25
+ max_job_minutes: "maxJobMinutes",
26
+ model: "model",
27
+ idempotency_key: "idempotency_key"
28
+ }.freeze
29
+ private_constant :PASSTHROUGH
30
+
31
+ # @api private
32
+ def initialize(http)
33
+ @http = http
34
+ end
35
+
36
+ # Make Simulang code, and optionally run, judge and repair it on a machine.
37
+ #
38
+ # - +create([turn])+: code from recorded turns (one session, each sent with
39
+ # +record_trajectory: true+). Answers with the code.
40
+ # - +create(initial_code:, guidance: or evaluations:)+: a repair. Answers with the code.
41
+ # - +create(turns, max_iterations: 3)+ or +create(task_template:, inputs:, max_iterations: 3)+:
42
+ # a job on the machine. Answers at once with +status == "running"+; read it with +wait+.
43
+ #
44
+ # +criteria+ is a sentence or a list of Check; +reset+ is +{ code: }+ or +{ task: }+;
45
+ # +evaluations+ a list of Evaluation. +template+ and +feedback+ are the old names of
46
+ # +task_template+ and +guidance+. +timeout+ is in seconds.
47
+ # @return [LearnResult]
48
+ def create(turns = [], task_template: nil, template: nil, inputs: nil, initial_code: nil, evaluations: nil,
49
+ guidance: nil, feedback: nil, machine: nil, criteria: nil, timeout: TIMEOUT, **options)
50
+ unknown = options.keys - PASSTHROUGH.keys
51
+ raise ArgumentError, "unknown keyword#{'s' if unknown.length > 1}: #{unknown.join(', ')}" unless unknown.empty?
52
+
53
+ body = { "turns" => turns.map { |t| Util.turn_id(t) } }
54
+ # The old names go out as sent, so code that uses them keeps working with an older API.
55
+ if !task_template.nil? then body["taskTemplate"] = task_template
56
+ elsif !template.nil? then body["template"] = template
57
+ end
58
+ body["inputs"] = inputs unless inputs.nil?
59
+ body["initialCode"] = initial_code unless initial_code.nil?
60
+ body["evaluations"] = evaluations.map { |e| Evaluation.to_api(e) } if evaluations
61
+ if !guidance.nil? then body["guidance"] = guidance
62
+ elsif !feedback.nil? then body["feedback"] = feedback
63
+ end
64
+ body["machineId"] = Util.id_of(machine) unless machine.nil?
65
+ body["criteria"] = Check.list_to_api(criteria) unless criteria.nil?
66
+ PASSTHROUGH.each { |k, name| body[name] = options[k] unless options[k].nil? }
67
+ LearnResult.parse(Parse.obj(@http.json("POST", "/v1/agents/learn", json: body, timeout: timeout)))
68
+ end
69
+
70
+ # A learn call's result, or a job's progress so far.
71
+ # @return [LearnResult]
72
+ def get(learn_id)
73
+ LearnResult.parse(Parse.obj(@http.json("GET", "/v1/agents/learn/#{Util.q(learn_id)}")))
74
+ end
75
+
76
+ # Read a learn job (a LearnResult or its id) until it ends (+succeeded+, +failed+ or
77
+ # +stopped+), every +poll_interval+ seconds. +timeout+ (seconds) raises Timeout::Error with the
78
+ # job still running; it keeps running.
79
+ # @return [LearnResult]
80
+ def wait(job, timeout: nil, poll_interval: POLL_INTERVAL)
81
+ learn_id = Util.id_of(job)
82
+ deadline = timeout.nil? ? nil : Util.monotonic + timeout
83
+ loop do
84
+ begin
85
+ r = get(learn_id)
86
+ rescue RateLimitError => e
87
+ sleep(e.retry_after.nil? ? 30 : e.retry_after)
88
+ next
89
+ end
90
+ return r if r.status != "running"
91
+ raise Timeout::Error, "Learn job #{learn_id} is still running." if deadline && Util.monotonic + poll_interval > deadline
92
+
93
+ sleep(poll_interval)
94
+ end
95
+ end
96
+
97
+ # Stop a learn job at once, whatever step it is on. It keeps the last complete results.
98
+ # @return [nil]
99
+ def cancel(job)
100
+ @http.json("POST", "/v1/agents/learn/#{Util.q(Util.id_of(job))}/cancel")
101
+ nil
102
+ end
103
+
104
+ # One run of a learn job's code: what it returned and logged, and its judgement.
105
+ # @return [LearnRun]
106
+ def rollout(job, iteration, rollout)
107
+ path = "/v1/agents/learn/#{Util.q(Util.id_of(job))}/iterations/#{iteration}/rollouts/#{rollout}"
108
+ LearnRun.parse(Parse.obj(@http.json("GET", path)))
109
+ end
110
+ end
111
+
112
+ # Judge recorded turns against new criteria (research preview).
113
+ class Judge
114
+ TIMEOUT = 180
115
+
116
+ # @api private
117
+ def initialize(http)
118
+ @http = http
119
+ end
120
+
121
+ # Judge recorded +turns+ (one session, as one task) against +criteria+: a sentence for a model
122
+ # to judge, or a list of Check. Nothing runs again, and the turns do not change. +task+
123
+ # defaults to the first turn's message.
124
+ # @return [Judgement]
125
+ def create(turns, criteria, task: nil, idempotency_key: nil, timeout: TIMEOUT)
126
+ body = { "turns" => turns.map { |t| Util.turn_id(t) }, "criteria" => Check.list_to_api(criteria) }
127
+ body["task"] = task unless task.nil?
128
+ body["idempotency_key"] = idempotency_key unless idempotency_key.nil?
129
+ Judgement.parse(Parse.obj(@http.json("POST", "/v1/agents/judge", json: body, timeout: timeout)))
130
+ end
131
+
132
+ # @return [Judgement]
133
+ def get(judgement_id)
134
+ Judgement.parse(Parse.obj(@http.json("GET", "/v1/agents/judge/#{Util.q(judgement_id)}")))
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ # The computers Sai works on: cloud computers, or the customer's own PC with the Sai app.
5
+ class Machines
6
+ # @api private
7
+ def initialize(http)
8
+ @http = http
9
+ end
10
+
11
+ # @return [Array<Machine>]
12
+ def list
13
+ d = @http.json("GET", "/v1/agents/machines") || {}
14
+ Parse.list(d["machines"]).map { |m| Machine.parse(m) }
15
+ end
16
+
17
+ # @raise [NotFoundError] when the machine is not on this account
18
+ def get(machine_id)
19
+ m = list.find { |x| x.id == machine_id }
20
+ raise NotFoundError.new("Machine #{machine_id} not found on this account.", status: 404) unless m
21
+
22
+ m
23
+ end
24
+
25
+ # The live screen: +live_url+, a page a person can open without a Simular sign-in, and a
26
+ # Guacamole tunnel for a viewer of your own. The machine must be running.
27
+ # @return [LiveView]
28
+ def live(machine)
29
+ id = Util.id_of(machine)
30
+ d = @http.json("POST", "/v1/agents/machines/#{Util.q(id)}/live") || {}
31
+ LiveView.new(
32
+ machine_id: Parse.str(d["machineId"]) || id,
33
+ live_url: Parse.str(d["liveUrl"]),
34
+ expires_at: Parse.num_or_nil(d["expiresAt"]),
35
+ websocket_url: d["websocketUrl"].to_s,
36
+ width: Parse.num(d["width"]),
37
+ height: Parse.num(d["height"])
38
+ )
39
+ end
40
+
41
+ # Revokes every +live_url+ handed out for the machine.
42
+ # @return [Integer] how many were revoked
43
+ def revoke_live(machine)
44
+ d = @http.json("DELETE", "/v1/agents/machines/#{Util.q(Util.id_of(machine))}/live")
45
+ Parse.num(Parse.obj(d)["revoked"])
46
+ end
47
+ end
48
+ end