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 +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/lib/typesafe/sdk/client.rb +335 -0
- data/lib/typesafe/sdk/env.rb +32 -0
- data/lib/typesafe/sdk/errors.rb +140 -0
- data/lib/typesafe/sdk/http.rb +153 -0
- data/lib/typesafe/sdk/logging.rb +100 -0
- data/lib/typesafe/sdk/questions.rb +65 -0
- data/lib/typesafe/sdk/resources/models.rb +31 -0
- data/lib/typesafe/sdk/retry.rb +75 -0
- data/lib/typesafe/sdk/types.rb +95 -0
- data/lib/typesafe/sdk/version.rb +8 -0
- data/lib/typesafe-sdk-ruby.rb +40 -0
- metadata +80 -0
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
|
+
[](https://rubygems.org/gems/typesafe-sdk-ruby)
|
|
4
|
+
[](https://github.com/afurm/typesafe-sdk-ruby/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|