typesafe-ai-ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/lib/typesafe/client.rb +56 -0
- data/lib/typesafe/configuration.rb +129 -0
- data/lib/typesafe/constants.rb +49 -0
- data/lib/typesafe/errors.rb +117 -0
- data/lib/typesafe/http/connection_manager.rb +82 -0
- data/lib/typesafe/http/net_http_transport.rb +74 -0
- data/lib/typesafe/http/request.rb +30 -0
- data/lib/typesafe/http/requestor.rb +153 -0
- data/lib/typesafe/http/response.rb +48 -0
- data/lib/typesafe/http/retrier.rb +57 -0
- data/lib/typesafe/instrumentation.rb +84 -0
- data/lib/typesafe/logging.rb +37 -0
- data/lib/typesafe/questions/choice.rb +33 -0
- data/lib/typesafe/questions/normalizer.rb +65 -0
- data/lib/typesafe/questions/noul.rb +36 -0
- data/lib/typesafe/questions/question.rb +48 -0
- data/lib/typesafe/questions/score.rb +32 -0
- data/lib/typesafe/request_options.rb +56 -0
- data/lib/typesafe/resources/models.rb +18 -0
- data/lib/typesafe/responses/answer.rb +143 -0
- data/lib/typesafe/responses/list_models_response.rb +93 -0
- data/lib/typesafe/responses/reader.rb +75 -0
- data/lib/typesafe/responses/system_one_response.rb +89 -0
- data/lib/typesafe/responses/usage.rb +37 -0
- data/lib/typesafe/retry_policy.rb +133 -0
- data/lib/typesafe/util.rb +156 -0
- data/lib/typesafe/version.rb +5 -0
- data/lib/typesafe-ai-ruby.rb +4 -0
- data/lib/typesafe.rb +114 -0
- data/sig/typesafe/http.rbs +77 -0
- data/sig/typesafe/questions.rbs +48 -0
- data/sig/typesafe/responses.rbs +131 -0
- data/sig/typesafe.rbs +261 -0
- metadata +97 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Questions
|
|
5
|
+
# Rates the state against ordered levels. Each level's index is its score, starting at zero.
|
|
6
|
+
#
|
|
7
|
+
# For example:
|
|
8
|
+
# TypeSafe::Score.new(
|
|
9
|
+
# instructions: "How urgent is this?",
|
|
10
|
+
# criteria: ["Can wait", "Needs attention this week", "Needs attention today"]
|
|
11
|
+
# )
|
|
12
|
+
class Score < Question
|
|
13
|
+
TYPE = "score"
|
|
14
|
+
MIN_LEVELS = 2
|
|
15
|
+
|
|
16
|
+
def initialize(criteria:, instructions: nil)
|
|
17
|
+
super(TYPE, instructions: instructions, criteria: self.class.normalize_criteria(criteria))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.normalize_criteria(criteria)
|
|
21
|
+
raise ValidationError, "Score criteria must be an Array of at least #{MIN_LEVELS} levels" unless
|
|
22
|
+
criteria.is_a?(Array) && criteria.length >= MIN_LEVELS
|
|
23
|
+
|
|
24
|
+
criteria.map do |level|
|
|
25
|
+
raise ValidationError, "Score levels must not be nil" if level.nil?
|
|
26
|
+
|
|
27
|
+
Util.deep_stringify(level)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
# Per-call overrides accepted by Client#system_one and Resources::Models#list.
|
|
5
|
+
class RequestOptions
|
|
6
|
+
KEYS = %i[timeout headers extra_body retry_policy].freeze
|
|
7
|
+
|
|
8
|
+
# Timeout per attempt in seconds for this call only.
|
|
9
|
+
attr_reader :timeout
|
|
10
|
+
# Additional headers for this call only.
|
|
11
|
+
attr_reader :headers
|
|
12
|
+
# Extra top-level body fields, shallow-merged last-write-wins.
|
|
13
|
+
attr_reader :extra_body
|
|
14
|
+
# Retry policy override for this call only: a RetryPolicy, or a Hash applied on the client policy.
|
|
15
|
+
attr_reader :retry_policy
|
|
16
|
+
|
|
17
|
+
def self.from(options)
|
|
18
|
+
case options
|
|
19
|
+
when RequestOptions then options
|
|
20
|
+
when nil then new
|
|
21
|
+
when Hash then new(**options.transform_keys(&:to_sym))
|
|
22
|
+
else raise ArgumentError, "request_options must be a Hash, got #{options.class}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(timeout: nil, headers: nil, extra_body: nil, retry_policy: nil)
|
|
27
|
+
@timeout = validate_timeout(timeout)
|
|
28
|
+
@headers = (headers || {}).each_with_object({}) { |(name, value), result| result[name.to_s] = value.to_s }
|
|
29
|
+
@extra_body = validate_extra_body(extra_body)
|
|
30
|
+
@retry_policy = validate_retry_policy(retry_policy)
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_timeout(timeout)
|
|
37
|
+
return nil if timeout.nil?
|
|
38
|
+
return timeout if timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
39
|
+
|
|
40
|
+
raise ArgumentError, "timeout must be a positive number of seconds, got #{timeout.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_retry_policy(retry_policy)
|
|
44
|
+
return retry_policy if retry_policy.nil? || retry_policy.is_a?(RetryPolicy) || retry_policy.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
raise ArgumentError, "retry_policy must be a RetryPolicy or a Hash, got #{retry_policy.class}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_extra_body(extra_body)
|
|
50
|
+
return nil if extra_body.nil?
|
|
51
|
+
raise ArgumentError, "extra_body must be a Hash, got #{extra_body.class}" unless extra_body.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
Util.deep_stringify(extra_body)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Resources
|
|
5
|
+
# Access to the models available to the account, reached through Client#models.
|
|
6
|
+
class Models
|
|
7
|
+
def initialize(requestor)
|
|
8
|
+
@requestor = requestor
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# List the models and aliases the account can send in the model field.
|
|
12
|
+
def list(request_options: {})
|
|
13
|
+
response = @requestor.get(Constants::MODELS_PATH, options: RequestOptions.from(request_options))
|
|
14
|
+
Responses::ListModelsResponse.from_http(response)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Responses
|
|
5
|
+
# Base class for answers. .from_hash dispatches on the "type" field.
|
|
6
|
+
class Answer
|
|
7
|
+
# "noul", "choice", "score", or an unrecognized type.
|
|
8
|
+
attr_reader :type
|
|
9
|
+
# The answer exactly as the API returned it, including unknown fields.
|
|
10
|
+
attr_reader :raw
|
|
11
|
+
|
|
12
|
+
def self.from_hash(hash, path)
|
|
13
|
+
data = Reader.hash!(hash, path)
|
|
14
|
+
type = Reader.string!(data["type"], "#{path}.type")
|
|
15
|
+
klass = TYPES.fetch(type, UnknownAnswer)
|
|
16
|
+
klass.parse(data, path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(raw)
|
|
20
|
+
@raw = raw.freeze
|
|
21
|
+
@type = raw["type"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The raw answer.
|
|
25
|
+
def to_h
|
|
26
|
+
raw
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_json(*args)
|
|
30
|
+
JSON.generate(raw, *args)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ==(other)
|
|
34
|
+
other.class == self.class && raw == other.raw
|
|
35
|
+
end
|
|
36
|
+
alias eql? ==
|
|
37
|
+
|
|
38
|
+
def hash
|
|
39
|
+
raw.hash
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<#{self.class.name} #{raw.inspect}>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A yes/no answer.
|
|
48
|
+
class NoulAnswer < Answer
|
|
49
|
+
# Probability of yes, from 0 to 1.
|
|
50
|
+
attr_reader :noul
|
|
51
|
+
|
|
52
|
+
def self.parse(data, path)
|
|
53
|
+
new(data, noul: Reader.number!(data["noul"], "#{path}.noul"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize(raw, noul:)
|
|
57
|
+
@noul = noul
|
|
58
|
+
super(raw)
|
|
59
|
+
freeze
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A selected option with its probability distribution.
|
|
64
|
+
class ChoiceAnswer < Answer
|
|
65
|
+
# The highest-probability option.
|
|
66
|
+
attr_reader :choice
|
|
67
|
+
# Probability per option.
|
|
68
|
+
attr_reader :probabilities
|
|
69
|
+
# Certainty derived from the distribution, from 0 to 1.
|
|
70
|
+
attr_reader :confidence
|
|
71
|
+
|
|
72
|
+
def self.parse(data, path)
|
|
73
|
+
new(
|
|
74
|
+
data,
|
|
75
|
+
choice: Reader.string!(data["choice"], "#{path}.choice"),
|
|
76
|
+
probabilities: Reader.number_map!(data["probabilities"], "#{path}.probabilities"),
|
|
77
|
+
confidence: Reader.number!(data["confidence"], "#{path}.confidence")
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def initialize(raw, choice:, probabilities:, confidence:)
|
|
82
|
+
@choice = choice
|
|
83
|
+
@probabilities = IndifferentHash.from(probabilities).freeze
|
|
84
|
+
@confidence = confidence
|
|
85
|
+
super(raw)
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# An expected score with its rubric and probability per level.
|
|
91
|
+
class ScoreAnswer < Answer
|
|
92
|
+
# Probability-weighted score; may fall between integer levels.
|
|
93
|
+
attr_reader :score
|
|
94
|
+
# Level index to description.
|
|
95
|
+
attr_reader :legend
|
|
96
|
+
# Probability per level index.
|
|
97
|
+
attr_reader :probabilities
|
|
98
|
+
# Certainty derived from the distribution, from 0 to 1.
|
|
99
|
+
attr_reader :confidence
|
|
100
|
+
|
|
101
|
+
def self.parse(data, path)
|
|
102
|
+
new(
|
|
103
|
+
data,
|
|
104
|
+
score: Reader.number!(data["score"], "#{path}.score"),
|
|
105
|
+
legend: Reader.integer_keyed(Reader.hash!(data["legend"], "#{path}.legend"), "#{path}.legend"),
|
|
106
|
+
probabilities: Reader.integer_keyed(Reader.number_map!(data["probabilities"], "#{path}.probabilities"),
|
|
107
|
+
"#{path}.probabilities"),
|
|
108
|
+
confidence: Reader.number!(data["confidence"], "#{path}.confidence")
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def initialize(raw, score:, legend:, probabilities:, confidence:)
|
|
113
|
+
@score = score
|
|
114
|
+
@legend = legend.freeze
|
|
115
|
+
@probabilities = probabilities.freeze
|
|
116
|
+
@confidence = confidence
|
|
117
|
+
super(raw)
|
|
118
|
+
freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Legend with the original string keys.
|
|
122
|
+
def raw_legend
|
|
123
|
+
raw["legend"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Probabilities with the original string keys.
|
|
127
|
+
def raw_probabilities
|
|
128
|
+
raw["probabilities"]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# An answer whose type this gem does not recognize. Only #raw is available.
|
|
133
|
+
class UnknownAnswer < Answer
|
|
134
|
+
def self.parse(data, _path)
|
|
135
|
+
new(data)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
Answer::TYPES = {
|
|
140
|
+
"noul" => NoulAnswer, "choice" => ChoiceAnswer, "score" => ScoreAnswer
|
|
141
|
+
}.freeze
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Responses
|
|
5
|
+
# One model or alias the account can send in the model field.
|
|
6
|
+
class ModelCard
|
|
7
|
+
# The model ID or alias, e.g. "jev-latest".
|
|
8
|
+
attr_reader :name
|
|
9
|
+
# What the model is for.
|
|
10
|
+
attr_reader :description
|
|
11
|
+
# When the model or alias was released.
|
|
12
|
+
attr_reader :release_date
|
|
13
|
+
# The entry as returned by the API.
|
|
14
|
+
attr_reader :raw
|
|
15
|
+
|
|
16
|
+
def self.from_hash(hash, path)
|
|
17
|
+
data = Reader.hash!(hash, path)
|
|
18
|
+
new(
|
|
19
|
+
data,
|
|
20
|
+
name: Reader.string!(data["name"], "#{path}.name"),
|
|
21
|
+
description: Reader.optional_string(data["description"], "#{path}.description"),
|
|
22
|
+
release_date: Reader.optional_string(data["release_date"], "#{path}.release_date")
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(raw, name:, description: nil, release_date: nil)
|
|
27
|
+
@raw = raw.freeze
|
|
28
|
+
@name = name
|
|
29
|
+
@description = description
|
|
30
|
+
@release_date = release_date
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
raw
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ==(other)
|
|
39
|
+
other.is_a?(ModelCard) && raw == other.raw
|
|
40
|
+
end
|
|
41
|
+
alias eql? ==
|
|
42
|
+
|
|
43
|
+
def hash
|
|
44
|
+
raw.hash
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The models available to the account.
|
|
49
|
+
class ListModelsResponse
|
|
50
|
+
include Enumerable
|
|
51
|
+
|
|
52
|
+
attr_reader :models, :http_response, :raw
|
|
53
|
+
|
|
54
|
+
def self.from_http(response)
|
|
55
|
+
json = response.json
|
|
56
|
+
data = Reader.hash!(json, "$")
|
|
57
|
+
new(models: parse_models(data["models"]), http_response: response, raw: data)
|
|
58
|
+
rescue InvalidField => e
|
|
59
|
+
raise Reader.validation_error(e, response, json)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.parse_models(list)
|
|
63
|
+
raise InvalidField.new("models", "expected an array, got #{list.class}") unless list.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
list.each_with_index.map { |entry, index| ModelCard.from_hash(entry, "models[#{index}]") }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(models:, http_response: nil, raw: nil)
|
|
69
|
+
@models = models.freeze
|
|
70
|
+
@http_response = http_response
|
|
71
|
+
@raw = raw
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def each(&)
|
|
76
|
+
models.each(&)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Model names.
|
|
80
|
+
def names
|
|
81
|
+
models.map(&:name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def request_id
|
|
85
|
+
http_response&.request_id
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_h
|
|
89
|
+
raw || { "models" => models.map(&:to_h) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Responses
|
|
5
|
+
# Raised internally while parsing a response body; converted to APIResponseValidationError.
|
|
6
|
+
class InvalidField < StandardError
|
|
7
|
+
attr_reader :field_path
|
|
8
|
+
|
|
9
|
+
def initialize(field_path, message)
|
|
10
|
+
@field_path = field_path
|
|
11
|
+
super("#{field_path}: #{message}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Typed accessors for JSON values that record the dotted path of any violation.
|
|
16
|
+
module Reader
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Build the public error for an InvalidField raised while parsing a response.
|
|
20
|
+
def validation_error(error, response, json)
|
|
21
|
+
APIResponseValidationError.new(
|
|
22
|
+
"Invalid response body: #{error.message}",
|
|
23
|
+
field_path: error.field_path,
|
|
24
|
+
status: response.status,
|
|
25
|
+
body: json || response.body,
|
|
26
|
+
headers: response.headers,
|
|
27
|
+
endpoint: response.request&.endpoint
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hash!(value, path)
|
|
32
|
+
raise InvalidField.new(path, "expected an object, got #{value.class}") unless value.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def string!(value, path)
|
|
38
|
+
raise InvalidField.new(path, "expected a string, got #{value.class}") unless value.is_a?(String)
|
|
39
|
+
|
|
40
|
+
value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def number!(value, path)
|
|
44
|
+
raise InvalidField.new(path, "expected a number, got #{value.class}") unless value.is_a?(Numeric)
|
|
45
|
+
raise InvalidField.new(path, "expected a finite number") if value.is_a?(Float) && !value.finite?
|
|
46
|
+
|
|
47
|
+
value.to_f
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def optional_integer(value, path)
|
|
51
|
+
return nil if value.nil?
|
|
52
|
+
raise InvalidField.new(path, "expected an integer, got #{value.class}") unless value.is_a?(Integer)
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def optional_string(value, path)
|
|
58
|
+
value.nil? ? nil : string!(value, path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def number_map!(value, path)
|
|
62
|
+
hash!(value, path).each_with_object({}) { |(key, v), result| result[key.to_s] = number!(v, "#{path}.#{key}") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Convert "0", "1", ... keys into Integers.
|
|
66
|
+
def integer_keyed(hash, path)
|
|
67
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
68
|
+
raise InvalidField.new("#{path}.#{key}", "expected an integer level key") unless key.match?(/\A\d+\z/)
|
|
69
|
+
|
|
70
|
+
result[key.to_i] = value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Responses
|
|
5
|
+
# Answers keyed by question name, with the model that answered and token usage.
|
|
6
|
+
class SystemOneResponse
|
|
7
|
+
# The versioned model that answered, e.g. "jev-1.13.0".
|
|
8
|
+
attr_reader :model
|
|
9
|
+
attr_reader :usage
|
|
10
|
+
# Every answer keyed by question name.
|
|
11
|
+
attr_reader :answers
|
|
12
|
+
# The underlying HTTP response.
|
|
13
|
+
attr_reader :http_response
|
|
14
|
+
# The parsed JSON body.
|
|
15
|
+
attr_reader :raw
|
|
16
|
+
|
|
17
|
+
def self.from_http(response)
|
|
18
|
+
json = response.json
|
|
19
|
+
parse(json, response)
|
|
20
|
+
rescue InvalidField => e
|
|
21
|
+
raise Reader.validation_error(e, response, json)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.parse(json, response)
|
|
25
|
+
data = Reader.hash!(json, "$")
|
|
26
|
+
new(
|
|
27
|
+
model: Reader.string!(data["model"], "model"),
|
|
28
|
+
usage: Usage.from_hash(data["usage"], "usage"),
|
|
29
|
+
answers: parse_answers(data["answers"]),
|
|
30
|
+
http_response: response,
|
|
31
|
+
raw: data
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.parse_answers(answers)
|
|
36
|
+
return {} if answers.nil?
|
|
37
|
+
|
|
38
|
+
Reader.hash!(answers, "answers").each_with_object({}) do |(name, answer), result|
|
|
39
|
+
result[name] = Answer.from_hash(answer, "answers.#{name}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(model:, usage:, answers:, http_response: nil, raw: nil)
|
|
44
|
+
@model = model
|
|
45
|
+
@usage = usage
|
|
46
|
+
@answers = IndifferentHash.from(answers).freeze
|
|
47
|
+
@http_response = http_response
|
|
48
|
+
@raw = raw
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The answer for a question name.
|
|
53
|
+
def [](name)
|
|
54
|
+
answers[name]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The x-typesafe-request-id header.
|
|
58
|
+
def request_id
|
|
59
|
+
http_response&.request_id
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def nouls
|
|
63
|
+
answers_of(NoulAnswer)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def choices
|
|
67
|
+
answers_of(ChoiceAnswer)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scores
|
|
71
|
+
answers_of(ScoreAnswer)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
raw || { "model" => model, "usage" => usage.to_h, "answers" => answers.transform_values(&:to_h) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_json(*args)
|
|
79
|
+
JSON.generate(to_h, *args)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def answers_of(klass)
|
|
85
|
+
IndifferentHash.from(answers.select { |_, answer| answer.is_a?(klass) }).freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
module Responses
|
|
5
|
+
# Token counts for a request, when reported by the API.
|
|
6
|
+
class Usage
|
|
7
|
+
attr_reader :input_tokens, :output_tokens
|
|
8
|
+
|
|
9
|
+
def self.from_hash(hash, path = "usage")
|
|
10
|
+
data = Reader.hash!(hash, path)
|
|
11
|
+
new(
|
|
12
|
+
input_tokens: Reader.optional_integer(data["input_tokens"], "#{path}.input_tokens"),
|
|
13
|
+
output_tokens: Reader.optional_integer(data["output_tokens"], "#{path}.output_tokens")
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(input_tokens: nil, output_tokens: nil)
|
|
18
|
+
@input_tokens = input_tokens
|
|
19
|
+
@output_tokens = output_tokens
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{ "input_tokens" => input_tokens, "output_tokens" => output_tokens }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.is_a?(Usage) && to_h == other.to_h
|
|
29
|
+
end
|
|
30
|
+
alias eql? ==
|
|
31
|
+
|
|
32
|
+
def hash
|
|
33
|
+
to_h.hash
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
# Retry configuration. Defaults match the official TypeSafe SDKs: two retries with
|
|
5
|
+
# exponential backoff and jitter, honoring Retry-After, for 408, 429 and 5xx responses
|
|
6
|
+
# as well as connection failures and timeouts.
|
|
7
|
+
#
|
|
8
|
+
# Policies are frozen values. Build one with keyword overrides, or derive a new one
|
|
9
|
+
# from an existing policy with #with.
|
|
10
|
+
class RetryPolicy
|
|
11
|
+
DEFAULT_HTTP_STATUSES = ([408, 429] + (500..599).to_a).freeze
|
|
12
|
+
|
|
13
|
+
# Maximum retries after the initial attempt; 0 disables retries.
|
|
14
|
+
attr_reader :max_retries
|
|
15
|
+
# First backoff delay in seconds, doubled each attempt up to backoff_max.
|
|
16
|
+
attr_reader :backoff_initial
|
|
17
|
+
# Maximum backoff delay in seconds.
|
|
18
|
+
attr_reader :backoff_max
|
|
19
|
+
# Fraction of each backoff delay randomly subtracted, from 0 to 1.
|
|
20
|
+
attr_reader :backoff_jitter
|
|
21
|
+
# HTTP status codes that are retried.
|
|
22
|
+
attr_reader :http_statuses
|
|
23
|
+
# Whether to honor Retry-After and retry-after-ms response headers.
|
|
24
|
+
attr_reader :respect_retry_after
|
|
25
|
+
# Maximum server-requested delay in seconds; longer delays fall back to backoff.
|
|
26
|
+
attr_reader :max_retry_after
|
|
27
|
+
# Whether to retry APIConnectionError.
|
|
28
|
+
attr_reader :api_connection_error
|
|
29
|
+
# Whether to retry APITimeoutError.
|
|
30
|
+
attr_reader :api_timeout_error
|
|
31
|
+
# Total budget in seconds per call including delays; nil disables the limit.
|
|
32
|
+
attr_reader :timeout
|
|
33
|
+
|
|
34
|
+
# Coerce nil, a Hash of overrides, or a RetryPolicy into a RetryPolicy.
|
|
35
|
+
# Hash overrides are applied on top of +base+ (the SDK defaults unless given).
|
|
36
|
+
def self.from(value, base: nil)
|
|
37
|
+
base ||= DEFAULT
|
|
38
|
+
case value
|
|
39
|
+
when nil then base
|
|
40
|
+
when RetryPolicy then value
|
|
41
|
+
when Hash then base.with(**value.transform_keys(&:to_sym))
|
|
42
|
+
else raise ArgumentError, "retry_policy must be a RetryPolicy or a Hash, got #{value.class}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(max_retries: 2, backoff_initial: 0.5, backoff_max: 5.0, backoff_jitter: 0.25,
|
|
47
|
+
http_statuses: DEFAULT_HTTP_STATUSES, respect_retry_after: true, max_retry_after: 60.0,
|
|
48
|
+
api_connection_error: true, api_timeout_error: true, timeout: 30.0)
|
|
49
|
+
@max_retries = non_negative_integer(:max_retries, max_retries)
|
|
50
|
+
@backoff_initial = non_negative_number(:backoff_initial, backoff_initial)
|
|
51
|
+
@backoff_max = non_negative_number(:backoff_max, backoff_max)
|
|
52
|
+
@backoff_jitter = fraction(:backoff_jitter, backoff_jitter)
|
|
53
|
+
@http_statuses = http_statuses.to_a.map(&:to_i).freeze
|
|
54
|
+
@respect_retry_after = respect_retry_after ? true : false
|
|
55
|
+
@max_retry_after = non_negative_number(:max_retry_after, max_retry_after)
|
|
56
|
+
@api_connection_error = api_connection_error ? true : false
|
|
57
|
+
@api_timeout_error = api_timeout_error ? true : false
|
|
58
|
+
@timeout = timeout.nil? ? nil : non_negative_number(:timeout, timeout)
|
|
59
|
+
freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
max_retries: max_retries, backoff_initial: backoff_initial, backoff_max: backoff_max,
|
|
65
|
+
backoff_jitter: backoff_jitter, http_statuses: http_statuses, respect_retry_after: respect_retry_after,
|
|
66
|
+
max_retry_after: max_retry_after, api_connection_error: api_connection_error,
|
|
67
|
+
api_timeout_error: api_timeout_error, timeout: timeout
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A copy of this policy with the given fields replaced.
|
|
72
|
+
def with(**overrides)
|
|
73
|
+
self.class.new(**to_h, **overrides)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ==(other)
|
|
77
|
+
other.is_a?(RetryPolicy) && to_h == other.to_h
|
|
78
|
+
end
|
|
79
|
+
alias eql? ==
|
|
80
|
+
|
|
81
|
+
def hash
|
|
82
|
+
to_h.hash
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def retry_status?(status)
|
|
86
|
+
http_statuses.include?(status)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether a connection-level error should be retried.
|
|
90
|
+
def retry_error?(error)
|
|
91
|
+
case error
|
|
92
|
+
when APITimeoutError then api_timeout_error
|
|
93
|
+
when APIConnectionError then api_connection_error
|
|
94
|
+
else false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Seconds to wait before the retry following +attempt+ (0 for the first retry).
|
|
99
|
+
# Uses the server's Retry-After when present and within max_retry_after, otherwise
|
|
100
|
+
# capped exponential backoff with jitter.
|
|
101
|
+
def delay_for(attempt, headers: nil, random: Kernel)
|
|
102
|
+
if respect_retry_after && headers
|
|
103
|
+
server_delay = Util.parse_retry_after(headers)
|
|
104
|
+
return server_delay if server_delay && server_delay <= max_retry_after
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
exponential = [backoff_initial * (2**attempt), backoff_max].min
|
|
108
|
+
exponential * (1 - (random.rand * backoff_jitter))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def non_negative_integer(name, value)
|
|
114
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "#{name} must be a non-negative Integer, got #{value.inspect}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def non_negative_number(name, value)
|
|
120
|
+
return value if value.is_a?(Numeric) && value.finite? && value >= 0
|
|
121
|
+
|
|
122
|
+
raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fraction(name, value)
|
|
126
|
+
return value if value.is_a?(Numeric) && value >= 0 && value <= 1
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, "#{name} must be between 0 and 1, got #{value.inspect}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
RetryPolicy::DEFAULT = RetryPolicy.new
|
|
133
|
+
end
|