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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +128 -7
- data/docs/COMPATIBILITY.md +48 -18
- data/docs/UPSTREAM_ISSUES.md +18 -8
- data/lib/typesafe/sdk/assets.rb +326 -0
- data/lib/typesafe/sdk/client.rb +57 -13
- data/lib/typesafe/sdk/compression.rb +50 -0
- data/lib/typesafe/sdk/errors.rb +19 -2
- data/lib/typesafe/sdk/http.rb +2 -1
- data/lib/typesafe/sdk/logging.rb +14 -6
- data/lib/typesafe/sdk/questions.rb +17 -4
- data/lib/typesafe/sdk/types.rb +58 -11
- data/lib/typesafe/sdk/version.rb +2 -2
- data/lib/typesafe-sdk-ruby.rb +30 -0
- metadata +7 -4
|
@@ -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
|
data/lib/typesafe/sdk/client.rb
CHANGED
|
@@ -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
|
|
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
|
|
61
|
+
# Answer named questions about text, structured state, or images.
|
|
57
62
|
#
|
|
58
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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" =>
|
|
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:
|
|
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,
|
|
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
|
data/lib/typesafe/sdk/errors.rb
CHANGED
|
@@ -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|
|
data/lib/typesafe/sdk/http.rb
CHANGED
|
@@ -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]
|
|
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]
|
data/lib/typesafe/sdk/logging.rb
CHANGED
|
@@ -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,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
44
|
+
return if STATE_TYPES.any? { |type| state.is_a?(type) }
|
|
36
45
|
|
|
37
|
-
raise TypeSafeError, "`state` must be a string, object, or
|
|
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
|
|