bynn-decisionnode 0.4.1
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/CHANGELOG.md +128 -0
- data/LICENSE +21 -0
- data/README.md +908 -0
- data/lib/decisionnode/batches.rb +246 -0
- data/lib/decisionnode/boosters.rb +692 -0
- data/lib/decisionnode/checks.rb +722 -0
- data/lib/decisionnode/client.rb +1100 -0
- data/lib/decisionnode/collections.rb +535 -0
- data/lib/decisionnode/document.rb +179 -0
- data/lib/decisionnode/errors.rb +138 -0
- data/lib/decisionnode/image.rb +194 -0
- data/lib/decisionnode/image_header.rb +121 -0
- data/lib/decisionnode/keys.rb +383 -0
- data/lib/decisionnode/questions.rb +135 -0
- data/lib/decisionnode/results.rb +539 -0
- data/lib/decisionnode/session.rb +286 -0
- data/lib/decisionnode/session_stream.rb +255 -0
- data/lib/decisionnode/supervisor.rb +107 -0
- data/lib/decisionnode/transport.rb +249 -0
- data/lib/decisionnode/uploads.rb +422 -0
- data/lib/decisionnode/version.rb +9 -0
- data/lib/decisionnode/video.rb +128 -0
- data/lib/decisionnode.rb +39 -0
- metadata +85 -0
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
class DecisionNode
|
|
9
|
+
include Uploads
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://api.decisionnode.com"
|
|
12
|
+
DEFAULT_MODEL = "decisionnode-moe"
|
|
13
|
+
DEFAULT_TIMEOUT = 10.0
|
|
14
|
+
DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
DEFAULT_CONCURRENCY = 8
|
|
16
|
+
# Seconds for one image PUT to storage, apart from `timeout`: a 10 MB image
|
|
17
|
+
# on a slow uplink needs more than an API call does.
|
|
18
|
+
DEFAULT_UPLOAD_TIMEOUT = 60.0
|
|
19
|
+
# Seconds for one document PUT to storage: a document is up to 50 MB.
|
|
20
|
+
DOCUMENT_UPLOAD_TIMEOUT = 120.0
|
|
21
|
+
# Seconds for one attempt of a document write with `wait: true`, when the
|
|
22
|
+
# caller set no timeout: the server holds such a call up to 10 s.
|
|
23
|
+
WAIT_TIMEOUT = 20.0
|
|
24
|
+
# Seconds for one attempt of a decision whose boosters name ocr, when the
|
|
25
|
+
# caller set no timeout: reading text takes longer than a decision does.
|
|
26
|
+
OCR_TIMEOUT = 30.0
|
|
27
|
+
private_constant :OCR_TIMEOUT, :WAIT_TIMEOUT
|
|
28
|
+
|
|
29
|
+
RETRY_STATUSES = ([408, 429] + (500..599).to_a).freeze
|
|
30
|
+
BACKOFF_START = 0.5
|
|
31
|
+
BACKOFF_CAP = 8.0
|
|
32
|
+
# The longest wait between attempts the client accepts from the server, in seconds.
|
|
33
|
+
RETRY_AFTER_CAP = 60.0
|
|
34
|
+
|
|
35
|
+
# run_batch: the shortest wait between two reads of a running batch, the
|
|
36
|
+
# default wait, and how long it waits for a batch to end, in seconds.
|
|
37
|
+
MIN_POLL_INTERVAL = 10.0
|
|
38
|
+
DEFAULT_POLL_INTERVAL = 60.0
|
|
39
|
+
DEFAULT_BATCH_TIMEOUT = 26 * 3600.0
|
|
40
|
+
|
|
41
|
+
# supervise: the backoff before connecting again, in seconds.
|
|
42
|
+
RECONNECT_START = 1.0
|
|
43
|
+
RECONNECT_CAP = 30.0
|
|
44
|
+
|
|
45
|
+
# The client class under a second name.
|
|
46
|
+
Client = self
|
|
47
|
+
|
|
48
|
+
UNSET = Object.new.freeze
|
|
49
|
+
private_constant :UNSET
|
|
50
|
+
|
|
51
|
+
# The questions sent by #gate.
|
|
52
|
+
GATE_QUESTIONS = {
|
|
53
|
+
"risk" => Questions.score(
|
|
54
|
+
"How much harm could this action cause if it is wrong?",
|
|
55
|
+
%w[none low moderate high severe]
|
|
56
|
+
),
|
|
57
|
+
"touches_production" => Questions.truth(
|
|
58
|
+
"Does this action change a production system, production data or anything customers use?"
|
|
59
|
+
),
|
|
60
|
+
"verdict" => Questions.choice(
|
|
61
|
+
"Should this action run as it is, wait for a person to confirm it, or be blocked?",
|
|
62
|
+
{
|
|
63
|
+
"allow" => "safe to run without asking",
|
|
64
|
+
"confirm" => "a person should confirm it first",
|
|
65
|
+
"block" => "should not run"
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
}.each_value { |q| q.each_value(&:freeze).freeze }.freeze
|
|
69
|
+
|
|
70
|
+
attr_reader :base_url, :model, :timeout, :max_retries, :inline_threshold, :upload_timeout
|
|
71
|
+
|
|
72
|
+
# The Batch API, a DecisionNode::Batches.
|
|
73
|
+
attr_reader :batches
|
|
74
|
+
|
|
75
|
+
# Collections of faces and knowledge bases, a DecisionNode::Collections.
|
|
76
|
+
attr_reader :collections
|
|
77
|
+
|
|
78
|
+
# Per-key usage, a DecisionNode::KeyUsage.
|
|
79
|
+
attr_reader :usage
|
|
80
|
+
|
|
81
|
+
# Decisions-only keys for devices, a DecisionNode::Keys.
|
|
82
|
+
attr_reader :keys
|
|
83
|
+
|
|
84
|
+
# Every argument is optional.
|
|
85
|
+
#
|
|
86
|
+
# api_key:: the API key; else DECISIONNODE_API_KEY.
|
|
87
|
+
# base_url:: else DECISIONNODE_BASE_URL, else https://api.decisionnode.com.
|
|
88
|
+
# model:: the default model; else DECISIONNODE_MODEL, else decisionnode-moe.
|
|
89
|
+
#
|
|
90
|
+
# An empty string for api_key, base_url or model counts as not given.
|
|
91
|
+
# The base URL must use https; http is accepted only for localhost,
|
|
92
|
+
# 127.0.0.1 and ::1, so a key never travels unencrypted.
|
|
93
|
+
# timeout:: seconds for a whole attempt, from sending the request to the
|
|
94
|
+
# last byte of the body (default 10, and 30 for a decision whose
|
|
95
|
+
# boosters name ocr; a timeout given here is used as given for
|
|
96
|
+
# every call). Must be above 0.
|
|
97
|
+
# max_retries:: retries after the first attempt (default 2). Must be 0 or more.
|
|
98
|
+
# headers:: extra headers sent with every API request and on a session's
|
|
99
|
+
# handshake (never on an image upload to storage).
|
|
100
|
+
# inline_threshold:: bytes; a request's local images that total less go
|
|
101
|
+
# inline as base64, at or above it they are uploaded
|
|
102
|
+
# (default DEFAULT_INLINE_THRESHOLD, 524,288). 0 always uploads.
|
|
103
|
+
# upload_timeout:: seconds for one image PUT to storage (default 60), and for
|
|
104
|
+
# one document PUT (default 120), apart from `timeout`. Must be
|
|
105
|
+
# above 0.
|
|
106
|
+
# http_client:: a NetHTTPTransport built with your own options, or another
|
|
107
|
+
# transport with the interface of NetHTTPTransport#call.
|
|
108
|
+
# logger:: a Logger; when given, each attempt is logged at debug level
|
|
109
|
+
# (method, path, status, request id and timing, never the body).
|
|
110
|
+
#
|
|
111
|
+
# A client is safe to share between threads and reuses its connections.
|
|
112
|
+
def initialize(api_key: nil, base_url: nil, model: nil, timeout: nil, max_retries: nil,
|
|
113
|
+
headers: nil, inline_threshold: nil, upload_timeout: nil, http_client: nil, logger: nil)
|
|
114
|
+
@api_key = given(api_key) || env("DECISIONNODE_API_KEY")
|
|
115
|
+
@base_url = checked_base_url((given(base_url) || env("DECISIONNODE_BASE_URL") || DEFAULT_BASE_URL).chomp("/"))
|
|
116
|
+
@model = given(model) || env("DECISIONNODE_MODEL") || DEFAULT_MODEL
|
|
117
|
+
@timeout = timeout.nil? ? DEFAULT_TIMEOUT : checked_timeout(timeout)
|
|
118
|
+
@timeout_given = !timeout.nil?
|
|
119
|
+
@max_retries = max_retries.nil? ? DEFAULT_MAX_RETRIES : checked_max_retries(max_retries)
|
|
120
|
+
@inline_threshold = inline_threshold.nil? ? DEFAULT_INLINE_THRESHOLD : checked_threshold(inline_threshold)
|
|
121
|
+
@upload_timeout = upload_timeout.nil? ? DEFAULT_UPLOAD_TIMEOUT : checked_timeout(upload_timeout, "upload_timeout")
|
|
122
|
+
@document_upload_timeout = upload_timeout.nil? ? DOCUMENT_UPLOAD_TIMEOUT : @upload_timeout
|
|
123
|
+
@headers = (headers || {}).to_h { |k, v| [k.to_s, v.to_s] }.freeze
|
|
124
|
+
@http = checked_http_client(http_client) || NetHTTPTransport.new
|
|
125
|
+
@logger = logger
|
|
126
|
+
@sleeper = ->(seconds) { sleep(seconds) }
|
|
127
|
+
@clock = -> { Time.now.to_f }
|
|
128
|
+
@uploads = UploadCache.new(clock: -> { @clock.call })
|
|
129
|
+
@held = {}
|
|
130
|
+
@inflight = {}
|
|
131
|
+
@inflight_lock = Mutex.new
|
|
132
|
+
@batches = Batches.new(self)
|
|
133
|
+
@collections = Collections.new(self)
|
|
134
|
+
@usage = KeyUsage.new(self)
|
|
135
|
+
@keys = Keys.new(self)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def inspect
|
|
139
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect} model=#{@model.inspect}>"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Sends one request and returns a Decision.
|
|
143
|
+
#
|
|
144
|
+
# state:: what is judged: a String or any JSON value (default "").
|
|
145
|
+
# questions:: question id to question (a builder's result or a wire-form Hash).
|
|
146
|
+
# images:: an Array of Image (or wire-form Hashes), up to 16.
|
|
147
|
+
# videos:: an Array of Video (or wire-form Hashes), up to 4, each sent as
|
|
148
|
+
# its frames.
|
|
149
|
+
# model:: overrides the client's model for this call (nil or "" uses the
|
|
150
|
+
# client's). A fine-tuned model's name ("decisionnode-moe@acme/fraud",
|
|
151
|
+
# or pinned, ".../fraud:3") is sent as given.
|
|
152
|
+
# boosters:: an Array of boosters that read the request's images before the
|
|
153
|
+
# decision: names ("age"), Booster objects or wire-form Hashes, sent
|
|
154
|
+
# in this order. Their readings come back in Decision#evidence.
|
|
155
|
+
# fallback:: true or false, sent only when given. Left out, the API's default
|
|
156
|
+
# (true) applies: a decisionnode-moe request that no ready
|
|
157
|
+
# worker can take at once is answered by Flash, and
|
|
158
|
+
# Decision#fallback names both by their pinned ids. false: such a request gets the 529
|
|
159
|
+
# OverloadedError instead, retried like every 529.
|
|
160
|
+
#
|
|
161
|
+
# The request is checked before anything is sent: a fault the API would
|
|
162
|
+
# refuse raises BadRequestError or ValidationError with no request made.
|
|
163
|
+
# Boosters are not checked locally: the API answers for them.
|
|
164
|
+
def decide(state: UNSET, questions: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)
|
|
165
|
+
decision_call("/v1/decide", state, questions, images, videos, model, boosters, fallback).first
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Runs several #decide calls, at most `concurrency` at a time. Each request is
|
|
169
|
+
# a Hash of #decide's arguments. Returns one entry per request, in input
|
|
170
|
+
# order: a Decision, or the error that request ended with. One failure does
|
|
171
|
+
# not stop the others. Each entry is a DecisionResult.
|
|
172
|
+
#
|
|
173
|
+
# Every request is checked before any is sent: a concurrency below 1 or a
|
|
174
|
+
# request #decide would refuse as an argument raises ArgumentError, and a
|
|
175
|
+
# request the API would refuse raises its BadRequestError or ValidationError,
|
|
176
|
+
# with nothing sent. Images uploaded for one request are reused by the others.
|
|
177
|
+
def decide_many(requests, concurrency: DEFAULT_CONCURRENCY)
|
|
178
|
+
limit = begin
|
|
179
|
+
Integer(concurrency)
|
|
180
|
+
rescue TypeError, FloatDomainError
|
|
181
|
+
raise ArgumentError, "concurrency must be a whole number of at least 1, got #{concurrency.inspect}"
|
|
182
|
+
end
|
|
183
|
+
raise ArgumentError, "concurrency must be at least 1, got #{limit}" if limit < 1
|
|
184
|
+
raise ArgumentError, "requests must be an Array of Hashes of decide's arguments" unless requests.respond_to?(:to_a)
|
|
185
|
+
|
|
186
|
+
calls = requests.to_a.each_with_index.map do |item, i|
|
|
187
|
+
prepared_call(**decide_args(item))
|
|
188
|
+
rescue ArgumentError => e
|
|
189
|
+
raise ArgumentError, "decide_many: request #{i}: #{e.message}"
|
|
190
|
+
end
|
|
191
|
+
results = Array.new(calls.size)
|
|
192
|
+
return results if calls.empty?
|
|
193
|
+
|
|
194
|
+
queue = Queue.new
|
|
195
|
+
calls.each_index { |i| queue << i }
|
|
196
|
+
workers = Array.new([limit, calls.size].min) do
|
|
197
|
+
Thread.new do
|
|
198
|
+
loop do
|
|
199
|
+
i = begin
|
|
200
|
+
queue.pop(true)
|
|
201
|
+
rescue ThreadError
|
|
202
|
+
break
|
|
203
|
+
end
|
|
204
|
+
results[i] = result_for { send_prepared("/v1/decide", calls[i]).first }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
workers.each(&:join)
|
|
209
|
+
results
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Asks one `truth` question (id "check") and returns its TruthAnswer.
|
|
213
|
+
# An answer that is missing or malformed raises ServerError, since a
|
|
214
|
+
# TruthAnswer is promised; #decide would return an UnknownAnswer instead.
|
|
215
|
+
def check(state: UNSET, instructions: nil, if_true: nil, if_false: nil, images: nil, videos: nil, model: nil,
|
|
216
|
+
boosters: nil, fallback: nil)
|
|
217
|
+
question = Questions.truth(instructions, if_true: if_true, if_false: if_false)
|
|
218
|
+
helper_answer("check", TruthAnswer, state, question, images, videos, model, boosters, fallback)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Asks one `choice` question (id "classify") and returns its ChoiceAnswer.
|
|
222
|
+
# An answer that is missing or malformed raises ServerError.
|
|
223
|
+
def classify(state: UNSET, instructions: nil, options: nil, images: nil, videos: nil, model: nil, boosters: nil,
|
|
224
|
+
fallback: nil)
|
|
225
|
+
helper_answer("classify", ChoiceAnswer, state, Questions.choice(instructions, options), images, videos, model,
|
|
226
|
+
boosters, fallback)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Asks one `score` question (id "score") and returns its ScoreAnswer.
|
|
230
|
+
# An answer that is missing or malformed raises ServerError.
|
|
231
|
+
def score(state: UNSET, instructions: nil, levels: nil, images: nil, videos: nil, model: nil, boosters: nil,
|
|
232
|
+
fallback: nil)
|
|
233
|
+
helper_answer("score", ScoreAnswer, state, Questions.score(instructions, levels), images, videos, model, boosters,
|
|
234
|
+
fallback)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Asks one `number` question (id "number") and returns its NumberAnswer.
|
|
238
|
+
# `min`, `max` and `step` left out take the server's defaults (0, 255, 1).
|
|
239
|
+
# An answer that is missing or malformed raises ServerError.
|
|
240
|
+
def number(state: UNSET, instructions: nil, min: nil, max: nil, step: nil, images: nil, videos: nil, model: nil,
|
|
241
|
+
boosters: nil, fallback: nil)
|
|
242
|
+
question = Questions.number(instructions, min: min, max: max, step: step)
|
|
243
|
+
helper_answer("number", NumberAnswer, state, question, images, videos, model, boosters, fallback)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Judges an action before it runs. Sends three questions: "risk" (score),
|
|
247
|
+
# "touches_production" (truth) and "verdict" (choice of allow, confirm or block).
|
|
248
|
+
def gate(action:, context: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)
|
|
249
|
+
state = { "action" => action }
|
|
250
|
+
state["context"] = context unless context.nil?
|
|
251
|
+
decide(state: state, questions: GATE_QUESTIONS, images: images, videos: videos, model: model, boosters: boosters,
|
|
252
|
+
fallback: fallback)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Lists the models with their published limits, as ModelInfo entries. The
|
|
256
|
+
# client keeps the list: a limit only `/v1/models` states (a session's
|
|
257
|
+
# `max_window`, a batch's `requests_per_batch`, a model without number
|
|
258
|
+
# questions) is then checked before sending.
|
|
259
|
+
def models
|
|
260
|
+
body, = request("GET", "/v1/models", nil)
|
|
261
|
+
list = body.is_a?(Hash) && body["models"].is_a?(Array) ? body["models"].grep(Hash) : []
|
|
262
|
+
@held = list.to_h { |m| [m["name"], m] }.freeze
|
|
263
|
+
list.map { |m| ModelInfo.new(m) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Sends `body` to POST /v1/decide exactly as given and returns the parsed
|
|
267
|
+
# response body. Use it for API fields this version does not know yet.
|
|
268
|
+
# Nothing is checked and no image is uploaded.
|
|
269
|
+
def decide_raw(body)
|
|
270
|
+
parsed, = request("POST", "/v1/decide", body)
|
|
271
|
+
parsed
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Opens a session (`POST /v1/sessions`) and returns a Session: one state and
|
|
275
|
+
# a fixed set of questions, answered again for every frame sent on its
|
|
276
|
+
# socket. Only the arguments given are sent, and `model` always is. A
|
|
277
|
+
# session takes no boosters.
|
|
278
|
+
#
|
|
279
|
+
# A 429 `session_limit_exceeded` (the key holds its most open sessions) is
|
|
280
|
+
# never retried and raises SessionLimitError, a RateLimitError.
|
|
281
|
+
def open_session(questions:, state: nil, instructions: nil, model: nil, window: nil, ttl_seconds: nil)
|
|
282
|
+
body = open_body(questions: questions, state: state, instructions: instructions, model: model,
|
|
283
|
+
window: window, ttl_seconds: ttl_seconds)
|
|
284
|
+
Checks.session_open!(body, limits: limits_for(body["model"]))
|
|
285
|
+
raw, = request("POST", "/v1/sessions", encode(body), encoded: true, policy: :session_open)
|
|
286
|
+
Session.new(self, raw)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
CHECKED_CALLS = %w[decide open_session batches.create batches.add].freeze
|
|
290
|
+
|
|
291
|
+
# Runs every local check a call would run before sending, alone: it raises
|
|
292
|
+
# exactly what the call would raise for this request (held /v1/models limits
|
|
293
|
+
# included) and returns nil when the request would be sent. Nothing is sent
|
|
294
|
+
# and nothing is uploaded.
|
|
295
|
+
#
|
|
296
|
+
# dn.check_request("decide", state: "...", questions: { q: DecisionNode.truth("?") })
|
|
297
|
+
# dn.check_request(:open_session, { questions: questions, window: 128 }) # raises ValidationError
|
|
298
|
+
#
|
|
299
|
+
# method:: "decide", "open_session", "batches.create" or "batches.add" (a
|
|
300
|
+
# String or Symbol).
|
|
301
|
+
# request:: the call's keyword arguments, as a Hash or as keywords. For
|
|
302
|
+
# the batch calls, `requests:`; a `batch_id:` plays no part.
|
|
303
|
+
# Frames are checked with Session#check_frame.
|
|
304
|
+
def check_request(method, request = nil, **args)
|
|
305
|
+
name = method.to_s
|
|
306
|
+
raise ArgumentError, "check_request takes #{CHECKED_CALLS.join(', ')}, got #{method.inspect}" unless CHECKED_CALLS.include?(name)
|
|
307
|
+
raise ArgumentError, "request must be a Hash of the call's arguments" unless request.nil? || request.is_a?(Hash)
|
|
308
|
+
|
|
309
|
+
kw = (request || {}).merge(args).to_h { |k, v| [k.to_sym, v] }
|
|
310
|
+
case name
|
|
311
|
+
when "decide"
|
|
312
|
+
unknown = kw.keys - DECIDE_ARGS
|
|
313
|
+
raise ArgumentError, "unknown argument(s) #{unknown.join(', ')} for #{name}" unless unknown.empty?
|
|
314
|
+
|
|
315
|
+
prepared_call(**kw)
|
|
316
|
+
when "open_session"
|
|
317
|
+
body = open_body(**kw)
|
|
318
|
+
Checks.session_open!(body, limits: limits_for(body["model"]))
|
|
319
|
+
else
|
|
320
|
+
raise ArgumentError, "requests must be an Array of Hashes" unless kw.fetch(:requests, []).respond_to?(:to_a)
|
|
321
|
+
|
|
322
|
+
check_lines(kw.fetch(:requests, []).to_a, allow_empty: name == "batches.create")
|
|
323
|
+
end
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Runs one long-lived feed through sessions, reopening them as the close
|
|
328
|
+
# codes say, and hands every Reply and FrameError to `on_reply` (or the
|
|
329
|
+
# block). Latest wins: each reading is sent as it comes, never queued
|
|
330
|
+
# behind an unanswered one.
|
|
331
|
+
#
|
|
332
|
+
# body:: open_session's arguments, as a Hash.
|
|
333
|
+
# feed:: an Enumerable of readings, each a Hash of Session#send's arguments.
|
|
334
|
+
#
|
|
335
|
+
# A session closed with 1001, 1006, 1011, 4404, 4408, 4409 or 4410 is
|
|
336
|
+
# reopened with the same body before the next reading (after a capped backoff
|
|
337
|
+
# with jitter, 1 s to 30 s, except after 4408 and 4410); 1013 connects again
|
|
338
|
+
# to the same session after the backoff; any other code stops it. When the
|
|
339
|
+
# feed ends it waits for the outstanding replies, ends the session and
|
|
340
|
+
# returns the last SessionClosed, or nil when none was seen; when it stops on
|
|
341
|
+
# a code it returns that SessionClosed.
|
|
342
|
+
def supervise(body, feed, on_reply = nil, max_sessions: 10, drain_timeout: 3.0, &block)
|
|
343
|
+
handler = on_reply || block
|
|
344
|
+
raise ArgumentError, "supervise needs on_reply or a block" if handler.nil?
|
|
345
|
+
raise ArgumentError, "body must be a Hash of open_session's arguments" unless body.is_a?(Hash)
|
|
346
|
+
|
|
347
|
+
Supervisor.new(self, body.to_h { |k, v| [k.to_sym, v] }, handler, max_sessions, drain_timeout).run(feed)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Runs a whole batch and returns `[batch, results]`: the ended Batch and one
|
|
351
|
+
# BatchResult per request. Creates an empty draft, adds the requests (in
|
|
352
|
+
# calls of at most 10 MiB), finalizes it, then reads it every `poll_interval`
|
|
353
|
+
# seconds (or the server's Retry-After when longer) until it ends, and reads
|
|
354
|
+
# its results. If an add fails, the draft is cancelled and the error raised.
|
|
355
|
+
#
|
|
356
|
+
# poll_interval:: seconds, at least 10 (default 60).
|
|
357
|
+
# timeout:: seconds to wait for the batch to end (default 26 hours);
|
|
358
|
+
# past it TimeoutError is raised and the batch keeps running.
|
|
359
|
+
def run_batch(requests, poll_interval: DEFAULT_POLL_INTERVAL, timeout: DEFAULT_BATCH_TIMEOUT)
|
|
360
|
+
interval = checked_seconds(poll_interval, "poll_interval")
|
|
361
|
+
raise ArgumentError, "poll_interval must be at least #{MIN_POLL_INTERVAL.to_i} seconds" if interval < MIN_POLL_INTERVAL
|
|
362
|
+
|
|
363
|
+
limit = checked_seconds(timeout, "timeout")
|
|
364
|
+
raise ArgumentError, "requests must be an Array of Hashes" unless requests.respond_to?(:to_a)
|
|
365
|
+
|
|
366
|
+
list = requests.to_a
|
|
367
|
+
check_lines(list, allow_empty: true)
|
|
368
|
+
draft = @batches.create([])
|
|
369
|
+
begin
|
|
370
|
+
@batches.add(draft.id, list) unless list.empty?
|
|
371
|
+
rescue StandardError
|
|
372
|
+
begin
|
|
373
|
+
@batches.cancel(draft.id)
|
|
374
|
+
rescue DecisionNodeError
|
|
375
|
+
nil
|
|
376
|
+
end
|
|
377
|
+
raise
|
|
378
|
+
end
|
|
379
|
+
batch = @batches.finalize(draft.id)
|
|
380
|
+
headers = {}
|
|
381
|
+
waited = 0.0
|
|
382
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
383
|
+
until batch.ended?
|
|
384
|
+
elapsed = [waited, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started].max
|
|
385
|
+
raise TimeoutError, "the batch #{batch.id} did not end within #{limit} s" if elapsed >= limit
|
|
386
|
+
|
|
387
|
+
server = retry_wait(headers)
|
|
388
|
+
wait = server && server > interval ? server : interval
|
|
389
|
+
@sleeper.call(wait)
|
|
390
|
+
waited += wait
|
|
391
|
+
batch, headers = @batches.get_with_headers(batch.id)
|
|
392
|
+
end
|
|
393
|
+
[batch, @batches.results(batch.id)]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Closes idle connections.
|
|
397
|
+
def close
|
|
398
|
+
@http.close if @http.respond_to?(:close)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
private
|
|
402
|
+
|
|
403
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
404
|
+
private_constant :LOOPBACK_HOSTS
|
|
405
|
+
|
|
406
|
+
# Raised inside an attempt when its whole-attempt deadline passes.
|
|
407
|
+
# An Exception, not a StandardError, so a transport's own rescue does not swallow it.
|
|
408
|
+
class AttemptDeadline < Exception; end
|
|
409
|
+
private_constant :AttemptDeadline
|
|
410
|
+
|
|
411
|
+
# A request checked and ready to send: the parts of its body, with its
|
|
412
|
+
# images still to be placed (inline or uploaded) when it is sent. Videos
|
|
413
|
+
# are in their wire form already: frames always go inline.
|
|
414
|
+
Prepared = Struct.new(:state, :questions, :images, :videos, :model, :encoded, :boosters, :fallback)
|
|
415
|
+
private_constant :Prepared
|
|
416
|
+
|
|
417
|
+
def checked_base_url(url)
|
|
418
|
+
uri = begin
|
|
419
|
+
URI.parse(url)
|
|
420
|
+
rescue URI::InvalidURIError => e
|
|
421
|
+
raise ArgumentError, "base_url is not a valid URL: #{e.message}"
|
|
422
|
+
end
|
|
423
|
+
host = uri.host.to_s.delete_prefix("[").delete_suffix("]").downcase
|
|
424
|
+
raise ArgumentError, "base_url must be an absolute URL with a host, got #{url.inspect}" if host.empty?
|
|
425
|
+
|
|
426
|
+
case uri.scheme&.downcase
|
|
427
|
+
when "https" then url
|
|
428
|
+
when "http"
|
|
429
|
+
return url if LOOPBACK_HOSTS.include?(host)
|
|
430
|
+
|
|
431
|
+
raise ArgumentError, "base_url must use https (http is accepted only for localhost, 127.0.0.1 and ::1), " \
|
|
432
|
+
"got #{url.inspect}"
|
|
433
|
+
else
|
|
434
|
+
raise ArgumentError, "base_url must use https, got #{url.inspect}"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def checked_timeout(value, name = "timeout")
|
|
439
|
+
seconds = Float(value)
|
|
440
|
+
raise ArgumentError, "#{name} must be above 0 seconds, got #{value.inspect}" unless seconds.positive? && seconds.finite?
|
|
441
|
+
|
|
442
|
+
seconds
|
|
443
|
+
rescue TypeError
|
|
444
|
+
raise ArgumentError, "#{name} must be a number of seconds, got #{value.inspect}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def checked_seconds(value, name)
|
|
448
|
+
raise ArgumentError, "#{name} must be a number of seconds, got #{value.inspect}" unless value.is_a?(Numeric)
|
|
449
|
+
|
|
450
|
+
seconds = value.to_f
|
|
451
|
+
raise ArgumentError, "#{name} must be a finite number above 0, got #{value.inspect}" unless seconds.positive? && seconds.finite?
|
|
452
|
+
|
|
453
|
+
seconds
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def checked_max_retries(value)
|
|
457
|
+
count = value.is_a?(Float) && value != value.floor ? nil : Integer(value)
|
|
458
|
+
raise ArgumentError, "max_retries must be a whole number of 0 or more, got #{value.inspect}" if count.nil? || count.negative?
|
|
459
|
+
|
|
460
|
+
count
|
|
461
|
+
rescue TypeError, FloatDomainError
|
|
462
|
+
raise ArgumentError, "max_retries must be a whole number of 0 or more, got #{value.inspect}"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def checked_threshold(value)
|
|
466
|
+
unless value.is_a?(Integer) && !value.negative?
|
|
467
|
+
raise ArgumentError, "inline_threshold must be a whole number of bytes, 0 or more, got #{value.inspect}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
value
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# A transport passed in must never follow a redirect, so the key is never
|
|
474
|
+
# sent to another URL. It says so by answering false to follow_redirects?;
|
|
475
|
+
# one that answers true, or does not answer, is refused.
|
|
476
|
+
def checked_http_client(http)
|
|
477
|
+
return nil if http.nil?
|
|
478
|
+
raise ArgumentError, "http_client must respond to call(method, url, headers, body, timeout)" unless http.respond_to?(:call)
|
|
479
|
+
|
|
480
|
+
unless http.respond_to?(:follow_redirects?) && http.follow_redirects? == false
|
|
481
|
+
raise ArgumentError, "http_client must never follow redirects and say so: follow_redirects? must return false"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
http
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# A value counts as given unless it is nil or an empty string.
|
|
488
|
+
def given(value)
|
|
489
|
+
return nil if value.nil?
|
|
490
|
+
|
|
491
|
+
text = value.to_s
|
|
492
|
+
text.empty? ? nil : text
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def env(name)
|
|
496
|
+
value = ENV.fetch(name, nil)
|
|
497
|
+
value.nil? || value.empty? ? nil : value
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
DECIDE_ARGS = %i[state questions images videos model boosters fallback].freeze
|
|
501
|
+
private_constant :DECIDE_ARGS
|
|
502
|
+
|
|
503
|
+
def decide_args(item)
|
|
504
|
+
raise ArgumentError, "each request must be a Hash of decide's arguments, got #{item.class}" unless item.is_a?(Hash)
|
|
505
|
+
|
|
506
|
+
args = item.to_h { |k, v| [k.to_sym, v] }
|
|
507
|
+
unknown = args.keys - DECIDE_ARGS
|
|
508
|
+
raise ArgumentError, "unknown argument(s) #{unknown.join(', ')}; decide takes #{DECIDE_ARGS.join(', ')}" unless unknown.empty?
|
|
509
|
+
|
|
510
|
+
args
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# The question map with String ids and String top-level keys in each
|
|
514
|
+
# question, checked against the API's fixed rules.
|
|
515
|
+
def prepared_questions(questions)
|
|
516
|
+
if questions.nil? || !questions.respond_to?(:each_pair) || questions.empty?
|
|
517
|
+
raise ArgumentError, "questions must hold at least one question"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# A question that is not a Hash is left for the checks, which refuse it as the API does.
|
|
521
|
+
questions.each_pair.to_h do |id, q|
|
|
522
|
+
[id.to_s, !q.is_a?(Hash) || q.keys.all?(String) ? q : q.transform_keys(&:to_s)]
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# The body of POST /v1/sessions: only the fields given, and the model always.
|
|
527
|
+
def open_body(questions:, state: nil, instructions: nil, model: nil, window: nil, ttl_seconds: nil)
|
|
528
|
+
qs = prepared_questions(questions)
|
|
529
|
+
body = { "model" => given(model) || @model }
|
|
530
|
+
body["instructions"] = instructions unless instructions.nil?
|
|
531
|
+
body["state"] = state unless state.nil?
|
|
532
|
+
body["questions"] = qs
|
|
533
|
+
body["window"] = window unless window.nil?
|
|
534
|
+
body["ttl_seconds"] = ttl_seconds unless ttl_seconds.nil?
|
|
535
|
+
body
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# The /v1/models entry of a model, when this client holds it, with the name
|
|
539
|
+
# the request uses: the entry of that name, else the entry that states the
|
|
540
|
+
# name as its `alias` or its pinned `version`. A name the held list does not
|
|
541
|
+
# state holds nothing, and its checks are left to the server.
|
|
542
|
+
def limits_for(model)
|
|
543
|
+
name = given(model) || @model
|
|
544
|
+
entry = @held[name] || @held.each_value.find { |m| m["alias"] == name || m["version"] == name }
|
|
545
|
+
entry ? entry.merge("model" => name) : {}
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Every fixed rule of a request before it is sent: the body (its images and
|
|
549
|
+
# videos as placeholders, so the nesting counts them) with the counts of
|
|
550
|
+
# its images and videos, then the images, then the videos.
|
|
551
|
+
def check_body(body, images, videos = [])
|
|
552
|
+
probe = body
|
|
553
|
+
probe = probe.merge("images" => images.map { |img| { "id" => image_id(img) } }) unless images.empty?
|
|
554
|
+
probe = probe.merge("videos" => Array.new(videos.size) { { "id" => "", "frames" => [{}] } }) unless videos.empty?
|
|
555
|
+
limits = limits_for(body["model"])
|
|
556
|
+
Checks.request!(probe, limits: limits, counts: Checks.count_entries(images, videos, limits: limits))
|
|
557
|
+
Checks.images!(images, limits: limits)
|
|
558
|
+
Checks.videos!(videos, image_ids: images.map { |img| image_id(img) })
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def image_id(img)
|
|
562
|
+
img.is_a?(Image) ? img.id : img["id"] || img[:id]
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# The image list as given: an Array of Image or wire-form Hashes.
|
|
566
|
+
def prepared_images(images)
|
|
567
|
+
return [] if images.nil?
|
|
568
|
+
raise ArgumentError, "images must be an Array of DecisionNode::Image" unless images.is_a?(Array)
|
|
569
|
+
|
|
570
|
+
images.each do |img|
|
|
571
|
+
next if img.is_a?(Image) || img.is_a?(Hash)
|
|
572
|
+
|
|
573
|
+
raise ArgumentError, "each image must be a DecisionNode::Image or a wire-form Hash, got #{img.class}"
|
|
574
|
+
end
|
|
575
|
+
images
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# The video list as given: an Array of Video or wire-form Hashes.
|
|
579
|
+
def prepared_videos(videos)
|
|
580
|
+
return [] if videos.nil?
|
|
581
|
+
raise ArgumentError, "videos must be an Array of DecisionNode::Video" unless videos.is_a?(Array)
|
|
582
|
+
|
|
583
|
+
videos.each do |video|
|
|
584
|
+
next if video.is_a?(Video) || video.is_a?(Hash)
|
|
585
|
+
|
|
586
|
+
raise ArgumentError, "each video must be a DecisionNode::Video or a wire-form Hash, got #{video.class}"
|
|
587
|
+
end
|
|
588
|
+
videos
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# The wire form of a request's videos, or nil when it has none.
|
|
592
|
+
def video_wires(videos)
|
|
593
|
+
videos.empty? ? nil : videos.map { |v| v.is_a?(Video) ? v.to_h : v }
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# The wire form of a request's boosters in the order given, or nil when it
|
|
597
|
+
# names none (nil or an empty Array), so the body then has no `boosters` key.
|
|
598
|
+
# A Booster becomes its wire form; anything else is sent as given, since
|
|
599
|
+
# boosters are the API's to judge.
|
|
600
|
+
def booster_wires(boosters)
|
|
601
|
+
return nil if boosters.nil? || (boosters.is_a?(Array) && boosters.empty?)
|
|
602
|
+
return boosters unless boosters.is_a?(Array)
|
|
603
|
+
|
|
604
|
+
boosters.map { |b| b.is_a?(Booster) ? b.wire : b }
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# The fallback argument: nil (left out of the request), true or false.
|
|
608
|
+
def checked_fallback(value)
|
|
609
|
+
return value if value.nil? || value == true || value == false
|
|
610
|
+
|
|
611
|
+
raise ArgumentError, "fallback must be true or false, got #{value.inspect}"
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Checks a request and keeps what is needed to send it. A request without
|
|
615
|
+
# local images is encoded once here.
|
|
616
|
+
def prepared_call(state: UNSET, questions: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)
|
|
617
|
+
fallback = checked_fallback(fallback)
|
|
618
|
+
qs = prepared_questions(questions)
|
|
619
|
+
imgs = prepared_images(images)
|
|
620
|
+
vids = prepared_videos(videos)
|
|
621
|
+
state = state.equal?(UNSET) || state.nil? ? "" : state
|
|
622
|
+
model = given(model) || @model
|
|
623
|
+
boosted = booster_wires(boosters)
|
|
624
|
+
check_body(body_for(model, state, qs, nil, nil, boosted, fallback), imgs, vids)
|
|
625
|
+
wired = video_wires(vids)
|
|
626
|
+
if imgs.any? { |img| img.is_a?(Image) && img.local? }
|
|
627
|
+
# The body as it will be sent, an image to upload named by a placeholder
|
|
628
|
+
# id of the real length, so nothing is uploaded to measure it. A value
|
|
629
|
+
# that cannot be serialised fails here too.
|
|
630
|
+
projected, = image_wires(imgs, project: true)
|
|
631
|
+
Checks.body_size!(encode(body_for(model, state, qs, projected, wired, boosted, fallback)))
|
|
632
|
+
Prepared.new(state, qs, imgs, wired, model, nil, boosted, fallback)
|
|
633
|
+
else
|
|
634
|
+
wires = imgs.empty? ? nil : imgs.map { |img| img.is_a?(Image) ? img.to_h : img }
|
|
635
|
+
data = encode(body_for(model, state, qs, wires, wired, boosted, fallback))
|
|
636
|
+
Checks.body_size!(data)
|
|
637
|
+
Prepared.new(state, qs, imgs, wired, model, data, boosted, fallback)
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def body_for(model, state, questions, images, videos = nil, boosters = nil, fallback = nil)
|
|
642
|
+
body = { "model" => model, "state" => state, "questions" => questions }
|
|
643
|
+
body["images"] = images unless images.nil?
|
|
644
|
+
body["videos"] = videos unless videos.nil?
|
|
645
|
+
body["boosters"] = boosters unless boosters.nil?
|
|
646
|
+
body["fallback"] = fallback unless fallback.nil?
|
|
647
|
+
body
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def decision_call(path, state, questions, images, videos, model, boosters, fallback)
|
|
651
|
+
call = prepared_call(state: state, questions: questions, images: images, videos: videos, model: model,
|
|
652
|
+
boosters: boosters, fallback: fallback)
|
|
653
|
+
send_prepared(path, call)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Sends a prepared request; returns the Decision and the HTTP status. A
|
|
657
|
+
# request that named uploads the SDK placed, answered with a stale-upload
|
|
658
|
+
# 400, is sent once more with the images uploaded again (not a retry).
|
|
659
|
+
def send_prepared(path, call)
|
|
660
|
+
ocr = ocr_options(call.boosters)
|
|
661
|
+
if call.encoded
|
|
662
|
+
body, headers, status = request("POST", path, call.encoded, encoded: true, **ocr)
|
|
663
|
+
return [Decision.new(body, headers), status]
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
2.times do |attempt|
|
|
667
|
+
wires, placed = image_wires(call.images)
|
|
668
|
+
data = encode(body_for(call.model, call.state, call.questions, wires.empty? ? nil : wires, call.videos, call.boosters,
|
|
669
|
+
call.fallback))
|
|
670
|
+
begin
|
|
671
|
+
body, headers, status = request("POST", path, data, encoded: true, **ocr)
|
|
672
|
+
return [Decision.new(body, headers), status]
|
|
673
|
+
rescue BadRequestError => e
|
|
674
|
+
raise unless attempt.zero? && !placed.empty? && STALE_UPLOAD.include?(e.error_type)
|
|
675
|
+
|
|
676
|
+
@uploads.drop(placed)
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# A decision whose boosters name ocr waits 30 s per attempt unless the
|
|
682
|
+
# caller set a timeout, and is never retried after a timeout: the server
|
|
683
|
+
# finishes that attempt and may bill it. Other decisions: no options.
|
|
684
|
+
def ocr_options(boosters)
|
|
685
|
+
list = boosters.is_a?(Array) ? boosters : [boosters]
|
|
686
|
+
names_ocr = list.any? { |b| booster_name(b) == "ocr" }
|
|
687
|
+
# A request naming knowledge keeps the client's timeout: that booster has
|
|
688
|
+
# its own budget of 800 ms.
|
|
689
|
+
return {} unless names_ocr && !list.any? { |b| booster_name(b) == "knowledge" }
|
|
690
|
+
|
|
691
|
+
{ policy: :ocr, timeout: @timeout_given ? nil : OCR_TIMEOUT }
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def booster_name(booster)
|
|
695
|
+
booster = booster.wire if booster.is_a?(Booster)
|
|
696
|
+
case booster
|
|
697
|
+
when Hash then (booster["name"] || booster[:name]).to_s
|
|
698
|
+
when String, Symbol then booster.to_s
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# The typed answer a helper promises, or a ServerError when it is missing
|
|
703
|
+
# or malformed.
|
|
704
|
+
def helper_answer(id, klass, state, question, images, videos, model, boosters, fallback)
|
|
705
|
+
decision, status = decision_call("/v1/decide", state, { id => question }, images, videos, model, boosters, fallback)
|
|
706
|
+
answer = decision.answers[id]
|
|
707
|
+
return answer if answer.is_a?(klass)
|
|
708
|
+
|
|
709
|
+
what = answer.nil? ? "has no answer" : "has a malformed answer"
|
|
710
|
+
raise ServerError.new("the response #{what} to the #{id.inspect} question",
|
|
711
|
+
status: status, request_id: decision.request_id,
|
|
712
|
+
reason: decision.headers["x-decisionnode-error"],
|
|
713
|
+
body: decision.raw, headers: decision.headers)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# One entry of #decide_many: a Decision or a DecisionNodeError. An error of
|
|
717
|
+
# any other class is wrapped, with the original as its cause.
|
|
718
|
+
def result_for
|
|
719
|
+
yield
|
|
720
|
+
rescue DecisionNodeError => e
|
|
721
|
+
e
|
|
722
|
+
rescue StandardError => e
|
|
723
|
+
begin
|
|
724
|
+
raise DecisionNodeError, "the request failed: #{e.message} (#{e.class})"
|
|
725
|
+
rescue DecisionNodeError => wrapped
|
|
726
|
+
wrapped
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# ---- sessions
|
|
731
|
+
|
|
732
|
+
# Waits between attempts, polls and reconnects; tests replace @sleeper.
|
|
733
|
+
def pause(seconds)
|
|
734
|
+
@sleeper.call(seconds)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# The `image` of a session frame: inline under the threshold, else by
|
|
738
|
+
# upload id; a URL is refused, since frames take bytes or an upload.
|
|
739
|
+
def frame_image(image)
|
|
740
|
+
return image if image.is_a?(Hash)
|
|
741
|
+
raise ArgumentError, "image must be a DecisionNode::Image, got #{image.class}" unless image.is_a?(Image)
|
|
742
|
+
|
|
743
|
+
wires, = image_wires([image])
|
|
744
|
+
wires.first.reject { |k, _| k == "id" }
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def session_stream(url)
|
|
748
|
+
raise AuthenticationError, "no API key: pass api_key: or set DECISIONNODE_API_KEY" if @api_key.nil?
|
|
749
|
+
raise ArgumentError, "the session has no stream_url" if url.nil?
|
|
750
|
+
|
|
751
|
+
headers = { "Authorization" => "Bearer #{@api_key}", "User-Agent" => "decisionnode-ruby/#{VERSION}" }
|
|
752
|
+
options = @http.respond_to?(:session_options) ? @http.session_options.to_h : {}
|
|
753
|
+
proxy = options.fetch(:proxy, :env)
|
|
754
|
+
proxy = URI.parse(proxy) if proxy.is_a?(String)
|
|
755
|
+
SessionStream.new(url, headers.merge(@headers), @timeout, proxy: proxy, tls: options.fetch(:tls, nil) || {})
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# ---- batches
|
|
759
|
+
|
|
760
|
+
def line_parts(req, index)
|
|
761
|
+
raise ArgumentError, "batch request #{index + 1} must be a Hash, got #{req.class}" unless req.is_a?(Hash)
|
|
762
|
+
|
|
763
|
+
args = req.to_h { |k, v| [k.to_s, v] }
|
|
764
|
+
checked_fallback(args["fallback"])
|
|
765
|
+
[args, prepared_questions(args["questions"]), prepared_images(args["images"]), prepared_videos(args["videos"])]
|
|
766
|
+
rescue ArgumentError => e
|
|
767
|
+
raise e if e.message.start_with?("batch request")
|
|
768
|
+
|
|
769
|
+
raise ArgumentError, "batch request #{index + 1}: #{e.message}"
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# The requests of one create or add call, every line checked before any is
|
|
773
|
+
# sent: the custom_id rules, then each line as a decide request, then each
|
|
774
|
+
# line as it would be sent against the body limit.
|
|
775
|
+
def check_lines(requests, allow_empty:)
|
|
776
|
+
Checks.lines!(requests, allow_empty: allow_empty, limits: limits_for(nil))
|
|
777
|
+
requests.each_with_index do |req, i|
|
|
778
|
+
args, questions, images, videos = line_parts(req, i)
|
|
779
|
+
check_body(body_for(given(args["model"]) || @model, args["state"].nil? ? "" : args["state"], questions, nil, nil,
|
|
780
|
+
booster_wires(args["boosters"]), args["fallback"]), images, videos)
|
|
781
|
+
end
|
|
782
|
+
requests.each_with_index { |req, i| Checks.body_size!(encode(line_for(req, i, project: true))) }
|
|
783
|
+
nil
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# One JSON Lines line: custom_id, model (always), state ("" when absent),
|
|
787
|
+
# questions, images, videos, boosters, fallback (only when given), then any
|
|
788
|
+
# other field as given.
|
|
789
|
+
def batch_line(req, index)
|
|
790
|
+
"#{encode(line_for(req, index))}\n"
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# A batch line as a Hash. With project: true nothing is uploaded: an image
|
|
794
|
+
# to upload is named by a placeholder id of the real length.
|
|
795
|
+
def line_for(req, index, project: false)
|
|
796
|
+
args, questions, images, videos = line_parts(req, index)
|
|
797
|
+
line = { "custom_id" => args["custom_id"], "model" => given(args["model"]) || @model,
|
|
798
|
+
"state" => args["state"].nil? ? "" : args["state"], "questions" => questions }
|
|
799
|
+
line["images"] = image_wires(images, project: project).first unless images.empty?
|
|
800
|
+
line["videos"] = video_wires(videos) unless videos.empty?
|
|
801
|
+
boosted = booster_wires(args["boosters"])
|
|
802
|
+
line["boosters"] = boosted unless boosted.nil?
|
|
803
|
+
line["fallback"] = args["fallback"] unless args["fallback"].nil?
|
|
804
|
+
args.each { |k, v| line[k] = v unless line.key?(k) || %w[images videos boosters fallback].include?(k) }
|
|
805
|
+
line
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# ---- transport
|
|
809
|
+
|
|
810
|
+
def encode(body)
|
|
811
|
+
JSON.generate(body, max_nesting: false) # the nesting limit is checked with the request
|
|
812
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError, SystemStackError => e
|
|
813
|
+
raise ArgumentError, "the request cannot be serialised as JSON: #{e.message}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Sends one API request with the retry policy and returns the parsed body
|
|
817
|
+
# (or the text with raw: true), the response headers and the status.
|
|
818
|
+
#
|
|
819
|
+
# policy:: :default retries 408, 429, 5xx, timeouts and connection errors;
|
|
820
|
+
# :mutation (batch create, add, finalize) only 429 and 529;
|
|
821
|
+
# :session_open never a 429 `session_limit_exceeded`;
|
|
822
|
+
# :ocr (a decision whose boosters name ocr) never a timeout, since
|
|
823
|
+
# the server finishes that attempt and may bill it;
|
|
824
|
+
# :idempotent (a call that carries its own Idempotency-Key, the
|
|
825
|
+
# same on every attempt) as :default, and also a 409
|
|
826
|
+
# `idempotency_in_progress` after its Retry-After.
|
|
827
|
+
# Under every policy a 429 `key_spend_cap_reached` is never retried.
|
|
828
|
+
# timeout:: seconds for each attempt in place of the client's timeout.
|
|
829
|
+
# extra_headers:: headers this call adds, after the client's own.
|
|
830
|
+
#
|
|
831
|
+
# With a block, a 2xx body is handed to the block line by line as it
|
|
832
|
+
# arrives (when the transport can stream), and the text returned is empty.
|
|
833
|
+
# A stream that fails after its first line is not retried.
|
|
834
|
+
def request(method, path, body, encoded: false, policy: :default, content_type: nil, raw: false, timeout: nil,
|
|
835
|
+
extra_headers: nil, &on_line)
|
|
836
|
+
raise AuthenticationError, "no API key: pass api_key: or set DECISIONNODE_API_KEY" if @api_key.nil?
|
|
837
|
+
|
|
838
|
+
data = body.nil? || encoded ? body : encode(body)
|
|
839
|
+
headers = {
|
|
840
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
841
|
+
"User-Agent" => "decisionnode-ruby/#{VERSION}",
|
|
842
|
+
"Accept" => "application/json"
|
|
843
|
+
}
|
|
844
|
+
headers["Content-Type"] = content_type || "application/json" unless data.nil?
|
|
845
|
+
headers.merge!(@headers)
|
|
846
|
+
headers.merge!(extra_headers) if extra_headers
|
|
847
|
+
url = @base_url + path
|
|
848
|
+
|
|
849
|
+
attempt = 0
|
|
850
|
+
loop do
|
|
851
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
852
|
+
lines = on_line && @http.respond_to?(:stream) ? LineSplitter.new(on_line) : nil
|
|
853
|
+
begin
|
|
854
|
+
response = if lines
|
|
855
|
+
stream_once(method, url, headers, lines)
|
|
856
|
+
else
|
|
857
|
+
attempt_once(method, url, headers, data, timeout: timeout || @timeout)
|
|
858
|
+
end
|
|
859
|
+
rescue TimeoutError, ConnectionError => e
|
|
860
|
+
log(method, path, e.class.name.split("::").last, nil, started, attempt)
|
|
861
|
+
raise if attempt >= @max_retries || policy == :mutation || lines&.started?
|
|
862
|
+
raise if policy == :ocr && e.is_a?(TimeoutError)
|
|
863
|
+
|
|
864
|
+
@sleeper.call(backoff(attempt))
|
|
865
|
+
attempt += 1
|
|
866
|
+
next
|
|
867
|
+
end
|
|
868
|
+
log(method, path, response.status, response.headers["x-request-id"], started, attempt)
|
|
869
|
+
|
|
870
|
+
# Only 2xx is a success. Redirects are never followed, so the key is
|
|
871
|
+
# never sent to another URL or over plain HTTP: a 3xx is an error.
|
|
872
|
+
if (200..299).cover?(response.status)
|
|
873
|
+
if lines
|
|
874
|
+
lines.finish
|
|
875
|
+
return ["", response.headers, response.status]
|
|
876
|
+
end
|
|
877
|
+
response.body.to_s.each_line(&on_line) if on_line
|
|
878
|
+
return [on_line ? "" : response.body.to_s, response.headers, response.status] if raw || on_line
|
|
879
|
+
|
|
880
|
+
return [parse_success(response), response.headers, response.status]
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
error = error_for(response)
|
|
884
|
+
if attempt < @max_retries && retryable?(error, policy)
|
|
885
|
+
wait = retry_wait(response.headers)
|
|
886
|
+
@sleeper.call(wait.nil? ? backoff(attempt) : wait)
|
|
887
|
+
attempt += 1
|
|
888
|
+
next
|
|
889
|
+
end
|
|
890
|
+
raise error
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# One streamed attempt: the transport hands a 2xx body over in chunks as it
|
|
895
|
+
# arrives, each read bounded by the timeout, so a results file of any size
|
|
896
|
+
# is read without holding it whole.
|
|
897
|
+
def stream_once(method, url, headers, lines)
|
|
898
|
+
response = @http.stream(method, url, headers, @timeout) { |chunk| lines << chunk }
|
|
899
|
+
unless response.is_a?(HTTPResponse)
|
|
900
|
+
raise ConnectionError, "the transport returned #{response.class}, not a DecisionNode::HTTPResponse"
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
response
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Cuts a stream of chunks into lines and hands each complete one on.
|
|
907
|
+
class LineSplitter
|
|
908
|
+
def initialize(on_line)
|
|
909
|
+
@on_line = on_line
|
|
910
|
+
@buffer = +""
|
|
911
|
+
@started = false
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def <<(chunk)
|
|
915
|
+
@buffer << chunk.b
|
|
916
|
+
while (cut = @buffer.index("\n"))
|
|
917
|
+
line = @buffer.slice!(0, cut + 1)
|
|
918
|
+
@started = true
|
|
919
|
+
@on_line.call(line.force_encoding(Encoding::UTF_8))
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def started?
|
|
924
|
+
@started
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def finish
|
|
928
|
+
return if @buffer.empty?
|
|
929
|
+
|
|
930
|
+
@started = true
|
|
931
|
+
@on_line.call(@buffer.force_encoding(Encoding::UTF_8))
|
|
932
|
+
@buffer = +""
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
private_constant :LineSplitter
|
|
936
|
+
|
|
937
|
+
def retryable?(error, policy)
|
|
938
|
+
# An enrollment with no price set: a retry never cures it, and the API
|
|
939
|
+
# sends no Retry-After for it.
|
|
940
|
+
return false if error.status == 529 && error.details&.dig("reason") == "enrollment_unpriced"
|
|
941
|
+
# Knowledge whose documents wait on ingestion: an outage of ingestion,
|
|
942
|
+
# so the caller decides.
|
|
943
|
+
return false if error.status == 529 && error.details&.dig("reason") == "ingestion_delayed"
|
|
944
|
+
# A key past its daily spend cap: its Retry-After runs to the cap's reset
|
|
945
|
+
# at 00:00 UTC, so it is raised at once.
|
|
946
|
+
return false if error.status == 429 && error.error_type == "key_spend_cap_reached"
|
|
947
|
+
|
|
948
|
+
case policy
|
|
949
|
+
when :mutation then [429, 529].include?(error.status)
|
|
950
|
+
when :session_open then RETRY_STATUSES.include?(error.status) && !error.is_a?(SessionLimitError)
|
|
951
|
+
when :idempotent
|
|
952
|
+
RETRY_STATUSES.include?(error.status) || (error.status == 409 && error.error_type == "idempotency_in_progress")
|
|
953
|
+
else RETRY_STATUSES.include?(error.status)
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
# One attempt under one deadline: the timeout covers the whole attempt, from
|
|
958
|
+
# sending the request to the last byte of the body, whatever the transport
|
|
959
|
+
# does in between.
|
|
960
|
+
def attempt_once(method, url, headers, data, timeout: @timeout)
|
|
961
|
+
response = begin
|
|
962
|
+
Timeout.timeout(timeout, AttemptDeadline) { @http.call(method, url, headers, data, timeout) }
|
|
963
|
+
rescue AttemptDeadline
|
|
964
|
+
raise TimeoutError, "no complete response within #{timeout} s"
|
|
965
|
+
end
|
|
966
|
+
unless response.is_a?(HTTPResponse)
|
|
967
|
+
raise ConnectionError, "the transport returned #{response.class}, not a DecisionNode::HTTPResponse"
|
|
968
|
+
end
|
|
969
|
+
# A transport that followed a redirect reports the address it ended at.
|
|
970
|
+
if response.url && response.url != url
|
|
971
|
+
raise DecisionNodeError.new("the transport followed a redirect to another URL; redirects are never followed",
|
|
972
|
+
status: response.status, headers: response.headers)
|
|
973
|
+
end
|
|
974
|
+
response
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def parse_success(response)
|
|
978
|
+
JSON.parse(response.body, max_nesting: false)
|
|
979
|
+
rescue JSON::ParserError, TypeError => e
|
|
980
|
+
raise ServerError.new("the response is not JSON: #{e.message}",
|
|
981
|
+
status: response.status, request_id: response.headers["x-request-id"],
|
|
982
|
+
headers: response.headers)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def backoff(attempt)
|
|
986
|
+
base = [BACKOFF_CAP, BACKOFF_START * (2**attempt)].min
|
|
987
|
+
base * (0.75 + (rand * 0.25))
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# The wait the server asks for, in seconds: retry-after-ms, else Retry-After
|
|
991
|
+
# in seconds or as an HTTP date. Capped at RETRY_AFTER_CAP; nil when neither
|
|
992
|
+
# header holds a usable value.
|
|
993
|
+
def retry_wait(headers)
|
|
994
|
+
ms = number_value(headers["retry-after-ms"])
|
|
995
|
+
seconds = ms.nil? ? nil : ms / 1000.0
|
|
996
|
+
seconds ||= number_value(headers["retry-after"]) || http_date_wait(headers["retry-after"])
|
|
997
|
+
return nil if seconds.nil? || seconds.nan?
|
|
998
|
+
|
|
999
|
+
seconds.clamp(0.0, RETRY_AFTER_CAP)
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
def http_date_wait(value)
|
|
1003
|
+
return nil if value.nil?
|
|
1004
|
+
|
|
1005
|
+
Time.httpdate(value.strip) - Time.now
|
|
1006
|
+
rescue ArgumentError
|
|
1007
|
+
nil
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def number_value(value)
|
|
1011
|
+
value.nil? ? nil : Float(value.strip)
|
|
1012
|
+
rescue ArgumentError
|
|
1013
|
+
nil
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# The text of an error status whose body is not JSON, the same in every
|
|
1017
|
+
# DecisionNode SDK and never the HTTP library's own wording.
|
|
1018
|
+
STATUS_TEXT = {
|
|
1019
|
+
400 => "Bad Request", 401 => "Unauthorized", 402 => "Payment Required", 403 => "Forbidden",
|
|
1020
|
+
404 => "Not Found", 405 => "Method Not Allowed", 408 => "Request Timeout", 409 => "Conflict",
|
|
1021
|
+
410 => "Gone", 413 => "Content Too Large", 422 => "Unprocessable Content", 429 => "Too Many Requests",
|
|
1022
|
+
500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable",
|
|
1023
|
+
504 => "Gateway Timeout", 529 => "Overloaded"
|
|
1024
|
+
}.freeze
|
|
1025
|
+
private_constant :STATUS_TEXT
|
|
1026
|
+
|
|
1027
|
+
def error_for(response)
|
|
1028
|
+
body = begin
|
|
1029
|
+
JSON.parse(response.body.to_s, max_nesting: false)
|
|
1030
|
+
rescue JSON::ParserError
|
|
1031
|
+
# Not the API's envelope (an edge's or a proxy's page, or no body): the
|
|
1032
|
+
# class follows the status alone and the body is never read.
|
|
1033
|
+
return error_from(response.status, response.headers, nil,
|
|
1034
|
+
message: edge_message(response.status, response.headers))
|
|
1035
|
+
end
|
|
1036
|
+
error_from(response.status, response.headers, body)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# "HTTP <status> <text>", then the request id when the response has one.
|
|
1040
|
+
def edge_message(status, headers)
|
|
1041
|
+
text = "HTTP #{status}"
|
|
1042
|
+
text += " #{STATUS_TEXT[status]}" if STATUS_TEXT.key?(status)
|
|
1043
|
+
request_id = headers["x-request-id"]
|
|
1044
|
+
request_id.nil? ? text : "#{text} (request id #{request_id})"
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# The error the API's answer gives: the class by status (and error_type),
|
|
1048
|
+
# with the fields read from the body and headers. `message` is given for a
|
|
1049
|
+
# body that is not JSON, which then plays no part.
|
|
1050
|
+
def error_from(status, headers, body, message: nil)
|
|
1051
|
+
detail = body.is_a?(Hash) ? body["detail"] : nil
|
|
1052
|
+
error_type = entries = category = nil
|
|
1053
|
+
case detail
|
|
1054
|
+
when Hash
|
|
1055
|
+
error_type = detail["error_type"]
|
|
1056
|
+
message = detail["message"]
|
|
1057
|
+
category = detail["category"]
|
|
1058
|
+
when String
|
|
1059
|
+
message = detail
|
|
1060
|
+
when Array
|
|
1061
|
+
entries = detail
|
|
1062
|
+
end
|
|
1063
|
+
kw = {
|
|
1064
|
+
status: status, error_type: error_type, request_id: headers["x-request-id"],
|
|
1065
|
+
reason: headers["x-decisionnode-error"], body: body, headers: headers
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
case status
|
|
1069
|
+
when 400
|
|
1070
|
+
error_type == "max_tokens_exceeded" ? ContextLimitError.new(message, **kw) : BadRequestError.new(message, **kw)
|
|
1071
|
+
when 401 then AuthenticationError.new(message, **kw)
|
|
1072
|
+
when 402 then InsufficientCreditError.new(message, **kw)
|
|
1073
|
+
when 403
|
|
1074
|
+
if error_type == "refusal_error"
|
|
1075
|
+
RefusalError.new(message, category: category, safety: Safety.parse(headers["x-decisionnode-safety"]), **kw)
|
|
1076
|
+
else
|
|
1077
|
+
PermissionDeniedError.new(message, **kw)
|
|
1078
|
+
end
|
|
1079
|
+
when 404 then NotFoundError.new(message, **kw)
|
|
1080
|
+
when 413 then RequestTooLargeError.new(message, **kw)
|
|
1081
|
+
# The API's 422 with no list of entries (`invalid_text`) has an empty list.
|
|
1082
|
+
when 422 then ValidationError.new(message, entries: entries || (body.is_a?(Hash) ? [] : nil), **kw)
|
|
1083
|
+
when 429
|
|
1084
|
+
klass = error_type == "session_limit_exceeded" ? SessionLimitError : RateLimitError
|
|
1085
|
+
klass.new(message, retry_after: retry_wait(headers), **kw)
|
|
1086
|
+
when 529 then OverloadedError.new(message, retry_after: retry_wait(headers), **kw)
|
|
1087
|
+
when 500..599 then ServerError.new(message, **kw)
|
|
1088
|
+
# Every other status, 3xx included: the base class with the status.
|
|
1089
|
+
else DecisionNodeError.new(message, **kw)
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
def log(method, path, status, request_id, started, attempt)
|
|
1094
|
+
return unless @logger
|
|
1095
|
+
|
|
1096
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
1097
|
+
@logger.debug("decisionnode: #{method} #{path} -> #{status} request_id=#{request_id || '-'} " \
|
|
1098
|
+
"#{ms} ms attempt=#{attempt + 1}")
|
|
1099
|
+
end
|
|
1100
|
+
end
|