typesafe-sdk 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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +359 -0
- data/lib/typesafe/sdk/answer_parser.rb +36 -0
- data/lib/typesafe/sdk/api_error.rb +43 -0
- data/lib/typesafe/sdk/api_error_factory.rb +36 -0
- data/lib/typesafe/sdk/api_response_validation_error.rb +20 -0
- data/lib/typesafe/sdk/api_timeout_error.rb +14 -0
- data/lib/typesafe/sdk/body_decoder.rb +18 -0
- data/lib/typesafe/sdk/choice.rb +39 -0
- data/lib/typesafe/sdk/choice_answer.rb +45 -0
- data/lib/typesafe/sdk/client.rb +108 -0
- data/lib/typesafe/sdk/configuration.rb +66 -0
- data/lib/typesafe/sdk/connection_pool.rb +82 -0
- data/lib/typesafe/sdk/error_message.rb +51 -0
- data/lib/typesafe/sdk/field_reader.rb +76 -0
- data/lib/typesafe/sdk/http_request.rb +30 -0
- data/lib/typesafe/sdk/http_response.rb +24 -0
- data/lib/typesafe/sdk/invalid_response_field.rb +14 -0
- data/lib/typesafe/sdk/json_value.rb +51 -0
- data/lib/typesafe/sdk/list_models_response.rb +41 -0
- data/lib/typesafe/sdk/model_metadata.rb +39 -0
- data/lib/typesafe/sdk/models.rb +37 -0
- data/lib/typesafe/sdk/net_http_transport.rb +53 -0
- data/lib/typesafe/sdk/noul.rb +49 -0
- data/lib/typesafe/sdk/noul_answer.rb +39 -0
- data/lib/typesafe/sdk/question_set.rb +55 -0
- data/lib/typesafe/sdk/rate_limit_error.rb +14 -0
- data/lib/typesafe/sdk/request_builder.rb +58 -0
- data/lib/typesafe/sdk/request_logger.rb +68 -0
- data/lib/typesafe/sdk/requester.rb +92 -0
- data/lib/typesafe/sdk/retry_after.rb +46 -0
- data/lib/typesafe/sdk/retry_policy.rb +96 -0
- data/lib/typesafe/sdk/score.rb +40 -0
- data/lib/typesafe/sdk/score_answer.rb +53 -0
- data/lib/typesafe/sdk/stderr_logger.rb +42 -0
- data/lib/typesafe/sdk/system_one_response.rb +48 -0
- data/lib/typesafe/sdk/usage.rb +37 -0
- data/lib/typesafe/sdk/version.rb +7 -0
- data/lib/typesafe/sdk.rb +47 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a54e918b7e673dea30f00fb5fa85ea16396686bb9b36a6e8ed46454435eb56eb
|
|
4
|
+
data.tar.gz: 259eada336581f878ab36ace53984b78fcaaac272b6be94e8f77060bbbc196a1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ff55db6ad6fe6c4b0a1fcc559b7435a3d9fa5e692b6a43d0be4c07e2e9ebbc0c24f55eadbea6887717b15a30f8701b036a9787722629b09aebf9ef5501d19979
|
|
7
|
+
data.tar.gz: 84cf649d5ad68239bf4c914853cd392f5c3ee498cf05fe0f456df5feb89228edd7089ae1de552d4e8db862ac9425c4e538cfb16ee7b90fe7a74cf040e4fab469
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-16
|
|
4
|
+
|
|
5
|
+
- Initial release, mirroring the TypeSafe Python SDK 0.6.0
|
|
6
|
+
- `Client#system_one` for Noul, Choice, and Score questions, with typed answers
|
|
7
|
+
- `Client#models.list` for the models available to your account
|
|
8
|
+
- Retry policy with exponential backoff, jitter, `Retry-After` support, and a total retry budget
|
|
9
|
+
- Typed errors for every documented status, plus connection, timeout, and response validation errors
|
|
10
|
+
- Thread-safe, fork-aware keep-alive connection pool on `Net::HTTP`
|
|
11
|
+
- Standard library HTTP and JSON, with Zeitwerk as the only runtime dependency
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Josh Brody
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# typesafe-sdk
|
|
2
|
+
|
|
3
|
+
A Ruby client for the [TypeSafe](https://typesafe.ai) System One API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle add typesafe-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or without bundler:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install typesafe-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Ruby 3.1 or newer.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
Grab an API key from the [TypeSafe console](https://console.typesafe.ai/settings/keys) and hand it to the client. It uses `jev-latest` unless you tell it otherwise.
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "typesafe/sdk"
|
|
25
|
+
|
|
26
|
+
client = Typesafe::SDK::Client.new(api_key: "sk-...")
|
|
27
|
+
|
|
28
|
+
ticket = "Hi, I've been trying to connect my Stripe account for 3 days and it keeps failing. " \
|
|
29
|
+
"I'm losing sales. Please help ASAP."
|
|
30
|
+
|
|
31
|
+
response = client.system_one(
|
|
32
|
+
state: ticket,
|
|
33
|
+
questions: {
|
|
34
|
+
department: Typesafe::SDK::Choice.new(
|
|
35
|
+
instructions: "Which team should handle this",
|
|
36
|
+
criteria: {
|
|
37
|
+
billing: "Payment or subscription issues",
|
|
38
|
+
technical: "Bugs or integration problems",
|
|
39
|
+
sales: "Pricing or account questions"
|
|
40
|
+
}
|
|
41
|
+
),
|
|
42
|
+
frustration: Typesafe::SDK::Score.new(
|
|
43
|
+
instructions: "How frustrated the customer appears",
|
|
44
|
+
criteria: ["Calm, just stating facts", "Frustrated but civil", "Very angry, strong language"]
|
|
45
|
+
),
|
|
46
|
+
is_urgent: Typesafe::SDK::Noul.new(instructions: "The message conveys urgency or time-sensitivity")
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
response.choices["department"].choice
|
|
51
|
+
response.scores["frustration"].score
|
|
52
|
+
response.nouls["is_urgent"].noul
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Questions
|
|
56
|
+
|
|
57
|
+
There are three question types. Every question takes optional `instructions`, and `instructions` and criteria can be a string, a hash, or an array if you need more structure than a sentence. See [primitives](https://docs.typesafe.ai/primitives) for when to reach for which.
|
|
58
|
+
|
|
59
|
+
`Noul` is a yes/no question. The answer is the probability of yes, from 0 to 1. Criteria are optional.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Typesafe::SDK::Noul.new(
|
|
63
|
+
instructions: "Is this message spam?",
|
|
64
|
+
criteria: { true: "Unsolicited advertising", false: "A real conversation" }
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`Choice` picks one option from a set. Criteria are required; use `nil` when the option name speaks for itself.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
Typesafe::SDK::Choice.new(
|
|
72
|
+
instructions: "What is the tone?",
|
|
73
|
+
criteria: { calm: nil, frustrated: nil, angry: "Shouting, threats, or profanity" }
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`Score` rates the state against ordered levels. Criteria are an array, and each level's position is its score starting at zero.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Typesafe::SDK::Score.new(
|
|
81
|
+
instructions: "How urgent is this?",
|
|
82
|
+
criteria: ["Can wait", "Needs attention this week", "Needs attention today"]
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
You can also pass a plain hash with a `type` key, and you can mix hashes and question objects in the same request. Extra keys go to the API untouched, which is handy when the API ships a field before this gem knows about it.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
client.system_one(
|
|
90
|
+
state: { message: "I was charged twice." },
|
|
91
|
+
questions: {
|
|
92
|
+
billing: { type: "noul", instructions: "Is this about billing?", weight: 2 },
|
|
93
|
+
tone: Typesafe::SDK::Choice.new(criteria: { calm: nil, angry: nil })
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The SDK checks the obvious mistakes before sending anything (no questions, a score with no levels, a hash choice with no criteria, a `Float::NAN` buried in your state) and raises `Typesafe::SDK::Error`, so you don't burn a round trip finding out.
|
|
99
|
+
|
|
100
|
+
## State
|
|
101
|
+
|
|
102
|
+
State is whatever you want the questions to be about: a string, a hash, or an array. Symbols and symbol keys get converted to strings; anything that responds to `as_json` gets converted through that. Anything else raises instead of silently sending `"#<Object:0x000...>"` to the model. See [state](https://docs.typesafe.ai/concepts/state) for how to structure it.
|
|
103
|
+
|
|
104
|
+
## Answers
|
|
105
|
+
|
|
106
|
+
`system_one` returns a `Typesafe::SDK::SystemOneResponse`.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
response.model
|
|
110
|
+
response.usage.input_tokens
|
|
111
|
+
response.usage.output_tokens
|
|
112
|
+
response.request_id
|
|
113
|
+
|
|
114
|
+
response.answers
|
|
115
|
+
response.nouls
|
|
116
|
+
response.choices
|
|
117
|
+
response.scores
|
|
118
|
+
response[:department]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`answers` holds everything keyed by the name you gave the question. Names always come back as strings, even when you sent symbols, which is why `response[:department]` exists. `nouls`, `choices`, and `scores` are the same answers filtered by type.
|
|
122
|
+
|
|
123
|
+
`NoulAnswer#noul` is a float from 0 to 1.
|
|
124
|
+
|
|
125
|
+
`ChoiceAnswer` has `choice`, `confidence`, and `probabilities`, a hash of option name to probability.
|
|
126
|
+
|
|
127
|
+
`ScoreAnswer` has `score`, `confidence`, `legend`, and `probabilities`. `score` is probability-weighted, so it can land between levels (1.6 is a real answer). `legend` and `probabilities` are keyed by integer level, same as the Python SDK.
|
|
128
|
+
|
|
129
|
+
Every answer object is frozen and has a `to_h`.
|
|
130
|
+
|
|
131
|
+
Confidence is the thing you want to gate actions on. Low confidence means the model is telling you it isn't sure, which is useful information and not a failure. The [confidence docs](https://docs.typesafe.ai/confidence) have a good pattern for picking thresholds by risk.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
action = response.choices["action"]
|
|
135
|
+
|
|
136
|
+
if action.confidence < 0.5
|
|
137
|
+
route_to_human(message)
|
|
138
|
+
elsif action.choice == "approve_transfer" && action.confidence > 0.9
|
|
139
|
+
confirm_then_execute(account)
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If the API sends back an answer type this version doesn't know about, the SDK logs a warning and skips it. The raw response is still on `response.http_response` if you need it:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
response.http_response.json["answers"]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Models
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
client.models.list.each do |model|
|
|
153
|
+
puts "#{model.name} #{model.release_date} #{model.description}"
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Pick a default model on the client, or override it per call:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
client = Typesafe::SDK::Client.new(api_key: api_key, model: "jev")
|
|
161
|
+
client.system_one(state: "hi", questions: questions, model: "jev-latest")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Configuration
|
|
165
|
+
|
|
166
|
+
Everything is set on the client. The SDK never reads environment variables, so where the key comes from (Rails credentials, `ENV.fetch`, a vault) is your call.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
client = Typesafe::SDK::Client.new(
|
|
170
|
+
api_key: Rails.application.credentials.dig(:typesafe, :api_key),
|
|
171
|
+
model: "jev-latest",
|
|
172
|
+
timeout: 5
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Option | Default |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| `api_key:` | required |
|
|
179
|
+
| `base_url:` | `https://api.typesafe.ai` |
|
|
180
|
+
| `model:` | `jev-latest` |
|
|
181
|
+
| `timeout:` | `10.0` seconds per HTTP operation |
|
|
182
|
+
| `headers:` | `{}` |
|
|
183
|
+
| `user_agent:` | `typesafe-sdk-ruby/VERSION` |
|
|
184
|
+
| `logger:` | none |
|
|
185
|
+
| `retry_policy:` | `Typesafe::SDK::RetryPolicy.new` |
|
|
186
|
+
| `transport:` | `Typesafe::SDK::NetHttpTransport.new` |
|
|
187
|
+
|
|
188
|
+
`system_one` also takes `model:`, `timeout:`, `retry_policy:`, `extra_headers:`, and `extra_body:` for a single call. `models.list` takes everything except the body stuff.
|
|
189
|
+
|
|
190
|
+
`extra_body:` is shallow-merged over the request body last, so it wins any collision with `state`, `model`, or `questions`. Use it for request fields the API has and this gem doesn't yet:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
client.system_one(state: "I was charged twice.", questions: questions, extra_body: { beam_width: 4 })
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`User-Agent` defaults to `typesafe-sdk-ruby/VERSION`. Set `user_agent:` on the client to change it for every request, or pass it in `extra_headers:` to change it for one call:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
client = Typesafe::SDK::Client.new(api_key: api_key, user_agent: "my-app/1.0")
|
|
200
|
+
client.user_agent
|
|
201
|
+
client.system_one(state: state, questions: questions, extra_headers: { "User-Agent" => "nightly-import/1.0" })
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A `User-Agent` in the client's `headers:` works too, but `user_agent:` wins if you pass both. `Authorization`, `Accept`, `X-TypeSafe-SDK`, and `X-TypeSafe-Runtime` always win over anything you pass.
|
|
205
|
+
|
|
206
|
+
## Retries
|
|
207
|
+
|
|
208
|
+
The client retries 408, 429, and every 5xx (including TypeSafe's 529 overloaded), plus connection failures and timeouts. It does two retries by default with exponential backoff and jitter, honors `Retry-After` and `retry-after-ms`, and gives up once the whole call would blow past a 30-second budget.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
policy = Typesafe::SDK::RetryPolicy.new(
|
|
212
|
+
max_retries: 3,
|
|
213
|
+
backoff_initial: 0.5,
|
|
214
|
+
backoff_max: 5.0,
|
|
215
|
+
backoff_jitter: 0.25,
|
|
216
|
+
http_statuses: [429, 500, 502, 503, 504, 529],
|
|
217
|
+
respect_retry_after: true,
|
|
218
|
+
api_connection_error: true,
|
|
219
|
+
api_timeout_error: true,
|
|
220
|
+
exceptions: [],
|
|
221
|
+
predicate: nil,
|
|
222
|
+
timeout: 30.0
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
client = Typesafe::SDK::Client.new(api_key: api_key, retry_policy: policy)
|
|
226
|
+
client.system_one(state: state, questions: questions, retry_policy: Typesafe::SDK::RetryPolicy.new(max_retries: 0))
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`timeout` on a retry policy is the total budget for the call across every attempt and sleep, and `nil` turns it off. That's a different thing from the client's `timeout:`, which caps each individual HTTP operation. Retries after the first attempt send an `X-TypeSafe-Retry-Count` header.
|
|
230
|
+
|
|
231
|
+
`exceptions` and `predicate` let you retry on things the built-in rules don't cover:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
Typesafe::SDK::RetryPolicy.new(predicate: ->(error) { error.is_a?(Typesafe::SDK::APIError) && error.status == 409 })
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Errors
|
|
238
|
+
|
|
239
|
+
Everything the SDK raises inherits from `Typesafe::SDK::Error`.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
begin
|
|
243
|
+
client.system_one(state: state, questions: questions)
|
|
244
|
+
rescue Typesafe::SDK::RateLimitError => e
|
|
245
|
+
e.retry_after_ms
|
|
246
|
+
rescue Typesafe::SDK::APIError => e
|
|
247
|
+
e.status
|
|
248
|
+
e.body
|
|
249
|
+
e.headers
|
|
250
|
+
e.request_id
|
|
251
|
+
rescue Typesafe::SDK::APIConnectionError => e
|
|
252
|
+
e.message
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Error | When |
|
|
257
|
+
| --- | --- |
|
|
258
|
+
| `Error` | bad input, a missing API key, or an invalid option, raised before any request goes out |
|
|
259
|
+
| `APIError` | any non-2xx response without a more specific class below |
|
|
260
|
+
| `BadRequestError` | 400 |
|
|
261
|
+
| `AuthenticationError` | 401 |
|
|
262
|
+
| `PermissionDeniedError` | 403 |
|
|
263
|
+
| `NotFoundError` | 404 |
|
|
264
|
+
| `UnprocessableEntityError` | 422 |
|
|
265
|
+
| `RateLimitError` | 429, with `retry_after_ms` |
|
|
266
|
+
| `InternalServerError` | 500 and up, including 529 |
|
|
267
|
+
| `APIResponseValidationError` | a 2xx whose body is missing something required, with `field_path` like `"answers.tone.confidence"` |
|
|
268
|
+
| `APIConnectionError` | the request never got a response |
|
|
269
|
+
| `APITimeoutError` | a subclass of `APIConnectionError`, with `timeout` |
|
|
270
|
+
|
|
271
|
+
Error messages pull the useful part out of the API's error body and include the endpoint and request ID, so a log line like this is usually enough to go on:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
POST https://api.typesafe.ai/v1/systemone: 401 Cannot authenticate with the server. Please check your API key and try again. (request_id=req_01a0aa64a96a...)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Those are raised after retries run out. Errors that aren't retryable (a 422, say) are raised on the first attempt.
|
|
278
|
+
|
|
279
|
+
## Logging
|
|
280
|
+
|
|
281
|
+
Pass anything that responds to `debug`, `info`, and `warn`, like `Logger`:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
Typesafe::SDK::Client.new(api_key: api_key, logger: Logger.new)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Or use the built-in stderr logger with a level of `debug`, `info`, `warn`, or `error`:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
Typesafe::SDK::Client.new(api_key: api_key, logger: Typesafe::SDK::StderrLogger.new(level: "info"))
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
No logger means no logging. The `info` level logs one line per request. The `debug` level adds request and response headers and bodies. `Authorization`, cookies, API keys, and any header with `token` or `secret` in the name get redacted. Bodies do not, so be careful turning on `debug` in production if your state has anything sensitive in it.
|
|
294
|
+
|
|
295
|
+
## Connections and threads
|
|
296
|
+
|
|
297
|
+
A client keeps a small pool of keep-alive connections, so you only pay for the TLS handshake once instead of on every call. It's safe to share one client across threads; each in-flight request checks out its own connection. The pool notices when your process forks (Puma, Unicorn, Sidekiq swarm) and starts fresh in the child instead of sharing sockets with the parent.
|
|
298
|
+
|
|
299
|
+
Call `close` when you're done, or use the block form, which closes for you:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
Typesafe::SDK::Client.open(api_key: api_key) do |client|
|
|
303
|
+
client.system_one(state: state, questions: questions)
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Proxies come from the usual `http_proxy`/`https_proxy` environment variables, because that's what `Net::HTTP` does.
|
|
308
|
+
|
|
309
|
+
## Custom transports
|
|
310
|
+
|
|
311
|
+
`transport:` takes any object with a `call(request)` method. It gets a `Typesafe::SDK::HTTPRequest` (`http_method`, `url`, `headers`, `body`, `timeout`) and must return a `Typesafe::SDK::HTTPResponse`. Raise `APITimeoutError` or `APIConnectionError` when the request fails without a response so the retry policy can do its thing. Implement `close` if you hold resources.
|
|
312
|
+
|
|
313
|
+
This is mostly for tests, or for when you want to use Faraday or HTTPX anyway:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
class RecordingTransport
|
|
317
|
+
def call(request)
|
|
318
|
+
Typesafe::SDK::HTTPResponse.new(
|
|
319
|
+
status: 200,
|
|
320
|
+
headers: { "content-type" => "application/json" },
|
|
321
|
+
body: File.read("spec/fixtures/system_one.json")
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
Typesafe::SDK::Client.new(api_key: "test", transport: RecordingTransport.new)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Differences from the Python SDK
|
|
330
|
+
|
|
331
|
+
There's no async client. Use threads, or wrap calls in whatever concurrency library you already have, since the client is thread-safe.
|
|
332
|
+
|
|
333
|
+
`retry` is a reserved word in Ruby, so the option is `retry_policy:`.
|
|
334
|
+
|
|
335
|
+
There are no environment variables. The Python SDK reads `TYPESAFE_API_KEY` and friends; this one only takes what you pass to the client.
|
|
336
|
+
|
|
337
|
+
`request_id` returns `nil` when the header is missing instead of raising.
|
|
338
|
+
|
|
339
|
+
`timeout:` is a single number of seconds. There's no equivalent to `httpx.Timeout` for setting connect and read separately.
|
|
340
|
+
|
|
341
|
+
The SDK identifies itself as `typesafe-sdk-ruby` in `X-TypeSafe-SDK`, and in `User-Agent` unless you override it.
|
|
342
|
+
|
|
343
|
+
## Development
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
bin/setup
|
|
347
|
+
bundle exec rake test
|
|
348
|
+
bin/console
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The tests use minitest. The transport tests stand up a real local TCP server, so there's no HTTP mocking library involved and nothing hits the real API.
|
|
352
|
+
|
|
353
|
+
## Contributing
|
|
354
|
+
|
|
355
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/joshmn/typesafe-sdk.
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class AnswerParser
|
|
6
|
+
ANSWER_CLASSES = {
|
|
7
|
+
NoulAnswer::TYPE => NoulAnswer,
|
|
8
|
+
ChoiceAnswer::TYPE => ChoiceAnswer,
|
|
9
|
+
ScoreAnswer::TYPE => ScoreAnswer
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(request_logger)
|
|
13
|
+
@request_logger = request_logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(answers)
|
|
17
|
+
FieldReader.object(answers, path: "answers").each_with_object({}) do |(name, raw), result|
|
|
18
|
+
path = FieldReader.join(path: "answers", key: name)
|
|
19
|
+
answer = FieldReader.object(raw, path: path)
|
|
20
|
+
type = FieldReader.string(hash: answer, key: "type", path: path)
|
|
21
|
+
answer_class = ANSWER_CLASSES[type]
|
|
22
|
+
if answer_class.nil?
|
|
23
|
+
request_logger.warn("ignoring answer #{name.inspect} with unrecognized type #{type.inspect}")
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result[name] = answer_class.from_hash(answer, path: path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :request_logger
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class APIError < Error
|
|
6
|
+
MAX_BODY_LENGTH = 200
|
|
7
|
+
|
|
8
|
+
attr_reader :status, :body, :headers, :endpoint
|
|
9
|
+
|
|
10
|
+
def initialize(status:, body:, headers:, message: nil, endpoint: nil)
|
|
11
|
+
@status = status
|
|
12
|
+
@body = body
|
|
13
|
+
@headers = headers
|
|
14
|
+
@endpoint = endpoint
|
|
15
|
+
super(full_message_for(message || default_message))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def request_id
|
|
19
|
+
headers[REQUEST_ID_HEADER]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def default_message
|
|
25
|
+
detail = ErrorMessage.extract(body)
|
|
26
|
+
return detail if detail
|
|
27
|
+
return "status code (no body)" if body.nil?
|
|
28
|
+
|
|
29
|
+
raw = body.is_a?(String) ? body : JSON.generate(body)
|
|
30
|
+
return raw if raw.length <= MAX_BODY_LENGTH
|
|
31
|
+
|
|
32
|
+
"#{raw[0, MAX_BODY_LENGTH]}..."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def full_message_for(detail)
|
|
36
|
+
text = detail.to_s.empty? ? status.to_s : "#{status} #{detail}"
|
|
37
|
+
text = "#{endpoint}: #{text}" if endpoint
|
|
38
|
+
text = "#{text} (request_id=#{request_id})" if request_id
|
|
39
|
+
text
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class APIErrorFactory
|
|
6
|
+
STATUS_ERRORS = {
|
|
7
|
+
400 => BadRequestError,
|
|
8
|
+
401 => AuthenticationError,
|
|
9
|
+
403 => PermissionDeniedError,
|
|
10
|
+
404 => NotFoundError,
|
|
11
|
+
422 => UnprocessableEntityError,
|
|
12
|
+
429 => RateLimitError
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def build(response:, endpoint:)
|
|
17
|
+
error_class_for(response.status).new(
|
|
18
|
+
status: response.status,
|
|
19
|
+
body: BodyDecoder.decode(response.body),
|
|
20
|
+
headers: response.headers,
|
|
21
|
+
endpoint: endpoint
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def error_class_for(status)
|
|
28
|
+
return STATUS_ERRORS[status] if STATUS_ERRORS.key?(status)
|
|
29
|
+
return InternalServerError if status >= 500
|
|
30
|
+
|
|
31
|
+
APIError
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class APIResponseValidationError < APIError
|
|
6
|
+
attr_reader :field_path
|
|
7
|
+
|
|
8
|
+
def initialize(status:, body:, headers:, field_path:, endpoint: nil)
|
|
9
|
+
@field_path = field_path
|
|
10
|
+
super(
|
|
11
|
+
status: status,
|
|
12
|
+
body: body,
|
|
13
|
+
headers: headers,
|
|
14
|
+
message: "invalid response data at #{field_path.inspect}",
|
|
15
|
+
endpoint: endpoint
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class APITimeoutError < APIConnectionError
|
|
6
|
+
attr_reader :timeout
|
|
7
|
+
|
|
8
|
+
def initialize(timeout:)
|
|
9
|
+
@timeout = timeout
|
|
10
|
+
super("request timed out (timeout=#{timeout})")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class BodyDecoder
|
|
6
|
+
class << self
|
|
7
|
+
def decode(body)
|
|
8
|
+
return if body.nil? || body.empty?
|
|
9
|
+
|
|
10
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
11
|
+
JSON.parse(text)
|
|
12
|
+
rescue JSON::ParserError, EncodingError
|
|
13
|
+
text.scrub
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class Choice
|
|
6
|
+
TYPE = "choice"
|
|
7
|
+
|
|
8
|
+
attr_reader :criteria, :instructions
|
|
9
|
+
|
|
10
|
+
def initialize(criteria:, instructions: nil)
|
|
11
|
+
raise(Error, "choice criteria must be a hash of options to descriptions") unless criteria.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
@criteria = criteria
|
|
14
|
+
@instructions = instructions
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def type
|
|
19
|
+
TYPE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
hash = { "type" => TYPE }
|
|
24
|
+
hash["instructions"] = JsonValue.normalize(instructions, path: "instructions") unless instructions.nil?
|
|
25
|
+
hash["criteria"] = JsonValue.normalize(criteria, path: "criteria")
|
|
26
|
+
hash
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ==(other)
|
|
30
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
31
|
+
end
|
|
32
|
+
alias eql? ==
|
|
33
|
+
|
|
34
|
+
def hash
|
|
35
|
+
[self.class, to_h].hash
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class ChoiceAnswer
|
|
6
|
+
TYPE = "choice"
|
|
7
|
+
|
|
8
|
+
attr_reader :choice, :confidence, :probabilities
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def from_hash(hash, path:)
|
|
12
|
+
new(
|
|
13
|
+
choice: FieldReader.string(hash: hash, key: "choice", path: path),
|
|
14
|
+
confidence: FieldReader.number(hash: hash, key: "confidence", path: path),
|
|
15
|
+
probabilities: FieldReader.number_map(hash: hash, key: "probabilities", path: path)
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(choice:, confidence:, probabilities:)
|
|
21
|
+
@choice = choice
|
|
22
|
+
@confidence = confidence
|
|
23
|
+
@probabilities = probabilities.freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def type
|
|
28
|
+
TYPE
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{ type: TYPE, choice: choice, confidence: confidence, probabilities: probabilities }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ==(other)
|
|
36
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
37
|
+
end
|
|
38
|
+
alias eql? ==
|
|
39
|
+
|
|
40
|
+
def hash
|
|
41
|
+
[self.class, to_h].hash
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|