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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +359 -0
  5. data/lib/typesafe/sdk/answer_parser.rb +36 -0
  6. data/lib/typesafe/sdk/api_error.rb +43 -0
  7. data/lib/typesafe/sdk/api_error_factory.rb +36 -0
  8. data/lib/typesafe/sdk/api_response_validation_error.rb +20 -0
  9. data/lib/typesafe/sdk/api_timeout_error.rb +14 -0
  10. data/lib/typesafe/sdk/body_decoder.rb +18 -0
  11. data/lib/typesafe/sdk/choice.rb +39 -0
  12. data/lib/typesafe/sdk/choice_answer.rb +45 -0
  13. data/lib/typesafe/sdk/client.rb +108 -0
  14. data/lib/typesafe/sdk/configuration.rb +66 -0
  15. data/lib/typesafe/sdk/connection_pool.rb +82 -0
  16. data/lib/typesafe/sdk/error_message.rb +51 -0
  17. data/lib/typesafe/sdk/field_reader.rb +76 -0
  18. data/lib/typesafe/sdk/http_request.rb +30 -0
  19. data/lib/typesafe/sdk/http_response.rb +24 -0
  20. data/lib/typesafe/sdk/invalid_response_field.rb +14 -0
  21. data/lib/typesafe/sdk/json_value.rb +51 -0
  22. data/lib/typesafe/sdk/list_models_response.rb +41 -0
  23. data/lib/typesafe/sdk/model_metadata.rb +39 -0
  24. data/lib/typesafe/sdk/models.rb +37 -0
  25. data/lib/typesafe/sdk/net_http_transport.rb +53 -0
  26. data/lib/typesafe/sdk/noul.rb +49 -0
  27. data/lib/typesafe/sdk/noul_answer.rb +39 -0
  28. data/lib/typesafe/sdk/question_set.rb +55 -0
  29. data/lib/typesafe/sdk/rate_limit_error.rb +14 -0
  30. data/lib/typesafe/sdk/request_builder.rb +58 -0
  31. data/lib/typesafe/sdk/request_logger.rb +68 -0
  32. data/lib/typesafe/sdk/requester.rb +92 -0
  33. data/lib/typesafe/sdk/retry_after.rb +46 -0
  34. data/lib/typesafe/sdk/retry_policy.rb +96 -0
  35. data/lib/typesafe/sdk/score.rb +40 -0
  36. data/lib/typesafe/sdk/score_answer.rb +53 -0
  37. data/lib/typesafe/sdk/stderr_logger.rb +42 -0
  38. data/lib/typesafe/sdk/system_one_response.rb +48 -0
  39. data/lib/typesafe/sdk/usage.rb +37 -0
  40. data/lib/typesafe/sdk/version.rb +7 -0
  41. data/lib/typesafe/sdk.rb +47 -0
  42. 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