typesafe-sdk-ruby 0.6.0.1 → 0.7.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,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "securerandom"
6
+
7
+ module Typesafe
8
+ module SDK
9
+ # A PNG or JPEG image asset, created by {SDK.image} or {Image.from_base64}.
10
+ #
11
+ # Place it in `state`, question instructions, or criteria, join it into text with
12
+ # {SDK.with_assets}, or pass it in `assets:` under a name of your choice.
13
+ class Image
14
+ # The file signatures that PNG (PNG spec §5.2) and JPEG (ITU T.81 SOI marker) files start
15
+ # with, and the content type each identifies.
16
+ CONTENT_TYPES = [
17
+ ["image/png", "\x89PNG\r\n\x1A\n".b],
18
+ ["image/jpeg", "\xFF\xD8\xFF".b]
19
+ ].freeze
20
+
21
+ # The asset type sent to the API.
22
+ def type
23
+ "image"
24
+ end
25
+
26
+ # The frozen binary file bytes, or a `Pathname` read when the request is sent.
27
+ attr_reader :data
28
+
29
+ # @param data [String, Pathname, #read] file bytes, a path to read when the request is
30
+ # sent, or an IO read now.
31
+ # @raise [TypeSafeError] `data` is empty, unreadable, or not a supported type.
32
+ def initialize(data)
33
+ @data = coerce(data)
34
+ size = @data.is_a?(Pathname) ? file_size(@data) : @data.bytesize
35
+ raise TypeSafeError, "Image data is empty." if size.zero?
36
+
37
+ @reference = "<image:#{SecureRandom.hex(16)}>".freeze
38
+ end
39
+
40
+ # Create an image from base64-encoded file bytes, decoding them now.
41
+ #
42
+ # @param text [String] the file bytes, base64-encoded, without a `data:` URL prefix.
43
+ # ASCII whitespace and missing padding are accepted, as by JavaScript's `atob`.
44
+ # @raise [TypeSafeError] `text` isn't valid base64 or decodes to no bytes.
45
+ def self.from_base64(text)
46
+ raise TypeSafeError, "Image data must contain valid base64 text." unless text.is_a?(String)
47
+
48
+ # The forgiving-base64 decode that `atob` uses (WHATWG Infra Standard).
49
+ compact = text.b.delete(" \t\n\f\r")
50
+ compact = compact.sub(/={1,2}\z/, "") if (compact.length % 4).zero?
51
+ unless compact.match?(%r{\A[A-Za-z0-9+/]*\z}) && compact.length % 4 != 1
52
+ raise TypeSafeError, "Image data must contain valid base64 text."
53
+ end
54
+
55
+ new("#{compact}#{'=' * (-compact.length % 4)}".unpack1("m"))
56
+ end
57
+
58
+ # The bytes to send, reading `Pathname` data.
59
+ # @param name [String] the asset's name or reference, for error messages.
60
+ def read(name)
61
+ return @data unless @data.is_a?(Pathname)
62
+
63
+ File.binread(@data).freeze
64
+ rescue SystemCallError, IOError => e
65
+ raise TypeSafeError.new("Asset #{name.inspect} could not be read: #{e.message}", cause: e)
66
+ end
67
+
68
+ # The unique reference this image is sent under, for referencing it inside text.
69
+ def to_s
70
+ @reference
71
+ end
72
+
73
+ # The reference, written wherever the image appears in JSON.
74
+ def to_json(*args)
75
+ @reference.to_json(*args)
76
+ end
77
+
78
+ def inspect # :nodoc:
79
+ "#<#{self.class.name} #{@reference}>"
80
+ end
81
+
82
+ # The content type whose signature `bytes` start with, or `application/octet-stream`.
83
+ def self.content_type_of(bytes)
84
+ CONTENT_TYPES.each { |type, signature| return type if bytes.b.start_with?(signature) }
85
+ "application/octet-stream"
86
+ end
87
+
88
+ private
89
+
90
+ def coerce(data)
91
+ case data
92
+ when String then data.b.freeze
93
+ when Pathname then data
94
+ else
95
+ unless data.respond_to?(:read)
96
+ raise TypeSafeError,
97
+ "Image data must be file bytes (a String), a Pathname, or an IO; got #{data.class}."
98
+ end
99
+
100
+ bytes = data.read
101
+ raise TypeSafeError, "Image data read from #{data.class} must be a String." unless bytes.is_a?(String)
102
+
103
+ bytes.b.freeze
104
+ end
105
+ end
106
+
107
+ def file_size(path)
108
+ File.size(path)
109
+ rescue SystemCallError => e
110
+ raise TypeSafeError.new("Image file could not be read: #{e.message}", cause: e)
111
+ end
112
+ end
113
+
114
+ # Text from {SDK.with_assets} or {SDK.join_assets}, with the assets written into it.
115
+ class TextWithAssets
116
+ # The text, with each asset written as its reference.
117
+ attr_reader :text
118
+ # The assets written into the text, including those in nested `TextWithAssets`.
119
+ attr_reader :assets
120
+
121
+ def initialize(text, assets)
122
+ @text = text.dup.freeze
123
+ @assets = assets.dup.freeze
124
+ end
125
+
126
+ def to_s
127
+ @text
128
+ end
129
+
130
+ # The text, written wherever this value appears in JSON.
131
+ def to_json(*args)
132
+ @text.to_json(*args)
133
+ end
134
+
135
+ def inspect # :nodoc:
136
+ "#<#{self.class.name} #{@text.inspect}>"
137
+ end
138
+
139
+ # Join `parts` into text, as an interpolated string would, and attach the assets among them.
140
+ def self.build(*parts)
141
+ attach(parts.map { |part| stringify(part) }.join, parts)
142
+ end
143
+
144
+ # Join `values` with `separator` and attach the assets among them.
145
+ def self.join(values, separator = ",")
146
+ values = values.to_a
147
+ attach(values.map { |value| stringify(value) }.join(separator.to_s), values)
148
+ end
149
+
150
+ # `text` with the assets in `values` whose references it contains, in order of appearance.
151
+ def self.attach(text, values)
152
+ interpolated = {}
153
+ collect(values, interpolated, {}.compare_by_identity)
154
+ assets = {}
155
+ text.scan(Assets::REFERENCE) do |reference|
156
+ asset = interpolated[reference]
157
+ assets[reference] = asset if asset
158
+ end
159
+ new(text, assets.values)
160
+ end
161
+
162
+ def self.stringify(value)
163
+ value.is_a?(TextWithAssets) ? value.text : value.to_s
164
+ end
165
+
166
+ # Add the images in `value`, including those in `TextWithAssets`, to `found` by reference.
167
+ def self.collect(value, found, visited)
168
+ case value
169
+ when Image then found[value.to_s] = value
170
+ when TextWithAssets then value.assets.each { |asset| found[asset.to_s] = asset }
171
+ when Hash, Array
172
+ return if visited.key?(value)
173
+
174
+ visited[value] = true
175
+ (value.is_a?(Hash) ? value.values : value).each { |item| collect(item, found, visited) }
176
+ end
177
+ end
178
+ end
179
+
180
+ # A `multipart/form-data` request body (RFC 7578) whose first part is a JSON payload.
181
+ class MultipartBody
182
+ # The JSON payload, sent as the `request` part.
183
+ attr_reader :payload
184
+ # The `Content-Type` header value, including the boundary.
185
+ attr_reader :content_type
186
+ # The encoded binary body.
187
+ attr_reader :content
188
+
189
+ # @param parts [Array<Hash>] `name`, `content_type`, `content`, and optional `filename`.
190
+ def initialize(payload, parts)
191
+ # A random boundary, as browsers use, is practically certain not to occur in the content.
192
+ boundary = SecureRandom.hex(16)
193
+ content = String.new(encoding: Encoding::BINARY)
194
+ [{ name: "request", content_type: "application/json", content: JSON.generate(payload) }, *parts]
195
+ .each do |part|
196
+ disposition = %(name="#{part[:name]}")
197
+ disposition += %(; filename="#{part[:filename]}") if part[:filename]
198
+ content << "--#{boundary}\r\nContent-Disposition: form-data; #{disposition}\r\n" \
199
+ "Content-Type: #{part[:content_type]}\r\n\r\n".b << part[:content].b << "\r\n"
200
+ end
201
+ content << "--#{boundary}--\r\n"
202
+ @payload = payload
203
+ @content_type = "multipart/form-data; boundary=#{boundary}"
204
+ @content = content.freeze
205
+ end
206
+ end
207
+
208
+ # Request serialization and `multipart/form-data` encoding for assets.
209
+ module Assets
210
+ # Matches `Image#to_s`: the reference an image is sent under.
211
+ REFERENCE = /<image:[0-9a-f]{32}>/
212
+
213
+ module_function
214
+
215
+ # Validate `assets:` and return it with string names, preserving order.
216
+ def normalize(assets)
217
+ return {} if assets.nil?
218
+ raise TypeSafeError, "`assets` must be a Hash of names to images." unless assets.is_a?(Hash)
219
+
220
+ assets.each_with_object({}) do |(name, asset), normalized|
221
+ key = name.to_s
222
+ raise TypeSafeError, "Asset names must be nonempty." if key.empty?
223
+ unless asset.is_a?(Image)
224
+ raise TypeSafeError,
225
+ "Asset #{key.inspect} must be an image created with Typesafe::SDK.image, got #{asset.class}."
226
+ end
227
+
228
+ normalized[key] = asset
229
+ end
230
+ end
231
+
232
+ # Serialize `payload` to JSON, writing each image as its name in `assets`, or else as its
233
+ # reference, and collect the assets the request sends.
234
+ #
235
+ # The assets are those in `assets`, then each image whose reference appears in the JSON, in
236
+ # order of appearance. Non-finite floats are sent as the strings `NaN`, `Infinity`, and
237
+ # `-Infinity`, as the JavaScript SDK does.
238
+ #
239
+ # @return [Array(String, Hash{String => Image}, nil)] the JSON and the assets to send.
240
+ # @raise [TypeSafeError] the payload holds a cycle, a reference of an image not attached to
241
+ # the request, or doesn't contain the name of an image in `assets`.
242
+ def serialize_request(payload, assets = {})
243
+ # An image under several names is written as the last one.
244
+ names = {}.compare_by_identity
245
+ assets.each { |name, asset| names[asset] = name }
246
+ known = assets.values.to_h { |asset| [asset.to_s, asset] }
247
+ json = JSON.generate(prepare(payload, names, known, {}.compare_by_identity))
248
+
249
+ unreferenced = assets.keys.reject { |name| json.include?(JSON.generate(name)[1..-2]) }
250
+ unless unreferenced.empty?
251
+ raise TypeSafeError,
252
+ "Assets not referenced in the request: #{unreferenced.join(', ')}. " \
253
+ "Write each name in `assets` into `state`, question instructions, or criteria."
254
+ end
255
+
256
+ sent = assets.dup
257
+ missing = []
258
+ json.scan(REFERENCE) do |reference|
259
+ next if sent.key?(reference)
260
+
261
+ asset = known[reference]
262
+ asset ? sent[reference] = asset : missing << reference
263
+ end
264
+ unless missing.empty?
265
+ raise TypeSafeError,
266
+ "Image references without an attached image: #{missing.uniq.join(', ')}. " \
267
+ "Join images into text with Typesafe::SDK.with_assets instead of string interpolation."
268
+ end
269
+
270
+ [json, sent.empty? ? nil : sent]
271
+ end
272
+
273
+ # A JSON-ready copy of `value` with assets written as names or references.
274
+ def prepare(value, names, known, path)
275
+ case value
276
+ when Image
277
+ name = names[value]
278
+ return name if name
279
+
280
+ known[value.to_s] = value
281
+ value.to_s
282
+ when TextWithAssets
283
+ value.assets.reduce(value.text) do |text, asset|
284
+ name = names[asset]
285
+ known[asset.to_s] = asset unless name
286
+ name ? text.gsub(asset.to_s) { name } : text
287
+ end
288
+ when Float
289
+ value.finite? ? value : value.to_s
290
+ when Hash, Array
291
+ raise TypeSafeError, "The request contains a circular reference." if path.key?(value)
292
+
293
+ path[value] = true
294
+ begin
295
+ if value.is_a?(Hash)
296
+ value.to_h { |key, item| [key.to_s, prepare(item, names, known, path)] }
297
+ else
298
+ value.map { |item| prepare(item, names, known, path) }
299
+ end
300
+ ensure
301
+ path.delete(value)
302
+ end
303
+ else
304
+ value
305
+ end
306
+ end
307
+
308
+ # A multipart body whose `request` part is `payload` with `assets` added, followed by each
309
+ # asset's file bytes in its own part. The API accepts each part for only one asset, so an
310
+ # image under several references is sent once per reference.
311
+ #
312
+ # @raise [TypeSafeError] a `Pathname` image can't be read.
313
+ def encode(payload, assets)
314
+ parts = []
315
+ entries = {}
316
+ assets.each do |name, asset|
317
+ bytes = asset.read(name)
318
+ part = "asset-#{parts.length}"
319
+ parts << { name: part, content_type: Image.content_type_of(bytes), content: bytes, filename: part }
320
+ entries[name] = { "type" => asset.type, "part" => part }
321
+ end
322
+ MultipartBody.new(payload.merge("assets" => entries), parts)
323
+ end
324
+ end
325
+ end
326
+ end
@@ -15,7 +15,7 @@ module Typesafe
15
15
  # Client for the TypeSafe AI API.
16
16
  class Client
17
17
  attr_reader :base_url, :default_model, :log_level, :logger, :retry, :timeout,
18
- :default_headers, :models
18
+ :default_headers, :compression, :models
19
19
 
20
20
  # Create a client for the TypeSafe AI API.
21
21
  #
@@ -30,11 +30,15 @@ module Typesafe
30
30
  # @param retry_policy [Hash, nil] overrides for {Retry::DEFAULT_RETRY_POLICY}.
31
31
  # @param timeout [Float, nil] timeout per attempt in seconds. Default: 10.
32
32
  # @param default_headers [Hash, nil] additional request headers.
33
+ # @param compression [Symbol, false, nil] request body compression, sent as `Content-Encoding`.
34
+ # Default: `:zstd` when the optional `zstd-ruby` gem can be loaded, otherwise `false`.
35
+ # Bodies under 1 KiB are always sent uncompressed.
33
36
  # @param http [Object] HTTP adapter responding to `request`. Default: {HTTP}.
34
- # @raise [TypeSafeError] the API key is missing or configuration is invalid.
37
+ # @raise [TypeSafeError] the API key is missing, configuration is invalid, or zstd
38
+ # compression is requested without the `zstd-ruby` gem.
35
39
  def initialize(api_key: nil, base_url: nil, default_model: nil, log_level: nil,
36
40
  logger: nil, retry_policy: nil, timeout: nil, default_headers: nil,
37
- http: nil, env: ::ENV)
41
+ compression: nil, http: nil, env: ::ENV)
38
42
  @api_key = validate_api_key(api_key.nil? ? env[ENV::API_KEY] : api_key)
39
43
 
40
44
  @base_url = strip_trailing_slashes(
@@ -48,21 +52,30 @@ module Typesafe
48
52
  @retry = resolve_retry_policy(Retry::DEFAULT_RETRY_POLICY, retry_policy)
49
53
  @timeout = assert_positive("timeout", timeout || Retry::DEFAULT_TIMEOUT_S)
50
54
  @default_headers = (default_headers || {}).dup.freeze
55
+ @compression = Compression.resolve(compression)
51
56
  @http = http || HTTP.new
52
57
  @models = Resources::Models.new(self)
53
58
  @request_count = 0
54
59
  end
55
60
 
56
- # Answer named questions about text or structured state.
61
+ # Answer named questions about text, structured state, or images.
57
62
  #
58
- # @param state [String, Hash, Array] the content to evaluate.
63
+ # Images in `state`, question content, or {SDK.with_assets} text are sent under their
64
+ # references; requests with images are sent as `multipart/form-data`.
65
+ #
66
+ # @param state [String, Hash, Array, Image, TextWithAssets] the content to evaluate.
59
67
  # @param questions [Hash{Symbol, String => Hash}] nonempty questions keyed by name.
60
68
  # @param model [String, nil] model override; omitted values inherit `default_model`.
69
+ # @param assets [Hash{String => Image}, nil] images keyed by names of your choice, matched
70
+ # literally in strings within `state`, question instructions, or criteria. Every name must
71
+ # appear in the request.
61
72
  # @param extra_body [Hash] additional JSON fields; named request arguments take precedence.
62
73
  # @param with_response [Boolean] return data with HTTP metadata and request ID.
63
74
  # @param options [Hash] per-call `timeout`, `retry_policy`, `headers`, and `signal` settings.
64
75
  # @return [SystemOneResult, WithResponse] answers keyed by question name, with model and token usage.
65
- # @raise [TypeSafeError] state or questions violate the locally checked API constraints.
76
+ # @raise [TypeSafeError] state or questions violate the locally checked API constraints,
77
+ # the request holds a cycle, a string holds the reference of an image not attached to the
78
+ # request, a name in `assets` doesn't appear in the request, or an image can't be read.
66
79
  # @raise [APIError] the server returns a non-2xx response after retries.
67
80
  # @raise [APIConnectionError] the request cannot connect or times out after retries.
68
81
  # @raise [APIUserAbortError] the caller cancels the request.
@@ -73,12 +86,18 @@ module Typesafe
73
86
  # questions: { billing: Typesafe::SDK.noul("Is this about billing?") },
74
87
  # )
75
88
  # response[:billing].noul # => 0.93
76
- def system_one(state:, questions:, model: nil, extra_body: {}, with_response: false, **options)
89
+ def system_one(state:, questions:, model: nil, assets: nil, extra_body: {}, with_response: false,
90
+ **options)
77
91
  Questions.validate_state!(state)
78
92
  Questions.validate!(questions)
79
- body = extra_body.transform_keys(&:to_s).merge(
93
+ named = Assets.normalize(assets)
94
+ request_body = extra_body.transform_keys(&:to_s).merge(
80
95
  "state" => state, "questions" => questions, "model" => model || @default_model
81
96
  )
97
+ # Serialized now, so changes to the request after this call don't change what is sent.
98
+ json, sent = Assets.serialize_request(request_body, named)
99
+ payload = JSON.parse(json)
100
+ body = sent.nil? ? payload : -> { Assets.encode(payload, sent) }
82
101
  response = request(:post, "/v1/systemone", body: body, **options)
83
102
  result = SystemOneResult.new(response.body)
84
103
  with_response ? WithResponse.new(result, response) : result
@@ -86,6 +105,9 @@ module Typesafe
86
105
 
87
106
  # Send a request and parse its response body. Internal; used by API resources.
88
107
  #
108
+ # `body` is a JSON-ready object, or a callable returning one or an {MultipartBody},
109
+ # called once before the first attempt.
110
+ #
89
111
  # @return [Response] the parsed response, with `body`, `status`, `headers`, and `request_id`.
90
112
  def request(method, path, body: nil, headers: {}, timeout: nil, retry_policy: nil,
91
113
  signal: nil)
@@ -251,27 +273,29 @@ module Typesafe
251
273
  # Retry eligible failures, logging attempt summaries at `info` and details at `debug`.
252
274
  def fetch_with_retries(tag, req)
253
275
  url = "#{@base_url}#{req[:path]}"
254
- # User-supplied headers go first so they can't clobber auth or the JSON content type.
276
+ multipart, payload, encoded, encoding, body = prepare_body(tag, req)
277
+ # User-supplied headers go first so they can't clobber auth, the content type, or the encoding.
255
278
  headers = merge_headers(req[:headers], {
256
279
  "Authorization" => "Bearer #{@api_key}",
257
280
  "Accept" => "application/json",
258
281
  "User-Agent" => "typesafe-sdk-ruby/#{VERSION}",
259
282
  "X-TypeSafe-SDK" => "typesafe-sdk-ruby/#{VERSION}",
260
283
  "X-TypeSafe-Runtime" => "ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})",
261
- "Content-Type" => req[:body].nil? ? nil : "application/json",
284
+ "Content-Type" => multipart&.content_type ||
285
+ (encoded.nil? ? nil : "application/json"),
286
+ "Content-Encoding" => (encoding.to_s if encoding),
262
287
  "X-TypeSafe-Retry-Count" => nil
263
288
  })
264
- payload = req[:body].nil? ? nil : JSON.generate(req[:body])
265
289
 
266
290
  attempt = 0
267
291
  loop do
268
292
  retries_left = req[:retry][:max_retries] - attempt
269
293
  attempt_headers = attempt.zero? ? headers : headers.merge("X-TypeSafe-Retry-Count" => attempt.to_s)
270
- @logger.debug("#{tag} -> #{url}", headers: Redaction.redact_headers(attempt_headers), body: req[:body])
294
+ @logger.debug("#{tag} -> #{url}", headers: Redaction.redact_headers(attempt_headers), body: payload)
271
295
 
272
296
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
273
297
  begin
274
- response = attempt_request(tag, url, req[:method], attempt_headers, payload, req)
298
+ response = attempt_request(tag, url, req[:method], attempt_headers, body, req)
275
299
  rescue APIUserAbortError
276
300
  raise
277
301
  rescue APIConnectionError => e
@@ -296,6 +320,26 @@ module Typesafe
296
320
  end
297
321
  end
298
322
 
323
+ # Resolve, encode, and compress the body once; every attempt sends the same bytes.
324
+ # The logs show the uncompressed payload.
325
+ def prepare_body(tag, req)
326
+ resolved = req[:body]
327
+ if resolved.respond_to?(:call)
328
+ req[:signal]&.check!
329
+ resolved = resolved.call
330
+ if req[:signal]&.canceled?
331
+ @logger.info("#{tag} aborted by caller while reading assets")
332
+ raise APIUserAbortError
333
+ end
334
+ end
335
+ multipart = resolved.is_a?(MultipartBody) ? resolved : nil
336
+ payload = multipart ? multipart.payload : resolved
337
+ encoded = multipart&.content || (payload.nil? ? nil : JSON.generate(payload))
338
+ encoding = encoded && encoded.bytesize >= Compression::MIN_COMPRESSED_BODY_BYTES ? @compression : false
339
+ body = encoding ? Compression.compress(encoded) : encoded
340
+ [multipart, payload, encoded, encoding, body]
341
+ end
342
+
299
343
  def retryable_connection_error?(error, policy)
300
344
  return policy[:api_timeout_error] if error.is_a?(APITimeoutError)
301
345
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Request body compression with zstd, provided by the optional `zstd-ruby` gem.
6
+ module Compression
7
+ # Bodies below this many bytes fit in a single TCP segment, so they are sent uncompressed.
8
+ MIN_COMPRESSED_BODY_BYTES = 1024
9
+
10
+ module_function
11
+
12
+ # Whether the `zstd-ruby` gem can be loaded. Checked once per process.
13
+ def available?
14
+ return @available unless @available.nil?
15
+
16
+ @available = begin
17
+ require "zstd-ruby"
18
+ true
19
+ rescue LoadError
20
+ false
21
+ end
22
+ end
23
+
24
+ # Resolve the `compression:` option to the `Content-Encoding` request bodies are sent with:
25
+ # `:zstd` when the runtime has it, otherwise `false`.
26
+ #
27
+ # @raise [TypeSafeError] the value isn't a supported option, or zstd is unavailable.
28
+ def resolve(compression)
29
+ return available? ? :zstd : false if compression.nil?
30
+ return false if compression == false
31
+
32
+ unless [:zstd, "zstd"].include?(compression)
33
+ raise TypeSafeError, "`compression` must be :zstd or false, got #{compression.inspect}."
34
+ end
35
+ unless available?
36
+ raise TypeSafeError,
37
+ "zstd compression requires the zstd-ruby gem. Add it to your Gemfile or pass " \
38
+ "`compression: false` instead."
39
+ end
40
+
41
+ :zstd
42
+ end
43
+
44
+ # Compress a request body with zstd.
45
+ def compress(body)
46
+ Zstd.compress(body).b
47
+ end
48
+ end
49
+ end
50
+ end
@@ -26,14 +26,17 @@ module Typesafe
26
26
  attr_reader :request_id
27
27
 
28
28
  def initialize(status, body, headers, message = nil)
29
- super(message || APIError.describe(status, body))
29
+ super(message || APIError.describe(status, body, headers))
30
30
  @status = status
31
31
  @body = body
32
32
  @headers = headers
33
33
  @request_id = headers[Typesafe::SDK::REQUEST_ID_HEADER]
34
34
  end
35
35
 
36
- def self.describe(status, body)
36
+ def self.describe(status, body, headers = nil)
37
+ edge = describe_edge_block(status, body, headers)
38
+ return edge if edge
39
+
37
40
  detail = extract_message(body)
38
41
  return "#{status} #{detail}" if detail && !detail.empty?
39
42
  return "#{status} status code (no body)" if body.nil?
@@ -81,6 +84,20 @@ module Typesafe
81
84
  nil
82
85
  end
83
86
 
87
+ # Describe an HTML page from Cloudflare, which answers before the request reaches the API,
88
+ # for example when its firewall matches text such as shell commands in the request.
89
+ def self.describe_edge_block(status, body, headers)
90
+ return nil unless headers && body.is_a?(String) && body.match?(/\A\s*<(?:!doctype|html)/i)
91
+
92
+ ray = headers["cf-ray"]
93
+ return nil unless ray || headers["server"].to_s.match?(/cloudflare/i)
94
+
95
+ "#{status} The response is a Cloudflare HTML page, not a TypeSafe API error" \
96
+ "#{" (cf-ray #{ray})" if ray}. An edge firewall may have blocked the request before it " \
97
+ "reached the API; content such as shell commands in the request can trigger it. " \
98
+ "Report the ray ID to TypeSafe support if the request is valid."
99
+ end
100
+
84
101
  # Format validation errors as semicolon-separated `path: message` entries.
85
102
  def self.describe_validation_errors(errors)
86
103
  parts = errors.filter_map do |e|
@@ -66,7 +66,8 @@ module Typesafe
66
66
  # @param method [Symbol] `:get` or `:post`.
67
67
  # @param url [String] absolute URL.
68
68
  # @param headers [Hash{String => String}] request headers.
69
- # @param body [String, nil] JSON-encoded request body.
69
+ # @param body [String, nil] encoded request body: JSON, or `multipart/form-data` when the
70
+ # request has images, compressed when `Content-Encoding` is set. Send it as-is.
70
71
  # @param timeout [Numeric] timeout in seconds.
71
72
  # @param signal [Signal, nil] cancellation handle.
72
73
  # @return [Response]
@@ -76,20 +76,28 @@ module Typesafe
76
76
  # Credential headers that retain a key suffix for identification.
77
77
  module Redaction
78
78
  KEY_HEADERS = %w[authorization proxy-authorization x-api-key].freeze
79
+ # Headers whose values may start with an authentication scheme such as `Bearer`.
80
+ SCHEME_HEADERS = %w[authorization proxy-authorization].freeze
79
81
  OPAQUE_HEADERS = %w[cookie set-cookie].freeze
82
+ # An alphabetic scheme followed by a nonempty credential (RFC 9110 §11.4).
83
+ SCHEMED = /\A([A-Za-z]+)[ \t]+(\S.*)\z/m
80
84
 
81
85
  module_function
82
86
 
83
- # Mask a key, preserving its scheme and the last four characters of secrets longer than eight.
84
- def redact_key(value)
85
- scheme, secret = value.include?(" ") ? value.split(/\s+/, 2) : [nil, value]
86
- tail = secret && secret.length > 8 ? secret[-4..] : ""
87
- "#{"#{scheme} " if scheme}***#{tail}"
87
+ # Mask a key, keeping the last four characters of secrets longer than eight. An alphabetic
88
+ # leading scheme, such as `Bearer`, is kept only when `scheme` is true and a credential
89
+ # follows it; otherwise the whole value is masked, so a secret is never shown as a scheme.
90
+ def redact_key(value, scheme: true)
91
+ value = value.to_s
92
+ match = scheme ? SCHEMED.match(value) : nil
93
+ prefix, secret = match ? ["#{match[1]} ", match[2].strip] : ["", value.strip]
94
+ tail = secret.length > 8 ? secret[-4..] : ""
95
+ "#{prefix}***#{tail}"
88
96
  end
89
97
 
90
98
  def redact(name, value)
91
99
  lower = name.downcase
92
- return redact_key(value) if KEY_HEADERS.include?(lower)
100
+ return redact_key(value, scheme: SCHEME_HEADERS.include?(lower)) if KEY_HEADERS.include?(lower)
93
101
  return "***" if OPAQUE_HEADERS.include?(lower)
94
102
 
95
103
  value
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Typesafe
4
4
  module SDK
5
- # Builders and validation for the three question types.
5
+ # Builders and validation for the noul, score, choice, and box question types.
6
6
  module Questions
7
7
  MAX_SCORE_LEVELS = 10
8
8
  MAX_CHOICE_OPTIONS = 255
@@ -10,7 +10,7 @@ module Typesafe
10
10
  module_function
11
11
 
12
12
  # Create a yes/no question. Supply instructions or at least one outcome description.
13
- # @param instructions [String, Hash, Array, nil]
13
+ # @param instructions [String, Hash, Array, Image, TextWithAssets, nil]
14
14
  # @param criteria [Hash, nil] optional true/false outcome descriptions.
15
15
  def noul(instructions = nil, criteria: nil)
16
16
  question = { type: "noul", instructions: instructions, criteria: criteria }
@@ -31,10 +31,19 @@ module Typesafe
31
31
  { type: "choice", instructions: instructions, criteria: criteria }
32
32
  end
33
33
 
34
+ # Create a question that locates what its instructions describe in the request's image.
35
+ # Use the `jev-preview` model for box questions.
36
+ # @param instructions [String, Hash, Array, Image, TextWithAssets, nil] what to locate.
37
+ def box(instructions = nil)
38
+ { type: "box", instructions: instructions }
39
+ end
40
+
41
+ STATE_TYPES = [String, Hash, Array, Image, TextWithAssets].freeze
42
+
34
43
  def validate_state!(state)
35
- return if state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
44
+ return if STATE_TYPES.any? { |type| state.is_a?(type) }
36
45
 
37
- raise TypeSafeError, "`state` must be a string, object, or array; nil is not supported."
46
+ raise TypeSafeError, "`state` must be a string, object, array, or image; nil is not supported."
38
47
  end
39
48
 
40
49
  # Validate builders and raw JSON-style questions without altering the caller's data.
@@ -55,6 +64,10 @@ module Typesafe
55
64
  end
56
65
 
57
66
  def validate_noul!(instructions, criteria, name)
67
+ unless criteria.nil? || criteria.is_a?(Hash)
68
+ raise TypeSafeError, "Noul question #{name.inspect} criteria must be an object."
69
+ end
70
+
58
71
  descriptions = criteria.is_a?(Hash) ? criteria.transform_keys(&:to_s) : {}
59
72
  return unless instructions.nil? && %w[true false].all? { |key| descriptions[key].nil? }
60
73