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,539 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DecisionNode
|
|
4
|
+
# The base of every answer. `raw` holds every field the server sent.
|
|
5
|
+
class Answer
|
|
6
|
+
attr_reader :raw, :type
|
|
7
|
+
|
|
8
|
+
# The typed answer for a known type whose fields are all present and of
|
|
9
|
+
# the right type; an UnknownAnswer holding the raw fields otherwise, so a
|
|
10
|
+
# new or malformed answer never fails the decision.
|
|
11
|
+
def self.from(raw)
|
|
12
|
+
return UnknownAnswer.new({ "value" => raw }) unless raw.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
klass = TYPES[raw["type"]]
|
|
15
|
+
# A point or box question asked with `max` answers a list of places,
|
|
16
|
+
# told by the answer's own list, so a batch line reads the same.
|
|
17
|
+
klass = PLACES[raw["type"]] if PLACES.key?(raw["type"]) && (raw.key?("points") || raw.key?("boxes"))
|
|
18
|
+
klass&.well_formed?(raw) ? klass.new(raw) : UnknownAnswer.new(raw)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Answers by question id, each typed with Answer.from.
|
|
22
|
+
def self.map(answers)
|
|
23
|
+
return {} unless answers.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
answers.each_with_object({}) { |(id, a), h| h[id] = from(a) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.number?(value)
|
|
29
|
+
value.is_a?(Numeric)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A string of decimal digits, as score probabilities and legends are keyed.
|
|
33
|
+
def self.index?(key)
|
|
34
|
+
key.is_a?(String) && key.match?(/\A(0|[1-9][0-9]*)\z/)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(raw)
|
|
38
|
+
@raw = raw
|
|
39
|
+
@type = raw["type"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A field of the answer as the server sent it.
|
|
43
|
+
def [](key)
|
|
44
|
+
raw[key.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
raw
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# An answer of a type this gem does not know yet. `raw` holds every field the
|
|
53
|
+
# server sent, so a new question type does not break an older client.
|
|
54
|
+
class UnknownAnswer < Answer; end
|
|
55
|
+
|
|
56
|
+
# The answer to a `truth` question. An answer under the API's older name
|
|
57
|
+
# for the type, which a raw question may still use, is read the same way.
|
|
58
|
+
class TruthAnswer < Answer
|
|
59
|
+
OLD_TYPE = "noul"
|
|
60
|
+
private_constant :OLD_TYPE
|
|
61
|
+
|
|
62
|
+
# The probability that the answer is true.
|
|
63
|
+
attr_reader :truth
|
|
64
|
+
|
|
65
|
+
def self.well_formed?(raw)
|
|
66
|
+
number?(raw[value_field(raw)])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.value_field(raw)
|
|
70
|
+
raw["type"] == OLD_TYPE ? OLD_TYPE : "truth"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(raw)
|
|
74
|
+
super
|
|
75
|
+
@truth = raw[self.class.value_field(raw)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The answer to a `choice` question.
|
|
80
|
+
class ChoiceAnswer < Answer
|
|
81
|
+
# The chosen option's name, exactly as sent.
|
|
82
|
+
attr_reader :choice
|
|
83
|
+
attr_reader :confidence
|
|
84
|
+
# Probability by option name, exactly as sent.
|
|
85
|
+
attr_reader :probabilities
|
|
86
|
+
|
|
87
|
+
def self.well_formed?(raw)
|
|
88
|
+
probs = raw["probabilities"]
|
|
89
|
+
raw["choice"].is_a?(String) && number?(raw["confidence"]) &&
|
|
90
|
+
probs.is_a?(Hash) && probs.each_value.all? { |v| number?(v) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize(raw)
|
|
94
|
+
super
|
|
95
|
+
@choice = raw["choice"]
|
|
96
|
+
@confidence = raw["confidence"]
|
|
97
|
+
@probabilities = raw["probabilities"].dup
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The answer to a `score` question.
|
|
102
|
+
class ScoreAnswer < Answer
|
|
103
|
+
# The expected level, a number from 0 to the number of levels minus one.
|
|
104
|
+
attr_reader :score
|
|
105
|
+
attr_reader :confidence
|
|
106
|
+
# Probability by level index (Integer).
|
|
107
|
+
attr_reader :probabilities
|
|
108
|
+
# Level index (Integer) to the level as it was sent.
|
|
109
|
+
attr_reader :legend
|
|
110
|
+
|
|
111
|
+
def self.well_formed?(raw)
|
|
112
|
+
probs = raw["probabilities"]
|
|
113
|
+
legend = raw["legend"]
|
|
114
|
+
number?(raw["score"]) && number?(raw["confidence"]) &&
|
|
115
|
+
probs.is_a?(Hash) && probs.all? { |k, v| index?(k) && number?(v) } &&
|
|
116
|
+
legend.is_a?(Hash) && legend.each_key.all? { |k| index?(k) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def initialize(raw)
|
|
120
|
+
super
|
|
121
|
+
@score = raw["score"]
|
|
122
|
+
@confidence = raw["confidence"]
|
|
123
|
+
@probabilities = int_keys(raw["probabilities"])
|
|
124
|
+
@legend = int_keys(raw["legend"])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def int_keys(map)
|
|
130
|
+
map.each_with_object({}) { |(k, v), h| h[Integer(k, 10)] = v }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The answer to a `number` question.
|
|
135
|
+
class NumberAnswer < Answer
|
|
136
|
+
# The most probable value of the grid, the JSON number as received.
|
|
137
|
+
attr_reader :number
|
|
138
|
+
# The probability-weighted mean over the grid, as received.
|
|
139
|
+
attr_reader :expected
|
|
140
|
+
# The probability of `number`.
|
|
141
|
+
attr_reader :confidence
|
|
142
|
+
# Probability by grid value, keyed by the value exactly as the server
|
|
143
|
+
# wrote it ("7", "3.5").
|
|
144
|
+
attr_reader :probabilities
|
|
145
|
+
|
|
146
|
+
def self.well_formed?(raw)
|
|
147
|
+
probs = raw["probabilities"]
|
|
148
|
+
number?(raw["number"]) && number?(raw["expected"]) && number?(raw["confidence"]) &&
|
|
149
|
+
probs.is_a?(Hash) && probs.each_value.all? { |v| number?(v) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def initialize(raw)
|
|
153
|
+
super
|
|
154
|
+
@number = raw["number"]
|
|
155
|
+
@expected = raw["expected"]
|
|
156
|
+
@confidence = raw["confidence"]
|
|
157
|
+
@probabilities = raw["probabilities"].dup
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# What the answers to `point` and `box` questions share: a place in an
|
|
162
|
+
# image. Every number is as received: integers in pixels, fractions when
|
|
163
|
+
# the question asked for normalized units.
|
|
164
|
+
class PlaceAnswer < Answer
|
|
165
|
+
# The probability that what the instructions describe is in the image
|
|
166
|
+
# at all; under a low value the place is only the best guess.
|
|
167
|
+
attr_reader :present
|
|
168
|
+
# The probability that the place is right if the thing is there.
|
|
169
|
+
attr_reader :confidence
|
|
170
|
+
# The id of the image looked in.
|
|
171
|
+
attr_reader :image
|
|
172
|
+
# The image's size in pixels.
|
|
173
|
+
attr_reader :width, :height
|
|
174
|
+
|
|
175
|
+
def self.well_formed?(raw)
|
|
176
|
+
(self::COORDINATES + %w[present confidence width height]).all? { |k| number?(raw[k]) } && raw["image"].is_a?(String)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def initialize(raw)
|
|
180
|
+
super
|
|
181
|
+
@present = raw["present"]
|
|
182
|
+
@confidence = raw["confidence"]
|
|
183
|
+
@image = raw["image"]
|
|
184
|
+
@width = raw["width"]
|
|
185
|
+
@height = raw["height"]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The answer to a `point` question: the spot `x`, `y` from the top-left
|
|
190
|
+
# corner of the image, `x` to the right and `y` down.
|
|
191
|
+
class PointAnswer < PlaceAnswer
|
|
192
|
+
COORDINATES = %w[x y].freeze
|
|
193
|
+
|
|
194
|
+
attr_reader :x, :y
|
|
195
|
+
|
|
196
|
+
def initialize(raw)
|
|
197
|
+
super
|
|
198
|
+
@x = raw["x"]
|
|
199
|
+
@y = raw["y"]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The answer to a `box` question: the top-left corner `x1`, `y1` and the
|
|
204
|
+
# bottom-right corner `x2`, `y2`.
|
|
205
|
+
class BoxAnswer < PlaceAnswer
|
|
206
|
+
COORDINATES = %w[x1 y1 x2 y2].freeze
|
|
207
|
+
|
|
208
|
+
attr_reader :x1, :y1, :x2, :y2
|
|
209
|
+
|
|
210
|
+
def initialize(raw)
|
|
211
|
+
super
|
|
212
|
+
@x1 = raw["x1"]
|
|
213
|
+
@y1 = raw["y1"]
|
|
214
|
+
@x2 = raw["x2"]
|
|
215
|
+
@y2 = raw["y2"]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private_constant :PlaceAnswer
|
|
220
|
+
|
|
221
|
+
# What the answers to `point` and `box` questions asked with `max` share:
|
|
222
|
+
# up to `max` places, one per thing the instructions describe, in a fixed
|
|
223
|
+
# order (left to right by the centre, ties from the top). Every number is
|
|
224
|
+
# as received.
|
|
225
|
+
class PlacesAnswer < Answer
|
|
226
|
+
# How many places follow, 0 to the question's `max`.
|
|
227
|
+
attr_reader :count
|
|
228
|
+
# The probability that at least one such thing is in the image.
|
|
229
|
+
attr_reader :present
|
|
230
|
+
# The probability that nothing was left out: low when the image holds
|
|
231
|
+
# more than `max`.
|
|
232
|
+
attr_reader :complete
|
|
233
|
+
# The id of the image looked in, and its size in pixels.
|
|
234
|
+
attr_reader :image, :width, :height
|
|
235
|
+
|
|
236
|
+
def self.well_formed?(raw)
|
|
237
|
+
list = raw[self::LIST]
|
|
238
|
+
raw["count"].is_a?(Integer) && !raw["count"].negative? &&
|
|
239
|
+
number?(raw["present"]) && number?(raw["complete"]) &&
|
|
240
|
+
list.is_a?(Array) && list.all? { |p| p.is_a?(Hash) && self::PLACE.members.all? { |k| number?(p[k.to_s]) } } &&
|
|
241
|
+
raw["image"].is_a?(String) && number?(raw["width"]) && number?(raw["height"])
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def initialize(raw)
|
|
245
|
+
super
|
|
246
|
+
@count = raw["count"]
|
|
247
|
+
@present = raw["present"]
|
|
248
|
+
@complete = raw["complete"]
|
|
249
|
+
@image = raw["image"]
|
|
250
|
+
@width = raw["width"]
|
|
251
|
+
@height = raw["height"]
|
|
252
|
+
@places = raw[self.class::LIST].map { |p| self.class::PLACE.new(*self.class::PLACE.members.map { |k| p[k.to_s] }) }.freeze
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The answer to a `point` question asked with `max`: `points`, each a
|
|
257
|
+
# PointsAnswer::Point with `x`, `y` and its own `confidence`.
|
|
258
|
+
class PointsAnswer < PlacesAnswer
|
|
259
|
+
# One spot of the answer.
|
|
260
|
+
Point = Struct.new(:x, :y, :confidence)
|
|
261
|
+
PLACE = Point
|
|
262
|
+
LIST = "points"
|
|
263
|
+
|
|
264
|
+
def points
|
|
265
|
+
@places
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The answer to a `box` question asked with `max`: `boxes`, each a
|
|
270
|
+
# BoxesAnswer::Box with `x1`, `y1`, `x2`, `y2` and its own `confidence`.
|
|
271
|
+
class BoxesAnswer < PlacesAnswer
|
|
272
|
+
# One rectangle of the answer.
|
|
273
|
+
Box = Struct.new(:x1, :y1, :x2, :y2, :confidence)
|
|
274
|
+
PLACE = Box
|
|
275
|
+
LIST = "boxes"
|
|
276
|
+
|
|
277
|
+
def boxes
|
|
278
|
+
@places
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private_constant :PlacesAnswer
|
|
283
|
+
|
|
284
|
+
class Answer
|
|
285
|
+
TYPES = {
|
|
286
|
+
"truth" => TruthAnswer, "noul" => TruthAnswer, "choice" => ChoiceAnswer,
|
|
287
|
+
"score" => ScoreAnswer, "number" => NumberAnswer, "point" => PointAnswer, "box" => BoxAnswer
|
|
288
|
+
}.freeze
|
|
289
|
+
PLACES = { "point" => PointsAnswer, "box" => BoxesAnswer }.freeze
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Token counts of a decision and, for a request with videos, the frames the
|
|
293
|
+
# API read (an Integer) and the seconds of video they cover (a number as
|
|
294
|
+
# received); for a request that named boosters, `booster_tokens` (the share
|
|
295
|
+
# of `input_tokens` the boosters' readings take) and `boosters` (the booster
|
|
296
|
+
# calls billed); `cost`, what the answer cost (a Cost). Each is nil when the
|
|
297
|
+
# response has none.
|
|
298
|
+
Usage = Struct.new(:input_tokens, :output_tokens, :video_frames, :video_seconds, :booster_tokens, :boosters,
|
|
299
|
+
:cost)
|
|
300
|
+
|
|
301
|
+
# What an answer cost, Usage#cost: `usd` in all, `model_usd` for the model,
|
|
302
|
+
# `boosters_usd` for the boosters (each the JSON number as received, in USD)
|
|
303
|
+
# and `free_tier` (true when the free tier paid for it). Read as sent: `usd`
|
|
304
|
+
# is the server's figure, never the sum of the parts here.
|
|
305
|
+
Cost = Struct.new(:usd, :model_usd, :boosters_usd, :free_tier) do
|
|
306
|
+
# The `cost` of a response's usage, or nil when it has none. An amount
|
|
307
|
+
# that is not a number, or a `free_tier` that is not true or false, is nil.
|
|
308
|
+
def self.parse(value)
|
|
309
|
+
return nil unless value.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
amount = ->(v) { v.is_a?(Integer) || v.is_a?(Float) ? v : nil }
|
|
312
|
+
free = value["free_tier"]
|
|
313
|
+
new(amount.call(value["usd"]), amount.call(value["model_usd"]), amount.call(value["boosters_usd"]),
|
|
314
|
+
[true, false].include?(free) ? free : nil)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# A fallback answer, Decision#fallback: a request for one model answered by
|
|
319
|
+
# another because no worker of the model asked for was ready at once.
|
|
320
|
+
# `served_model` is the model that answered (also Decision#model), from the
|
|
321
|
+
# `x-decisionnode-fallback` header; `requested_model` the one asked for, from
|
|
322
|
+
# `x-decisionnode-requested-model`: the family id for a family id or an older
|
|
323
|
+
# id, the pinned id for a pinned id (nil when the header is absent).
|
|
324
|
+
Fallback = Struct.new(:served_model, :requested_model)
|
|
325
|
+
|
|
326
|
+
# The result of the safety check that runs on every decision on the hosted API,
|
|
327
|
+
# parsed from the `x-decisionnode-safety` response header.
|
|
328
|
+
class Safety
|
|
329
|
+
# The header value as received.
|
|
330
|
+
attr_reader :header
|
|
331
|
+
# Category name to its number, for example {"harm" => 0.031, "self_harm" => 0.004}.
|
|
332
|
+
attr_reader :scores
|
|
333
|
+
# The categories above the key's thresholds, in header order.
|
|
334
|
+
attr_reader :flagged
|
|
335
|
+
# Why the check did not run, when it did not.
|
|
336
|
+
attr_reader :reason
|
|
337
|
+
|
|
338
|
+
# Parses the header value. Returns nil when there is no header.
|
|
339
|
+
def self.parse(value)
|
|
340
|
+
return nil if value.nil?
|
|
341
|
+
|
|
342
|
+
new(value.to_s.strip)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def initialize(header)
|
|
346
|
+
@header = header
|
|
347
|
+
@scores = {}
|
|
348
|
+
@flagged = []
|
|
349
|
+
@reason = nil
|
|
350
|
+
if header.start_with?("unchecked")
|
|
351
|
+
@checked = false
|
|
352
|
+
header.split(";").drop(1).each do |param|
|
|
353
|
+
key, _, val = param.strip.partition("=")
|
|
354
|
+
@reason = val.strip.delete_prefix('"').delete_suffix('"') if key == "reason"
|
|
355
|
+
end
|
|
356
|
+
else
|
|
357
|
+
@checked = true
|
|
358
|
+
header.split(",").each do |member|
|
|
359
|
+
member = member.strip
|
|
360
|
+
next if member.empty?
|
|
361
|
+
|
|
362
|
+
head, *params = member.split(";")
|
|
363
|
+
name, _, num = head.partition("=")
|
|
364
|
+
name = name.strip
|
|
365
|
+
@scores[name] = parse_number(num)
|
|
366
|
+
@flagged << name if params.any? { |p| %w[flagged blocked].include?(p.strip) }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def checked
|
|
372
|
+
@checked
|
|
373
|
+
end
|
|
374
|
+
alias checked? checked
|
|
375
|
+
|
|
376
|
+
def harm
|
|
377
|
+
@scores["harm"]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def self_harm
|
|
381
|
+
@scores["self_harm"]
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def [](name)
|
|
385
|
+
@scores[name.to_s]
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def to_h
|
|
389
|
+
return { "checked" => false, "reason" => reason } unless checked
|
|
390
|
+
|
|
391
|
+
{ "checked" => true, "harm" => nil, "self_harm" => nil }.merge(@scores).merge("flagged" => flagged)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def parse_number(text)
|
|
397
|
+
f = Float(text.strip)
|
|
398
|
+
f
|
|
399
|
+
rescue ArgumentError, TypeError
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The answers to one request, with the metadata the server sent with them.
|
|
405
|
+
class Decision
|
|
406
|
+
include DecisionResult
|
|
407
|
+
|
|
408
|
+
# Question id to Answer.
|
|
409
|
+
attr_reader :answers
|
|
410
|
+
# The resolved model version.
|
|
411
|
+
attr_reader :model
|
|
412
|
+
attr_reader :usage
|
|
413
|
+
# From the `x-request-id` header.
|
|
414
|
+
attr_reader :request_id
|
|
415
|
+
# The server's processing time, from the `x-decisionnode-latency-ms` header.
|
|
416
|
+
attr_reader :latency_ms
|
|
417
|
+
# A Safety, or nil when the server sent no safety header.
|
|
418
|
+
attr_reader :safety
|
|
419
|
+
# What the API says it changed while reading the request's videos (a
|
|
420
|
+
# lower rate or resolution), each sentence as sent; empty when it sent none.
|
|
421
|
+
attr_reader :warnings
|
|
422
|
+
# What each booster the request named found, as booster name to
|
|
423
|
+
# Evidence in the request's order; nil when the request named none.
|
|
424
|
+
attr_reader :evidence
|
|
425
|
+
# A Fallback when another model answered in place of the one asked for;
|
|
426
|
+
# nil on an ordinary answer.
|
|
427
|
+
attr_reader :fallback
|
|
428
|
+
# The parsed response body.
|
|
429
|
+
attr_reader :raw
|
|
430
|
+
# The response headers, names in lower case.
|
|
431
|
+
attr_reader :headers
|
|
432
|
+
|
|
433
|
+
def initialize(body, headers = {})
|
|
434
|
+
body = {} unless body.is_a?(Hash)
|
|
435
|
+
@raw = body
|
|
436
|
+
@headers = headers
|
|
437
|
+
@model = body["model"]
|
|
438
|
+
@answers = Answer.map(body["answers"])
|
|
439
|
+
usage = body["usage"].is_a?(Hash) ? body["usage"] : {}
|
|
440
|
+
@usage = Usage.new(usage["input_tokens"], usage["output_tokens"],
|
|
441
|
+
usage["video_frames"].is_a?(Integer) ? usage["video_frames"] : nil,
|
|
442
|
+
usage["video_seconds"].is_a?(Numeric) ? usage["video_seconds"] : nil,
|
|
443
|
+
usage["booster_tokens"].is_a?(Integer) ? usage["booster_tokens"] : nil,
|
|
444
|
+
usage["boosters"].is_a?(Integer) ? usage["boosters"] : nil,
|
|
445
|
+
Cost.parse(usage["cost"]))
|
|
446
|
+
@evidence = Evidence.map(body["evidence"])
|
|
447
|
+
@warnings = body["warnings"].is_a?(Array) ? body["warnings"].grep(String).freeze : [].freeze
|
|
448
|
+
@request_id = headers["x-request-id"]
|
|
449
|
+
@latency_ms = parse_latency(headers["x-decisionnode-latency-ms"])
|
|
450
|
+
@safety = Safety.parse(headers["x-decisionnode-safety"])
|
|
451
|
+
served = headers["x-decisionnode-fallback"]
|
|
452
|
+
@fallback = served.nil? ? nil : Fallback.new(served, headers["x-decisionnode-requested-model"])
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# The answer to the question with this id.
|
|
456
|
+
def [](id)
|
|
457
|
+
answers.fetch(id.to_s) { raise KeyError, "no answer with the id #{id.to_s.inspect}" }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def key?(id)
|
|
461
|
+
answers.key?(id.to_s)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
def parse_latency(value)
|
|
467
|
+
return nil if value.nil?
|
|
468
|
+
|
|
469
|
+
f = Float(value.strip)
|
|
470
|
+
f == f.floor ? f.to_i : f
|
|
471
|
+
rescue ArgumentError, TypeError
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# A model listed by `GET /v1/models`: a model of the catalogue, or one of
|
|
477
|
+
# the key's workspace's fine-tuned models.
|
|
478
|
+
class ModelInfo
|
|
479
|
+
attr_reader :name, :description, :release_date
|
|
480
|
+
# Every other field of the entry: the model's published limits, the
|
|
481
|
+
# `sessions` and `batch` objects included.
|
|
482
|
+
attr_reader :limits
|
|
483
|
+
attr_reader :raw
|
|
484
|
+
# true for a fine-tuned model; nil for a model of the catalogue.
|
|
485
|
+
attr_reader :fine_tuned
|
|
486
|
+
# A fine-tuned model's base model (its pinned name), else nil.
|
|
487
|
+
attr_reader :base
|
|
488
|
+
# A fine-tuned model's version number (an Integer), else nil.
|
|
489
|
+
attr_reader :adapter_version
|
|
490
|
+
# A fine-tuned model's status: "deployed" (it answers decisions), "ready"
|
|
491
|
+
# (trained, not deployed), or another value as sent; else nil.
|
|
492
|
+
attr_reader :status
|
|
493
|
+
# The prices in USD per million input, output, image and video tokens,
|
|
494
|
+
# as sent; nil when the entry states none.
|
|
495
|
+
attr_reader :price_per_million_input, :price_per_million_output,
|
|
496
|
+
:price_per_million_image, :price_per_million_video
|
|
497
|
+
# The share of the live prices a batch line on this model pays, as sent;
|
|
498
|
+
# nil when the entry states none, as on a fine-tuned model.
|
|
499
|
+
attr_reader :batch_price_factor
|
|
500
|
+
|
|
501
|
+
# The prices an entry states, read into the readers of the same names.
|
|
502
|
+
PRICES = %w[price_per_million_input price_per_million_output price_per_million_image
|
|
503
|
+
price_per_million_video batch_price_factor].freeze
|
|
504
|
+
|
|
505
|
+
def initialize(raw)
|
|
506
|
+
@raw = raw.is_a?(Hash) ? raw : {}
|
|
507
|
+
@name = @raw["name"]
|
|
508
|
+
@description = @raw["description"]
|
|
509
|
+
@release_date = @raw["release_date"]
|
|
510
|
+
@limits = @raw.reject { |k, _| %w[name description release_date].include?(k) }
|
|
511
|
+
@fine_tuned = [true, false].include?(@raw["fine_tuned"]) ? @raw["fine_tuned"] : nil
|
|
512
|
+
@base = @raw["base"].is_a?(String) ? @raw["base"] : nil
|
|
513
|
+
@adapter_version = @raw["adapter_version"].is_a?(Integer) ? @raw["adapter_version"] : nil
|
|
514
|
+
@status = @raw["status"].is_a?(String) ? @raw["status"] : nil
|
|
515
|
+
PRICES.each { |k| instance_variable_set("@#{k}", @raw[k].is_a?(Numeric) ? @raw[k] : nil) }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Whether this is a fine-tuned model.
|
|
519
|
+
def fine_tuned?
|
|
520
|
+
fine_tuned == true
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# One published limit by name, for example "context_tokens", or a path
|
|
524
|
+
# into a nested one: limit("sessions", "max_per_key").
|
|
525
|
+
def limit(*path)
|
|
526
|
+
limits.dig(*path.map(&:to_s))
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# The session limits (`max_per_key`, `max_window`, `max_frame_tokens`, ...), or nil.
|
|
530
|
+
def sessions
|
|
531
|
+
limits["sessions"]
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# The Batch API limits (`requests_per_batch`, `add_bytes`, ...), or nil.
|
|
535
|
+
def batch
|
|
536
|
+
limits["batch"]
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|