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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21d48754dd7b9582a87e2905f23d01de27c9d8e8f47a14c3eaf5e7f5bccf2e19
4
- data.tar.gz: '0958ee9c8ef35da5b53b90dbacfdf3012ceaef7f79c27bf244ff50803deab56c'
3
+ metadata.gz: 7f892e52635bd8e5f079a2b3f6c9240cff084c3fdb61a11b80e0b4b347708e28
4
+ data.tar.gz: 00b8ecee6d1afef19586bc64887347a2c0b9b4bfe80b26651c2e0cfaddd147c4
5
5
  SHA512:
6
- metadata.gz: 6bb1ef123a99b204a3dae6e22ff9567494d328c2ec1bd5ed47061554aa2ac2d2c2b76f796f53cd40cae2f4821d9dee4f7f940b3695b3e34446ef176c922f968c
7
- data.tar.gz: 95750fac853da2683a7c41b452518bb027ca2696552fc7312b8075377c6ac47668fc55242488d52ce26328d2e96f70f4140fe7aeb31ea4adc98aba5abc68a4c1
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.1.0...HEAD
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/settings/keys) and set it as `TYPESAFE_API_KEY`, or pass it to the client directly.
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(api_key: ENV["TYPESAFE_API_KEY"])
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: TypeSafe::Choice.new(
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: TypeSafe::Score.new(
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: TypeSafe::Noul.new(instructions: "Does the message convey urgency?")
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
- There are three question types. `instructions` and every description can be a string, a Hash, or an Array when a sentence is not enough.
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
- - `TypeSafe::Noul` asks a yes/no question and returns the probability of yes. Criteria are optional: `criteria: { true: "Spam", false: "A real conversation" }`.
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
- The shorthand helpers take the instructions first:
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.noul("Is this spam?")
71
- TypeSafe.choice("What is the tone?", calm: nil, frustrated: nil, angry: nil)
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
- You can also pass a plain Hash with a `type` key. Extra keys are sent to the API untouched, so new API fields work before this library knows about them.
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") # 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: TypeSafe.noul("Is this 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
@@ -23,16 +23,26 @@ module TypeSafe
23
23
  @models = Resources::Models.new(@requestor)
24
24
  end
25
25
 
26
- # Answer named questions about text or structured state.
27
- def system_one(state:, questions:, model: nil, request_options: {})
28
- options = RequestOptions.from(request_options)
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
- response = @requestor.post(Constants::SYSTEM_ONE_PATH, body: body, options: options)
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)
@@ -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
@@ -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
- def post(path, body:, options: RequestOptions.new)
25
- execute(build_request(:post, path, body: body, options: options), options)
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 = body.nil? ? nil : JSON.generate(body)
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, body: encoded),
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
- def build_headers(extra, body:)
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] = Constants::JSON_CONTENT_TYPE if body
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)}} body=#{request.body.inspect}"
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