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.
@@ -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.
@@ -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 = 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?
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, nil] the content to evaluate.
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 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.
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.answers[:billing].noul # => 0.93
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 = { state: state, questions: questions, model: model || @default_model }
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 `data`, `status`, `headers`, and `request_id`.
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: o.fetch(:respect_retry_after, base[: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: o.fetch(:api_connection_error, base[:api_connection_error]),
207
- api_timeout_error: o.fetch(:api_timeout_error, base[: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(seconds, signal)
339
+ def sleep_with_signal(milliseconds, signal)
316
340
  signal&.check!
317
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (seconds / 1000.0)
341
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (milliseconds / 1000.0)
318
342
  loop do
319
343
  if signal
320
344
  signal.check!
@@ -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
- # Server retry delay in milliseconds, or `nil` when absent or invalid.
107
- def retry_after_ms
108
- Typesafe::SDK::Retry.parse_retry_after(@headers)
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
 
@@ -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 { @canceled = true }
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
- while !canceled? && (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
31
- sleep([remaining, 0.05].min)
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
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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 Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
97
- SocketError, OpenSSL::SSL::SSLError, EOFError, IOError => e
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
@@ -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.public_send(severity, message, **data)
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 with optional descriptions for either outcome.
10
- #
11
- # @param instructions [String, Hash, Array, nil] the question; defaults to `nil`.
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 using an ordered rubric.
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
- unless criteria.is_a?(Array)
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 question that selects between named alternatives.
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
- if criteria.is_a?(Array)
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
- # Reject empty question sets and score questions without a list of at least two criteria.
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." if questions.empty?
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
- next unless question.is_a?(Hash) && question[:type] == "score"
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
- criteria = question[:criteria]
51
- unless criteria.is_a?(Array)
52
- raise TypeSafeError,
53
- "Score question \"#{name}\" has criteria that are not a list; " \
54
- "score criteria must be a list of descriptions indexed by score from zero."
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
- next unless criteria.length < 2
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 \"#{name}\" has #{criteria.length} criteria; " \
60
- "at least two scores are required."
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`, `retry`, `headers`, and `signal` settings.
15
- # @return [Array<ModelCard>]
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
@@ -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 = Integer(ms, exception: false)
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 (seconds * 1000).round if seconds && seconds >= 0
49
+ return seconds >= 0 ? seconds * 1000 : nil if seconds&.finite?
50
50
 
51
51
  date = begin
52
52
  Time.parse(raw)
@@ -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
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Typesafe
4
4
  module SDK
5
- # Keep in sync with the gemspec version. Checked by the version consistency spec.
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