typesafe-jev 0.6.0 → 1.0.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: 331918548db2a5fdbd2f3dc3c2ac3bf650ff80d530e1bf5df205ddabede2299b
4
- data.tar.gz: ce1c7bef8487e643d53fcfa8cb338fdb4abd26a085c19478e50cb53eac01d336
3
+ metadata.gz: 7545ebcb0bdc5d72f1038ae64b4305c3d5aeb6110b0b5e53b0c37cb88de77ee5
4
+ data.tar.gz: 50b7471d5f3c5c5dad8c1c4b14c0dbc3f2b062fe0ee7e4cd076d2ff44b4b46e0
5
5
  SHA512:
6
- metadata.gz: 18dee0e01b1df600771dcc3eff223841f7074df76f309494dbdfc3b200ff6db4cc6627c6ce8cd0a3934665302385b07986e6a66f3bd96b51af3126f61d95e823
7
- data.tar.gz: cb4261224cfec035eed1b1bac36a02f84cc388a9ff05997aff7d9bc59a35a31108465eb6bd277cd94c0296408321ae17c8c8883b41718b1d95a9b363236ec120
6
+ metadata.gz: 3d8c3637a1d48b857019ac364b7aca9875cf0accad92023021a02f00488569b518c685afceccb8c117e98bf72e80f63eae3469d632b213c745e7b6b7fd88a4ca
7
+ data.tar.gz: f4f8167797e08f99b23a0faa879ffe10e7d01370487e4c18d4472ef52256835df82c63426c37bb8d847672502f71902624f375520108880447880aace31c00ba
data/CHANGELOG.md CHANGED
@@ -1,30 +1,76 @@
1
1
  # Changelog
2
2
 
3
- ## 0.6.0
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0] - 2026-09-20
8
+
9
+ ### Added
10
+
11
+ - Full MIT `LICENSE` text, a complete README (quickstart, question/answer/error guide), a `CONTRIBUTING.md` guide and GitHub issue templates.
12
+ - Gemspec metadata: `documentation_uri` (rubydoc.info) and `rubygems_mfa_required`; homepage now points at the GitHub repository.
13
+
14
+ ### Changed
15
+
16
+ - **Stability**: the public API shipped in 0.7.0 is now declared stable and covered by Semantic Versioning; breaking changes will require a major version bump from here on.
17
+
18
+ ## [0.7.0] - 2026-09-20
19
+
20
+ ### Changed
21
+
22
+ - **Breaking**: `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now raises typed `Typesafe::Error` subclasses on any non-2xx response instead of raw `Net::HTTPClientException`/`Net::HTTPFatalError`: `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `UnprocessableEntityError` (422, with `#errors`), `RateLimitError` (429, with `#retry_after`), `OverloadedError` (529) and `ServerError` (5xx), all under the `Typesafe::APIError` base with `status`, `body`, `headers`, `request_id` and `#retryable?`. `Typesafe::Error` is the root rescuable class.
23
+
24
+ ## [0.6.0] - 2026-09-20
25
+
26
+ ### Changed
4
27
 
5
28
  - **Breaking**: `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now returns a typed `Typesafe::Response` instead of the raw parsed Hash; answers come back as `NoulAnswer`/`ChoiceAnswer`/`ScoreAnswer` objects accessible via `response[question_id]` (String or Symbol). Use `response.to_h` for the previous raw-Hash behavior.
6
29
 
7
- ## 0.5.0
30
+ ## [0.5.0] - 2026-09-20
31
+
32
+ ### Added
33
+
34
+ - Response classes mirroring the question classes: abstract `Typesafe::Answer` base with `Answer.from_h` dispatching on the `type` tag, plus `Typesafe::NoulAnswer` (`noul`), `Typesafe::ChoiceAnswer` (`choice`, `probabilities`, `confidence`) and `Typesafe::ScoreAnswer` (`score`, `legend`, `probabilities`, `confidence`). Frozen, immutable value objects that validate their inputs and serialize via `#to_h`/`#to_json`.
35
+ - `Typesafe::Usage` (token counts) and `Typesafe::Response` (`model`, `answers`, `usage`), with `Response.from_json`/`from_h` to parse an API response body into typed Answer objects; `Response#[]` accepts String or Symbol question ids.
36
+
37
+ ## [0.4.1] - 2026-09-20
8
38
 
9
- - Add response classes mirroring the question classes: abstract `Typesafe::Answer` base with `Answer.from_h` dispatching on the `type` tag, plus `Typesafe::NoulAnswer` (`noul`), `Typesafe::ChoiceAnswer` (`choice`, `probabilities`, `confidence`) and `Typesafe::ScoreAnswer` (`score`, `legend`, `probabilities`, `confidence`). Frozen, immutable value objects that validate their inputs and serialize via `#to_h`/`#to_json`.
10
- - Add `Typesafe::Usage` (token counts) and `Typesafe::Response` (`model`, `answers`, `usage`), with `Response.from_json`/`from_h` to parse an API response body into typed Answer objects; `Response#[]` accepts String or Symbol question ids.
39
+ ### Fixed
11
40
 
12
- ## 0.4.1
41
+ - Gemspec metadata: point `source_code_uri`/`changelog_uri` and author/email at the `dtheofr` GitHub account.
13
42
 
14
- - Fix gemspec metadata: point `source_code_uri`/`changelog_uri` and author/email at the `dtheofr` GitHub account.
43
+ ## [0.4.0] - 2026-09-20
15
44
 
16
- ## 0.4.0
45
+ ### Added
17
46
 
18
- - Add `Typesafe::Jev`, a `Typesafe::Client` subclass with the model pinned to `jev-latest`: `Jev.new(api_key: nil).evaluate(...)` plus a one-shot `Typesafe::Jev.evaluate(state:, questions:, api_key: nil)`.
47
+ - `Typesafe::Jev`, a `Typesafe::Client` subclass with the model pinned to `jev-latest`: `Jev.new(api_key: nil).evaluate(...)` plus a one-shot `Typesafe::Jev.evaluate(state:, questions:, api_key: nil)`.
19
48
 
20
- ## 0.3.0
49
+ ## [0.3.0] - 2026-09-20
21
50
 
22
- - Add `Typesafe::Client`: `initialize(api_key:, model:)` (key from argument or `TYPESAFE_API_KEY`, model defaults to `jev-latest`) and `evaluate(state:, questions:, model: nil)` posting to the System One endpoint. Returns the parsed JSON response; non-2xx responses raise the stdlib `Net::HTTP` error as-is.
51
+ ### Added
23
52
 
24
- ## 0.2.0
53
+ - `Typesafe::Client`: `initialize(api_key:, model:)` (key from argument or `TYPESAFE_API_KEY`, model defaults to `jev-latest`) and `evaluate(state:, questions:, model: nil)` posting to the System One endpoint. Returns the parsed JSON response; non-2xx responses raise the stdlib `Net::HTTP` error as-is.
25
54
 
26
- - Add question classes: `Typesafe::Noul`, `Typesafe::Choice`, `Typesafe::Score` with the abstract `Typesafe::Question` base. Frozen, immutable value objects that validate their inputs and serialize to the TypeSafe API shape via `#to_h`/`#to_json`.
55
+ ## [0.2.0] - 2026-09-20
27
56
 
28
- ## 0.1.0
57
+ ### Added
58
+
59
+ - Question classes: `Typesafe::Noul`, `Typesafe::Choice`, `Typesafe::Score` with the abstract `Typesafe::Question` base. Frozen, immutable value objects that validate their inputs and serialize to the TypeSafe API shape via `#to_h`/`#to_json`.
60
+
61
+ ## [0.1.0] - 2026-09-20
62
+
63
+ ### Added
29
64
 
30
65
  - Initial gem scaffold.
66
+
67
+ [unreleased]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v1.0.0...HEAD
68
+ [1.0.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.7.0...v1.0.0
69
+ [0.7.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.6.0...v0.7.0
70
+ [0.6.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.5.0...v0.6.0
71
+ [0.5.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.4.1...v0.5.0
72
+ [0.4.1]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.4.0...v0.4.1
73
+ [0.4.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.3.0...v0.4.0
74
+ [0.3.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.2.0...v0.3.0
75
+ [0.2.0]: https://github.com/dtheofr/typesafe-jev-ruby/compare/v0.1.0...v0.2.0
76
+ [0.1.0]: https://github.com/dtheofr/typesafe-jev-ruby/releases/tag/v0.1.0
data/LICENSE CHANGED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dtheofr
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 CHANGED
@@ -1,14 +1,147 @@
1
1
  # typesafe-jev
2
2
 
3
- Ruby client for [Jev](https://docs.typesafe.ai), TypeSafe's System One model — starting point, under construction.
3
+ [![CI](https://github.com/dtheofr/typesafe-jev-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/dtheofr/typesafe-jev-ruby/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/typesafe-jev?logo=rubygems&color=brightgreen)](https://rubygems.org/gems/typesafe-jev)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red?logo=ruby)](https://rubygems.org/gems/typesafe-jev)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ Ruby client for [Jev](https://docs.typesafe.ai), TypeSafe's System One model. Ask natural-language questions about your application state and get typed, probabilistic answers you can program against.
9
+
10
+ **Zero runtime dependencies**: pure Ruby stdlib (`Net::HTTP`, `JSON`), so it works in plain Ruby and Rails alike.
11
+
12
+ ## Installation
13
+
14
+ ```sh
15
+ gem install typesafe-jev
16
+ ```
17
+
18
+ or in your `Gemfile`:
19
+
20
+ ```ruby
21
+ gem "typesafe-jev"
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```ruby
27
+ require "typesafe"
28
+
29
+ response = Typesafe::Jev.evaluate(
30
+ state: { ticket: { text: "My payouts failed twice this week." } },
31
+ questions: {
32
+ refund_requested: Typesafe::Noul.new("Does the customer request a refund?"),
33
+ category: Typesafe::Choice.new("Which category does this ticket belong to?", criteria: {
34
+ billing: "Payment, invoices, refunds",
35
+ technical: "Bugs and outages",
36
+ sales: "Pre-sales questions"
37
+ }),
38
+ severity: Typesafe::Score.new("How severe is this ticket?", criteria: [
39
+ "Low: routine request",
40
+ "Medium: degraded experience",
41
+ "High: blocked user",
42
+ "Critical: money lost or security issue"
43
+ ])
44
+ }
45
+ )
46
+
47
+ response[:refund_requested].noul # => 0.95 (yes/no probability, 0..1)
48
+ response[:category].choice # => "billing"
49
+ response[:category].probabilities # => { "billing" => 0.88, "technical" => 0.12, "sales" => 0.0 }
50
+ response[:severity].score # => "High: blocked user"
51
+ response.usage.input_tokens # => 296
52
+ ```
53
+
54
+ `Typesafe::Jev.evaluate` is the one-shot facade with the model pinned to `jev-latest`. For another model, use `Typesafe::Client` directly.
55
+
56
+ ## Questions
57
+
58
+ Questions are frozen, immutable value objects. The question **id is not in the object**; it is the Hash key in the `questions:` map, and answers come back under the same key.
59
+
60
+ | Class | Answers with | Criteria |
61
+ |---|---|---|
62
+ | `Typesafe::Noul` | a yes/no probability (`noul`, 0..1) | optional `{ true: ..., false: ... }` |
63
+ | `Typesafe::Choice` | one option + full probability distribution + `confidence` | non-empty `{ key: "description" }` |
64
+ | `Typesafe::Score` | a level + `legend` + distribution + `confidence` | non-empty `["Level 1", "Level 2", ...]` |
65
+
66
+ ```ruby
67
+ Typesafe::Noul.new("Does the customer request a refund?",
68
+ criteria: { true: "The customer explicitly asks for their money back",
69
+ false: "The customer does not mention refunds" })
70
+
71
+ Typesafe::Choice.new("Which category does this ticket belong to?",
72
+ criteria: { billing: "Payment, invoices, refunds", technical: "Bugs and outages" })
73
+
74
+ Typesafe::Score.new("How severe is this ticket?",
75
+ criteria: ["Low", "Medium", "High", "Critical"])
76
+ ```
77
+
78
+ Invalid input raises `ArgumentError` at construction time, so bad questions fail fast, before any HTTP call.
79
+
80
+ ## Responses
81
+
82
+ `Client#evaluate` returns a frozen `Typesafe::Response`:
83
+
84
+ - `response[id]` (or `response.answers`): a typed `NoulAnswer`, `ChoiceAnswer` or `ScoreAnswer`; String and Symbol ids both work
85
+ - `response.model`: the model that performed the evaluation
86
+ - `response.usage`: `Typesafe::Usage` with `input_tokens` / `output_tokens`
87
+ - `response.to_h` / `response.to_json`: the raw API shape
88
+
89
+ Each answer type exposes its own accessors (`#noul`, `#choice`, `#probabilities`, `#confidence`, `#score`, `#legend`, …) and serializes via `#to_h`/`#to_json`.
90
+
91
+ ## Errors
92
+
93
+ Any non-2xx HTTP response raises a typed error. All inherit from `Typesafe::APIError` (root: `Typesafe::Error`), which carries `#status`, `#body`, `#headers`, `#request_id` and `#retryable?`.
94
+
95
+ | Error | Status | Extras |
96
+ |---|---|---|
97
+ | `Typesafe::BadRequestError` | 400 | |
98
+ | `Typesafe::AuthenticationError` | 401 | |
99
+ | `Typesafe::PermissionDeniedError` | 403 | |
100
+ | `Typesafe::NotFoundError` | 404 | |
101
+ | `Typesafe::UnprocessableEntityError` | 422 | `#errors` (validation details) |
102
+ | `Typesafe::RateLimitError` | 429 | `#retry_after` |
103
+ | `Typesafe::OverloadedError` | 529 | |
104
+ | `Typesafe::ServerError` | 5xx | |
105
+
106
+ ```ruby
107
+ begin
108
+ Typesafe::Jev.evaluate(state: state, questions: questions)
109
+ rescue Typesafe::RateLimitError => e
110
+ sleep(e.retry_after || 1.0)
111
+ retry
112
+ rescue Typesafe::OverloadedError
113
+ sleep(2**attempt)
114
+ retry
115
+ rescue Typesafe::APIError => e
116
+ raise "typesafe request failed (#{e.status}, request #{e.request_id}): #{e.body}"
117
+ end
118
+ ```
119
+
120
+ ## Configuration
121
+
122
+ The API key is read from the `TYPESAFE_API_KEY` environment variable unless passed explicitly:
123
+
124
+ ```ruby
125
+ jev = Typesafe::Jev.new # key from ENV
126
+ jev = Typesafe::Jev.new(api_key: "sk-...") # or explicit
127
+
128
+ client = Typesafe::Client.new(api_key: "sk-...", model: "other-model")
129
+ client.evaluate(state:, questions:, model: "one-off-model") # per-call override
130
+ ```
131
+
132
+ ## Documentation
133
+
134
+ TypeSafe docs live at [docs.typesafe.ai](https://docs.typesafe.ai). The full API reference for this gem is on [rubydoc.info](https://rubydoc.info/gems/typesafe-jev).
4
135
 
5
136
  ## Development
6
137
 
7
138
  ```sh
8
139
  bundle install
9
- bundle exec rake test
140
+ bundle exec rake spec # or: bundle exec rspec
10
141
  ```
11
142
 
143
+ The test suite runs against Ruby 3.1 and 3.4 in CI. See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions.
144
+
12
145
  ## License
13
146
 
14
- MIT
147
+ [MIT](LICENSE)
@@ -5,7 +5,7 @@ module Typesafe
5
5
  # System One API: {NoulAnswer}, {ChoiceAnswer}, and {ScoreAnswer}.
6
6
  #
7
7
  # An answer is an immutable value object mirroring its question: it carries
8
- # what the model returned for one question, but not the question id that
8
+ # what the model returned for one question, but not the question id; that
9
9
  # is the Hash key under which the answer appears in a {Response}.
10
10
  #
11
11
  # Use {Answer.from_h} (or {Response.from_json}) to build typed answers from
@@ -55,8 +55,11 @@ module Typesafe
55
55
  # String/Symbol keys to Question values, +model+ is invalid, or the
56
56
  # response body is not a valid response shape.
57
57
  # @raise [JSON::ParserError] if the response body is not valid JSON.
58
- # @raise [Net::HTTPClientException, Net::HTTPFatalError] on any non-2xx
59
- # HTTP response.
58
+ # @raise [Typesafe::APIError] (or a subclass) on any non-2xx HTTP response:
59
+ # {Typesafe::BadRequestError}, {Typesafe::AuthenticationError},
60
+ # {Typesafe::PermissionDeniedError}, {Typesafe::NotFoundError},
61
+ # {Typesafe::UnprocessableEntityError}, {Typesafe::RateLimitError},
62
+ # {Typesafe::OverloadedError} and {Typesafe::ServerError}.
60
63
  def evaluate(state:, questions:, model: nil)
61
64
  model = model.nil? ? self.model : freeze_string(model, "model must be a non-empty String")
62
65
  questions = validate_questions!(questions)
@@ -68,7 +71,10 @@ module Typesafe
68
71
  )
69
72
 
70
73
  response = post(body)
71
- response.value
74
+ unless response.is_a?(Net::HTTPSuccess)
75
+ raise Errors.from_response(status: response.code.to_i, headers: response, body: response.body)
76
+ end
77
+
72
78
  Response.from_json(response.body)
73
79
  end
74
80
 
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ # Root of all errors raised by the gem, so users can `rescue Typesafe::Error`.
5
+ class Error < StandardError; end
6
+
7
+ # A non-2xx HTTP response from the TypeSafe API.
8
+ #
9
+ # begin
10
+ # client.evaluate(state:, questions:)
11
+ # rescue Typesafe::RateLimitError => e
12
+ # sleep(e.retry_after || 1.0)
13
+ # retry
14
+ # end
15
+ #
16
+ # The `detail` field of an error body comes in three shapes, all of which
17
+ # are rendered into the exception message:
18
+ #
19
+ # * a plain String: `{"detail": "Unknown model: jev-99"}`
20
+ # * a Hash with error_type/message:
21
+ # `{"detail": {"error_type": "authentication_error", "message": "..."}}`
22
+ # * an Array of validation entries (FastAPI/pydantic style):
23
+ # `{"detail": [{"type": "missing", "loc": ["body", "questions"], "msg": "Field required"}]}`
24
+ class APIError < Error
25
+ # @return [Integer] the HTTP status code.
26
+ attr_reader :status
27
+ # @return [Hash, Array, String, nil] the parsed JSON body (Hash or Array),
28
+ # the raw body String when it is not valid JSON, or nil for an empty body.
29
+ attr_reader :body
30
+ # @return [Hash{String => String}] the response headers, as received.
31
+ attr_reader :headers
32
+ # @return [String, nil] the +x-typesafe-request-id+ response header, when present.
33
+ attr_reader :request_id
34
+
35
+ # @param status [Integer] the HTTP status code.
36
+ # @param body [Hash, Array, String, nil] the parsed or raw response body.
37
+ # @param headers [Hash{String => String}] the response headers.
38
+ # @param message [String, nil] overrides the rendered +detail+-based message.
39
+ def initialize(status:, body: nil, headers: {}, message: nil)
40
+ @status = status
41
+ @body = body
42
+ @headers = headers
43
+ @request_id = header("x-typesafe-request-id")
44
+ super(message || "The TypeSafe API returned status #{status}.")
45
+ end
46
+
47
+ # True when the request may succeed if retried after a delay: rate limits
48
+ # (429), overload (529) and server errors (5xx).
49
+ # @return [Boolean]
50
+ def retryable?
51
+ status == 429 || status == 529 || (500..599).cover?(status)
52
+ end
53
+
54
+ private
55
+
56
+ # Case-insensitive header lookup, since header casing depends on the server.
57
+ def header(name)
58
+ headers.each { |key, value| return value if key.to_s.downcase == name }
59
+ nil
60
+ end
61
+ end
62
+
63
+ # The request was invalid (400).
64
+ class BadRequestError < APIError; end
65
+
66
+ # Missing or invalid API key (401).
67
+ class AuthenticationError < APIError; end
68
+
69
+ # Access denied (403). The API also returns this for a missing API key.
70
+ class PermissionDeniedError < APIError; end
71
+
72
+ # The endpoint or resource was not found (404).
73
+ class NotFoundError < APIError; end
74
+
75
+ # The request body failed server-side validation (422).
76
+ class UnprocessableEntityError < APIError
77
+ # The parsed validation entries, e.g.
78
+ # `{"type" => "missing", "loc" => ["body", "questions"], "msg" => "Field required"}`;
79
+ # empty when the body carries none.
80
+ # @return [Array<Hash>]
81
+ def errors
82
+ detail = body.is_a?(Hash) ? body["detail"] : nil
83
+ detail.is_a?(Array) ? detail : []
84
+ end
85
+ end
86
+
87
+ # The rate limit was exceeded (429).
88
+ class RateLimitError < APIError
89
+ # The server's requested wait before retrying, parsed from the
90
+ # +Retry-After+ (seconds) or +Retry-After-Ms+ (milliseconds) header.
91
+ # @return [Float, Integer, nil]
92
+ def retry_after
93
+ if (ms = header("retry-after-ms"))
94
+ Float(ms) / 1000
95
+ elsif (seconds = header("retry-after"))
96
+ Float(seconds)
97
+ end
98
+ rescue ArgumentError, TypeError
99
+ nil
100
+ end
101
+ end
102
+
103
+ # TypeSafe is temporarily overloaded (529).
104
+ class OverloadedError < APIError; end
105
+
106
+ # The server failed to process the request (5xx, excluding 529).
107
+ class ServerError < APIError; end
108
+
109
+ # Builds error instances from raw HTTP responses.
110
+ module Errors
111
+ module_function
112
+
113
+ # Maps a non-2xx response to the matching error class.
114
+ #
115
+ # @param status [Integer] the HTTP status code.
116
+ # @param headers [Hash, #each_header] the response headers.
117
+ # @param body [String, Hash, Array, nil] the raw or parsed response body.
118
+ # @return [APIError] an instance of the class matching +status+.
119
+ def from_response(status:, headers: {}, body: nil)
120
+ parsed_body = parse_body(body)
121
+ klass = STATUS_CLASSES.fetch(status) do
122
+ (500..599).cover?(status) ? ServerError : APIError
123
+ end
124
+
125
+ klass.new(
126
+ status: status,
127
+ body: parsed_body.is_a?(String) ? parsed_body.dup.freeze : parsed_body.freeze,
128
+ headers: normalize_headers(headers).freeze,
129
+ message: message_from_body(parsed_body, status)
130
+ )
131
+ end
132
+
133
+ def parse_body(body)
134
+ return body unless body.is_a?(String)
135
+
136
+ JSON.parse(body)
137
+ rescue JSON::ParserError, TypeError
138
+ body
139
+ end
140
+
141
+ def message_from_body(parsed_body, status)
142
+ detail = parsed_body.is_a?(Hash) ? parsed_body["detail"] : nil
143
+ case detail
144
+ when String then detail
145
+ when Hash then detail["message"] || detail[:message] || "Invalid request."
146
+ when Array then detail.map { |entry| validation_message(entry) }.join("; ")
147
+ else "The TypeSafe API returned status #{status}."
148
+ end
149
+ end
150
+
151
+ def validation_message(entry)
152
+ return entry.to_s unless entry.is_a?(Hash)
153
+
154
+ loc = Array(entry["loc"]).join(".")
155
+ msg = entry["msg"] || entry[:msg]
156
+ loc.empty? ? msg.to_s : "#{loc}: #{msg}"
157
+ end
158
+
159
+ def normalize_headers(headers)
160
+ return headers.each_header.to_h if headers.respond_to?(:each_header)
161
+
162
+ headers.dup
163
+ rescue TypeError
164
+ {}
165
+ end
166
+
167
+ STATUS_CLASSES = {
168
+ 400 => BadRequestError,
169
+ 401 => AuthenticationError,
170
+ 403 => PermissionDeniedError,
171
+ 404 => NotFoundError,
172
+ 422 => UnprocessableEntityError,
173
+ 429 => RateLimitError,
174
+ 529 => OverloadedError
175
+ }.freeze
176
+ private_constant :STATUS_CLASSES
177
+ end
178
+ end
data/lib/typesafe/jev.rb CHANGED
@@ -35,8 +35,8 @@ module Typesafe
35
35
  # @raise [ArgumentError] if +questions+ is invalid, +model+ is not the
36
36
  # pinned one, or the response body is not a valid response shape.
37
37
  # @raise [JSON::ParserError] if the response body is not valid JSON.
38
- # @raise [Net::HTTPClientException, Net::HTTPFatalError] on any non-2xx
39
- # HTTP response.
38
+ # @raise [Typesafe::APIError] (or a subclass) on any non-2xx HTTP response;
39
+ # see {Typesafe::Client#evaluate}.
40
40
  def evaluate(state:, questions:, model: nil)
41
41
  if model && model != PINNED_MODEL
42
42
  raise ArgumentError,
@@ -8,7 +8,7 @@ module Typesafe
8
8
  # response = Typesafe::Response.from_json(json)
9
9
  # response[:refund_requested] # => #<Typesafe::NoulAnswer @noul=0.95>
10
10
  #
11
- # The answer objects do not carry their question id it is the Hash key
11
+ # The answer objects do not carry their question id: it is the Hash key
12
12
  # under which each answer appears in {#answers}, mirroring how questions
13
13
  # are sent.
14
14
  class Response
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typesafe
4
- VERSION = "0.6.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/typesafe.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "typesafe/score_answer"
16
16
 
17
17
  require_relative "typesafe/usage"
18
18
  require_relative "typesafe/response"
19
+ require_relative "typesafe/errors"
19
20
  require_relative "typesafe/client"
20
21
  require_relative "typesafe/jev"
21
22
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typesafe-jev
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dtheofr
@@ -66,6 +66,7 @@ files:
66
66
  - lib/typesafe/choice.rb
67
67
  - lib/typesafe/choice_answer.rb
68
68
  - lib/typesafe/client.rb
69
+ - lib/typesafe/errors.rb
69
70
  - lib/typesafe/jev.rb
70
71
  - lib/typesafe/noul.rb
71
72
  - lib/typesafe/noul_answer.rb
@@ -75,13 +76,15 @@ files:
75
76
  - lib/typesafe/score_answer.rb
76
77
  - lib/typesafe/usage.rb
77
78
  - lib/typesafe/version.rb
78
- homepage: https://docs.typesafe.ai
79
+ homepage: https://github.com/dtheofr/typesafe-jev-ruby
79
80
  licenses:
80
81
  - MIT
81
82
  metadata:
82
- homepage_uri: https://docs.typesafe.ai
83
+ homepage_uri: https://github.com/dtheofr/typesafe-jev-ruby
83
84
  source_code_uri: https://github.com/dtheofr/typesafe-jev-ruby
84
85
  changelog_uri: https://github.com/dtheofr/typesafe-jev-ruby/blob/main/CHANGELOG.md
86
+ documentation_uri: https://rubydoc.info/gems/typesafe-jev
87
+ rubygems_mfa_required: 'true'
85
88
  rdoc_options: []
86
89
  require_paths:
87
90
  - lib