jevrb 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.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class APIError < Error
9
+ attr_reader :status, :body, :headers, :endpoint
10
+
11
+ def initialize(status:, body:, headers:, endpoint:, message: nil)
12
+ @status = status
13
+ @body = body
14
+ @headers = headers.freeze
15
+ @endpoint = endpoint
16
+
17
+ super(message || "TypeSafe API request failed with status #{status}")
18
+ end
19
+
20
+ def request_id
21
+ headers["x-typesafe-request-id"]
22
+ end
23
+ end
24
+
25
+ class BadRequestError < APIError; end
26
+ class AuthenticationError < APIError; end
27
+ class PermissionDeniedError < APIError; end
28
+ class NotFoundError < APIError; end
29
+ class UnprocessableEntityError < APIError; end
30
+
31
+ class RateLimitError < APIError
32
+ def retry_after_ms
33
+ value = headers["retry-after-ms"]
34
+ return if value.nil?
35
+
36
+ Float(value)
37
+ rescue ArgumentError, TypeError
38
+ nil
39
+ end
40
+ end
41
+
42
+ class InternalServerError < APIError; end
43
+
44
+ class ConnectionError < Error
45
+ attr_reader :endpoint
46
+
47
+ def initialize(endpoint:, message: nil)
48
+ @endpoint = endpoint
49
+ super(message || "TypeSafe API connection failed")
50
+ end
51
+ end
52
+
53
+ class TimeoutError < ConnectionError
54
+ attr_reader :timeout
55
+
56
+ def initialize(endpoint:, timeout:, message: nil)
57
+ @timeout = timeout
58
+ super(endpoint: endpoint, message: message || "TypeSafe API request exceeded #{timeout} seconds")
59
+ end
60
+ end
61
+
62
+ class ResponseValidationError < APIError
63
+ attr_reader :field_path
64
+
65
+ def initialize(status:, body:, headers:, field_path:, endpoint:, message: nil)
66
+ @field_path = field_path
67
+ super(
68
+ status: status,
69
+ body: body,
70
+ headers: headers,
71
+ endpoint: endpoint,
72
+ message: message || "TypeSafe API response is invalid at #{field_path}"
73
+ )
74
+ end
75
+ end
76
+ end
data/lib/jevrb/noul.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Noul < Question
5
+ CRITERIA_KEYS = %w[true false].freeze
6
+
7
+ def self.build(instructions:, criteria: nil)
8
+ new(instructions: instructions, criteria: criteria)
9
+ end
10
+
11
+ private
12
+
13
+ def initialize(instructions:, criteria:)
14
+ super(type: "noul", instructions: instructions, criteria: normalize_criteria(criteria))
15
+ end
16
+
17
+ def normalize_criteria(criteria)
18
+ return if criteria.nil?
19
+ raise ArgumentError, "criteria must be a hash" unless criteria.is_a?(Hash)
20
+
21
+ normalized = criteria.each_with_object({}) do |(key, value), result|
22
+ normalized_key = key.to_s
23
+ raise ArgumentError, "criteria accepts only true and false keys" unless CRITERIA_KEYS.include?(normalized_key)
24
+ raise ArgumentError, "criteria contains duplicate key #{normalized_key.inspect}" if result.key?(normalized_key)
25
+
26
+ result[normalized_key.freeze] = Support.normalize_json_content(
27
+ value,
28
+ path: "criteria.#{normalized_key}",
29
+ allow_nil: true
30
+ )
31
+ end
32
+
33
+ normalized.freeze
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Question
5
+ attr_reader :type, :instructions, :criteria
6
+
7
+ def to_h
8
+ hash = { type: type, instructions: instructions }
9
+ hash[:criteria] = criteria unless criteria.nil?
10
+ hash
11
+ end
12
+
13
+ private
14
+
15
+ def initialize(type:, instructions:, criteria:)
16
+ @type = type.freeze
17
+ @instructions = Support.normalize_json_content(instructions, path: "instructions")
18
+ @criteria = criteria
19
+ freeze
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Result
5
+ ANSWER_CLASSES = {
6
+ "noul" => NoulAnswer,
7
+ "choice" => ChoiceAnswer,
8
+ "score" => ScoreAnswer
9
+ }.freeze
10
+
11
+ attr_reader :model, :answers, :usage, :request_id, :nouls, :choices, :scores
12
+
13
+ def self.from_hash(value, key_map: {}, request_id: nil)
14
+ value = ResponseSupport.hash(value, "response")
15
+ model = ResponseSupport.string(ResponseSupport.required(value, "model", "response"), "model")
16
+ usage = Usage.from_hash(ResponseSupport.required(value, "usage", "response"))
17
+ answers = parse_answers(ResponseSupport.required(value, "answers", "response"), key_map)
18
+
19
+ new(model: model, answers: answers, usage: usage, request_id: request_id)
20
+ end
21
+
22
+ def self.parse_answers(value, key_map)
23
+ ResponseSupport.hash(value, "answers").each_with_object({}) do |(key, answer), result|
24
+ key = ResponseSupport.string(key, "answers key")
25
+ answer = ResponseSupport.hash(answer, "answers.#{key}")
26
+ type = ResponseSupport.string(
27
+ ResponseSupport.required(answer, "type", "answers.#{key}"),
28
+ "answers.#{key}.type"
29
+ )
30
+ answer_class = ANSWER_CLASSES[type]
31
+ raise ResponseDataError.new("answers.#{key}.type", "unknown answer type #{type.inspect}") unless answer_class
32
+
33
+ result[key_map.fetch(key, key)] = answer_class.from_hash(answer, path: "answers.#{key}")
34
+ end.freeze
35
+ end
36
+ private_class_method :parse_answers
37
+
38
+ def to_h
39
+ {
40
+ model: model,
41
+ answers: answers.transform_values(&:to_h),
42
+ usage: usage.to_h
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def initialize(model:, answers:, usage:, request_id:)
49
+ @model = model
50
+ @answers = answers
51
+ @usage = usage
52
+ @request_id = request_id&.dup&.freeze
53
+ @nouls = answers.select { |_key, answer| answer.is_a?(NoulAnswer) }.freeze
54
+ @choices = answers.select { |_key, answer| answer.is_a?(ChoiceAnswer) }.freeze
55
+ @scores = answers.select { |_key, answer| answer.is_a?(ScoreAnswer) }.freeze
56
+ freeze
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Jevrb
6
+ class RetryPolicy
7
+ DEFAULT_HTTP_STATUSES = [408, 429, *(500..599)].freeze
8
+
9
+ attr_reader :max_retries, :backoff_initial, :backoff_max, :backoff_jitter,
10
+ :http_statuses, :respect_retry_after, :retry_connection_errors,
11
+ :retry_timeout_errors, :timeout
12
+
13
+ def initialize(
14
+ max_retries: 2,
15
+ backoff_initial: 0.5,
16
+ backoff_max: 5.0,
17
+ backoff_jitter: 0.25,
18
+ http_statuses: DEFAULT_HTTP_STATUSES,
19
+ respect_retry_after: true,
20
+ retry_connection_errors: true,
21
+ retry_timeout_errors: true,
22
+ timeout: 30.0,
23
+ sleeper: nil,
24
+ random: nil,
25
+ clock: nil
26
+ )
27
+ @max_retries = nonnegative_integer(max_retries, "max_retries")
28
+ @backoff_initial = nonnegative_number(backoff_initial, "backoff_initial")
29
+ @backoff_max = nonnegative_number(backoff_max, "backoff_max")
30
+ @backoff_jitter = fraction(backoff_jitter, "backoff_jitter")
31
+ @http_statuses = http_statuses.to_a.uniq.freeze
32
+ @respect_retry_after = boolean(respect_retry_after, "respect_retry_after")
33
+ @retry_connection_errors = boolean(retry_connection_errors, "retry_connection_errors")
34
+ @retry_timeout_errors = boolean(retry_timeout_errors, "retry_timeout_errors")
35
+ @timeout = timeout.nil? ? nil : nonnegative_number(timeout, "timeout")
36
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
37
+ @random = random || Random.new
38
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
39
+ freeze
40
+ end
41
+
42
+ def execute
43
+ retries = 0
44
+ started_at = @clock.call
45
+
46
+ begin
47
+ yield
48
+ rescue Error => e
49
+ raise unless retries < max_retries && retryable?(e)
50
+
51
+ delay = retry_delay(e, retries)
52
+ elapsed = @clock.call - started_at
53
+ raise if timeout && elapsed + delay >= timeout
54
+
55
+ @sleeper.call(delay)
56
+ retries += 1
57
+ retry
58
+ end
59
+ end
60
+
61
+ def retryable?(error)
62
+ return retry_timeout_errors if error.is_a?(TimeoutError)
63
+ return retry_connection_errors if error.is_a?(ConnectionError)
64
+ return http_statuses.include?(error.status) if error.is_a?(APIError)
65
+
66
+ false
67
+ end
68
+
69
+ def retry_delay(error, retry_number)
70
+ header_delay = retry_after_delay(error)
71
+ return header_delay unless header_delay.nil?
72
+
73
+ base = [backoff_initial * (2**retry_number), backoff_max].min
74
+ base * (1.0 - (@random.rand * backoff_jitter))
75
+ end
76
+
77
+ private
78
+
79
+ def retry_after_delay(error)
80
+ return unless respect_retry_after && error.is_a?(APIError)
81
+
82
+ milliseconds = parse_nonnegative_number(error.headers["retry-after-ms"])
83
+ return milliseconds / 1000.0 unless milliseconds.nil?
84
+
85
+ value = error.headers["retry-after"]
86
+ return if value.nil?
87
+
88
+ seconds = parse_nonnegative_number(value)
89
+ return seconds unless seconds.nil?
90
+
91
+ [Time.httpdate(value) - Time.now, 0.0].max
92
+ rescue ArgumentError
93
+ nil
94
+ end
95
+
96
+ def parse_nonnegative_number(value)
97
+ return if value.nil?
98
+
99
+ number = Float(value)
100
+ number if number.finite? && number >= 0
101
+ rescue ArgumentError, TypeError
102
+ nil
103
+ end
104
+
105
+ def nonnegative_integer(value, name)
106
+ return value if value.is_a?(Integer) && value >= 0
107
+
108
+ raise ArgumentError, "#{name} must be a nonnegative integer"
109
+ end
110
+
111
+ def nonnegative_number(value, name)
112
+ return value.to_f if (value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value >= 0
113
+
114
+ raise ArgumentError, "#{name} must be a nonnegative number"
115
+ end
116
+
117
+ def fraction(value, name)
118
+ number = nonnegative_number(value, name)
119
+ return number if number <= 1.0
120
+
121
+ raise ArgumentError, "#{name} must be between 0 and 1"
122
+ end
123
+
124
+ def boolean(value, name)
125
+ return value if [true, false].include?(value)
126
+
127
+ raise ArgumentError, "#{name} must be true or false"
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Score < Question
5
+ MIN_LEVELS = 2
6
+ MAX_LEVELS = 10
7
+
8
+ def self.build(instructions:, criteria:)
9
+ new(instructions: instructions, criteria: criteria)
10
+ end
11
+
12
+ private
13
+
14
+ def initialize(instructions:, criteria:)
15
+ super(type: "score", instructions: instructions, criteria: normalize_criteria(criteria))
16
+ end
17
+
18
+ def normalize_criteria(criteria)
19
+ raise ArgumentError, "criteria must be an array" unless criteria.is_a?(Array)
20
+ unless (MIN_LEVELS..MAX_LEVELS).cover?(criteria.length)
21
+ raise ArgumentError, "criteria must contain #{MIN_LEVELS} through #{MAX_LEVELS} levels"
22
+ end
23
+
24
+ criteria.each_with_index.map do |value, index|
25
+ Support.normalize_json_content(value, path: "criteria[#{index}]")
26
+ end.freeze
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ module Support
5
+ module_function
6
+
7
+ def normalize_json_content(value, path:, allow_nil: false)
8
+ return nil if value.nil? && allow_nil
9
+
10
+ unless value.is_a?(String) || value.is_a?(Hash) || value.is_a?(Array)
11
+ raise ArgumentError, "#{path} must be a string, hash, or array"
12
+ end
13
+
14
+ normalize_json_value(value, path: path)
15
+ end
16
+
17
+ def normalize_json_value(value, path:)
18
+ case value
19
+ when String
20
+ value.dup.freeze
21
+ when Integer, TrueClass, FalseClass, NilClass
22
+ value
23
+ when Float
24
+ raise ArgumentError, "#{path} must contain only finite numbers" unless value.finite?
25
+
26
+ value
27
+ when Array
28
+ value.each_with_index.map do |entry, index|
29
+ normalize_json_value(entry, path: "#{path}[#{index}]")
30
+ end.freeze
31
+ when Hash
32
+ normalize_json_hash(value, path: path)
33
+ else
34
+ raise ArgumentError, "#{path} contains a non-JSON value: #{value.class}"
35
+ end
36
+ end
37
+
38
+ def normalize_json_hash(value, path:)
39
+ value.each_with_object({}) do |(key, entry), result|
40
+ unless key.is_a?(String) || key.is_a?(Symbol)
41
+ raise ArgumentError, "#{path} contains a non-string key: #{key.inspect}"
42
+ end
43
+
44
+ normalized_key = key.to_s
45
+ raise ArgumentError, "#{path} contains duplicate key #{normalized_key.inspect}" if result.key?(normalized_key)
46
+
47
+ result[normalized_key.freeze] = normalize_json_value(entry, path: "#{path}.#{normalized_key}")
48
+ end.freeze
49
+ end
50
+
51
+ def required_hash_value(hash, key, path:)
52
+ return hash[key] if hash.key?(key)
53
+
54
+ raise KeyError, "missing required field #{path}.#{key}"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Jevrb
8
+ class Transport
9
+ Response = Struct.new(:status, :body, :headers, keyword_init: true)
10
+ private_constant :Response
11
+
12
+ def initialize(base_url:, http_factory: Net::HTTP.method(:new))
13
+ @base_url = base_url.chomp("/")
14
+ @http_factory = http_factory
15
+ end
16
+
17
+ def endpoint(path)
18
+ "#{@base_url}/#{path.sub(%r{\A/+}, "")}"
19
+ end
20
+
21
+ def post(path, body:, headers:, timeout:)
22
+ url = endpoint(path)
23
+ uri = URI.parse(url)
24
+ http = @http_factory.call(uri.host, uri.port)
25
+ http.use_ssl = uri.scheme == "https"
26
+ http.open_timeout = timeout
27
+ http.read_timeout = timeout
28
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
29
+
30
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
31
+ request.body = body
32
+ response = http.request(request)
33
+
34
+ Response.new(
35
+ status: response.code.to_i,
36
+ body: response.body,
37
+ headers: response.each_header.to_h.freeze
38
+ ).freeze
39
+ rescue Timeout::Error => e
40
+ raise TimeoutError.new(endpoint: "POST #{url}", timeout: timeout, message: e.message)
41
+ rescue IOError, SocketError, SystemCallError, OpenSSL::SSL::SSLError, Net::ProtocolError => e
42
+ raise ConnectionError.new(endpoint: "POST #{url}", message: e.message)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Usage
5
+ attr_reader :input_tokens, :output_tokens
6
+
7
+ def self.from_hash(value, path: "usage")
8
+ value = ResponseSupport.hash(value, path)
9
+ new(
10
+ input_tokens: ResponseSupport.integer_or_nil(value["input_tokens"], "#{path}.input_tokens"),
11
+ output_tokens: ResponseSupport.integer_or_nil(value["output_tokens"], "#{path}.output_tokens")
12
+ )
13
+ end
14
+
15
+ def to_h
16
+ { input_tokens: input_tokens, output_tokens: output_tokens }
17
+ end
18
+
19
+ private
20
+
21
+ def initialize(input_tokens:, output_tokens:)
22
+ @input_tokens = input_tokens
23
+ @output_tokens = output_tokens
24
+ freeze
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jevrb.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jevrb/version"
4
+ require_relative "jevrb/constants"
5
+ require_relative "jevrb/errors"
6
+ require_relative "jevrb/support"
7
+ require_relative "jevrb/question"
8
+ require_relative "jevrb/noul"
9
+ require_relative "jevrb/choice"
10
+ require_relative "jevrb/score"
11
+ require_relative "jevrb/answer"
12
+ require_relative "jevrb/usage"
13
+ require_relative "jevrb/result"
14
+ require_relative "jevrb/retry_policy"
15
+ require_relative "jevrb/transport"
16
+ require_relative "jevrb/client"
17
+
18
+ module Jevrb
19
+ private_constant :Support, :Transport
20
+ end
21
+
22
+ Jev = Jevrb