judge_rails 0.0.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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Judge
7
+ module Generators
8
+ class AttributeGenerator < ::Rails::Generators::NamedBase
9
+ include ::ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds Judge judgment attributes to a model: a migration plus the model declarations."
14
+
15
+ argument :judge_attributes, type: :array, default: [], banner: "name:noul name:choice name:score"
16
+ class_option :database, type: :string, aliases: %i[--db],
17
+ desc: "The database whose migrations path receives the migration"
18
+
19
+ TYPES = %w[noul choice score].freeze
20
+ NAME = /\A[a-z_][a-z0-9_]*\z/
21
+
22
+ QUESTION_ARGUMENTS = {
23
+ "noul" => "",
24
+ "choice" => ", %w[first_option second_option third_option]",
25
+ "score" => ", 1..5"
26
+ }.freeze
27
+
28
+ def validate_pairs
29
+ raise ::Rails::Generators::Error, "give at least one name:type pair" if pairs.empty?
30
+
31
+ invalid = pairs.map(&:first).grep_v(NAME)
32
+ unless invalid.empty?
33
+ raise ::Rails::Generators::Error,
34
+ "invalid attribute name(s) #{invalid.join(", ")}: " \
35
+ "use lowercase letters, digits and underscores"
36
+ end
37
+
38
+ unknown = pairs.reject { |_, type| TYPES.include?(type) }
39
+ return if unknown.empty?
40
+
41
+ raise ::Rails::Generators::Error,
42
+ "unknown judge type(s) #{unknown.map(&:last).uniq.join(", ")}, expected #{TYPES.join(", ")}"
43
+ end
44
+
45
+ def create_migration_file
46
+ migration_template "migration.rb.tt", File.join(db_migrate_path, "#{migration_basename}.rb")
47
+ end
48
+
49
+ def inject_model_declarations
50
+ return say_status(:skip, "#{model_path} not found", :yellow) unless model_source
51
+ return remove_declarations if behavior == :revoke
52
+
53
+ declarations = missing_declarations
54
+ return say_status(:identical, model_path, :blue) if declarations.empty?
55
+
56
+ if judge_source?
57
+ point = insertion_point
58
+ unless point
59
+ return say_status(:skip, "could not find class #{class_name} in #{model_path}; add:\n" \
60
+ "#{declarations.join}", :yellow)
61
+ end
62
+
63
+ inject_into_file model_path, declarations.join, after: /\A#{Regexp.escape(model_source[0...point])}/
64
+ else
65
+ inject_into_class model_path, class_name, "#{source_line}#{declarations.join}\n"
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def pairs
72
+ @pairs ||= judge_attributes.map do |pair|
73
+ name, type = pair.split(":", 2)
74
+ [name, type.to_s]
75
+ end
76
+ end
77
+
78
+ def migration_basename
79
+ "add_judge_#{pairs.map(&:first).join("_")}_to_#{table_name}"
80
+ end
81
+
82
+ def model_path
83
+ File.join("app", "models", class_path, "#{file_name}.rb")
84
+ end
85
+
86
+ def model_source
87
+ return @model_source if defined?(@model_source)
88
+
89
+ full = File.join(destination_root, model_path)
90
+ @model_source = File.exist?(full) ? File.read(full) : nil
91
+ end
92
+
93
+ def judge_source?
94
+ model_source.to_s.match?(/^\s*judge_source\b/)
95
+ end
96
+
97
+ def insertion_point
98
+ lines = model_source.lines
99
+ class_index = lines.index do |line|
100
+ line.match?(/^\s*class\s+(?:[\w:]+::)?#{Regexp.escape(class_name.demodulize)}\b/)
101
+ end
102
+ return unless class_index
103
+
104
+ indent = lines[class_index][/\A\s*/]
105
+ closing = ((class_index + 1)...lines.size).find { |i| lines[i].match?(/\A#{indent}end\b/) }
106
+ closing && lines[0...closing].join.length
107
+ end
108
+
109
+ def source_line
110
+ columns = text_columns
111
+ return " judge_source { [#{columns.join(", ")}] }\n" if columns.any?
112
+
113
+ " judge_source { [] } # TODO: the attributes Judge should read; nothing is judged until you set it\n"
114
+ end
115
+
116
+ def text_columns
117
+ model = class_name.safe_constantize
118
+ return [] unless model.respond_to?(:columns)
119
+
120
+ model.columns.select { |c| c.type == :text }.map(&:name).grep_v(/_judge\z/)
121
+ rescue StandardError
122
+ []
123
+ end
124
+
125
+ def missing_declarations
126
+ pairs.reject { |name, _| declared?(name) }.map { |name, type| " #{declaration(name, type)}\n" }
127
+ end
128
+
129
+ def remove_declarations
130
+ pairs.each do |name, type|
131
+ gsub_file model_path, " #{declaration(name, type)}\n", "", force: true
132
+ end
133
+ remaining = File.read(File.join(destination_root, model_path))
134
+ return if remaining.match?(/^\s*judge_attribute\b/)
135
+
136
+ gsub_file model_path, source_line, "", force: true
137
+ end
138
+
139
+ def declared?(name)
140
+ model_source.to_s.match?(/^\s*judge_attribute\s+:#{Regexp.escape(name)}\b/)
141
+ end
142
+
143
+ def declaration(name, type)
144
+ prompt = %("TODO: describe the judgment for #{name}"#{QUESTION_ARGUMENTS.fetch(type)})
145
+ "judge_attribute :#{name}, Judge.#{type}(#{prompt})"
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Judge
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates config/initializers/judge.rb and explains how to supply an API key."
11
+
12
+ def create_initializer
13
+ template "initializer.rb.tt", "config/initializers/judge.rb"
14
+ end
15
+
16
+ def print_next_steps
17
+ return unless behavior == :invoke
18
+
19
+ say ""
20
+ say "Judge is installed.", :green
21
+ say ""
22
+ say " 1. Provide an API key, either as the JEV_API_KEY environment variable"
23
+ say " or as the Rails credential judge.api_key (bin/rails credentials:edit)."
24
+ say " 2. Add judgments to a model, for example:"
25
+ say ""
26
+ say " bin/rails generate judge:attribute Ticket urgency:noul intent:choice"
27
+ say ""
28
+ say " 3. Replace the TODO questions and check judge_source: until you do, every save"
29
+ say " is judged with the placeholder wording, and each judgment is a billed call."
30
+ say " 4. Run bin/rails db:migrate. Async attributes (the default) need ActiveJob;"
31
+ say " backfill existing rows with Ticket.judge_refresh_all."
32
+ say ""
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ Judge.configure do |config|
4
+ # Never commit a key. Set JEV_API_KEY (or TYPESAFE_API_KEY) in the environment, or store it
5
+ # in Rails credentials under judge.api_key (bin/rails credentials:edit).
6
+ config.api_key ||= Rails.application.credentials.dig(:judge, :api_key)
7
+
8
+ config.logger = Rails.logger
9
+
10
+ # Defaults, uncomment to override:
11
+ #
12
+ # config.base_url = "https://api.typesafe.ai/v1/systemone"
13
+ # config.model = "jev-latest"
14
+ # config.timeout = 10.0
15
+ # config.open_timeout = 5.0
16
+ # config.max_retries = 2
17
+ # config.max_retry_wait = 10.0
18
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ <% pairs.each do |name, type| -%>
6
+ judge_attribute :<%= table_name %>, :<%= name %>, :<%= type %>
7
+ <% end -%>
8
+ end
9
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ # An adapter answers `call(state:, questions:, model:) -> Judge::ResultSet`, building each
5
+ # result with `Judge::Result.from_values`. Nothing outside an adapter knows a wire format.
6
+ module Adapter
7
+ @lock = Mutex.new
8
+
9
+ class << self
10
+ def registry
11
+ @registry ||= { jev: -> { Client.new } }
12
+ end
13
+
14
+ def register(name, &build)
15
+ raise ArgumentError, "register needs a block returning an adapter" unless build
16
+
17
+ @lock.synchronize do
18
+ registry[name.to_sym] = build
19
+ @built = nil
20
+ end
21
+ name.to_sym
22
+ end
23
+
24
+ def build(name)
25
+ return name if name.respond_to?(:call)
26
+
27
+ factory = registry[name.to_s.to_sym]
28
+ raise ConfigurationError, unknown(name) unless factory
29
+
30
+ factory.call
31
+ end
32
+
33
+ def resolve(name)
34
+ return name if name.respond_to?(:call)
35
+
36
+ built = @built
37
+ return built.last if built&.first == name
38
+
39
+ adapter = build(name)
40
+ @lock.synchronize do
41
+ @built = [name, adapter] unless @built&.first == name
42
+ @built.last
43
+ end
44
+ end
45
+
46
+ def names
47
+ registry.keys
48
+ end
49
+
50
+ def reset!
51
+ @lock.synchronize do
52
+ @registry = nil
53
+ @built = nil
54
+ end
55
+ end
56
+
57
+ def reset_built!
58
+ @lock.synchronize { @built = nil }
59
+ end
60
+
61
+ private
62
+
63
+ def unknown(name)
64
+ "unknown adapter #{name.inspect}. Known: #{names.map(&:inspect).join(", ")}. " \
65
+ "Register one with Judge::Adapter.register(#{name.to_s.to_sym.inspect}) { ... }"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "zlib"
7
+ require "uri"
8
+
9
+ module Judge
10
+ class Client
11
+ BACKOFF_BASE = 0.25
12
+ BACKOFF_CAP = 8.0
13
+ CONNECTIONS_KEY = :judge_client_connections
14
+ TRANSPORT_ERRORS = [Timeout::Error, SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError,
15
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error].freeze
16
+ UNSAFE_TO_RESEND = [Net::ReadTimeout].freeze
17
+ RETRYABLE_STATUSES = (500..599)
18
+
19
+ attr_reader :config
20
+ attr_accessor :sleeper
21
+
22
+ def self.close_thread_connections
23
+ store = Thread.current[CONNECTIONS_KEY]
24
+ Thread.current[CONNECTIONS_KEY] = nil
25
+ return unless store && store[:pid] == Process.pid
26
+
27
+ store[:connections].each_value do |http|
28
+ http.finish if http.started?
29
+ rescue IOError
30
+ nil
31
+ end
32
+ end
33
+
34
+ def inspect
35
+ "#<Judge::Client base_url=#{@config.base_url.inspect} model=#{@config.model.inspect}>"
36
+ end
37
+
38
+ def initialize(config: nil, sleeper: nil)
39
+ @config = config || Judge.config
40
+ @sleeper = sleeper || Kernel.method(:sleep)
41
+ end
42
+
43
+ def call(state:, questions:, model: nil)
44
+ raise ArgumentError, "state must not be nil" if state.nil?
45
+ raise ArgumentError, "questions must not be empty" if questions.nil? || questions.empty?
46
+
47
+ payload = JSON.generate(build_payload(state, questions, model))
48
+ event = { model: model || @config.model, questions: questions.size,
49
+ request_bytes: payload.bytesize }
50
+
51
+ instrument(event) do
52
+ started = monotonic
53
+ body = perform(payload)
54
+ set = ResultSet.from_response(body, questions: questions, latency: monotonic - started)
55
+ event[:latency] = set.latency
56
+ event[:input_tokens] = set.usage&.input_tokens
57
+ event[:output_tokens] = set.usage&.output_tokens
58
+ set
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def instrument(event, &)
65
+ return yield unless defined?(::ActiveSupport::Notifications)
66
+
67
+ ::ActiveSupport::Notifications.instrument("request.judge", event, &)
68
+ end
69
+
70
+ def build_payload(state, questions, model)
71
+ {
72
+ "state" => state,
73
+ "model" => model || @config.model,
74
+ "questions" => questions.to_h { |name, question| [name.to_s, question.to_payload] }
75
+ }
76
+ end
77
+
78
+ def perform(payload)
79
+ authorization = "Bearer #{@config.api_key!}"
80
+ attempt = 0
81
+
82
+ loop do
83
+ attempt += 1
84
+ response = attempt_request(authorization, payload, attempt)
85
+ next if response.nil?
86
+
87
+ status = response.code.to_i
88
+ return parse_body(response) if status < 300
89
+
90
+ raise error_for(status, response) unless retryable?(status) && attempt <= @config.max_retries
91
+
92
+ pause(retry_delay(response, status, attempt))
93
+ end
94
+ end
95
+
96
+ def retry_delay(response, status, attempt)
97
+ after = [429, 503].include?(status) ? retry_after(response) : nil
98
+ raise error_for(status, response) if after && @config.max_retry_wait && after > @config.max_retry_wait
99
+
100
+ after || backoff(attempt)
101
+ end
102
+
103
+ def attempt_request(authorization, payload, attempt)
104
+ started = monotonic
105
+ response = execute(authorization, payload)
106
+ log(response.code, monotonic - started, attempt)
107
+ response
108
+ rescue *TRANSPORT_ERRORS => e
109
+ close_connection
110
+ log(e.class.name, monotonic - started, attempt)
111
+ if attempt > @config.max_retries || UNSAFE_TO_RESEND.any? { |klass| e.is_a?(klass) }
112
+ raise TransportError, "#{e.class}: #{e.message}"
113
+ end
114
+
115
+ pause(backoff(attempt))
116
+ nil
117
+ end
118
+
119
+ def execute(authorization, payload)
120
+ request = Net::HTTP::Post.new(uri.request_uri)
121
+ request["Authorization"] = authorization
122
+ request["Content-Type"] = "application/json"
123
+ request["Accept"] = "application/json"
124
+ request.body = payload
125
+ connection.request(request)
126
+ end
127
+
128
+ def uri
129
+ url = @config.base_url
130
+ @uri = URI.parse(url) if @uri_source != url
131
+ @uri_source = url
132
+ @uri
133
+ end
134
+
135
+ def connection_key
136
+ [@config.base_url, @config.open_timeout, @config.timeout].freeze
137
+ end
138
+
139
+ def connection
140
+ store = connections
141
+ key = connection_key
142
+ http = store[key]
143
+ return http if http&.started?
144
+
145
+ store[key] = start_connection(key.first)
146
+ end
147
+
148
+ def start_connection(base_url)
149
+ uri = URI.parse(base_url)
150
+ http = Net::HTTP.new(uri.host, uri.port)
151
+ http.use_ssl = uri.scheme == "https"
152
+ http.open_timeout = @config.open_timeout
153
+ http.read_timeout = @config.timeout
154
+ http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
155
+ http.keep_alive_timeout = 30
156
+ http.start
157
+ http
158
+ end
159
+
160
+ def connections
161
+ store = Thread.current[CONNECTIONS_KEY]
162
+ unless store && store[:pid] == Process.pid
163
+ store = { pid: Process.pid, connections: {} }
164
+ Thread.current[CONNECTIONS_KEY] = store
165
+ end
166
+ store[:connections]
167
+ end
168
+
169
+ def close_connection
170
+ http = connections.delete(connection_key)
171
+ http.finish if http&.started?
172
+ rescue IOError
173
+ nil
174
+ end
175
+
176
+ def parse_body(response)
177
+ body = response.body.to_s
178
+ parsed = JSON.parse(body)
179
+ raise InvalidResponseError, "expected a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
180
+
181
+ parsed
182
+ rescue JSON::ParserError => e
183
+ raise InvalidResponseError, "could not parse response body: #{e.message}"
184
+ end
185
+
186
+ def retryable?(status)
187
+ status == 429 || RETRYABLE_STATUSES.cover?(status)
188
+ end
189
+
190
+ def retry_after(response)
191
+ value = response["Retry-After"]
192
+ return nil if value.nil?
193
+
194
+ Float(value)
195
+ rescue ArgumentError, TypeError
196
+ nil
197
+ end
198
+
199
+ def backoff(attempt)
200
+ window = [BACKOFF_BASE * (2**(attempt - 1)), BACKOFF_CAP].min
201
+ window * (0.5 + (rand * 0.5))
202
+ end
203
+
204
+ def pause(seconds)
205
+ @sleeper.call(seconds) if seconds.positive?
206
+ end
207
+
208
+ def error_for(status, response)
209
+ body = response.body.to_s
210
+ message = "Judge API returned #{status}: #{error_message(body)}"
211
+
212
+ case status
213
+ when 401, 403 then AuthenticationError.new(message, status: status, body: body)
214
+ when 400, 404, 422 then InvalidRequestError.new(message, status: status, body: body)
215
+ when 413 then PayloadTooLargeError.new(message, status: status, body: body)
216
+ when 429
217
+ RateLimitError.new(message, status: status, body: body, retry_after: retry_after(response))
218
+ when RETRYABLE_STATUSES then ServerError.new(message, status: status, body: body)
219
+ else APIError.new(message, status: status, body: body)
220
+ end
221
+ end
222
+
223
+ def error_message(body)
224
+ parsed = JSON.parse(body)
225
+ return truncate(body) unless parsed.is_a?(Hash)
226
+
227
+ error = parsed["error"]
228
+ message = error.is_a?(Hash) ? error["message"] : error
229
+ (message || parsed["message"] || truncate(body)).to_s
230
+ rescue JSON::ParserError
231
+ truncate(body)
232
+ end
233
+
234
+ def truncate(body, limit = 200)
235
+ body.length > limit ? "#{body[0, limit]}..." : body
236
+ end
237
+
238
+ def log(status, latency, attempt)
239
+ logger = @config.logger
240
+ return unless logger
241
+
242
+ logger.debug do
243
+ "Judge POST #{uri.path} status=#{status} latency=#{latency.round(3)}s attempt=#{attempt}"
244
+ end
245
+ end
246
+
247
+ def monotonic
248
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
249
+ end
250
+ end
251
+ end
252
+
253
+ Judge::Pool.on_worker_exit { Judge::Client.close_thread_connections }
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ class Configuration
5
+ DEFAULT_BASE_URL = "https://api.typesafe.ai/v1/systemone"
6
+ DEFAULT_MODEL = "jev-latest"
7
+
8
+ attr_reader :api_key, :base_url, :model, :timeout, :open_timeout, :max_retries, :max_retry_wait,
9
+ :concurrency, :adapter
10
+ attr_accessor :logger
11
+
12
+ def initialize
13
+ self.api_key = [ENV.fetch("JEV_API_KEY", nil), ENV.fetch("TYPESAFE_API_KEY", nil)]
14
+ .find { |value| !blank?(value) }
15
+ self.base_url = present_env("JEV_BASE_URL") || DEFAULT_BASE_URL
16
+ self.model = present_env("JEV_MODEL") || DEFAULT_MODEL
17
+ self.timeout = 10.0
18
+ self.open_timeout = 5.0
19
+ self.max_retries = 2
20
+ self.max_retry_wait = 10.0
21
+ self.concurrency = nil
22
+ self.adapter = present_env("JUDGE_ADAPTER") || :jev
23
+ @logger = nil
24
+ end
25
+
26
+ def api_key=(value)
27
+ @api_key = blank?(value) ? nil : value.to_s.strip
28
+ end
29
+
30
+ def base_url=(value)
31
+ raise ArgumentError, "base_url must not be blank" if blank?(value)
32
+
33
+ @base_url = value.to_s.strip
34
+ end
35
+
36
+ def model=(value)
37
+ raise ArgumentError, "model must not be blank" if blank?(value)
38
+
39
+ @model = value.to_s.strip
40
+ end
41
+
42
+ def timeout=(value)
43
+ @timeout = positive_number(value, "timeout")
44
+ end
45
+
46
+ def open_timeout=(value)
47
+ @open_timeout = positive_number(value, "open_timeout")
48
+ end
49
+
50
+ def max_retries=(value)
51
+ unless value.is_a?(Integer) && !value.negative?
52
+ raise ArgumentError, "max_retries must be an Integer of 0 or more, got #{value.inspect}"
53
+ end
54
+
55
+ @max_retries = value
56
+ end
57
+
58
+ def max_retry_wait=(value)
59
+ @max_retry_wait = value.nil? ? nil : positive_number(value, "max_retry_wait")
60
+ end
61
+
62
+ def concurrency=(value)
63
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
64
+ raise ArgumentError, "concurrency must be nil or a positive Integer, got #{value.inspect}"
65
+ end
66
+
67
+ @concurrency = value
68
+ end
69
+
70
+ def adapter=(value)
71
+ @adapter = value.respond_to?(:call) ? value : value.to_s.strip.to_sym
72
+ end
73
+
74
+ def api_key!
75
+ return @api_key if @api_key
76
+
77
+ raise ConfigurationError, "No Judge API key. Set JEV_API_KEY or Judge.configure { |c| c.api_key = ... }"
78
+ end
79
+
80
+ def inspect
81
+ key = @api_key ? "[FILTERED]" : "nil"
82
+ "#<Judge::Configuration api_key=#{key} base_url=#{@base_url.inspect} model=#{@model.inspect} " \
83
+ "adapter=#{@adapter.inspect}>"
84
+ end
85
+
86
+ private
87
+
88
+ def present_env(name)
89
+ value = ENV.fetch(name, nil)
90
+ blank?(value) ? nil : value.strip
91
+ end
92
+
93
+ def blank?(value)
94
+ value.nil? || value.to_s.strip.empty?
95
+ end
96
+
97
+ def positive_number(value, label)
98
+ return value.to_f if value.is_a?(Numeric) && value.positive?
99
+
100
+ raise ArgumentError, "#{label} must be a positive number of seconds, got #{value.inspect}"
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Decision
5
+ module_function
6
+
7
+ def call(probability, above:, below: nil)
8
+ low = band_floor(above, below)
9
+ return :yes if probability >= above
10
+ return :no if probability <= low
11
+
12
+ :unsure
13
+ end
14
+
15
+ def band_floor(above, below)
16
+ low = below || [(1.0 - above).round(12), above].min
17
+ return low if low <= above
18
+
19
+ raise ArgumentError,
20
+ "decide needs below (#{low}) to be at or under above (#{above}), " \
21
+ "otherwise no probability can land in :no or :unsure"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class TransportError < Error; end
9
+
10
+ class APIError < Error
11
+ attr_reader :status, :body
12
+
13
+ def initialize(message, status: nil, body: nil)
14
+ @status = status
15
+ @body = body
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ class AuthenticationError < APIError; end
21
+ class InvalidRequestError < APIError; end
22
+ class ServerError < APIError; end
23
+
24
+ class RateLimitError < APIError
25
+ attr_reader :retry_after
26
+
27
+ def initialize(message, status: nil, body: nil, retry_after: nil)
28
+ @retry_after = retry_after
29
+ super(message, status: status, body: body)
30
+ end
31
+ end
32
+
33
+ class PayloadTooLargeError < APIError; end
34
+
35
+ class InvalidResponseError < Error; end
36
+ end