typesafe-ai-ruby 0.1.0 → 0.2.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 +12 -1
- data/README.md +62 -19
- data/lib/typesafe/assets.rb +64 -0
- data/lib/typesafe/client.rb +33 -6
- data/lib/typesafe/configuration.rb +13 -2
- data/lib/typesafe/constants.rb +8 -0
- data/lib/typesafe/errors.rb +6 -0
- data/lib/typesafe/http/multipart_encoder.rb +65 -0
- data/lib/typesafe/http/request.rb +6 -2
- data/lib/typesafe/http/requestor.rb +26 -9
- data/lib/typesafe/image.rb +123 -0
- data/lib/typesafe/questions/box.rb +19 -0
- data/lib/typesafe/questions/choice.rb +6 -3
- data/lib/typesafe/questions/normalizer.rb +18 -11
- data/lib/typesafe/questions/noul.rb +6 -3
- data/lib/typesafe/questions/question.rb +7 -2
- data/lib/typesafe/questions/score.rb +6 -3
- data/lib/typesafe/request_options.rb +20 -9
- data/lib/typesafe/responses/answer.rb +74 -1
- data/lib/typesafe/responses/system_one_response.rb +4 -0
- data/lib/typesafe/util.rb +29 -9
- data/lib/typesafe/version.rb +1 -1
- data/lib/typesafe.rb +13 -0
- data/sig/typesafe/http.rbs +27 -2
- data/sig/typesafe/questions.rbs +15 -8
- data/sig/typesafe/responses.rbs +27 -0
- data/sig/typesafe.rbs +57 -5
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f892e52635bd8e5f079a2b3f6c9240cff084c3fdb61a11b80e0b4b347708e28
|
|
4
|
+
data.tar.gz: 00b8ecee6d1afef19586bc64887347a2c0b9b4bfe80b26651c2e0cfaddd147c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2380c1c3b218440779ac943690c669464f8cebf0d774d78737044e29cd6ef61b980a9e790e414b47ed6761a99d273d26be8068af13c4dad1c3c4ac277daa1cb5
|
|
7
|
+
data.tar.gz: 29fb85145eef1e720c10da2a0edeb5d196f4e44f8629b14a6ca55e3c7d31e01a7af351094da9d88a180f5186b39fd6779809f746c816621c6d3629b45f13d5ec
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
Support for Jev 1.14's experimental image input.
|
|
13
|
+
|
|
14
|
+
- `TypeSafe::Image` from bytes, a file path, or base64 text. Place images in state, instructions or
|
|
15
|
+
criteria; they are uploaded as multipart assets. Images written into text go in `assets:`.
|
|
16
|
+
- `box` questions and `TypeSafe::Responses::BoxAnswer` with `box` coordinates (`response.boxes`).
|
|
17
|
+
- `danger_zone:` option on the client and per call to turn on `:images` and `:long_context`.
|
|
18
|
+
- `TypeSafe::PayloadTooLargeError` (413) and `TypeSafe::UnsupportedMediaTypeError` (415).
|
|
19
|
+
|
|
10
20
|
## [0.1.0] - 2026-09-19
|
|
11
21
|
|
|
12
22
|
Initial release.
|
|
@@ -17,5 +27,6 @@ Initial release.
|
|
|
17
27
|
- Retries with backoff and `Retry-After`, keep-alive connections, logging and instrumentation hooks.
|
|
18
28
|
- RBS signatures.
|
|
19
29
|
|
|
20
|
-
[Unreleased]: https://github.com/hnegishi/typesafe-ai-ruby/compare/v0.
|
|
30
|
+
[Unreleased]: https://github.com/hnegishi/typesafe-ai-ruby/compare/v0.2.0...HEAD
|
|
31
|
+
[0.2.0]: https://github.com/hnegishi/typesafe-ai-ruby/compare/v0.1.0...v0.2.0
|
|
21
32
|
[0.1.0]: https://github.com/hnegishi/typesafe-ai-ruby/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -27,25 +27,33 @@ gem install typesafe-ai-ruby
|
|
|
27
27
|
|
|
28
28
|
## Usage
|
|
29
29
|
|
|
30
|
-
Get an API key from the [TypeSafe console](https://console.typesafe.ai/
|
|
30
|
+
Get an API key from the [TypeSafe console](https://console.typesafe.ai/keys) and export it as `TYPESAFE_API_KEY`:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
export TYPESAFE_API_KEY=...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The client reads it automatically, so `TypeSafe::Client.new` needs no arguments. You can also pass `api_key:` explicitly.
|
|
31
37
|
|
|
32
38
|
```ruby
|
|
33
39
|
require "typesafe-ai-ruby"
|
|
34
40
|
|
|
35
|
-
client = TypeSafe::Client.new
|
|
41
|
+
client = TypeSafe::Client.new
|
|
36
42
|
|
|
37
43
|
response = client.system_one(
|
|
38
44
|
state: "I was charged twice. Please fix this ASAP.",
|
|
39
45
|
questions: {
|
|
40
|
-
department:
|
|
46
|
+
department: {
|
|
47
|
+
type: :choice,
|
|
41
48
|
instructions: "Which team should handle this?",
|
|
42
49
|
criteria: { billing: "Payment issues", technical: "Bugs or integrations", sales: "Pricing questions" }
|
|
43
|
-
|
|
44
|
-
frustration:
|
|
50
|
+
},
|
|
51
|
+
frustration: {
|
|
52
|
+
type: :score,
|
|
45
53
|
instructions: "How frustrated is the customer?",
|
|
46
54
|
criteria: ["Calm", "Frustrated but civil", "Very angry"]
|
|
47
|
-
|
|
48
|
-
is_urgent:
|
|
55
|
+
},
|
|
56
|
+
is_urgent: { type: :noul, instructions: "Does the message convey urgency?" }
|
|
49
57
|
}
|
|
50
58
|
)
|
|
51
59
|
|
|
@@ -58,21 +66,22 @@ response.model # => "jev-1.13.0"
|
|
|
58
66
|
|
|
59
67
|
### Questions
|
|
60
68
|
|
|
61
|
-
|
|
69
|
+
Questions are plain Hashes matching the [API reference](https://docs.typesafe.ai/api): a `type`, optional `instructions`, and type-specific `criteria`. Instructions and descriptions can be a string, a Hash, or an Array.
|
|
70
|
+
|
|
71
|
+
- `noul` asks a yes/no question and returns the probability of yes. Criteria are optional: `criteria: { true: "Spam", false: "A real conversation" }`.
|
|
72
|
+
- `choice` picks one option from a set. Criteria are required; use `nil` when the name speaks for itself.
|
|
73
|
+
- `score` rates the state against ordered levels. Criteria are an Array of at least two levels, and each level's index is its score.
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
- `TypeSafe::Choice` picks one option from a set. Criteria are required; use `nil` when the name speaks for itself.
|
|
65
|
-
- `TypeSafe::Score` rates the state against ordered levels. Criteria are an Array of at least two levels, and each level's index is its score.
|
|
75
|
+
Extra keys are passed through untouched, so new API fields work before this library knows about them.
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
To reuse a question, keep a `TypeSafe::Noul`, `TypeSafe::Choice` or `TypeSafe::Score` in a constant and pass it in place of a Hash:
|
|
68
78
|
|
|
69
79
|
```ruby
|
|
70
|
-
TypeSafe.
|
|
71
|
-
|
|
72
|
-
TypeSafe.score("How urgent is this?", ["Can wait", "This week", "Today"])
|
|
73
|
-
```
|
|
80
|
+
DEPARTMENT = TypeSafe::Choice.new(instructions: "Which team should handle this?",
|
|
81
|
+
criteria: { billing: nil, technical: nil, sales: nil })
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
client.system_one(state: ticket, questions: { department: DEPARTMENT })
|
|
84
|
+
```
|
|
76
85
|
|
|
77
86
|
`state` is whatever the questions are about: a String, a Hash, or an Array. Symbols are converted to strings, and objects that respond to `as_json` are converted through it.
|
|
78
87
|
|
|
@@ -110,13 +119,47 @@ end
|
|
|
110
119
|
|
|
111
120
|
Malformed questions raise `TypeSafe::ValidationError` before anything is sent.
|
|
112
121
|
|
|
122
|
+
### Images (experimental)
|
|
123
|
+
|
|
124
|
+
Jev can look at PNG and JPEG images. The feature is behind a "danger zone" flag because its format and limits can still change, so turn it on per client or per call with `danger_zone: :images`. See the [images guide](https://docs.typesafe.ai/images) for limits and tips.
|
|
125
|
+
|
|
126
|
+
Build a `TypeSafe::Image` from bytes, a file path, or base64 text, and place it anywhere text can go. It is uploaded once and referenced from the request:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
door = TypeSafe::Image.open("door-camera.jpg")
|
|
130
|
+
|
|
131
|
+
response = client.system_one(
|
|
132
|
+
state: { door: door },
|
|
133
|
+
questions: {
|
|
134
|
+
person_present: { type: :noul, instructions: "Is there a person in `door`?" },
|
|
135
|
+
mug: { type: :box, instructions: "the coffee mug in `door`" }
|
|
136
|
+
},
|
|
137
|
+
model: "jev-preview",
|
|
138
|
+
danger_zone: :images
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
response.nouls[:person_present].noul # => 0.97
|
|
142
|
+
response.boxes[:mug].box.to_a # => [left, top, right, bottom], as fractions of the image size
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A `box` question returns where something is in an image and needs Jev 1.14 (`jev-preview`). To write an image into a sentence, interpolate it and pass it in `assets:`:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
client.system_one(
|
|
149
|
+
state: { photo: door },
|
|
150
|
+
questions: { same: TypeSafe.noul("Is the dog in `photo` the dog in #{poster}?") },
|
|
151
|
+
assets: [poster],
|
|
152
|
+
danger_zone: :images
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
113
156
|
## Configuration
|
|
114
157
|
|
|
115
158
|
Options can be passed to `TypeSafe::Client.new` or set once for the default client. Explicit options win over environment variables, which win over the defaults.
|
|
116
159
|
|
|
117
160
|
```ruby
|
|
118
161
|
TypeSafe.configure do |c|
|
|
119
|
-
c.api_key = ENV.fetch("TYPESAFE_API_KEY")
|
|
162
|
+
c.api_key = ENV.fetch("TYPESAFE_API_KEY")
|
|
120
163
|
c.model = "jev-1.13.0" # TYPESAFE_DEFAULT_MODEL, default jev-latest
|
|
121
164
|
c.base_url = "https://api.typesafe.ai" # TYPESAFE_BASE_URL
|
|
122
165
|
c.timeout = 10 # seconds per attempt
|
|
@@ -124,7 +167,7 @@ TypeSafe.configure do |c|
|
|
|
124
167
|
c.log_level = :info # TYPESAFE_LOG_LEVEL, default :warn
|
|
125
168
|
end
|
|
126
169
|
|
|
127
|
-
TypeSafe.client.system_one(state: "...", questions: { urgent:
|
|
170
|
+
TypeSafe.client.system_one(state: "...", questions: { urgent: { type: :noul, instructions: "Is this urgent?" } })
|
|
128
171
|
```
|
|
129
172
|
|
|
130
173
|
Per-call overrides go in `request_options`:
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeSafe
|
|
4
|
+
# Turns the images in a request into the assets map sent with it.
|
|
5
|
+
#
|
|
6
|
+
# Images placed directly in state, instructions or criteria are collected while the request
|
|
7
|
+
# is normalized. Images written into text through their reference, or sent under a name of
|
|
8
|
+
# your choice, are passed in assets:. Every reference in the request must resolve to an
|
|
9
|
+
# image, and every attached image must be referenced, or the API rejects the request.
|
|
10
|
+
module Assets
|
|
11
|
+
MAX_REFERENCE_BYTES = 1024
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Normalize the assets: argument into reference => Image.
|
|
16
|
+
def attach(value)
|
|
17
|
+
case value
|
|
18
|
+
when nil then {}
|
|
19
|
+
when Image then { value.reference => value }
|
|
20
|
+
when Array then value.to_h { |image| [image!(image).reference, image] }
|
|
21
|
+
when Hash then value.to_h { |name, image| [reference!(name), image!(image)] }
|
|
22
|
+
else raise ValidationError, "assets must be an Image, an Array of images, or a Hash of reference => Image"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Merge collected and attached images and check that references and assets match up.
|
|
27
|
+
# +json+ is the encoded request body without the assets map.
|
|
28
|
+
def resolve(collected, attached, json)
|
|
29
|
+
assets = collected.to_h { |image| [image.reference, image] }.merge(attached)
|
|
30
|
+
json.scan(Image::REFERENCE_PATTERN).uniq.each do |reference|
|
|
31
|
+
next if assets.key?(reference)
|
|
32
|
+
|
|
33
|
+
raise ValidationError, "#{reference} refers to an image that is not attached to the request; pass it in assets:"
|
|
34
|
+
end
|
|
35
|
+
attached.each_key do |reference|
|
|
36
|
+
next if json.include?(escape(reference))
|
|
37
|
+
|
|
38
|
+
raise ValidationError, "asset #{reference.inspect} is not referenced anywhere in the request"
|
|
39
|
+
end
|
|
40
|
+
assets.each_value(&:validate!)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def image!(value)
|
|
44
|
+
return value if value.is_a?(Image)
|
|
45
|
+
|
|
46
|
+
raise ValidationError, "assets must contain TypeSafe::Image values, got #{value.class}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reference!(name)
|
|
50
|
+
reference = name.to_s
|
|
51
|
+
raise ValidationError, "asset references must not be blank" if reference.strip.empty?
|
|
52
|
+
if reference.bytesize > MAX_REFERENCE_BYTES
|
|
53
|
+
raise ValidationError, "asset reference is #{reference.bytesize} bytes; the limit is #{MAX_REFERENCE_BYTES}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
reference
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A reference as it appears inside a JSON string.
|
|
60
|
+
def escape(reference)
|
|
61
|
+
JSON.generate(reference)[1...-1]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/typesafe/client.rb
CHANGED
|
@@ -23,16 +23,26 @@ module TypeSafe
|
|
|
23
23
|
@models = Resources::Models.new(@requestor)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# Answer named questions about text
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
# Answer named questions about text, structured state, or images.
|
|
27
|
+
#
|
|
28
|
+
# Images placed in state or questions are sent as assets automatically. Images written into
|
|
29
|
+
# text through their reference go in assets:, as an Array or as a Hash of your own
|
|
30
|
+
# reference names. Images and box questions need the :images danger zone turned on.
|
|
31
|
+
#
|
|
32
|
+
# danger_zone turns on experimental features for this call, such as :images; it is a
|
|
33
|
+
# shortcut for request_options: { danger_zone: [...] }.
|
|
34
|
+
def system_one(state:, questions:, model: nil, assets: nil, danger_zone: nil, request_options: {})
|
|
35
|
+
options = RequestOptions.from(request_options, danger_zone: danger_zone)
|
|
36
|
+
collected = []
|
|
29
37
|
body = {
|
|
30
|
-
"state" => Questions::Normalizer.state(state),
|
|
38
|
+
"state" => Questions::Normalizer.state(state, assets: collected),
|
|
31
39
|
"model" => resolve_model(model),
|
|
32
|
-
"questions" => Questions::Normalizer.questions(questions)
|
|
40
|
+
"questions" => Questions::Normalizer.questions(questions, assets: collected)
|
|
33
41
|
}
|
|
34
42
|
body.merge!(options.extra_body) if options.extra_body
|
|
35
|
-
|
|
43
|
+
asset_map = Assets.resolve(collected, Assets.attach(assets), JSON.generate(body))
|
|
44
|
+
ensure_images_enabled!(asset_map, body["questions"], options)
|
|
45
|
+
response = @requestor.post(Constants::SYSTEM_ONE_PATH, body: body, options: options, assets: asset_map)
|
|
36
46
|
Responses::SystemOneResponse.from_http(response)
|
|
37
47
|
end
|
|
38
48
|
|
|
@@ -46,6 +56,23 @@ module TypeSafe
|
|
|
46
56
|
|
|
47
57
|
private
|
|
48
58
|
|
|
59
|
+
def ensure_images_enabled!(assets, questions, options)
|
|
60
|
+
return if assets.empty? && questions.none? { |_, question| question["type"] == Questions::Box::TYPE }
|
|
61
|
+
return if images_enabled?(options)
|
|
62
|
+
|
|
63
|
+
raise ValidationError,
|
|
64
|
+
"images and box questions are experimental; turn them on with danger_zone: :images"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def images_enabled?(options)
|
|
68
|
+
return true if (config.danger_zone + options.danger_zone).include?(:images)
|
|
69
|
+
|
|
70
|
+
header = Constants::Headers::DANGER_ZONE_IMAGES.downcase
|
|
71
|
+
[config.headers, options.headers].any? do |headers|
|
|
72
|
+
headers.any? { |name, value| name.downcase == header && value.to_s.casecmp?("true") }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
49
76
|
def resolve_model(model)
|
|
50
77
|
return config.model if model.nil?
|
|
51
78
|
raise ValidationError, "model must be a non-blank String" if Util.blank?(model)
|
|
@@ -28,9 +28,11 @@ module TypeSafe
|
|
|
28
28
|
attr_accessor :retry_policy
|
|
29
29
|
# Custom transport responding to #call(request); defaults to Net::HTTP.
|
|
30
30
|
attr_accessor :transport
|
|
31
|
+
# Experimental features to turn on for every request: :images and/or :long_context.
|
|
32
|
+
attr_accessor :danger_zone
|
|
31
33
|
|
|
32
34
|
def initialize(api_key: nil, base_url: nil, model: nil, timeout: nil, headers: nil, logger: nil,
|
|
33
|
-
log_level: nil, retry_policy: nil, transport: nil)
|
|
35
|
+
log_level: nil, retry_policy: nil, transport: nil, danger_zone: nil)
|
|
34
36
|
@api_key = api_key
|
|
35
37
|
@base_url = base_url
|
|
36
38
|
@model = model
|
|
@@ -40,13 +42,15 @@ module TypeSafe
|
|
|
40
42
|
@log_level = log_level
|
|
41
43
|
@retry_policy = retry_policy
|
|
42
44
|
@transport = transport
|
|
45
|
+
@danger_zone = danger_zone
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
# The explicitly set options, suitable for splatting into Client.new.
|
|
46
49
|
def to_h
|
|
47
50
|
{
|
|
48
51
|
api_key: api_key, base_url: base_url, model: model, timeout: timeout, headers: headers,
|
|
49
|
-
logger: logger, log_level: log_level, retry_policy: retry_policy, transport: transport
|
|
52
|
+
logger: logger, log_level: log_level, retry_policy: retry_policy, transport: transport,
|
|
53
|
+
danger_zone: danger_zone
|
|
50
54
|
}.compact
|
|
51
55
|
end
|
|
52
56
|
|
|
@@ -59,6 +63,7 @@ module TypeSafe
|
|
|
59
63
|
resolved.timeout = resolve_timeout
|
|
60
64
|
resolved.headers = resolve_headers
|
|
61
65
|
resolved.retry_policy = RetryPolicy.from(retry_policy)
|
|
66
|
+
resolved.danger_zone = resolve_danger_zone
|
|
62
67
|
resolve_logging(resolved, env)
|
|
63
68
|
resolved.freeze
|
|
64
69
|
end
|
|
@@ -107,6 +112,12 @@ module TypeSafe
|
|
|
107
112
|
(headers || {}).each_with_object({}) { |(name, value), result| result[name.to_s] = value.to_s }
|
|
108
113
|
end
|
|
109
114
|
|
|
115
|
+
def resolve_danger_zone
|
|
116
|
+
Util.danger_zones(danger_zone).freeze
|
|
117
|
+
rescue ArgumentError => e
|
|
118
|
+
raise ConfigurationError, e.message
|
|
119
|
+
end
|
|
120
|
+
|
|
110
121
|
def resolve_logging(resolved, env)
|
|
111
122
|
resolved.log_level = resolve_log_level(env)
|
|
112
123
|
resolved.logger = logger || build_logger(resolved.log_level)
|
data/lib/typesafe/constants.rb
CHANGED
|
@@ -44,6 +44,14 @@ module TypeSafe
|
|
|
44
44
|
RUNTIME = "X-TypeSafe-Runtime"
|
|
45
45
|
RETRY_COUNT = "X-TypeSafe-Retry-Count"
|
|
46
46
|
REQUEST_ID = "x-typesafe-request-id"
|
|
47
|
+
DANGER_ZONE_IMAGES = "TypeSafe-Danger-Zone-Images"
|
|
48
|
+
DANGER_ZONE_LONG_CONTEXT = "TypeSafe-Danger-Zone-Long-Context"
|
|
47
49
|
end
|
|
50
|
+
|
|
51
|
+
# Experimental features that must be turned on per request with a TypeSafe-Danger-Zone-* header.
|
|
52
|
+
DANGER_ZONES = {
|
|
53
|
+
images: Headers::DANGER_ZONE_IMAGES,
|
|
54
|
+
long_context: Headers::DANGER_ZONE_LONG_CONTEXT
|
|
55
|
+
}.freeze
|
|
48
56
|
end
|
|
49
57
|
end
|
data/lib/typesafe/errors.rb
CHANGED
|
@@ -65,6 +65,10 @@ module TypeSafe
|
|
|
65
65
|
class PermissionDeniedError < APIError; end
|
|
66
66
|
# The resource was not found (404).
|
|
67
67
|
class NotFoundError < APIError; end
|
|
68
|
+
# The request body or an image is over a size limit (413).
|
|
69
|
+
class PayloadTooLargeError < APIError; end
|
|
70
|
+
# The request used an unsupported Content-Encoding (415).
|
|
71
|
+
class UnsupportedMediaTypeError < APIError; end
|
|
68
72
|
# The request failed server-side validation (422).
|
|
69
73
|
class UnprocessableEntityError < APIError; end
|
|
70
74
|
# The server failed to process the request (5xx).
|
|
@@ -96,6 +100,8 @@ module TypeSafe
|
|
|
96
100
|
401 => AuthenticationError,
|
|
97
101
|
403 => PermissionDeniedError,
|
|
98
102
|
404 => NotFoundError,
|
|
103
|
+
413 => PayloadTooLargeError,
|
|
104
|
+
415 => UnsupportedMediaTypeError,
|
|
99
105
|
422 => UnprocessableEntityError,
|
|
100
106
|
429 => RateLimitError,
|
|
101
107
|
529 => OverloadedError
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module TypeSafe
|
|
6
|
+
module HTTP
|
|
7
|
+
# Encodes a multipart/form-data body: the JSON request as the first part, named
|
|
8
|
+
# "request", followed by one file part per image asset.
|
|
9
|
+
class MultipartEncoder
|
|
10
|
+
Part = Struct.new(:name, :content_type, :content, keyword_init: true)
|
|
11
|
+
Encoded = Struct.new(:body, :content_type, :summary, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Encode a request body Hash with its assets (reference => Image). The assets map in the
|
|
14
|
+
# JSON points each reference at its file part, named asset-0, asset-1 and so on.
|
|
15
|
+
def self.build(body, assets)
|
|
16
|
+
parts = parts_for(assets)
|
|
17
|
+
entries = assets.keys.zip(parts).to_h { |ref, part| [ref, { "type" => "image", "part" => part.name }] }
|
|
18
|
+
json = JSON.generate(body.merge("assets" => entries))
|
|
19
|
+
encoder = new
|
|
20
|
+
Encoded.new(body: encoder.encode(json, parts), content_type: encoder.content_type,
|
|
21
|
+
summary: summary_for(json, parts))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.parts_for(assets)
|
|
25
|
+
assets.each_value.with_index.map do |image, index|
|
|
26
|
+
Part.new(name: "asset-#{index}", content_type: image.content_type, content: image.bytes)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.summary_for(json, parts)
|
|
31
|
+
"#{json} + #{parts.map { |p| "#{p.name} (#{p.content_type}, #{p.content.bytesize} bytes)" }.join(", ")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :boundary
|
|
35
|
+
|
|
36
|
+
def initialize(boundary: SecureRandom.hex(16))
|
|
37
|
+
@boundary = boundary
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_type
|
|
41
|
+
"multipart/form-data; boundary=#{boundary}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The encoded body as a binary String.
|
|
45
|
+
def encode(json, parts)
|
|
46
|
+
body = +"".b
|
|
47
|
+
append(body, "request", "application/json", json)
|
|
48
|
+
parts.each { |part| append(body, part.name, part.content_type, part.content, filename: part.name) }
|
|
49
|
+
body << "--#{boundary}--\r\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def append(body, name, content_type, content, filename: nil)
|
|
55
|
+
disposition = %(form-data; name="#{name}")
|
|
56
|
+
disposition += %(; filename="#{filename}") if filename
|
|
57
|
+
body << "--#{boundary}\r\n"
|
|
58
|
+
body << "Content-Disposition: #{disposition}\r\n"
|
|
59
|
+
body << "Content-Type: #{content_type}\r\n\r\n"
|
|
60
|
+
body << content.b
|
|
61
|
+
body << "\r\n"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -5,13 +5,16 @@ module TypeSafe
|
|
|
5
5
|
# An outgoing HTTP request: method, absolute URI, headers, encoded body and per-attempt timeout.
|
|
6
6
|
class Request
|
|
7
7
|
attr_reader :method, :uri, :headers, :body, :timeout
|
|
8
|
+
# What to show for the body in debug logs; multipart bodies summarize their file parts.
|
|
9
|
+
attr_reader :body_summary
|
|
8
10
|
|
|
9
|
-
def initialize(method:, uri:, headers:, body:, timeout:)
|
|
11
|
+
def initialize(method:, uri:, headers:, body:, timeout:, body_summary: nil)
|
|
10
12
|
@method = method
|
|
11
13
|
@uri = uri
|
|
12
14
|
@headers = headers.freeze
|
|
13
15
|
@body = body
|
|
14
16
|
@timeout = timeout
|
|
17
|
+
@body_summary = body_summary || body
|
|
15
18
|
freeze
|
|
16
19
|
end
|
|
17
20
|
|
|
@@ -23,7 +26,8 @@ module TypeSafe
|
|
|
23
26
|
|
|
24
27
|
# A copy with additional headers merged in.
|
|
25
28
|
def with_headers(extra)
|
|
26
|
-
self.class.new(method: method, uri: uri, headers: headers.merge(extra), body: body, timeout: timeout
|
|
29
|
+
self.class.new(method: method, uri: uri, headers: headers.merge(extra), body: body, timeout: timeout,
|
|
30
|
+
body_summary: body_summary)
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -20,9 +20,10 @@ module TypeSafe
|
|
|
20
20
|
@clock = clock
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
# Send a POST and return the successful response.
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
# Send a POST and return the successful response. With assets (reference => Image), the
|
|
24
|
+
# body goes out as multipart/form-data with one file part per image.
|
|
25
|
+
def post(path, body:, options: RequestOptions.new, assets: {})
|
|
26
|
+
execute(build_request(:post, path, body: body, options: options, assets: assets), options)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Send a GET and return the successful response.
|
|
@@ -97,28 +98,43 @@ module TypeSafe
|
|
|
97
98
|
RetryPolicy.from(options.retry_policy, base: config.retry_policy)
|
|
98
99
|
end
|
|
99
100
|
|
|
100
|
-
def build_request(method, path, body: nil, options: RequestOptions.new)
|
|
101
|
-
encoded
|
|
101
|
+
def build_request(method, path, body: nil, options: RequestOptions.new, assets: {})
|
|
102
|
+
encoded, content_type, summary = encode_body(body, assets)
|
|
102
103
|
Request.new(
|
|
103
104
|
method: method,
|
|
104
105
|
uri: URI.parse("#{config.base_url}#{path}"),
|
|
105
|
-
headers: build_headers(options.headers,
|
|
106
|
+
headers: build_headers(options.headers, content_type: content_type, danger_zone: options.danger_zone),
|
|
106
107
|
body: encoded,
|
|
108
|
+
body_summary: summary,
|
|
107
109
|
timeout: options.timeout || config.timeout
|
|
108
110
|
)
|
|
109
111
|
end
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
# [body, content type, log summary] for a JSON body, or a multipart body when assets are present.
|
|
114
|
+
def encode_body(body, assets)
|
|
115
|
+
return [nil, nil, nil] if body.nil?
|
|
116
|
+
return [JSON.generate(body), Constants::JSON_CONTENT_TYPE, nil] if assets.empty?
|
|
117
|
+
|
|
118
|
+
encoded = MultipartEncoder.build(body, assets)
|
|
119
|
+
[encoded.body, encoded.content_type, encoded.summary]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_headers(extra, content_type:, danger_zone: [])
|
|
112
123
|
headers = strip_protected(config.headers).merge(strip_protected(extra))
|
|
124
|
+
headers.merge!(danger_zone_headers(danger_zone))
|
|
113
125
|
headers[Constants::Headers::AUTHORIZATION] = "Bearer #{config.api_key}"
|
|
114
126
|
headers[Constants::Headers::ACCEPT] = Constants::JSON_CONTENT_TYPE
|
|
115
127
|
headers[Constants::Headers::USER_AGENT] = sdk_identifier
|
|
116
128
|
headers[Constants::Headers::SDK] = sdk_identifier
|
|
117
129
|
headers[Constants::Headers::RUNTIME] = Util.runtime
|
|
118
|
-
headers[Constants::Headers::CONTENT_TYPE] =
|
|
130
|
+
headers[Constants::Headers::CONTENT_TYPE] = content_type if content_type
|
|
119
131
|
headers
|
|
120
132
|
end
|
|
121
133
|
|
|
134
|
+
def danger_zone_headers(zones)
|
|
135
|
+
(config.danger_zone + zones).uniq.to_h { |zone| [Constants::DANGER_ZONES.fetch(zone), "true"] }
|
|
136
|
+
end
|
|
137
|
+
|
|
122
138
|
def strip_protected(headers)
|
|
123
139
|
headers.reject { |name, _| PROTECTED_HEADERS.include?(name.downcase) }
|
|
124
140
|
end
|
|
@@ -129,7 +145,8 @@ module TypeSafe
|
|
|
129
145
|
|
|
130
146
|
def log_request(request)
|
|
131
147
|
Logging.debug(config) do
|
|
132
|
-
"-> #{request.endpoint} headers={#{Logging.format_headers(request.headers)}}
|
|
148
|
+
"-> #{request.endpoint} headers={#{Logging.format_headers(request.headers)}} " \
|
|
149
|
+
"body=#{request.body_summary.inspect}"
|
|
133
150
|
end
|
|
134
151
|
end
|
|
135
152
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module TypeSafe
|
|
6
|
+
# A PNG or JPEG image to send with a request. Images are an experimental API feature and
|
|
7
|
+
# need the :images danger zone turned on.
|
|
8
|
+
#
|
|
9
|
+
# Build one from file bytes, a path (read when the request is sent), or base64 text, then
|
|
10
|
+
# place it anywhere text can go in state, instructions or criteria. Each image is sent once
|
|
11
|
+
# under its reference, and to_s returns that reference so an image can be written into a
|
|
12
|
+
# sentence; images used that way must be passed in assets: as well.
|
|
13
|
+
#
|
|
14
|
+
# For example:
|
|
15
|
+
# door = TypeSafe::Image.open("door-camera.jpg")
|
|
16
|
+
# client.system_one(
|
|
17
|
+
# state: { door: door },
|
|
18
|
+
# questions: { person: { type: :noul, instructions: "Is there a person in `door`?" } },
|
|
19
|
+
# danger_zone: :images
|
|
20
|
+
# )
|
|
21
|
+
class Image
|
|
22
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
23
|
+
REFERENCE_PATTERN = /<image:[0-9a-f]{32}>/
|
|
24
|
+
SIGNATURES = {
|
|
25
|
+
"image/png" => "\x89PNG\r\n\x1a\n".b,
|
|
26
|
+
"image/jpeg" => "\xFF\xD8\xFF".b
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# The unique string this image is sent under, such as "<image:3f2a...>".
|
|
30
|
+
attr_reader :reference
|
|
31
|
+
# The path the image is read from, when built with Image.open.
|
|
32
|
+
attr_reader :path
|
|
33
|
+
|
|
34
|
+
# An image read from a file when the request is sent.
|
|
35
|
+
def self.open(path)
|
|
36
|
+
new(nil, path: path.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An image from base64 text, such as an upload. Whitespace is ignored; a data: prefix is not allowed.
|
|
40
|
+
def self.from_base64(text)
|
|
41
|
+
raise ValidationError, "base64 image data must be a String, got #{text.class}" unless text.is_a?(String)
|
|
42
|
+
|
|
43
|
+
compact = text.gsub(/\s+/, "")
|
|
44
|
+
bytes = compact.unpack1("m0") # strict base64, the standard alphabet with padding
|
|
45
|
+
new(bytes)
|
|
46
|
+
rescue ArgumentError
|
|
47
|
+
raise ValidationError, "base64 image data is not valid base64"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether a string is an image reference.
|
|
51
|
+
def self.reference?(string)
|
|
52
|
+
string.is_a?(String) && REFERENCE_PATTERN.match?(string) && string.match(REFERENCE_PATTERN)[0] == string
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(bytes = nil, path: nil)
|
|
56
|
+
raise ArgumentError, "pass either image bytes or a path" if bytes.nil? == path.nil?
|
|
57
|
+
|
|
58
|
+
@path = path
|
|
59
|
+
@bytes = bytes.nil? ? nil : validate_bytes(bytes)
|
|
60
|
+
@reference = "<image:#{SecureRandom.hex(16)}>"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The image file bytes, read from the path on first use.
|
|
64
|
+
def bytes
|
|
65
|
+
@bytes ||= validate_bytes(read_file)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def bytesize
|
|
69
|
+
bytes.bytesize
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# "image/png" or "image/jpeg", from the file signature.
|
|
73
|
+
def content_type
|
|
74
|
+
SIGNATURES.find { |_, signature| bytes.start_with?(signature) }&.first ||
|
|
75
|
+
raise(ValidationError, "#{describe} is not a PNG or JPEG file")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# "png" or "jpeg".
|
|
79
|
+
def format
|
|
80
|
+
content_type.delete_prefix("image/")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check that the bytes are a PNG or JPEG within the size limit; raises ValidationError otherwise.
|
|
84
|
+
def validate!
|
|
85
|
+
content_type
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_s
|
|
90
|
+
reference
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_json(*args)
|
|
94
|
+
reference.to_json(*args)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inspect
|
|
98
|
+
"#<#{self.class.name} #{path ? path.inspect : "#{@bytes&.bytesize || "?"} bytes"} #{reference}>"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def read_file
|
|
104
|
+
File.binread(path)
|
|
105
|
+
rescue SystemCallError => e
|
|
106
|
+
raise ValidationError, "cannot read image #{path.inspect}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_bytes(data)
|
|
110
|
+
raise ValidationError, "image data must be a String of bytes, got #{data.class}" unless data.is_a?(String)
|
|
111
|
+
raise ValidationError, "#{describe} is empty" if data.empty?
|
|
112
|
+
if data.bytesize > MAX_BYTES
|
|
113
|
+
raise ValidationError, "#{describe} is #{data.bytesize} bytes; the limit is #{MAX_BYTES}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
data.b.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def describe
|
|
120
|
+
path ? "image #{path.inspect}" : "image data"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|