jevrb 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b6cbd6d5e5d64de85540ed6e81c4138c52513d71d65d25d0e2392450aa9c8f31
4
+ data.tar.gz: c3854400cd7512cc76db614f12fb29806b34d718d8b5642f76dfe2c1d9f3bbcd
5
+ SHA512:
6
+ metadata.gz: eae41617e1af7c67c64a47daba002b1ba46b48aef4e4a791cdedd9b6bfa864037c3d323893c8e850f15595b3ffd9203bd017c9af7fde803f8752409e02371e44
7
+ data.tar.gz: 74c0c33ae5996a765cb8c8c1bd5b2e97dff653d13e0cf62a118060935c6743bcf61069cb931b82373db5902b802897e3edfe6321613966e648dae23fc9d963a8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maful
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # Jevrb
2
+
3
+ Jevrb is a synchronous Ruby client for the TypeSafe System One API. It returns immutable Ruby objects for every documented answer type.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.2 or later.
8
+ - A TypeSafe API key.
9
+
10
+ ## Installation
11
+
12
+ Add the gem to your `Gemfile`:
13
+
14
+ ```ruby
15
+ gem "jevrb"
16
+ ```
17
+
18
+ Install the bundle:
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ Set the API key:
27
+
28
+ ```bash
29
+ export TYPESAFE_API_KEY="your-api-key"
30
+ ```
31
+
32
+ Create a client and send a request:
33
+
34
+ ```ruby
35
+ require "jevrb"
36
+
37
+ client = Jev::Client.new
38
+
39
+ result = client.system_one(
40
+ "Help! My payouts have been failing for 3 days.",
41
+ questions: {
42
+ is_urgent: Jev::Noul.build(
43
+ instructions: "Does this convey urgency?"
44
+ ),
45
+ department: Jev::Choice.build(
46
+ instructions: "Which team must handle this?",
47
+ criteria: {
48
+ billing: "Payments, invoicing, and refunds",
49
+ technical: "Bugs, outages, and integrations"
50
+ }
51
+ ),
52
+ frustration: Jev::Score.build(
53
+ instructions: "How frustrated is the customer?",
54
+ criteria: ["Calm", "Frustrated", "Very angry"]
55
+ )
56
+ }
57
+ )
58
+ ```
59
+
60
+ ## Read the result
61
+
62
+ Each answer is a typed object:
63
+
64
+ ```ruby
65
+ result # Jev::Result
66
+ result.model # "jev-1.13.0"
67
+ result.usage # Jev::Usage
68
+ result.answers[:is_urgent] # Jev::NoulAnswer
69
+ result.answers[:department] # Jev::ChoiceAnswer
70
+ result.answers[:frustration] # Jev::ScoreAnswer
71
+ ```
72
+
73
+ Use the grouped accessors when a request contains different question types:
74
+
75
+ ```ruby
76
+ result.nouls[:is_urgent].noul
77
+
78
+ result.choices[:department].choice
79
+ result.choices[:department].probabilities
80
+ result.choices[:department].confidence
81
+
82
+ result.scores[:frustration].score
83
+ result.scores[:frustration].legend
84
+ result.scores[:frustration].probabilities
85
+ result.scores[:frustration].confidence
86
+ ```
87
+
88
+ ## Structured content
89
+
90
+ The state and instructions accept strings, hashes, and arrays. Nested values must be valid JSON values.
91
+
92
+ ```ruby
93
+ question = Jev::Noul.build(
94
+ instructions: {
95
+ potential_duplicate: {
96
+ name: "John Smith",
97
+ location: "Oakland, California"
98
+ },
99
+ question: "Is the resume for the same person as `potential_duplicate`?"
100
+ }
101
+ )
102
+ ```
103
+
104
+ ## Client options
105
+
106
+ `Jev::Client.new` accepts these options:
107
+
108
+ | Option | Default |
109
+ |---|---|
110
+ | `api_key:` | `TYPESAFE_API_KEY` |
111
+ | `base_url:` | `TYPESAFE_BASE_URL` or `https://api.typesafe.ai` |
112
+ | `model:` | `TYPESAFE_DEFAULT_MODEL` or `jev-latest` |
113
+ | `timeout:` | `10.0` seconds |
114
+ | `retry_policy:` | `Jev::RetryPolicy.new` |
115
+
116
+ Set `model:` on `system_one` to replace the client model for one request.
117
+
118
+ ## Retries
119
+
120
+ The default policy retries connection errors, timeout errors, and these HTTP responses:
121
+
122
+ - `408 Request Timeout`.
123
+ - `429 Too Many Requests`.
124
+ - HTTP status codes from 500 through 599.
125
+
126
+ The policy retries twice after the first request. It obeys `Retry-After` and `retry-after-ms` response headers.
127
+
128
+ Disable retries when you create the client:
129
+
130
+ ```ruby
131
+ client = Jev::Client.new(
132
+ retry_policy: Jev::RetryPolicy.new(max_retries: 0)
133
+ )
134
+ ```
135
+
136
+ ## Errors
137
+
138
+ All SDK errors inherit from `Jev::Error`.
139
+
140
+ Client and response errors include these classes:
141
+
142
+ - `Jev::ConfigurationError`.
143
+ - `Jev::ConnectionError`.
144
+ - `Jev::TimeoutError`.
145
+ - `Jev::ResponseValidationError`.
146
+
147
+ HTTP errors inherit from `Jev::APIError`. The error exposes `status`, `body`, `headers`, `endpoint`, and `request_id`.
148
+
149
+ ## Static types
150
+
151
+ The gem includes RBS declarations in `sig/jevrb.rbs`.
152
+
153
+ Validate the declarations:
154
+
155
+ ```bash
156
+ bundle exec rbs -I sig validate
157
+ ```
158
+
159
+ ## Development
160
+
161
+ Install the development dependencies:
162
+
163
+ ```bash
164
+ bin/setup
165
+ ```
166
+
167
+ Run the tests, RBS validation, and RuboCop:
168
+
169
+ ```bash
170
+ bundle exec rake
171
+ ```
172
+
173
+ ## License
174
+
175
+ Jevrb uses the MIT License.
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class ResponseDataError < StandardError
5
+ attr_reader :field_path
6
+
7
+ def initialize(field_path, message)
8
+ @field_path = field_path
9
+ super(message)
10
+ end
11
+ end
12
+ private_constant :ResponseDataError
13
+
14
+ module ResponseSupport
15
+ module_function
16
+
17
+ def hash(value, path)
18
+ return value if value.is_a?(Hash)
19
+
20
+ raise ResponseDataError.new(path, "#{path} must be an object")
21
+ end
22
+
23
+ def required(value, key, path)
24
+ return value[key] if value.key?(key)
25
+
26
+ raise ResponseDataError.new("#{path}.#{key}", "#{path}.#{key} is required")
27
+ end
28
+
29
+ def string(value, path)
30
+ return value.dup.freeze if value.is_a?(String)
31
+
32
+ raise ResponseDataError.new(path, "#{path} must be a string")
33
+ end
34
+
35
+ def number(value, path, range: nil)
36
+ unless (value.is_a?(Integer) || value.is_a?(Float)) && (!value.is_a?(Float) || value.finite?)
37
+ raise ResponseDataError.new(path, "#{path} must be a finite number")
38
+ end
39
+ if range && !range.cover?(value)
40
+ raise ResponseDataError.new(path, "#{path} must be between #{range.begin} and #{range.end}")
41
+ end
42
+
43
+ value.to_f
44
+ end
45
+
46
+ def integer_or_nil(value, path)
47
+ return if value.nil?
48
+ return value if value.is_a?(Integer) && value >= 0
49
+
50
+ raise ResponseDataError.new(path, "#{path} must be a nonnegative integer or null")
51
+ end
52
+
53
+ def probability_hash(value, path, integer_keys: false)
54
+ hash(value, path).each_with_object({}) do |(key, probability), result|
55
+ normalized_key = integer_keys ? integer_key(key, path) : string(key, "#{path} key")
56
+ result[normalized_key] = number(probability, "#{path}.#{key}", range: 0.0..1.0)
57
+ end.freeze
58
+ end
59
+
60
+ def integer_key(value, path)
61
+ integer = Integer(value, 10)
62
+ return integer if integer.to_s == value.to_s
63
+
64
+ raise ArgumentError
65
+ rescue ArgumentError, TypeError
66
+ raise ResponseDataError.new(path, "#{path} keys must be integer strings")
67
+ end
68
+
69
+ def json_content(value, path)
70
+ Support.normalize_json_content(value, path: path)
71
+ rescue ArgumentError => e
72
+ raise ResponseDataError.new(path, e.message)
73
+ end
74
+ end
75
+ private_constant :ResponseSupport
76
+
77
+ class Answer
78
+ attr_reader :type
79
+
80
+ private
81
+
82
+ def initialize(type)
83
+ @type = type.freeze
84
+ end
85
+
86
+ def finish_initialization
87
+ freeze
88
+ end
89
+ end
90
+
91
+ class NoulAnswer < Answer
92
+ attr_reader :noul
93
+
94
+ def self.from_hash(value, path:)
95
+ value = ResponseSupport.hash(value, path)
96
+ type = ResponseSupport.string(ResponseSupport.required(value, "type", path), "#{path}.type")
97
+ raise ResponseDataError.new("#{path}.type", "#{path}.type must be noul") unless type == "noul"
98
+
99
+ new(noul: ResponseSupport.number(
100
+ ResponseSupport.required(value, "noul", path),
101
+ "#{path}.noul",
102
+ range: 0.0..1.0
103
+ ))
104
+ end
105
+
106
+ def to_h
107
+ { type: type, noul: noul }
108
+ end
109
+
110
+ private
111
+
112
+ def initialize(noul:)
113
+ super("noul")
114
+ @noul = noul
115
+ finish_initialization
116
+ end
117
+ end
118
+
119
+ class ChoiceAnswer < Answer
120
+ attr_reader :choice, :probabilities, :confidence
121
+
122
+ def self.from_hash(value, path:)
123
+ value = ResponseSupport.hash(value, path)
124
+ type = ResponseSupport.string(ResponseSupport.required(value, "type", path), "#{path}.type")
125
+ raise ResponseDataError.new("#{path}.type", "#{path}.type must be choice") unless type == "choice"
126
+
127
+ new(
128
+ choice: ResponseSupport.string(ResponseSupport.required(value, "choice", path), "#{path}.choice"),
129
+ probabilities: ResponseSupport.probability_hash(
130
+ ResponseSupport.required(value, "probabilities", path),
131
+ "#{path}.probabilities"
132
+ ),
133
+ confidence: ResponseSupport.number(
134
+ ResponseSupport.required(value, "confidence", path),
135
+ "#{path}.confidence",
136
+ range: 0.0..1.0
137
+ )
138
+ )
139
+ end
140
+
141
+ def to_h
142
+ {
143
+ type: type,
144
+ choice: choice,
145
+ probabilities: probabilities,
146
+ confidence: confidence
147
+ }
148
+ end
149
+
150
+ private
151
+
152
+ def initialize(choice:, probabilities:, confidence:)
153
+ super("choice")
154
+ @choice = choice
155
+ @probabilities = probabilities
156
+ @confidence = confidence
157
+ finish_initialization
158
+ end
159
+ end
160
+
161
+ class ScoreAnswer < Answer
162
+ attr_reader :score, :legend, :probabilities, :confidence
163
+
164
+ def self.from_hash(value, path:)
165
+ value = ResponseSupport.hash(value, path)
166
+ type = ResponseSupport.string(ResponseSupport.required(value, "type", path), "#{path}.type")
167
+ raise ResponseDataError.new("#{path}.type", "#{path}.type must be score") unless type == "score"
168
+
169
+ new(
170
+ score: ResponseSupport.number(ResponseSupport.required(value, "score", path), "#{path}.score"),
171
+ legend: parse_legend(ResponseSupport.required(value, "legend", path), "#{path}.legend"),
172
+ probabilities: ResponseSupport.probability_hash(
173
+ ResponseSupport.required(value, "probabilities", path),
174
+ "#{path}.probabilities",
175
+ integer_keys: true
176
+ ),
177
+ confidence: ResponseSupport.number(
178
+ ResponseSupport.required(value, "confidence", path),
179
+ "#{path}.confidence",
180
+ range: 0.0..1.0
181
+ )
182
+ )
183
+ end
184
+
185
+ def self.parse_legend(value, path)
186
+ ResponseSupport.hash(value, path).each_with_object({}) do |(key, description), result|
187
+ integer_key = ResponseSupport.integer_key(key, path)
188
+ result[integer_key] = ResponseSupport.json_content(description, "#{path}.#{key}")
189
+ end.freeze
190
+ end
191
+ private_class_method :parse_legend
192
+
193
+ def to_h
194
+ {
195
+ type: type,
196
+ score: score,
197
+ legend: legend,
198
+ probabilities: probabilities,
199
+ confidence: confidence
200
+ }
201
+ end
202
+
203
+ private
204
+
205
+ def initialize(score:, legend:, probabilities:, confidence:)
206
+ super("score")
207
+ @score = score
208
+ @legend = legend
209
+ @probabilities = probabilities
210
+ @confidence = confidence
211
+ finish_initialization
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ class Choice < Question
5
+ MAX_OPTIONS = 255
6
+
7
+ def self.build(instructions:, criteria:)
8
+ new(instructions: instructions, criteria: criteria)
9
+ end
10
+
11
+ private
12
+
13
+ def initialize(instructions:, criteria:)
14
+ super(type: "choice", instructions: instructions, criteria: normalize_criteria(criteria))
15
+ end
16
+
17
+ def normalize_criteria(criteria)
18
+ raise ArgumentError, "criteria must be a hash" unless criteria.is_a?(Hash)
19
+ raise ArgumentError, "criteria must not be empty" if criteria.empty?
20
+ raise ArgumentError, "criteria cannot contain more than #{MAX_OPTIONS} options" if criteria.size > MAX_OPTIONS
21
+
22
+ criteria.each_with_object({}) do |(key, value), result|
23
+ unless key.is_a?(String) || key.is_a?(Symbol)
24
+ raise ArgumentError, "criteria option names must be strings or symbols"
25
+ end
26
+
27
+ normalized_key = key.to_s
28
+ if result.key?(normalized_key)
29
+ raise ArgumentError,
30
+ "criteria contains duplicate option #{normalized_key.inspect}"
31
+ end
32
+
33
+ result[normalized_key.freeze] = Support.normalize_json_content(
34
+ value,
35
+ path: "criteria.#{normalized_key}",
36
+ allow_nil: true
37
+ )
38
+ end.freeze
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Jevrb
7
+ class Client
8
+ NOT_GIVEN = Object.new.freeze
9
+ SYSTEM_ONE_PATH = "/v1/systemone"
10
+
11
+ attr_reader :api_key, :base_url, :model, :timeout, :retry_policy
12
+
13
+ def initialize(
14
+ api_key: NOT_GIVEN,
15
+ base_url: NOT_GIVEN,
16
+ model: NOT_GIVEN,
17
+ timeout: DEFAULT_TIMEOUT,
18
+ retry_policy: nil
19
+ )
20
+ @api_key = client_value(api_key, API_KEY_ENV)
21
+ @base_url = client_value(base_url, BASE_URL_ENV, DEFAULT_BASE_URL)
22
+ @model = client_value(model, DEFAULT_MODEL_ENV, DEFAULT_MODEL)
23
+ @timeout = positive_number(timeout, "timeout")
24
+ @retry_policy = retry_policy || RetryPolicy.new
25
+
26
+ validate_configuration
27
+ @transport = Transport.new(base_url: @base_url)
28
+ end
29
+
30
+ def system_one(state, questions:, model: nil)
31
+ state = Support.normalize_json_content(state, path: "state")
32
+ questions, key_map = serialize_questions(questions)
33
+ request_model = model.nil? ? @model : nonempty_string(model, "model")
34
+ body = JSON.generate(state: state, model: request_model, questions: questions)
35
+ endpoint = @transport.endpoint(SYSTEM_ONE_PATH)
36
+
37
+ retry_policy.execute do
38
+ response = @transport.post(
39
+ SYSTEM_ONE_PATH,
40
+ body: body,
41
+ headers: request_headers,
42
+ timeout: timeout
43
+ )
44
+ handle_response(response, endpoint: endpoint, key_map: key_map)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def client_value(explicit, environment_name, default = nil)
51
+ return normalize_client_value(explicit) unless explicit.equal?(NOT_GIVEN)
52
+
53
+ environment_value = ENV.fetch(environment_name, nil)
54
+ normalized = normalize_client_value(environment_value)
55
+ normalized.nil? ? default : normalized
56
+ end
57
+
58
+ def normalize_client_value(value)
59
+ return if value.nil?
60
+ return value.strip if value.is_a?(String)
61
+
62
+ value
63
+ end
64
+
65
+ def validate_configuration
66
+ raise ConfigurationError, "api_key is required" unless api_key.is_a?(String) && !api_key.empty?
67
+
68
+ raise ConfigurationError, "model must be a nonempty string" unless model.is_a?(String) && !model.empty?
69
+
70
+ validate_base_url
71
+ return if retry_policy.is_a?(RetryPolicy)
72
+
73
+ raise ConfigurationError, "retry_policy must be a Jevrb::RetryPolicy"
74
+ end
75
+
76
+ def validate_base_url
77
+ unless base_url.is_a?(String) && !base_url.empty?
78
+ raise ConfigurationError, "base_url must be an HTTP or HTTPS URL"
79
+ end
80
+
81
+ uri = URI.parse(base_url)
82
+ valid = %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
83
+ raise ConfigurationError, "base_url must be an HTTP or HTTPS URL" unless valid
84
+ rescue URI::InvalidURIError
85
+ raise ConfigurationError, "base_url must be an HTTP or HTTPS URL"
86
+ end
87
+
88
+ def nonempty_string(value, name)
89
+ return value if value.is_a?(String) && !value.empty?
90
+
91
+ raise ArgumentError, "#{name} must be a nonempty string"
92
+ end
93
+
94
+ def positive_number(value, name)
95
+ return value.to_f if (value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value.positive?
96
+
97
+ raise ConfigurationError, "#{name} must be a positive number"
98
+ end
99
+
100
+ def serialize_questions(questions)
101
+ raise ArgumentError, "questions must be a hash" unless questions.is_a?(Hash)
102
+ raise ArgumentError, "questions must not be empty" if questions.empty?
103
+
104
+ key_map = {}
105
+ serialized = questions.each_with_object({}) do |(key, question), result|
106
+ raise ArgumentError, "question names must be strings or symbols" unless key.is_a?(String) || key.is_a?(Symbol)
107
+ raise ArgumentError, "questions must contain Jevrb question objects" unless question.is_a?(Question)
108
+
109
+ normalized_key = key.to_s
110
+ if result.key?(normalized_key)
111
+ raise ArgumentError,
112
+ "questions contains duplicate name #{normalized_key.inspect}"
113
+ end
114
+
115
+ result[normalized_key] = question.to_h
116
+ key_map[normalized_key] = key
117
+ end
118
+
119
+ [serialized, key_map.freeze]
120
+ end
121
+
122
+ def request_headers
123
+ {
124
+ "Authorization" => "Bearer #{api_key}",
125
+ "Content-Type" => "application/json",
126
+ "Accept" => "application/json",
127
+ "User-Agent" => "jevrb/#{VERSION}"
128
+ }
129
+ end
130
+
131
+ def handle_response(response, endpoint:, key_map:)
132
+ raise api_error(response, endpoint: endpoint) unless (200..299).cover?(response.status)
133
+
134
+ parsed = JSON.parse(response.body.to_s)
135
+ Result.from_hash(
136
+ parsed,
137
+ key_map: key_map,
138
+ request_id: response.headers["x-typesafe-request-id"]
139
+ )
140
+ rescue JSON::ParserError => e
141
+ raise response_validation_error(response, endpoint, "$", e.message)
142
+ rescue ResponseDataError => e
143
+ raise response_validation_error(response, endpoint, e.field_path, e.message)
144
+ end
145
+
146
+ def response_validation_error(response, endpoint, field_path, message)
147
+ ResponseValidationError.new(
148
+ status: response.status,
149
+ body: response.body,
150
+ headers: response.headers,
151
+ field_path: field_path,
152
+ endpoint: "POST #{endpoint}",
153
+ message: message
154
+ )
155
+ end
156
+
157
+ def api_error(response, endpoint:)
158
+ error_class = case response.status
159
+ when 400 then BadRequestError
160
+ when 401 then AuthenticationError
161
+ when 403 then PermissionDeniedError
162
+ when 404 then NotFoundError
163
+ when 422 then UnprocessableEntityError
164
+ when 429 then RateLimitError
165
+ when 500..599 then InternalServerError
166
+ else APIError
167
+ end
168
+
169
+ error_class.new(
170
+ status: response.status,
171
+ body: parse_error_body(response.body),
172
+ headers: response.headers,
173
+ endpoint: "POST #{endpoint}"
174
+ )
175
+ end
176
+
177
+ def parse_error_body(body)
178
+ return if body.nil? || body.empty?
179
+
180
+ JSON.parse(body)
181
+ rescue JSON::ParserError
182
+ body
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevrb
4
+ API_KEY_ENV = "TYPESAFE_API_KEY"
5
+ BASE_URL_ENV = "TYPESAFE_BASE_URL"
6
+ DEFAULT_MODEL_ENV = "TYPESAFE_DEFAULT_MODEL"
7
+
8
+ DEFAULT_BASE_URL = "https://api.typesafe.ai"
9
+ DEFAULT_MODEL = "jev-latest"
10
+ DEFAULT_TIMEOUT = 10.0
11
+ end