typesafe-sdk-ruby 0.6.0 → 0.6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/LICENSE +1 -0
- data/README.md +241 -91
- data/docs/COMPATIBILITY.md +89 -0
- data/docs/RELEASING.md +38 -0
- data/docs/UPSTREAM_ISSUES.md +64 -0
- data/lib/typesafe/sdk/client.rb +45 -21
- data/lib/typesafe/sdk/errors.rb +16 -4
- data/lib/typesafe/sdk/http.rb +56 -8
- data/lib/typesafe/sdk/logging.rb +5 -1
- data/lib/typesafe/sdk/questions.rb +60 -34
- data/lib/typesafe/sdk/resources/models.rb +6 -4
- data/lib/typesafe/sdk/retry.rb +4 -4
- data/lib/typesafe/sdk/types.rb +11 -0
- data/lib/typesafe/sdk/version.rb +3 -2
- metadata +12 -5
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Upstream issue audit
|
|
2
|
+
|
|
3
|
+
Reviewed all **11 issues** returned by the official JavaScript repository's all-state issue
|
|
4
|
+
list on **2026-09-21**, including their comments. All were open at review time. The baseline
|
|
5
|
+
is `@typesafe-ai/sdk` **0.6.0**, commit `66880ccded6cb642dc1809620c2b108c33730214`.
|
|
6
|
+
Ruby changes below are included in **0.6.0.1**.
|
|
7
|
+
|
|
8
|
+
This audit distinguishes client fixes from runtime differences and server feature requests.
|
|
9
|
+
It does not claim that the upstream issues are closed or that every requested API feature
|
|
10
|
+
is available. Valid supported requests continue to follow the JS wire format; reproducing
|
|
11
|
+
known JS bugs is not a compatibility goal.
|
|
12
|
+
|
|
13
|
+
## Disposition of every issue
|
|
14
|
+
|
|
15
|
+
| Upstream issue | Ruby disposition | Evidence or remaining limitation |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| [#14: API key disclosure and empty keys](https://github.com/typesafe-ai/typesafe-sdk-js/issues/14) | Fixed locally | Validate explicit and environment keys at construction, trim outer spaces/tabs/line endings, reject empty/non-ASCII/control/embedded-whitespace values, and copy/freeze the key. Validation errors contain no key and make no request or retry. Header-construction failures also propagate without connection retries. |
|
|
18
|
+
| [#13: actionable HTTP error classes](https://github.com/typesafe-ai/typesafe-sdk-js/issues/13) | Implemented | HTTP 402, 409, and 413 map to `PaymentRequiredError`, `ConflictError`, and `PayloadTooLargeError`. Each remains an `APIError`, retains response metadata, and is not retried by default. |
|
|
19
|
+
| [#12: null score levels](https://github.com/typesafe-ai/typesafe-sdk-js/issues/12) | Fixed locally | Builders and raw hashes reject nil levels. Use an explicit empty string for an undescribed position; entries are never removed or renumbered. This workaround is reported accepted in the upstream issue, not independently verified against the live API here. |
|
|
20
|
+
| [#11: calibrated multi-label selection](https://github.com/typesafe-ai/typesafe-sdk-js/issues/11) | Requires upstream API/model support | Choice selects one option. Multiple noul questions are independent judgments, not a calibrated competitive multi-label substitute. The Ruby SDK does not invent such an endpoint or promise equivalent calibration. |
|
|
21
|
+
| [#10: cached or registered criteria](https://github.com/typesafe-ai/typesafe-sdk-js/issues/10) | Requires upstream API support | Reusing a Ruby question Hash still sends the criteria on each request and does not reduce billed tokens. There is no supported registration/cache API in the audited JS SDK. No synthetic `cached_tokens` field is exposed. |
|
|
22
|
+
| [#9: blank retry delay headers](https://github.com/typesafe-ai/typesafe-sdk-js/issues/9) | Fixed locally | Blank/whitespace-only delays fall back to configured backoff; blank milliseconds can fall through to valid seconds. Literal zero is still honored. Request-path tests verify the actual retry delay. |
|
|
23
|
+
| [#8: Node timer overflow](https://github.com/typesafe-ai/typesafe-sdk-js/issues/8) | Node-specific; Ruby equivalent tested | Ruby does not use Node timers. Real loopback requests with delayed responses succeed at and just above Node's 2,147,483,647 ms boundary, for both constructor and per-call settings. This is not a guarantee for every arbitrarily large OS timeout. |
|
|
24
|
+
| [#7: list the community Go SDK](https://github.com/typesafe-ai/typesafe-sdk-js/issues/7) | Upstream documentation request | No Ruby defect. Whether TypeSafe lists third-party clients is an upstream decision; this repository clearly identifies itself as unofficial. |
|
|
25
|
+
| [#6: API-invalid request shapes and limits](https://github.com/typesafe-ai/typesafe-sdk-js/issues/6) | Fixed locally | Require string/object/array state and reject nil; require noul instructions or a non-nil true/false description; enforce 2–10 score levels, 1–255 choice options, and nonempty question names. Empty state containers, single-option choice, and whitespace-only names remain allowed. |
|
|
26
|
+
| [#4: numeric choice label inference](https://github.com/typesafe-ai/typesafe-sdk-js/issues/4) | TypeScript-specific; Ruby equivalent tested | Ruby has no TypeScript `never` inference. Numeric labels serialize as JSON string keys and choice answers/probability keys remain strings. |
|
|
27
|
+
| [#2: handled cancellation crashes Node](https://github.com/typesafe-ai/typesafe-sdk-js/issues/2) | Different transport; Ruby equivalent tested | The Net::HTTP worker is cleaned up before cancellation returns. Separate processes with `Thread.abort_on_exception = true` catch `APIUserAbortError` and exit normally after cancellation during successful and error response bodies, for inference and model listing. Existing socket tests also exercise cancellation and worker cleanup. |
|
|
28
|
+
|
|
29
|
+
## Compatibility and migration
|
|
30
|
+
|
|
31
|
+
The local validation, blank-header handling, and added error subclasses intentionally differ
|
|
32
|
+
from JS 0.6.0. Previously accepted local inputs that are reported rejected by the API now raise
|
|
33
|
+
`TypeSafeError` earlier. In particular, replace `noul()` with an actual question or outcome
|
|
34
|
+
criteria, supply non-nil state, and replace nil score placeholders explicitly with descriptions
|
|
35
|
+
or empty strings. Do not drop score slots to pass validation, because their positions define
|
|
36
|
+
the scale. Nil choice descriptions remain supported.
|
|
37
|
+
|
|
38
|
+
Raw question hashes, including JSON-loaded hashes with string keys, receive the same checks
|
|
39
|
+
as builders. These targeted checks are not a complete copy of the server's evolving schema;
|
|
40
|
+
the API remains authoritative for other validation and model behavior.
|
|
41
|
+
|
|
42
|
+
New error subclasses retain the public `APIError` contract. Catching `APIError` continues to
|
|
43
|
+
work; code comparing exact classes may need updating. HTTP 402/409/413 are still excluded
|
|
44
|
+
from the default retry statuses.
|
|
45
|
+
|
|
46
|
+
For future server fields, `with_response: true` exposes the parsed response body, including
|
|
47
|
+
its raw `usage` object. This does not mean the server currently reports cache usage. Consult
|
|
48
|
+
upstream documentation before relying on newly introduced fields or sending `extra_body:`.
|
|
49
|
+
|
|
50
|
+
## Verification scope
|
|
51
|
+
|
|
52
|
+
- [Issue regressions](../spec/typesafe/sdk/upstream_issues_spec.rb): request validation,
|
|
53
|
+
API key handling, retry scheduling, error mapping, and numeric labels using synthetic data.
|
|
54
|
+
- [Socket regressions](../spec/typesafe/sdk/http_spec.rb): actual Ruby transport behavior,
|
|
55
|
+
including the Node timer boundary, cancellation, and slow/truncated bodies.
|
|
56
|
+
- [Subprocess cancellation](../spec/typesafe/sdk/cancellation_process_spec.rb): handled
|
|
57
|
+
cancellation must not crash the caller process or leak an uncaught worker error.
|
|
58
|
+
- [JS baseline comparisons](../spec/typesafe/sdk/parity_spec.rb): fixtures generated by
|
|
59
|
+
running the published JS package; known corrections are explicit in assertions and the
|
|
60
|
+
fixture preserves the original JS output for those cases.
|
|
61
|
+
|
|
62
|
+
No live API key was available. Input constraints and reported server behavior above come from
|
|
63
|
+
upstream issue reports; tests prove Ruby's handling, not an independent live reproduction.
|
|
64
|
+
See [compatibility and live verification](COMPATIBILITY.md) for the opt-in smoke test.
|
data/lib/typesafe/sdk/client.rb
CHANGED
|
@@ -35,8 +35,7 @@ module Typesafe
|
|
|
35
35
|
def initialize(api_key: nil, base_url: nil, default_model: nil, log_level: nil,
|
|
36
36
|
logger: nil, retry_policy: nil, timeout: nil, default_headers: nil,
|
|
37
37
|
http: nil, env: ::ENV)
|
|
38
|
-
@api_key =
|
|
39
|
-
raise TypeSafeError, missing_api_key_message if @api_key.nil? || @api_key.empty?
|
|
38
|
+
@api_key = validate_api_key(api_key.nil? ? env[ENV::API_KEY] : api_key)
|
|
40
39
|
|
|
41
40
|
@base_url = strip_trailing_slashes(
|
|
42
41
|
ENV.from_code_or_env(base_url, ENV::BASE_URL, source: env) || DEFAULT_BASE_URL
|
|
@@ -56,13 +55,14 @@ module Typesafe
|
|
|
56
55
|
|
|
57
56
|
# Answer named questions about text or structured state.
|
|
58
57
|
#
|
|
59
|
-
# @param state [String, Hash, Array
|
|
58
|
+
# @param state [String, Hash, Array] the content to evaluate.
|
|
60
59
|
# @param questions [Hash{Symbol, String => Hash}] nonempty questions keyed by name.
|
|
61
60
|
# @param model [String, nil] model override; omitted values inherit `default_model`.
|
|
62
|
-
# @param
|
|
63
|
-
# @
|
|
64
|
-
# @
|
|
65
|
-
#
|
|
61
|
+
# @param extra_body [Hash] additional JSON fields; named request arguments take precedence.
|
|
62
|
+
# @param with_response [Boolean] return data with HTTP metadata and request ID.
|
|
63
|
+
# @param options [Hash] per-call `timeout`, `retry_policy`, `headers`, and `signal` settings.
|
|
64
|
+
# @return [SystemOneResult, WithResponse] answers keyed by question name, with model and token usage.
|
|
65
|
+
# @raise [TypeSafeError] state or questions violate the locally checked API constraints.
|
|
66
66
|
# @raise [APIError] the server returns a non-2xx response after retries.
|
|
67
67
|
# @raise [APIConnectionError] the request cannot connect or times out after retries.
|
|
68
68
|
# @raise [APIUserAbortError] the caller cancels the request.
|
|
@@ -72,17 +72,21 @@ module Typesafe
|
|
|
72
72
|
# state: "I was charged twice. Please help.",
|
|
73
73
|
# questions: { billing: Typesafe::SDK.noul("Is this about billing?") },
|
|
74
74
|
# )
|
|
75
|
-
# response
|
|
76
|
-
def system_one(state:, questions:, model: nil, **options)
|
|
75
|
+
# response[:billing].noul # => 0.93
|
|
76
|
+
def system_one(state:, questions:, model: nil, extra_body: {}, with_response: false, **options)
|
|
77
|
+
Questions.validate_state!(state)
|
|
77
78
|
Questions.validate!(questions)
|
|
78
|
-
body =
|
|
79
|
+
body = extra_body.transform_keys(&:to_s).merge(
|
|
80
|
+
"state" => state, "questions" => questions, "model" => model || @default_model
|
|
81
|
+
)
|
|
79
82
|
response = request(:post, "/v1/systemone", body: body, **options)
|
|
80
|
-
SystemOneResult.new(response.body)
|
|
83
|
+
result = SystemOneResult.new(response.body)
|
|
84
|
+
with_response ? WithResponse.new(result, response) : result
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
# Send a request and parse its response body. Internal; used by API resources.
|
|
84
88
|
#
|
|
85
|
-
# @return [Response] the parsed response, with `
|
|
89
|
+
# @return [Response] the parsed response, with `body`, `status`, `headers`, and `request_id`.
|
|
86
90
|
def request(method, path, body: nil, headers: {}, timeout: nil, retry_policy: nil,
|
|
87
91
|
signal: nil)
|
|
88
92
|
resolved = {
|
|
@@ -106,6 +110,21 @@ module Typesafe
|
|
|
106
110
|
|
|
107
111
|
private
|
|
108
112
|
|
|
113
|
+
def validate_api_key(value)
|
|
114
|
+
raise TypeSafeError, missing_api_key_message if value.nil?
|
|
115
|
+
unless value.is_a?(String) && value.valid_encoding? && value.ascii_only?
|
|
116
|
+
raise TypeSafeError, "API key must be an ASCII string."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
key = value.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, "")
|
|
120
|
+
raise TypeSafeError, missing_api_key_message if key.empty?
|
|
121
|
+
if key.match?(/[\x00-\x20\x7f]/)
|
|
122
|
+
raise TypeSafeError, "API key must not contain whitespace or control characters."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
key.freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
109
128
|
def missing_api_key_message
|
|
110
129
|
"No API key was provided. Pass `api_key:` to Typesafe::SDK::Client.new or set the " \
|
|
111
130
|
"#{ENV::API_KEY} environment variable."
|
|
@@ -121,7 +140,7 @@ module Typesafe
|
|
|
121
140
|
end
|
|
122
141
|
|
|
123
142
|
def assert_positive(name, value)
|
|
124
|
-
unless value.is_a?(Numeric) && value.positive?
|
|
143
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
125
144
|
raise TypeSafeError,
|
|
126
145
|
"`#{name}` must be a positive number, got #{value.inspect}."
|
|
127
146
|
end
|
|
@@ -138,7 +157,7 @@ module Typesafe
|
|
|
138
157
|
end
|
|
139
158
|
|
|
140
159
|
def assert_non_negative(name, value)
|
|
141
|
-
unless value.is_a?(Numeric) && value >= 0
|
|
160
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0
|
|
142
161
|
raise TypeSafeError, "`#{name}` must be a non-negative number, got #{value.inspect}."
|
|
143
162
|
end
|
|
144
163
|
|
|
@@ -146,7 +165,7 @@ module Typesafe
|
|
|
146
165
|
end
|
|
147
166
|
|
|
148
167
|
def assert_fraction(name, value)
|
|
149
|
-
unless value.is_a?(Numeric) && value >= 0 && value <= 1
|
|
168
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0 && value <= 1
|
|
150
169
|
raise TypeSafeError, "`#{name}` must be between 0 and 1, got #{value.inspect}."
|
|
151
170
|
end
|
|
152
171
|
|
|
@@ -159,7 +178,7 @@ module Typesafe
|
|
|
159
178
|
raise TypeSafeError, "`#{name}` must contain HTTP status codes, got #{status.inspect}."
|
|
160
179
|
end
|
|
161
180
|
end
|
|
162
|
-
statuses
|
|
181
|
+
statuses.freeze
|
|
163
182
|
end
|
|
164
183
|
|
|
165
184
|
# Merge and validate retry overrides, copying the status list to isolate later mutations.
|
|
@@ -196,18 +215,23 @@ module Typesafe
|
|
|
196
215
|
else
|
|
197
216
|
base[:http_statuses]
|
|
198
217
|
end,
|
|
199
|
-
respect_retry_after:
|
|
218
|
+
respect_retry_after: resolve_retry_flag(base, o, :respect_retry_after),
|
|
200
219
|
max_retry_after_ms: if o.key?(:max_retry_after_ms)
|
|
201
220
|
assert_non_negative("retry.max_retry_after_ms",
|
|
202
221
|
o[:max_retry_after_ms])
|
|
203
222
|
else
|
|
204
223
|
base[:max_retry_after_ms]
|
|
205
224
|
end,
|
|
206
|
-
api_connection_error:
|
|
207
|
-
api_timeout_error:
|
|
225
|
+
api_connection_error: resolve_retry_flag(base, o, :api_connection_error),
|
|
226
|
+
api_timeout_error: resolve_retry_flag(base, o, :api_timeout_error)
|
|
208
227
|
}.freeze
|
|
209
228
|
end
|
|
210
229
|
|
|
230
|
+
# Like upstream's nullish fallback, nil inherits while false stays explicit.
|
|
231
|
+
def resolve_retry_flag(base, overrides, name)
|
|
232
|
+
overrides[name].nil? ? base[name] : overrides[name]
|
|
233
|
+
end
|
|
234
|
+
|
|
211
235
|
# Last value wins regardless of casing; `nil` removes a protected header.
|
|
212
236
|
def merge_headers(*sources)
|
|
213
237
|
merged = {}
|
|
@@ -312,9 +336,9 @@ module Typesafe
|
|
|
312
336
|
sleep_with_signal(delay, req[:signal])
|
|
313
337
|
end
|
|
314
338
|
|
|
315
|
-
def sleep_with_signal(
|
|
339
|
+
def sleep_with_signal(milliseconds, signal)
|
|
316
340
|
signal&.check!
|
|
317
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (
|
|
341
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (milliseconds / 1000.0)
|
|
318
342
|
loop do
|
|
319
343
|
if signal
|
|
320
344
|
signal.check!
|
data/lib/typesafe/sdk/errors.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Typesafe
|
|
|
35
35
|
|
|
36
36
|
def self.describe(status, body)
|
|
37
37
|
detail = extract_message(body)
|
|
38
|
-
return "#{status} #{detail}" if detail
|
|
38
|
+
return "#{status} #{detail}" if detail && !detail.empty?
|
|
39
39
|
return "#{status} status code (no body)" if body.nil?
|
|
40
40
|
|
|
41
41
|
raw = body.is_a?(String) ? body : JSON.generate(body)
|
|
@@ -56,8 +56,11 @@ module Typesafe
|
|
|
56
56
|
@status_classes ||= {
|
|
57
57
|
400 => Typesafe::SDK::BadRequestError,
|
|
58
58
|
401 => Typesafe::SDK::AuthenticationError,
|
|
59
|
+
402 => Typesafe::SDK::PaymentRequiredError,
|
|
59
60
|
403 => Typesafe::SDK::PermissionDeniedError,
|
|
60
61
|
404 => Typesafe::SDK::NotFoundError,
|
|
62
|
+
409 => Typesafe::SDK::ConflictError,
|
|
63
|
+
413 => Typesafe::SDK::PayloadTooLargeError,
|
|
61
64
|
422 => Typesafe::SDK::UnprocessableEntityError,
|
|
62
65
|
429 => Typesafe::SDK::RateLimitError
|
|
63
66
|
}.freeze
|
|
@@ -94,18 +97,27 @@ module Typesafe
|
|
|
94
97
|
class BadRequestError < APIError; end
|
|
95
98
|
# HTTP 401: authentication failed.
|
|
96
99
|
class AuthenticationError < APIError; end
|
|
100
|
+
# HTTP 402: payment is required.
|
|
101
|
+
class PaymentRequiredError < APIError; end
|
|
97
102
|
# HTTP 403: access is denied.
|
|
98
103
|
class PermissionDeniedError < APIError; end
|
|
99
104
|
# HTTP 404: the resource was not found.
|
|
100
105
|
class NotFoundError < APIError; end
|
|
106
|
+
# HTTP 409: the request conflicts with the current state.
|
|
107
|
+
class ConflictError < APIError; end
|
|
108
|
+
# HTTP 413: the request payload is too large.
|
|
109
|
+
class PayloadTooLargeError < APIError; end
|
|
101
110
|
# HTTP 422: request validation failed.
|
|
102
111
|
class UnprocessableEntityError < APIError; end
|
|
103
112
|
|
|
104
113
|
# HTTP 429: the rate limit was exceeded.
|
|
105
114
|
class RateLimitError < APIError
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
# Captured at response time, so HTTP-date delays do not change between reads.
|
|
116
|
+
attr_reader :retry_after_ms
|
|
117
|
+
|
|
118
|
+
def initialize(...)
|
|
119
|
+
super
|
|
120
|
+
@retry_after_ms = Typesafe::SDK::Retry.parse_retry_after(@headers)
|
|
109
121
|
end
|
|
110
122
|
end
|
|
111
123
|
|
data/lib/typesafe/sdk/http.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Typesafe
|
|
|
9
9
|
def initialize
|
|
10
10
|
@mutex = Mutex.new
|
|
11
11
|
@canceled = false
|
|
12
|
+
@condition = ConditionVariable.new
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def canceled?
|
|
@@ -16,7 +17,10 @@ module Typesafe
|
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def cancel
|
|
19
|
-
@mutex.synchronize
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@canceled = true
|
|
22
|
+
@condition.broadcast
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
# Raise {APIUserAbortError} if canceled.
|
|
@@ -27,8 +31,10 @@ module Typesafe
|
|
|
27
31
|
# Wait up to `seconds`, returning early when canceled.
|
|
28
32
|
def wait(seconds)
|
|
29
33
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
while !@canceled && (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
|
|
36
|
+
@condition.wait(@mutex, remaining)
|
|
37
|
+
end
|
|
32
38
|
end
|
|
33
39
|
end
|
|
34
40
|
end
|
|
@@ -69,6 +75,7 @@ module Typesafe
|
|
|
69
75
|
check_signal!(signal)
|
|
70
76
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
71
77
|
http.use_ssl = uri.scheme == "https"
|
|
78
|
+
http.max_retries = 0 # Only the SDK owns retries and retry-count headers.
|
|
72
79
|
http.open_timeout = timeout
|
|
73
80
|
http.read_timeout = timeout
|
|
74
81
|
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
@@ -78,9 +85,7 @@ module Typesafe
|
|
|
78
85
|
headers.each { |name, value| req[name] = value }
|
|
79
86
|
req.body = body if body
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
raw = http.request(req)
|
|
83
|
-
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
88
|
+
raw = perform(http, req, timeout, signal)
|
|
84
89
|
body_text = raw.body.to_s
|
|
85
90
|
parsed = parse_body(body_text, raw["content-type"])
|
|
86
91
|
Response.new(
|
|
@@ -93,13 +98,56 @@ module Typesafe
|
|
|
93
98
|
raise
|
|
94
99
|
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
95
100
|
raise APITimeoutError.new((timeout * 1000).round, cause: e)
|
|
96
|
-
rescue
|
|
97
|
-
|
|
101
|
+
rescue SystemCallError, SocketError, OpenSSL::SSL::SSLError, EOFError, IOError,
|
|
102
|
+
Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => e
|
|
98
103
|
raise APIConnectionError.new("Connection error: #{e.message}", cause: e)
|
|
99
104
|
end
|
|
100
105
|
|
|
101
106
|
private
|
|
102
107
|
|
|
108
|
+
# Net::HTTP's read timeout resets for every read. A private worker bounds the
|
|
109
|
+
# whole connection/upload/body lifecycle without interrupting the caller thread.
|
|
110
|
+
# Killing it runs Net::HTTP's ensure blocks; join completes socket cleanup before return.
|
|
111
|
+
def perform(http, request, timeout, signal)
|
|
112
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
113
|
+
worker = Thread.new do
|
|
114
|
+
Thread.current.report_on_exception = false
|
|
115
|
+
begin
|
|
116
|
+
[read_response(http, request), nil]
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
[nil, e]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
loop do
|
|
122
|
+
signal&.check!
|
|
123
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
124
|
+
raise APITimeoutError, (timeout * 1000).round unless remaining.positive?
|
|
125
|
+
break if worker.join([remaining, 0.01].min)
|
|
126
|
+
end
|
|
127
|
+
signal&.check!
|
|
128
|
+
response, error = worker.value
|
|
129
|
+
raise error if error
|
|
130
|
+
|
|
131
|
+
response
|
|
132
|
+
ensure
|
|
133
|
+
worker&.kill&.join
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def read_response(http, request)
|
|
137
|
+
http.start do
|
|
138
|
+
http.request(request) do |response|
|
|
139
|
+
response.ignore_eof = false if response.respond_to?(:ignore_eof=)
|
|
140
|
+
response.read_body
|
|
141
|
+
# Older Ruby releases silently accept short Content-Length bodies.
|
|
142
|
+
# Net::HTTP removes Content-Length when automatically decompressing.
|
|
143
|
+
length = response["content-length"]
|
|
144
|
+
if length && response.body && response.body.bytesize < length.to_i
|
|
145
|
+
raise EOFError, "Response body ended before Content-Length bytes arrived"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
103
151
|
def check_signal!(signal)
|
|
104
152
|
signal&.check!
|
|
105
153
|
end
|
data/lib/typesafe/sdk/logging.rb
CHANGED
|
@@ -64,7 +64,11 @@ module Typesafe
|
|
|
64
64
|
define_method(severity) do |message, **data|
|
|
65
65
|
return if LogLevel::RANK.fetch(severity) < @rank
|
|
66
66
|
|
|
67
|
-
@sink.
|
|
67
|
+
if data.empty? || @sink.method(severity).parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
|
|
68
|
+
@sink.public_send(severity, message, **data)
|
|
69
|
+
else
|
|
70
|
+
@sink.public_send(severity, "#{message} #{data.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}")
|
|
71
|
+
end
|
|
68
72
|
end
|
|
69
73
|
end
|
|
70
74
|
end
|
|
@@ -4,61 +4,87 @@ module Typesafe
|
|
|
4
4
|
module SDK
|
|
5
5
|
# Builders and validation for the three question types.
|
|
6
6
|
module Questions
|
|
7
|
+
MAX_SCORE_LEVELS = 10
|
|
8
|
+
MAX_CHOICE_OPTIONS = 255
|
|
9
|
+
|
|
7
10
|
module_function
|
|
8
11
|
|
|
9
|
-
# Create a yes/no question
|
|
10
|
-
#
|
|
11
|
-
# @param
|
|
12
|
-
# @param criteria [Hash, nil] optional descriptions of the yes and no outcomes.
|
|
12
|
+
# Create a yes/no question. Supply instructions or at least one outcome description.
|
|
13
|
+
# @param instructions [String, Hash, Array, nil]
|
|
14
|
+
# @param criteria [Hash, nil] optional true/false outcome descriptions.
|
|
13
15
|
def noul(instructions = nil, criteria: nil)
|
|
14
|
-
{ type: "noul", instructions: instructions, criteria: criteria }
|
|
16
|
+
question = { type: "noul", instructions: instructions, criteria: criteria }
|
|
17
|
+
validate_noul!(instructions, criteria, "noul")
|
|
18
|
+
question
|
|
15
19
|
end
|
|
16
20
|
|
|
17
|
-
# Create a score question
|
|
18
|
-
#
|
|
19
|
-
# @param instructions [String, Hash, Array, nil] the question.
|
|
20
|
-
# @param criteria [Array] at least two descriptions indexed by score from zero.
|
|
21
|
+
# Create a score question with 2–10 ordered descriptions. Nil levels are not supported.
|
|
22
|
+
# An empty string preserves a position without a description.
|
|
21
23
|
def score(instructions, criteria)
|
|
22
|
-
|
|
23
|
-
raise TypeSafeError,
|
|
24
|
-
"Score criteria must be a list of descriptions indexed by score from zero, " \
|
|
25
|
-
"not a map."
|
|
26
|
-
end
|
|
27
|
-
|
|
24
|
+
validate_score!(criteria, "score")
|
|
28
25
|
{ type: "score", instructions: instructions, criteria: criteria }
|
|
29
26
|
end
|
|
30
27
|
|
|
31
|
-
# Create a
|
|
32
|
-
#
|
|
33
|
-
# @param instructions [String, Hash, Array, nil] the question.
|
|
34
|
-
# @param criteria [Hash] labels mapped to descriptions, or `nil` for undescribed labels.
|
|
28
|
+
# Create a choice between 1–255 named options; nil leaves a label undescribed.
|
|
35
29
|
def choice(instructions, criteria)
|
|
36
|
-
|
|
37
|
-
raise TypeSafeError, "Choice criteria must be a map of labels to descriptions, not a list."
|
|
38
|
-
end
|
|
39
|
-
|
|
30
|
+
validate_choice!(criteria, "choice")
|
|
40
31
|
{ type: "choice", instructions: instructions, criteria: criteria }
|
|
41
32
|
end
|
|
42
33
|
|
|
43
|
-
|
|
34
|
+
def validate_state!(state)
|
|
35
|
+
return if state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
raise TypeSafeError, "`state` must be a string, object, or array; nil is not supported."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate builders and raw JSON-style questions without altering the caller's data.
|
|
44
41
|
def validate!(questions)
|
|
45
|
-
raise TypeSafeError, "At least one question is required."
|
|
42
|
+
raise TypeSafeError, "At least one question is required." unless questions.is_a?(Hash) && !questions.empty?
|
|
46
43
|
|
|
47
44
|
questions.each do |name, question|
|
|
48
|
-
|
|
45
|
+
raise TypeSafeError, "Question key cannot be empty." if name.to_s.empty?
|
|
46
|
+
raise TypeSafeError, "Question must be an object." unless question.is_a?(Hash)
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
wire = question.transform_keys(&:to_s)
|
|
49
|
+
case wire["type"].to_s
|
|
50
|
+
when "score" then validate_score!(wire["criteria"], name)
|
|
51
|
+
when "choice" then validate_choice!(wire["criteria"], name)
|
|
52
|
+
when "noul" then validate_noul!(wire["instructions"], wire["criteria"], name)
|
|
55
53
|
end
|
|
56
|
-
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_noul!(instructions, criteria, name)
|
|
58
|
+
descriptions = criteria.is_a?(Hash) ? criteria.transform_keys(&:to_s) : {}
|
|
59
|
+
return unless instructions.nil? && %w[true false].all? { |key| descriptions[key].nil? }
|
|
60
|
+
|
|
61
|
+
raise TypeSafeError, "Noul question #{name.inspect} must have instructions or criteria."
|
|
62
|
+
end
|
|
57
63
|
|
|
64
|
+
def validate_score!(criteria, name)
|
|
65
|
+
unless criteria.is_a?(Array)
|
|
58
66
|
raise TypeSafeError,
|
|
59
|
-
"Score question
|
|
60
|
-
"
|
|
67
|
+
"Score question #{name.inspect} has criteria that are not a list; " \
|
|
68
|
+
"score criteria must be a list of descriptions indexed by score from zero."
|
|
69
|
+
end
|
|
70
|
+
raise TypeSafeError, "Score question #{name.inspect}: at least two scores are required." if criteria.length < 2
|
|
71
|
+
if criteria.length > MAX_SCORE_LEVELS
|
|
72
|
+
raise TypeSafeError, "Score question #{name.inspect}: at most #{MAX_SCORE_LEVELS} levels are allowed."
|
|
61
73
|
end
|
|
74
|
+
return unless criteria.any?(&:nil?)
|
|
75
|
+
|
|
76
|
+
raise TypeSafeError,
|
|
77
|
+
"Score question #{name.inspect} contains a nil level; use a description or an empty string " \
|
|
78
|
+
"to preserve its position."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_choice!(criteria, name)
|
|
82
|
+
unless criteria.is_a?(Hash)
|
|
83
|
+
raise TypeSafeError, "Choice criteria must be a map of labels to descriptions, not a list."
|
|
84
|
+
end
|
|
85
|
+
return if criteria.length.between?(1, MAX_CHOICE_OPTIONS)
|
|
86
|
+
|
|
87
|
+
raise TypeSafeError, "Choice question #{name.inspect} requires 1–#{MAX_CHOICE_OPTIONS} options."
|
|
62
88
|
end
|
|
63
89
|
end
|
|
64
90
|
end
|
|
@@ -11,11 +11,12 @@ module Typesafe
|
|
|
11
11
|
|
|
12
12
|
# List the models available to the account.
|
|
13
13
|
#
|
|
14
|
-
# @param options [Hash] per-call `timeout`, `
|
|
15
|
-
# @
|
|
14
|
+
# @param options [Hash] per-call `timeout`, `retry_policy`, `headers`, and `signal` settings.
|
|
15
|
+
# @param with_response [Boolean] return model cards with HTTP metadata and request ID.
|
|
16
|
+
# @return [Array<ModelCard>, WithResponse]
|
|
16
17
|
# @raise [TypeSafeError] the response shape is unexpected.
|
|
17
18
|
# @raise [APIError] the server returns a non-2xx response after retries.
|
|
18
|
-
def list(**options)
|
|
19
|
+
def list(with_response: false, **options)
|
|
19
20
|
response = @client.request(:get, "/v1/models", **options)
|
|
20
21
|
wire = response.body
|
|
21
22
|
unless wire.is_a?(Hash) && wire["models"].is_a?(Array)
|
|
@@ -23,7 +24,8 @@ module Typesafe
|
|
|
23
24
|
"Unexpected response shape from GET /v1/models; expected { models: [...] }."
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
wire["models"].map { |card| ModelCard.new(card) }
|
|
27
|
+
result = wire["models"].map { |card| ModelCard.new(card) }
|
|
28
|
+
with_response ? WithResponse.new(result, response) : result
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
data/lib/typesafe/sdk/retry.rb
CHANGED
|
@@ -38,15 +38,15 @@ module Typesafe
|
|
|
38
38
|
return nil unless headers.respond_to?(:[])
|
|
39
39
|
|
|
40
40
|
if (ms = headers["retry-after-ms"])
|
|
41
|
-
parsed =
|
|
42
|
-
return parsed if parsed && parsed >= 0
|
|
41
|
+
parsed = Float(ms, exception: false) unless ms.to_s.strip.empty?
|
|
42
|
+
return parsed if parsed&.finite? && parsed >= 0
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
raw = headers["retry-after"]
|
|
46
|
-
return nil if raw.nil?
|
|
46
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
47
47
|
|
|
48
48
|
seconds = Float(raw, exception: false)
|
|
49
|
-
return
|
|
49
|
+
return seconds >= 0 ? seconds * 1000 : nil if seconds&.finite?
|
|
50
50
|
|
|
51
51
|
date = begin
|
|
52
52
|
Time.parse(raw)
|
data/lib/typesafe/sdk/types.rb
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Typesafe
|
|
4
4
|
module SDK
|
|
5
|
+
# Parsed data together with HTTP metadata, like the JS SDK withResponse() result.
|
|
6
|
+
class WithResponse
|
|
7
|
+
attr_reader :data, :response, :request_id
|
|
8
|
+
|
|
9
|
+
def initialize(data, response)
|
|
10
|
+
@data = data
|
|
11
|
+
@response = response
|
|
12
|
+
@request_id = response.request_id
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
5
16
|
# Token usage for a request.
|
|
6
17
|
class Usage
|
|
7
18
|
attr_reader :input_tokens, :output_tokens
|
data/lib/typesafe/sdk/version.rb
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Typesafe
|
|
4
4
|
module SDK
|
|
5
|
-
#
|
|
6
|
-
VERSION = "0.6.0"
|
|
5
|
+
# Ruby-only fixes use a fourth numeric component; the first three track upstream.
|
|
6
|
+
VERSION = "0.6.0.1"
|
|
7
|
+
UPSTREAM_VERSION = "0.6.0"
|
|
7
8
|
end
|
|
8
9
|
end
|