typesafe-sdk-ruby 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c2f7c356ecf7107179e8d01aeec562af105267ef4fc640b9a95a8d0c0904413f
4
+ data.tar.gz: 5a7657f8bc722b9c7ecae8f126b7792b2911b08bbc006a54114f16fb0bf73bb3
5
+ SHA512:
6
+ metadata.gz: 62fd70fb931ccaae55504bb8ed8db5b674e347978704d938a178003e339c0061ed8f4f5de62574298471dd29b21369e6a82cf8d549985cf9e0e8c22781299a1d
7
+ data.tar.gz: ac8620432b7afd9cb36ec5e1a64b16fe3709590c3597234401cc71ab797558676441034af33b4b17ff4f650082a7f7627579dba5248db0a98c1ca5b734a48e04
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
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/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.6.0] - 2026-09-20
9
+
10
+ Initial release. A community port of the official
11
+ [TypeSafe JavaScript SDK](https://github.com/typesafe-ai/typesafe-sdk-js) v0.6.0.
12
+
13
+ Published as the `typesafe-sdk-ruby` gem (the `typesafe-sdk` name is already taken on
14
+ RubyGems by an unrelated project).
15
+
16
+ ### Added
17
+
18
+ - `Typesafe::SDK::Client` with `system_one` and a `models` resource.
19
+ - Question builders: `noul`, `score`, and `choice`, with request validation.
20
+ - Typed answer objects: `NoulResponse`, `ChoiceResponse`, `ScoreResponse`, plus `Usage`
21
+ and `ModelCard`.
22
+ - Automatic retries for HTTP 408/429/5xx, connection failures, and timeouts, with capped
23
+ exponential backoff, jitter, and `Retry-After` / `retry-after-ms` support.
24
+ - Per-attempt timeouts and cooperative cancellation via `Typesafe::SDK::Signal`.
25
+ - Typed error hierarchy: `TypeSafeError`, `APIError` subclasses per HTTP status,
26
+ `APIConnectionError`, `APITimeoutError`, and `APIUserAbortError`.
27
+ - Structured logging with level filtering and credential-header redaction.
28
+ - Configuration from `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`, `TYPESAFE_DEFAULT_MODEL`,
29
+ and `TYPESAFE_LOG_LEVEL`, with explicit options taking precedence.
30
+ - Pluggable HTTP adapter for custom transport or testing.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TypeSafe Ruby SDK Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # Unofficial TypeSafe AI Ruby SDK
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/typesafe-sdk-ruby)](https://rubygems.org/gems/typesafe-sdk-ruby)
4
+ [![CI](https://github.com/afurm/typesafe-sdk-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/afurm/typesafe-sdk-ruby/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ **Community-maintained, unofficial** Ruby SDK for [TypeSafe AI](https://typesafe.ai). Ask named
8
+ questions about text or structured state and get typed answers back: yes/no (noul), choice, and
9
+ score questions — with retries, timeouts, structured logging, and typed errors.
10
+
11
+ > This gem is not affiliated with, endorsed by, or supported by TypeSafe AI. It is a faithful
12
+ > community port of the official [JavaScript SDK](https://github.com/typesafe-ai/typesafe-sdk-js).
13
+ > Official SDKs: [JavaScript](https://github.com/typesafe-ai/typesafe-sdk-js) and Python.
14
+
15
+ > **Versioning:** gem versions intentionally mirror the official JavaScript SDK so it is
16
+ > obvious which upstream release each port tracks (e.g. gem 0.6.0 ≈ JS SDK 0.6.0).
17
+
18
+ ## Requirements
19
+
20
+ - Ruby 3.1 or newer
21
+ - A TypeSafe API key (`TYPESAFE_API_KEY`)
22
+
23
+ ## Installation
24
+
25
+ Install the gem:
26
+
27
+ ```sh
28
+ gem install typesafe-sdk-ruby
29
+ ```
30
+
31
+ Or add it to your application's `Gemfile`:
32
+
33
+ ```ruby
34
+ gem "typesafe-sdk-ruby"
35
+ ```
36
+
37
+ and run `bundle install`.
38
+
39
+ ## Quickstart
40
+
41
+ ```ruby
42
+ require "typesafe-sdk-ruby"
43
+
44
+ client = Typesafe::SDK::Client.new
45
+
46
+ response = client.system_one(
47
+ state: { document: "I was charged twice. Please fix this ASAP." },
48
+ questions: {
49
+ category: Typesafe::SDK.choice("What is this ticket about?", {
50
+ billing: nil,
51
+ technical: nil,
52
+ other: nil,
53
+ }),
54
+ },
55
+ )
56
+
57
+ puts response.answers["category"].choice
58
+ ```
59
+
60
+ Answer objects are typed by the question that produced them:
61
+
62
+ | Question | Answer class | Key fields |
63
+ | --- | --- | --- |
64
+ | `noul` | `Typesafe::SDK::NoulResponse` | `noul` (probability of yes) |
65
+ | `choice` | `Typesafe::SDK::ChoiceResponse` | `choice`, `confidence`, `probabilities` |
66
+ | `score` | `Typesafe::SDK::ScoreResponse` | `score`, `confidence`, `legend`, `probabilities` |
67
+
68
+ ## Configuration
69
+
70
+ Explicit options take precedence over environment variables, then SDK defaults.
71
+
72
+ | Option | Environment variable | Default |
73
+ | --- | --- | --- |
74
+ | `api_key:` | `TYPESAFE_API_KEY` | — (required) |
75
+ | `base_url:` | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` |
76
+ | `default_model:` | `TYPESAFE_DEFAULT_MODEL` | `jev-latest` |
77
+ | `log_level:` | `TYPESAFE_LOG_LEVEL` | `warn` |
78
+
79
+ ```ruby
80
+ client = Typesafe::SDK::Client.new(
81
+ api_key: "sk-...",
82
+ base_url: "https://api.typesafe.ai",
83
+ default_model: "jev-latest",
84
+ log_level: :info, # :debug, :info, :warn, :error, :off
85
+ timeout: 10, # seconds per attempt
86
+ retry_policy: { max_retries: 2, backoff_initial_ms: 500 },
87
+ default_headers: { "X-My-Header" => "value" },
88
+ )
89
+ ```
90
+
91
+ ## Retries and timeouts
92
+
93
+ The SDK retries HTTP `408`, `429`, and `5xx` responses plus connection failures and timeouts,
94
+ with capped exponential backoff and jitter. It honors `Retry-After` and `retry-after-ms`
95
+ headers up to a cap. Every option is overridable per client or per call:
96
+
97
+ ```ruby
98
+ client.system_one(
99
+ state: "...",
100
+ questions: { ... },
101
+ timeout: 30,
102
+ retry_policy: { max_retries: 0 }, # disable retries for this call
103
+ )
104
+ ```
105
+
106
+ ## Error handling
107
+
108
+ ```ruby
109
+ begin
110
+ client.system_one(state: "...", questions: { ... })
111
+ rescue Typesafe::SDK::RateLimitError => e
112
+ retry_after e.retry_after_ms
113
+ rescue Typesafe::SDK::APIError => e
114
+ warn "API error #{e.status} (request #{e.request_id}): #{e.body}"
115
+ end
116
+ ```
117
+
118
+ Error hierarchy:
119
+
120
+ - `Typesafe::SDK::TypeSafeError` — base class
121
+ - `Typesafe::SDK::APIError` — non-2xx HTTP responses
122
+ - `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403),
123
+ `NotFoundError` (404), `UnprocessableEntityError` (422), `RateLimitError` (429),
124
+ `InternalServerError` (5xx)
125
+ - `Typesafe::SDK::APIConnectionError` — DNS, TLS, connection failures
126
+ - `Typesafe::SDK::APITimeoutError`
127
+ - `Typesafe::SDK::APIUserAbortError` — caller cancellation
128
+
129
+ ## Cancellation
130
+
131
+ ```ruby
132
+ signal = Typesafe::SDK::Signal.new
133
+ Thread.new { sleep 5; signal.cancel }
134
+
135
+ client.system_one(state: "...", questions: { ... }, signal: signal)
136
+ # raises Typesafe::SDK::APIUserAbortError once canceled
137
+ ```
138
+
139
+ ## Listing models
140
+
141
+ ```ruby
142
+ client.models.list.each do |model|
143
+ puts "#{model.name}: #{model.description}"
144
+ end
145
+ ```
146
+
147
+ ## Logging
148
+
149
+ The default logger writes to `$stderr` with a `[typesafe-sdk-ruby]` prefix. `info` logs request
150
+ summaries; `debug` adds headers (credentials redacted) and bodies. Pass any object responding
151
+ to `debug`/`info`/`warn`/`error`:
152
+
153
+ ```ruby
154
+ client = Typesafe::SDK::Client.new(logger: Rails.logger, log_level: :info)
155
+ ```
156
+
157
+ ## Ruby on Rails
158
+
159
+ The gem is framework-agnostic and works out of the box in Rails. A common pattern is a
160
+ wrapped initializer:
161
+
162
+ ```ruby
163
+ # config/initializers/typesafe.rb
164
+ TYPESAFE = Typesafe::SDK::Client.new(log_level: :info)
165
+ ```
166
+
167
+ ```ruby
168
+ # app/models/concerns/typesafe_classifiable.rb
169
+ module TypesafeClassifiable
170
+ def classify(text)
171
+ TYPESAFE.system_one(
172
+ state: text,
173
+ questions: {
174
+ category: Typesafe::SDK.choice("Category?", {
175
+ billing: nil, technical: nil, other: nil,
176
+ }),
177
+ },
178
+ ).answers["category"].choice
179
+ end
180
+ end
181
+ ```
182
+
183
+ ## Development
184
+
185
+ ```sh
186
+ bundle install
187
+ bundle exec rake # specs + RuboCop
188
+ bundle exec rspec # specs only
189
+ bundle exec rubocop # lint only
190
+ ```
191
+
192
+ ## Contributing
193
+
194
+ Bug reports and pull requests are welcome on
195
+ [GitHub](https://github.com/afurm/typesafe-sdk-ruby/issues). See
196
+ [CONTRIBUTING.md](CONTRIBUTING.md).
197
+
198
+ ## License
199
+
200
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Typesafe
9
+ module SDK
10
+ # Default API root.
11
+ DEFAULT_BASE_URL = "https://api.typesafe.ai"
12
+ # Default model used when a request omits `model`.
13
+ DEFAULT_MODEL = "jev-latest"
14
+
15
+ # Client for the TypeSafe AI API.
16
+ class Client
17
+ attr_reader :base_url, :default_model, :log_level, :logger, :retry, :timeout,
18
+ :default_headers, :models
19
+
20
+ # Create a client for the TypeSafe AI API.
21
+ #
22
+ # Explicit options take precedence over environment variables, then SDK defaults.
23
+ # Empty or whitespace-only environment values are ignored.
24
+ #
25
+ # @param api_key [String, nil] required unless `TYPESAFE_API_KEY` is set.
26
+ # @param base_url [String, nil] API root; falls back to `TYPESAFE_BASE_URL`.
27
+ # @param default_model [String, nil] falls back to `TYPESAFE_DEFAULT_MODEL`, then `jev-latest`.
28
+ # @param log_level [Symbol, String, nil] falls back to `TYPESAFE_LOG_LEVEL`, then `warn`.
29
+ # @param logger [#debug, #info, #warn, #error] default: {ConsoleLogger}.
30
+ # @param retry_policy [Hash, nil] overrides for {Retry::DEFAULT_RETRY_POLICY}.
31
+ # @param timeout [Float, nil] timeout per attempt in seconds. Default: 10.
32
+ # @param default_headers [Hash, nil] additional request headers.
33
+ # @param http [Object] HTTP adapter responding to `request`. Default: {HTTP}.
34
+ # @raise [TypeSafeError] the API key is missing or configuration is invalid.
35
+ def initialize(api_key: nil, base_url: nil, default_model: nil, log_level: nil,
36
+ logger: nil, retry_policy: nil, timeout: nil, default_headers: nil,
37
+ http: nil, env: ::ENV)
38
+ @api_key = ENV.from_code_or_env(api_key, ENV::API_KEY, source: env)
39
+ raise TypeSafeError, missing_api_key_message if @api_key.nil? || @api_key.empty?
40
+
41
+ @base_url = strip_trailing_slashes(
42
+ ENV.from_code_or_env(base_url, ENV::BASE_URL, source: env) || DEFAULT_BASE_URL
43
+ )
44
+ @default_model = ENV.from_code_or_env(default_model, ENV::DEFAULT_MODEL, source: env) ||
45
+ DEFAULT_MODEL
46
+ @log_level = resolve_log_level(log_level, env)
47
+ sink = logger || ConsoleLogger.new
48
+ @logger = LevelLogger.new(sink, @log_level)
49
+ @retry = resolve_retry_policy(Retry::DEFAULT_RETRY_POLICY, retry_policy)
50
+ @timeout = assert_positive("timeout", timeout || Retry::DEFAULT_TIMEOUT_S)
51
+ @default_headers = (default_headers || {}).dup.freeze
52
+ @http = http || HTTP.new
53
+ @models = Resources::Models.new(self)
54
+ @request_count = 0
55
+ end
56
+
57
+ # Answer named questions about text or structured state.
58
+ #
59
+ # @param state [String, Hash, Array, nil] the content to evaluate.
60
+ # @param questions [Hash{Symbol, String => Hash}] nonempty questions keyed by name.
61
+ # @param model [String, nil] model override; omitted values inherit `default_model`.
62
+ # @param options [Hash] per-call `timeout`, `retry`, `headers`, and `signal` settings.
63
+ # @return [SystemOneResult] answers keyed by question name, with model and token usage.
64
+ # @raise [TypeSafeError] questions are empty, or score criteria are not a list of at
65
+ # least two entries.
66
+ # @raise [APIError] the server returns a non-2xx response after retries.
67
+ # @raise [APIConnectionError] the request cannot connect or times out after retries.
68
+ # @raise [APIUserAbortError] the caller cancels the request.
69
+ #
70
+ # @example
71
+ # response = client.system_one(
72
+ # state: "I was charged twice. Please help.",
73
+ # questions: { billing: Typesafe::SDK.noul("Is this about billing?") },
74
+ # )
75
+ # response.answers[:billing].noul # => 0.93
76
+ def system_one(state:, questions:, model: nil, **options)
77
+ Questions.validate!(questions)
78
+ body = { state: state, questions: questions, model: model || @default_model }
79
+ response = request(:post, "/v1/systemone", body: body, **options)
80
+ SystemOneResult.new(response.body)
81
+ end
82
+
83
+ # Send a request and parse its response body. Internal; used by API resources.
84
+ #
85
+ # @return [Response] the parsed response, with `data`, `status`, `headers`, and `request_id`.
86
+ def request(method, path, body: nil, headers: {}, timeout: nil, retry_policy: nil,
87
+ signal: nil)
88
+ resolved = {
89
+ method: method,
90
+ path: path,
91
+ body: body,
92
+ headers: merge_headers(@default_headers, headers),
93
+ timeout: timeout.nil? ? @timeout : assert_positive("timeout", timeout),
94
+ retry: resolve_retry_policy(@retry, retry_policy),
95
+ signal: signal
96
+ }
97
+ @request_count += 1
98
+ tag = "##{@request_count} #{method.to_s.upcase} #{path}"
99
+ fetch_with_retries(tag, resolved)
100
+ end
101
+
102
+ # The API key, excluded from inspection output.
103
+ def inspect # :nodoc:
104
+ "#<#{self.class.name} base_url=#{@base_url.inspect} default_model=#{@default_model.inspect}>"
105
+ end
106
+
107
+ private
108
+
109
+ def missing_api_key_message
110
+ "No API key was provided. Pass `api_key:` to Typesafe::SDK::Client.new or set the " \
111
+ "#{ENV::API_KEY} environment variable."
112
+ end
113
+
114
+ def resolve_log_level(from_code, env)
115
+ value = from_code || ENV.read(ENV::LOG_LEVEL, source: env) || LogLevel::DEFAULT
116
+ LogLevel.parse!(value, from_code ? "the `log_level` option" : ENV::LOG_LEVEL)
117
+ end
118
+
119
+ def strip_trailing_slashes(url)
120
+ url.sub(%r{/+\z}, "")
121
+ end
122
+
123
+ def assert_positive(name, value)
124
+ unless value.is_a?(Numeric) && value.positive?
125
+ raise TypeSafeError,
126
+ "`#{name}` must be a positive number, got #{value.inspect}."
127
+ end
128
+
129
+ value
130
+ end
131
+
132
+ def assert_non_negative_integer(name, value)
133
+ unless value.is_a?(Integer) && value >= 0
134
+ raise TypeSafeError, "`#{name}` must be a non-negative integer, got #{value.inspect}."
135
+ end
136
+
137
+ value
138
+ end
139
+
140
+ def assert_non_negative(name, value)
141
+ unless value.is_a?(Numeric) && value >= 0
142
+ raise TypeSafeError, "`#{name}` must be a non-negative number, got #{value.inspect}."
143
+ end
144
+
145
+ value
146
+ end
147
+
148
+ def assert_fraction(name, value)
149
+ unless value.is_a?(Numeric) && value >= 0 && value <= 1
150
+ raise TypeSafeError, "`#{name}` must be between 0 and 1, got #{value.inspect}."
151
+ end
152
+
153
+ value
154
+ end
155
+
156
+ def assert_status_set(name, statuses)
157
+ statuses.each do |status|
158
+ unless status.is_a?(Integer) && status.between?(100, 999)
159
+ raise TypeSafeError, "`#{name}` must contain HTTP status codes, got #{status.inspect}."
160
+ end
161
+ end
162
+ statuses
163
+ end
164
+
165
+ # Merge and validate retry overrides, copying the status list to isolate later mutations.
166
+ def resolve_retry_policy(base, overrides)
167
+ o = overrides || {}
168
+ {
169
+ max_retries: if o.key?(:max_retries)
170
+ assert_non_negative_integer("retry.max_retries",
171
+ o[:max_retries])
172
+ else
173
+ base[:max_retries]
174
+ end,
175
+ backoff_initial_ms: if o.key?(:backoff_initial_ms)
176
+ assert_non_negative("retry.backoff_initial_ms",
177
+ o[:backoff_initial_ms])
178
+ else
179
+ base[:backoff_initial_ms]
180
+ end,
181
+ backoff_max_ms: if o.key?(:backoff_max_ms)
182
+ assert_non_negative("retry.backoff_max_ms",
183
+ o[:backoff_max_ms])
184
+ else
185
+ base[:backoff_max_ms]
186
+ end,
187
+ backoff_jitter: if o.key?(:backoff_jitter)
188
+ assert_fraction("retry.backoff_jitter",
189
+ o[:backoff_jitter])
190
+ else
191
+ base[:backoff_jitter]
192
+ end,
193
+ http_statuses: if o.key?(:http_statuses)
194
+ assert_status_set("retry.http_statuses",
195
+ o[:http_statuses].dup)
196
+ else
197
+ base[:http_statuses]
198
+ end,
199
+ respect_retry_after: o.fetch(:respect_retry_after, base[:respect_retry_after]),
200
+ max_retry_after_ms: if o.key?(:max_retry_after_ms)
201
+ assert_non_negative("retry.max_retry_after_ms",
202
+ o[:max_retry_after_ms])
203
+ else
204
+ base[:max_retry_after_ms]
205
+ end,
206
+ api_connection_error: o.fetch(:api_connection_error, base[:api_connection_error]),
207
+ api_timeout_error: o.fetch(:api_timeout_error, base[:api_timeout_error])
208
+ }.freeze
209
+ end
210
+
211
+ # Last value wins regardless of casing; `nil` removes a protected header.
212
+ def merge_headers(*sources)
213
+ merged = {}
214
+ sources.each do |source|
215
+ source.each do |name, value|
216
+ key = name.to_s.downcase
217
+ if value.nil?
218
+ merged.delete(key)
219
+ else
220
+ merged[key] = value
221
+ end
222
+ end
223
+ end
224
+ merged
225
+ end
226
+
227
+ # Retry eligible failures, logging attempt summaries at `info` and details at `debug`.
228
+ def fetch_with_retries(tag, req)
229
+ url = "#{@base_url}#{req[:path]}"
230
+ # User-supplied headers go first so they can't clobber auth or the JSON content type.
231
+ headers = merge_headers(req[:headers], {
232
+ "Authorization" => "Bearer #{@api_key}",
233
+ "Accept" => "application/json",
234
+ "User-Agent" => "typesafe-sdk-ruby/#{VERSION}",
235
+ "X-TypeSafe-SDK" => "typesafe-sdk-ruby/#{VERSION}",
236
+ "X-TypeSafe-Runtime" => "ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})",
237
+ "Content-Type" => req[:body].nil? ? nil : "application/json",
238
+ "X-TypeSafe-Retry-Count" => nil
239
+ })
240
+ payload = req[:body].nil? ? nil : JSON.generate(req[:body])
241
+
242
+ attempt = 0
243
+ loop do
244
+ retries_left = req[:retry][:max_retries] - attempt
245
+ attempt_headers = attempt.zero? ? headers : headers.merge("X-TypeSafe-Retry-Count" => attempt.to_s)
246
+ @logger.debug("#{tag} -> #{url}", headers: Redaction.redact_headers(attempt_headers), body: req[:body])
247
+
248
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
249
+ begin
250
+ response = attempt_request(tag, url, req[:method], attempt_headers, payload, req)
251
+ rescue APIUserAbortError
252
+ raise
253
+ rescue APIConnectionError => e
254
+ raise if retries_left <= 0 || !retryable_connection_error?(e, req[:retry])
255
+
256
+ back_off(tag, attempt, retries_left, e.message, nil, req)
257
+ attempt += 1
258
+ next
259
+ end
260
+
261
+ request_id = response.request_id
262
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
263
+ @logger.info("#{tag} <- #{response.status} in #{elapsed}ms#{" (request #{request_id})" if request_id}")
264
+ return parse_success(tag, response) if response.ok?
265
+
266
+ error = APIError.from_response(response.status, response.body, response.headers)
267
+ @logger.debug("#{tag} <- error body", body: response.body)
268
+ raise error if retries_left <= 0 || !Retry.retryable_status?(response.status, req[:retry])
269
+
270
+ back_off(tag, attempt, retries_left, response.status.to_s, response.headers, req)
271
+ attempt += 1
272
+ end
273
+ end
274
+
275
+ def retryable_connection_error?(error, policy)
276
+ return policy[:api_timeout_error] if error.is_a?(APITimeoutError)
277
+
278
+ policy[:api_connection_error]
279
+ end
280
+
281
+ def parse_success(tag, response)
282
+ @logger.debug("#{tag} <- body", body: response.body)
283
+ response
284
+ end
285
+
286
+ # One HTTP round trip, including body delivery, with a timeout.
287
+ def attempt_request(tag, url, method, headers, payload, req)
288
+ @http.request(
289
+ method: method,
290
+ url: url,
291
+ headers: headers,
292
+ body: payload,
293
+ timeout: req[:timeout],
294
+ signal: req[:signal]
295
+ )
296
+ rescue APIUserAbortError
297
+ raise
298
+ rescue APITimeoutError
299
+ @logger.info("#{tag} timed out")
300
+ raise
301
+ rescue APIConnectionError => e
302
+ @logger.info("#{tag} connection error", error: e.message)
303
+ raise
304
+ end
305
+
306
+ # Wait before retrying; caller cancellation raises `APIUserAbortError`.
307
+ def back_off(tag, attempt, retries_left, reason, headers, req)
308
+ delay = Retry.retry_delay_ms(attempt, headers, req[:retry])
309
+ nth = attempt + 1
310
+ total = attempt + retries_left
311
+ @logger.info("#{tag} retrying in #{delay}ms (retry #{nth}/#{total}) after #{reason}")
312
+ sleep_with_signal(delay, req[:signal])
313
+ end
314
+
315
+ def sleep_with_signal(seconds, signal)
316
+ signal&.check!
317
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (seconds / 1000.0)
318
+ loop do
319
+ if signal
320
+ signal.check!
321
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
322
+ signal.wait(remaining) if remaining.positive?
323
+ signal.check!
324
+ else
325
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
326
+ break if remaining <= 0
327
+
328
+ sleep([remaining, 0.1].min)
329
+ end
330
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Environment variable names for client configuration. Explicit options take precedence.
6
+ module ENV
7
+ # Required API key; used when `api_key` is omitted.
8
+ API_KEY = "TYPESAFE_API_KEY"
9
+ # API root; defaults to `https://api.typesafe.ai`.
10
+ BASE_URL = "TYPESAFE_BASE_URL"
11
+ # Default model name; defaults to `jev-latest`.
12
+ DEFAULT_MODEL = "TYPESAFE_DEFAULT_MODEL"
13
+ # Log level; defaults to `warn`.
14
+ LOG_LEVEL = "TYPESAFE_LOG_LEVEL"
15
+
16
+ ALL = [API_KEY, BASE_URL, DEFAULT_MODEL, LOG_LEVEL].freeze
17
+
18
+ module_function
19
+
20
+ # Read a trimmed environment value, returning `nil` for missing or blank values.
21
+ def read(name, source: ::ENV)
22
+ value = source[name].to_s.strip
23
+ value.empty? ? nil : value
24
+ end
25
+
26
+ # Return the explicit value, falling back to the environment.
27
+ def from_code_or_env(from_code, name, source: ::ENV)
28
+ from_code || read(name, source: source)
29
+ end
30
+ end
31
+ end
32
+ end