typesafe-sdk-ruby 0.6.0 → 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.
@@ -2,63 +2,102 @@
2
2
 
3
3
  module Typesafe
4
4
  module SDK
5
- # Builders and validation for the three question types.
5
+ # Builders and validation for the noul, score, choice, and box question types.
6
6
  module Questions
7
+ MAX_SCORE_LEVELS = 10
8
+ MAX_CHOICE_OPTIONS = 255
9
+
7
10
  module_function
8
11
 
9
- # Create a yes/no question with optional descriptions for either outcome.
10
- #
11
- # @param instructions [String, Hash, Array, nil] the question; defaults to `nil`.
12
- # @param criteria [Hash, nil] optional descriptions of the yes and no outcomes.
12
+ # Create a yes/no question. Supply instructions or at least one outcome description.
13
+ # @param instructions [String, Hash, Array, Image, TextWithAssets, nil]
14
+ # @param criteria [Hash, nil] optional true/false outcome descriptions.
13
15
  def noul(instructions = nil, criteria: nil)
14
- { type: "noul", instructions: instructions, criteria: criteria }
16
+ question = { type: "noul", instructions: instructions, criteria: criteria }
17
+ validate_noul!(instructions, criteria, "noul")
18
+ question
15
19
  end
16
20
 
17
- # Create a score question using an ordered rubric.
18
- #
19
- # @param instructions [String, Hash, Array, nil] the question.
20
- # @param criteria [Array] at least two descriptions indexed by score from zero.
21
+ # Create a score question with 2–10 ordered descriptions. Nil levels are not supported.
22
+ # An empty string preserves a position without a description.
21
23
  def score(instructions, criteria)
22
- unless criteria.is_a?(Array)
23
- raise TypeSafeError,
24
- "Score criteria must be a list of descriptions indexed by score from zero, " \
25
- "not a map."
26
- end
27
-
24
+ validate_score!(criteria, "score")
28
25
  { type: "score", instructions: instructions, criteria: criteria }
29
26
  end
30
27
 
31
- # Create a question that selects between named alternatives.
32
- #
33
- # @param instructions [String, Hash, Array, nil] the question.
34
- # @param criteria [Hash] labels mapped to descriptions, or `nil` for undescribed labels.
28
+ # Create a choice between 1–255 named options; nil leaves a label undescribed.
35
29
  def choice(instructions, criteria)
36
- if criteria.is_a?(Array)
37
- raise TypeSafeError, "Choice criteria must be a map of labels to descriptions, not a list."
38
- end
39
-
30
+ validate_choice!(criteria, "choice")
40
31
  { type: "choice", instructions: instructions, criteria: criteria }
41
32
  end
42
33
 
43
- # Reject empty question sets and score questions without a list of at least two criteria.
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
+
43
+ def validate_state!(state)
44
+ return if STATE_TYPES.any? { |type| state.is_a?(type) }
45
+
46
+ raise TypeSafeError, "`state` must be a string, object, array, or image; nil is not supported."
47
+ end
48
+
49
+ # Validate builders and raw JSON-style questions without altering the caller's data.
44
50
  def validate!(questions)
45
- raise TypeSafeError, "At least one question is required." if questions.empty?
51
+ raise TypeSafeError, "At least one question is required." unless questions.is_a?(Hash) && !questions.empty?
46
52
 
47
53
  questions.each do |name, question|
48
- next unless question.is_a?(Hash) && question[:type] == "score"
54
+ raise TypeSafeError, "Question key cannot be empty." if name.to_s.empty?
55
+ raise TypeSafeError, "Question must be an object." unless question.is_a?(Hash)
49
56
 
50
- criteria = question[:criteria]
51
- unless criteria.is_a?(Array)
52
- raise TypeSafeError,
53
- "Score question \"#{name}\" has criteria that are not a list; " \
54
- "score criteria must be a list of descriptions indexed by score from zero."
57
+ wire = question.transform_keys(&:to_s)
58
+ case wire["type"].to_s
59
+ when "score" then validate_score!(wire["criteria"], name)
60
+ when "choice" then validate_choice!(wire["criteria"], name)
61
+ when "noul" then validate_noul!(wire["instructions"], wire["criteria"], name)
55
62
  end
56
- next unless criteria.length < 2
63
+ end
64
+ end
65
+
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
+
71
+ descriptions = criteria.is_a?(Hash) ? criteria.transform_keys(&:to_s) : {}
72
+ return unless instructions.nil? && %w[true false].all? { |key| descriptions[key].nil? }
57
73
 
74
+ raise TypeSafeError, "Noul question #{name.inspect} must have instructions or criteria."
75
+ end
76
+
77
+ def validate_score!(criteria, name)
78
+ unless criteria.is_a?(Array)
58
79
  raise TypeSafeError,
59
- "Score question \"#{name}\" has #{criteria.length} criteria; " \
60
- "at least two scores are required."
80
+ "Score question #{name.inspect} has criteria that are not a list; " \
81
+ "score criteria must be a list of descriptions indexed by score from zero."
61
82
  end
83
+ raise TypeSafeError, "Score question #{name.inspect}: at least two scores are required." if criteria.length < 2
84
+ if criteria.length > MAX_SCORE_LEVELS
85
+ raise TypeSafeError, "Score question #{name.inspect}: at most #{MAX_SCORE_LEVELS} levels are allowed."
86
+ end
87
+ return unless criteria.any?(&:nil?)
88
+
89
+ raise TypeSafeError,
90
+ "Score question #{name.inspect} contains a nil level; use a description or an empty string " \
91
+ "to preserve its position."
92
+ end
93
+
94
+ def validate_choice!(criteria, name)
95
+ unless criteria.is_a?(Hash)
96
+ raise TypeSafeError, "Choice criteria must be a map of labels to descriptions, not a list."
97
+ end
98
+ return if criteria.length.between?(1, MAX_CHOICE_OPTIONS)
99
+
100
+ raise TypeSafeError, "Choice question #{name.inspect} requires 1–#{MAX_CHOICE_OPTIONS} options."
62
101
  end
63
102
  end
64
103
  end
@@ -11,11 +11,12 @@ module Typesafe
11
11
 
12
12
  # List the models available to the account.
13
13
  #
14
- # @param options [Hash] per-call `timeout`, `retry`, `headers`, and `signal` settings.
15
- # @return [Array<ModelCard>]
14
+ # @param options [Hash] per-call `timeout`, `retry_policy`, `headers`, and `signal` settings.
15
+ # @param with_response [Boolean] return model cards with HTTP metadata and request ID.
16
+ # @return [Array<ModelCard>, WithResponse]
16
17
  # @raise [TypeSafeError] the response shape is unexpected.
17
18
  # @raise [APIError] the server returns a non-2xx response after retries.
18
- def list(**options)
19
+ def list(with_response: false, **options)
19
20
  response = @client.request(:get, "/v1/models", **options)
20
21
  wire = response.body
21
22
  unless wire.is_a?(Hash) && wire["models"].is_a?(Array)
@@ -23,7 +24,8 @@ module Typesafe
23
24
  "Unexpected response shape from GET /v1/models; expected { models: [...] }."
24
25
  end
25
26
 
26
- wire["models"].map { |card| ModelCard.new(card) }
27
+ result = wire["models"].map { |card| ModelCard.new(card) }
28
+ with_response ? WithResponse.new(result, response) : result
27
29
  end
28
30
  end
29
31
  end
@@ -38,15 +38,15 @@ module Typesafe
38
38
  return nil unless headers.respond_to?(:[])
39
39
 
40
40
  if (ms = headers["retry-after-ms"])
41
- parsed = Integer(ms, exception: false)
42
- return parsed if parsed && parsed >= 0
41
+ parsed = Float(ms, exception: false) unless ms.to_s.strip.empty?
42
+ return parsed if parsed&.finite? && parsed >= 0
43
43
  end
44
44
 
45
45
  raw = headers["retry-after"]
46
- return nil if raw.nil?
46
+ return nil if raw.nil? || raw.to_s.strip.empty?
47
47
 
48
48
  seconds = Float(raw, exception: false)
49
- return (seconds * 1000).round if seconds && seconds >= 0
49
+ return seconds >= 0 ? seconds * 1000 : nil if seconds&.finite?
50
50
 
51
51
  date = begin
52
52
  Time.parse(raw)
@@ -2,7 +2,33 @@
2
2
 
3
3
  module Typesafe
4
4
  module SDK
5
- # Token usage for a request.
5
+ # Parsed data together with HTTP metadata, like the JS SDK withResponse() result.
6
+ class WithResponse
7
+ attr_reader :data, :response, :request_id
8
+
9
+ def initialize(data, response)
10
+ @data = data
11
+ @response = response
12
+ @request_id = response.request_id
13
+ end
14
+ end
15
+
16
+ # An answer, keeping every field the API returned, including fields not modeled here.
17
+ class Answer
18
+ attr_reader :type
19
+
20
+ def initialize(data)
21
+ @data = data
22
+ @type = data["type"]
23
+ end
24
+
25
+ # The answer as the API returned it, with string keys.
26
+ def to_h
27
+ @data.dup
28
+ end
29
+ end
30
+
31
+ # Token usage for a request. Each count is `nil` when the API did not report it.
6
32
  class Usage
7
33
  attr_reader :input_tokens, :output_tokens
8
34
 
@@ -13,21 +39,21 @@ module Typesafe
13
39
  end
14
40
 
15
41
  # A yes/no answer.
16
- class NoulResponse
17
- attr_reader :type, :noul
42
+ class NoulResponse < Answer
43
+ attr_reader :noul
18
44
 
19
45
  def initialize(data)
20
- @type = data["type"]
46
+ super
21
47
  @noul = data["noul"]
22
48
  end
23
49
  end
24
50
 
25
51
  # A selected label with its probabilities.
26
- class ChoiceResponse
27
- attr_reader :type, :choice, :confidence, :probabilities
52
+ class ChoiceResponse < Answer
53
+ attr_reader :choice, :confidence, :probabilities
28
54
 
29
55
  def initialize(data)
30
- @type = data["type"]
56
+ super
31
57
  @choice = data["choice"]
32
58
  @confidence = data["confidence"]
33
59
  @probabilities = data["probabilities"]
@@ -35,11 +61,11 @@ module Typesafe
35
61
  end
36
62
 
37
63
  # An expected score with its rubric and probabilities.
38
- class ScoreResponse
39
- attr_reader :type, :score, :confidence, :legend, :probabilities
64
+ class ScoreResponse < Answer
65
+ attr_reader :score, :confidence, :legend, :probabilities
40
66
 
41
67
  def initialize(data)
42
- @type = data["type"]
68
+ super
43
69
  @score = data["score"]
44
70
  @confidence = data["confidence"]
45
71
  @legend = data["legend"]
@@ -47,12 +73,44 @@ module Typesafe
47
73
  end
48
74
  end
49
75
 
76
+ # A box's edges as fractions of the image's height (`top`, `bottom`) and width
77
+ # (`left`, `right`), each from zero to one.
78
+ class BoxCoordinates
79
+ attr_reader :top, :bottom, :left, :right
80
+
81
+ def initialize(data)
82
+ @top = data["top"]
83
+ @bottom = data["bottom"]
84
+ @left = data["left"]
85
+ @right = data["right"]
86
+ end
87
+
88
+ def to_h
89
+ { "top" => top, "bottom" => bottom, "left" => left, "right" => right }
90
+ end
91
+ end
92
+
93
+ # The answer to a box question.
94
+ class BoxResponse < Answer
95
+ # Where the instructions' subject is in the image, or `nil` when absent.
96
+ attr_reader :box
97
+ # Confidence in the box, from zero to one, or `nil` when the API didn't report it.
98
+ attr_reader :confidence
99
+
100
+ def initialize(data)
101
+ super
102
+ @box = data["box"].is_a?(Hash) ? BoxCoordinates.new(data["box"]) : nil
103
+ @confidence = data["confidence"]
104
+ end
105
+ end
106
+
50
107
  # Builds the answer object matching the question type.
51
108
  module ResponseFactory
52
109
  CLASSES = {
53
110
  "noul" => NoulResponse,
54
111
  "choice" => ChoiceResponse,
55
- "score" => ScoreResponse
112
+ "score" => ScoreResponse,
113
+ "box" => BoxResponse
56
114
  }.freeze
57
115
 
58
116
  module_function
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Typesafe
4
4
  module SDK
5
- # Keep in sync with the gemspec version. Checked by the version consistency spec.
6
- VERSION = "0.6.0"
5
+ # Ruby-only fixes use a fourth numeric component; the first three track upstream.
6
+ VERSION = "0.7.0"
7
+ UPSTREAM_VERSION = "0.7.0"
7
8
  end
8
9
  end
@@ -5,6 +5,8 @@ require_relative "typesafe/sdk/env"
5
5
  require_relative "typesafe/sdk/logging"
6
6
  require_relative "typesafe/sdk/retry"
7
7
  require_relative "typesafe/sdk/errors"
8
+ require_relative "typesafe/sdk/assets"
9
+ require_relative "typesafe/sdk/compression"
8
10
  require_relative "typesafe/sdk/questions"
9
11
  require_relative "typesafe/sdk/http"
10
12
  require_relative "typesafe/sdk/types"
@@ -36,5 +38,33 @@ module Typesafe
36
38
  def choice(instructions, criteria)
37
39
  Questions.choice(instructions, criteria)
38
40
  end
41
+
42
+ # Create a box question. @see Questions.box
43
+ def box(instructions = nil)
44
+ Questions.box(instructions)
45
+ end
46
+
47
+ # Create a PNG or JPEG image asset from file bytes, a Pathname, or an IO.
48
+ # Use {Image.from_base64} for base64 text. @see Image#initialize
49
+ def image(data)
50
+ Image.new(data)
51
+ end
52
+
53
+ # Join text and images into text that carries its images, like JavaScript's `withAssets`
54
+ # tagged template. Other values are converted with `to_s`, as string interpolation does.
55
+ #
56
+ # @example
57
+ # Typesafe::SDK.with_assets("Does ", photo, " show the product in ", listing, "?")
58
+ # @return [TextWithAssets]
59
+ def with_assets(*parts)
60
+ TextWithAssets.build(*parts)
61
+ end
62
+
63
+ # Join `values` with `separator` (default `","`, as in JavaScript's `withAssets.join`) into
64
+ # text that carries the images among them.
65
+ # @return [TextWithAssets]
66
+ def join_assets(values, separator = ",")
67
+ TextWithAssets.join(values, separator)
68
+ end
39
69
  end
40
70
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typesafe-sdk-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TypeSafe Ruby SDK Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-20 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -17,6 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '2.7'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -24,12 +27,15 @@ dependencies:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '2.7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
27
33
  description: |-
28
34
  Unofficial Ruby SDK for the TypeSafe AI API (Jev model). Ask named questions about
29
- text or structured state and get typed answers: yes/no (noul), choice, and score
30
- questions, with retries, timeouts, structured logging, and typed errors.
35
+ text, structured state, or images and get typed answers: yes/no (noul), choice, score,
36
+ and box questions, with retries, timeouts, request compression, structured logging, and
37
+ typed errors.
31
38
  email:
32
- - opensource@typesafe.ai
33
39
  executables: []
34
40
  extensions: []
35
41
  extra_rdoc_files: []
@@ -37,8 +43,13 @@ files:
37
43
  - CHANGELOG.md
38
44
  - LICENSE
39
45
  - README.md
46
+ - docs/COMPATIBILITY.md
47
+ - docs/RELEASING.md
48
+ - docs/UPSTREAM_ISSUES.md
40
49
  - lib/typesafe-sdk-ruby.rb
50
+ - lib/typesafe/sdk/assets.rb
41
51
  - lib/typesafe/sdk/client.rb
52
+ - lib/typesafe/sdk/compression.rb
42
53
  - lib/typesafe/sdk/env.rb
43
54
  - lib/typesafe/sdk/errors.rb
44
55
  - lib/typesafe/sdk/http.rb
@@ -54,8 +65,7 @@ licenses:
54
65
  metadata:
55
66
  bug_tracker_uri: https://github.com/afurm/typesafe-sdk-ruby/issues
56
67
  changelog_uri: https://github.com/afurm/typesafe-sdk-ruby/blob/main/CHANGELOG.md
57
- documentation_uri: https://docs.typesafe.ai/
58
- homepage_uri: https://github.com/afurm/typesafe-sdk-ruby
68
+ documentation_uri: https://github.com/afurm/typesafe-sdk-ruby#readme
59
69
  rubygems_mfa_required: 'true'
60
70
  source_code_uri: https://github.com/afurm/typesafe-sdk-ruby
61
71
  post_install_message: