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 +4 -4
- data/CHANGELOG.md +59 -13
- data/LICENSE +21 -0
- data/README.md +136 -3
- data/lib/typesafe/answer.rb +1 -1
- data/lib/typesafe/client.rb +9 -3
- data/lib/typesafe/errors.rb +178 -0
- data/lib/typesafe/jev.rb +2 -2
- data/lib/typesafe/response.rb +1 -1
- data/lib/typesafe/version.rb +1 -1
- data/lib/typesafe.rb +1 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7545ebcb0bdc5d72f1038ae64b4305c3d5aeb6110b0b5e53b0c37cb88de77ee5
|
|
4
|
+
data.tar.gz: 50b7471d5f3c5c5dad8c1c4b14c0dbc3f2b062fe0ee7e4cd076d2ff44b4b46e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d8c3637a1d48b857019ac364b7aca9875cf0accad92023021a02f00488569b518c685afceccb8c117e98bf72e80f63eae3469d632b213c745e7b6b7fd88a4ca
|
|
7
|
+
data.tar.gz: f4f8167797e08f99b23a0faa879ffe10e7d01370487e4c18d4472ef52256835df82c63426c37bb8d847672502f71902624f375520108880447880aace31c00ba
|
data/CHANGELOG.md
CHANGED
|
@@ -1,30 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
- Gemspec metadata: point `source_code_uri`/`changelog_uri` and author/email at the `dtheofr` GitHub account.
|
|
13
42
|
|
|
14
|
-
|
|
43
|
+
## [0.4.0] - 2026-09-20
|
|
15
44
|
|
|
16
|
-
|
|
45
|
+
### Added
|
|
17
46
|
|
|
18
|
-
-
|
|
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
|
-
|
|
51
|
+
### Added
|
|
23
52
|
|
|
24
|
-
|
|
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
|
-
|
|
55
|
+
## [0.2.0] - 2026-09-20
|
|
27
56
|
|
|
28
|
-
|
|
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
|
-
|
|
3
|
+
[](https://github.com/dtheofr/typesafe-jev-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/typesafe-jev)
|
|
5
|
+
[](https://rubygems.org/gems/typesafe-jev)
|
|
6
|
+
[](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
|
|
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)
|
data/lib/typesafe/answer.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/typesafe/client.rb
CHANGED
|
@@ -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 [
|
|
59
|
-
#
|
|
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.
|
|
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 [
|
|
39
|
-
#
|
|
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,
|
data/lib/typesafe/response.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/typesafe/version.rb
CHANGED
data/lib/typesafe.rb
CHANGED
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.
|
|
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://
|
|
79
|
+
homepage: https://github.com/dtheofr/typesafe-jev-ruby
|
|
79
80
|
licenses:
|
|
80
81
|
- MIT
|
|
81
82
|
metadata:
|
|
82
|
-
homepage_uri: https://
|
|
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
|