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.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Base class for SDK errors.
6
+ class TypeSafeError < StandardError
7
+ def initialize(message = nil, cause: nil)
8
+ super(message)
9
+ @cause = cause
10
+ end
11
+
12
+ attr_reader :cause
13
+ end
14
+
15
+ # An unsuccessful HTTP response from the API.
16
+ class APIError < TypeSafeError
17
+ MAX_RAW_BODY_IN_MESSAGE = 200
18
+
19
+ # HTTP response status code.
20
+ attr_reader :status
21
+ # HTTP response headers (a case-insensitive hash).
22
+ attr_reader :headers
23
+ # Parsed JSON, response text, or `nil` for an empty body.
24
+ attr_reader :body
25
+ # Request ID from `x-typesafe-request-id`, or `nil` when absent.
26
+ attr_reader :request_id
27
+
28
+ def initialize(status, body, headers, message = nil)
29
+ super(message || APIError.describe(status, body))
30
+ @status = status
31
+ @body = body
32
+ @headers = headers
33
+ @request_id = headers[Typesafe::SDK::REQUEST_ID_HEADER]
34
+ end
35
+
36
+ def self.describe(status, body)
37
+ detail = extract_message(body)
38
+ return "#{status} #{detail}" if detail
39
+ return "#{status} status code (no body)" if body.nil?
40
+
41
+ raw = body.is_a?(String) ? body : JSON.generate(body)
42
+ raw = "#{raw[0, MAX_RAW_BODY_IN_MESSAGE]}…" if raw.length > MAX_RAW_BODY_IN_MESSAGE
43
+ "#{status} #{raw}"
44
+ end
45
+
46
+ # Create the error subclass for an HTTP status code.
47
+ def self.from_response(status, body, headers)
48
+ klass = status_classes[status]
49
+ klass ||= InternalServerError if status >= 500
50
+ return klass.new(status, body, headers) if klass
51
+
52
+ new(status, body, headers)
53
+ end
54
+
55
+ def self.status_classes
56
+ @status_classes ||= {
57
+ 400 => Typesafe::SDK::BadRequestError,
58
+ 401 => Typesafe::SDK::AuthenticationError,
59
+ 403 => Typesafe::SDK::PermissionDeniedError,
60
+ 404 => Typesafe::SDK::NotFoundError,
61
+ 422 => Typesafe::SDK::UnprocessableEntityError,
62
+ 429 => Typesafe::SDK::RateLimitError
63
+ }.freeze
64
+ end
65
+
66
+ def self.extract_message(body)
67
+ return body if body.is_a?(String) && !body.empty?
68
+ return nil unless body.is_a?(Hash)
69
+
70
+ error = body["error"]
71
+ return error if error.is_a?(String)
72
+ return error["message"] if error.is_a?(Hash) && error["message"].is_a?(String)
73
+ return body["message"] if body["message"].is_a?(String)
74
+ return body["detail"] if body["detail"].is_a?(String)
75
+ return body["detail"]["message"] if body["detail"].is_a?(Hash) && body["detail"]["message"].is_a?(String)
76
+ return describe_validation_errors(body["detail"]) if body["detail"].is_a?(Array)
77
+
78
+ nil
79
+ end
80
+
81
+ # Format validation errors as semicolon-separated `path: message` entries.
82
+ def self.describe_validation_errors(errors)
83
+ parts = errors.filter_map do |e|
84
+ next nil unless e.is_a?(Hash) && e["msg"].is_a?(String)
85
+
86
+ loc = e["loc"].is_a?(Array) ? e["loc"].reject { |x| x == "body" }.join(".") : ""
87
+ loc.empty? ? e["msg"] : "#{loc}: #{e['msg']}"
88
+ end
89
+ parts.empty? ? nil : parts.join("; ")
90
+ end
91
+ end
92
+
93
+ # HTTP 400: the request is invalid.
94
+ class BadRequestError < APIError; end
95
+ # HTTP 401: authentication failed.
96
+ class AuthenticationError < APIError; end
97
+ # HTTP 403: access is denied.
98
+ class PermissionDeniedError < APIError; end
99
+ # HTTP 404: the resource was not found.
100
+ class NotFoundError < APIError; end
101
+ # HTTP 422: request validation failed.
102
+ class UnprocessableEntityError < APIError; end
103
+
104
+ # HTTP 429: the rate limit was exceeded.
105
+ 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)
109
+ end
110
+ end
111
+
112
+ # HTTP 5xx: the server failed to handle the request.
113
+ class InternalServerError < APIError; end
114
+
115
+ # The request or response-body delivery failed (DNS, TLS, connection closed, etc.).
116
+ class APIConnectionError < TypeSafeError
117
+ def initialize(message = "Connection error.", cause: nil)
118
+ super
119
+ end
120
+ end
121
+
122
+ # The full response did not arrive within the timeout. A kind of `APIConnectionError`.
123
+ class APITimeoutError < APIConnectionError
124
+ # Configured timeout in milliseconds.
125
+ attr_reader :timeout_ms
126
+
127
+ def initialize(timeout_ms, cause: nil)
128
+ super("Request timed out after #{timeout_ms}ms.", cause: cause)
129
+ @timeout_ms = timeout_ms
130
+ end
131
+ end
132
+
133
+ # The caller cancelled the request.
134
+ class APIUserAbortError < TypeSafeError
135
+ def initialize(message = "Request was aborted.", cause: nil)
136
+ super
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Typesafe
6
+ module SDK
7
+ # A cancellation handle for requests. Thread-safe; call {#cancel} from another thread.
8
+ class Signal
9
+ def initialize
10
+ @mutex = Mutex.new
11
+ @canceled = false
12
+ end
13
+
14
+ def canceled?
15
+ @mutex.synchronize { @canceled }
16
+ end
17
+
18
+ def cancel
19
+ @mutex.synchronize { @canceled = true }
20
+ end
21
+
22
+ # Raise {APIUserAbortError} if canceled.
23
+ def check!
24
+ raise APIUserAbortError if canceled?
25
+ end
26
+
27
+ # Wait up to `seconds`, returning early when canceled.
28
+ def wait(seconds)
29
+ 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)
32
+ end
33
+ end
34
+ end
35
+
36
+ # A parsed HTTP response.
37
+ class Response
38
+ attr_reader :status, :headers, :body, :request_id
39
+
40
+ def initialize(status:, headers:, body:, request_id:)
41
+ @status = status
42
+ @headers = headers
43
+ @body = body
44
+ @request_id = request_id
45
+ end
46
+
47
+ def ok?
48
+ status.between?(200, 299)
49
+ end
50
+ end
51
+
52
+ # The default HTTP adapter built on `Net::HTTP`.
53
+ #
54
+ # Replace it by passing `http:` to {Client} with any object responding to `request`
55
+ # that returns a {Response} and raises {APIConnectionError}, {APITimeoutError}, or
56
+ # {APIUserAbortError} on failure.
57
+ class HTTP
58
+ # Perform one HTTP round trip.
59
+ #
60
+ # @param method [Symbol] `:get` or `:post`.
61
+ # @param url [String] absolute URL.
62
+ # @param headers [Hash{String => String}] request headers.
63
+ # @param body [String, nil] JSON-encoded request body.
64
+ # @param timeout [Numeric] timeout in seconds.
65
+ # @param signal [Signal, nil] cancellation handle.
66
+ # @return [Response]
67
+ def request(method:, url:, headers:, body:, timeout:, signal:)
68
+ uri = URI(url)
69
+ check_signal!(signal)
70
+ http = Net::HTTP.new(uri.host, uri.port)
71
+ http.use_ssl = uri.scheme == "https"
72
+ http.open_timeout = timeout
73
+ http.read_timeout = timeout
74
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
75
+
76
+ request_class = { get: Net::HTTP::Get, post: Net::HTTP::Post }.fetch(method)
77
+ req = request_class.new(uri.request_uri)
78
+ headers.each { |name, value| req[name] = value }
79
+ req.body = body if body
80
+
81
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ raw = http.request(req)
83
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
84
+ body_text = raw.body.to_s
85
+ parsed = parse_body(body_text, raw["content-type"])
86
+ Response.new(
87
+ status: raw.code.to_i,
88
+ headers: Headers.new(raw.each_header.to_h),
89
+ body: parsed,
90
+ request_id: raw["x-typesafe-request-id"]
91
+ )
92
+ rescue APIUserAbortError
93
+ raise
94
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
95
+ 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
98
+ raise APIConnectionError.new("Connection error: #{e.message}", cause: e)
99
+ end
100
+
101
+ private
102
+
103
+ def check_signal!(signal)
104
+ signal&.check!
105
+ end
106
+
107
+ def parse_body(text, content_type)
108
+ return nil if text.empty?
109
+
110
+ if content_type.to_s.include?("application/json")
111
+ begin
112
+ return JSON.parse(text)
113
+ rescue JSON::ParserError
114
+ return text
115
+ end
116
+ end
117
+ # Be lenient: servers and proxies don't always set content-type.
118
+ begin
119
+ JSON.parse(text)
120
+ rescue JSON::ParserError
121
+ text
122
+ end
123
+ end
124
+ end
125
+
126
+ # Case-insensitive header lookup, matching the JS SDK's `Headers` semantics.
127
+ class Headers
128
+ def initialize(hash)
129
+ @hash = hash.to_h { |k, v| [k.to_s.downcase, v] }
130
+ end
131
+
132
+ def [](name)
133
+ @hash[name.to_s.downcase]
134
+ end
135
+
136
+ def key?(name)
137
+ @hash.key?(name.to_s.downcase)
138
+ end
139
+
140
+ def to_h
141
+ @hash.dup
142
+ end
143
+
144
+ def each(&)
145
+ @hash.each(&)
146
+ end
147
+
148
+ def ==(other)
149
+ @hash == (other.is_a?(Headers) ? other.to_h : other)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Log verbosity; `off` disables logging.
6
+ module LogLevel
7
+ DEBUG = :debug
8
+ INFO = :info
9
+ WARN = :warn
10
+ ERROR = :error
11
+ OFF = :off
12
+
13
+ LEVELS = [DEBUG, INFO, WARN, ERROR, OFF].freeze
14
+ DEFAULT = WARN
15
+
16
+ RANK = { DEBUG => 0, INFO => 1, WARN => 2, ERROR => 3, OFF => 4 }.freeze
17
+
18
+ module_function
19
+
20
+ # Validate a configured log level, raising {Typesafe::SDK::TypeSafeError} for unknown values.
21
+ def parse!(value, source)
22
+ return value if LEVELS.include?(value)
23
+ return value.to_sym if value.is_a?(String) && LEVELS.include?(value.to_sym)
24
+
25
+ raise Typesafe::SDK::TypeSafeError,
26
+ "Invalid log level #{value.inspect} from #{source}. " \
27
+ "Expected one of: #{LEVELS.join(', ')}."
28
+ end
29
+ end
30
+
31
+ # Default logger writing to `$stderr` with the `[typesafe-ai]` prefix.
32
+ class ConsoleLogger
33
+ PREFIX = "[typesafe-ai]"
34
+
35
+ def initialize(io: $stderr)
36
+ @io = io
37
+ end
38
+
39
+ %i[debug info warn error].each do |severity|
40
+ define_method(severity) do |message, **data|
41
+ @io.puts("#{PREFIX} #{severity.upcase}: #{message}#{format_data(data)}")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def format_data(data)
48
+ return "" if data.empty?
49
+
50
+ " #{data.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}"
51
+ end
52
+ end
53
+
54
+ # Filter logger calls to the configured level and above.
55
+ class LevelLogger
56
+ attr_reader :sink
57
+
58
+ def initialize(sink, level)
59
+ @sink = sink
60
+ @rank = LogLevel::RANK.fetch(level)
61
+ end
62
+
63
+ %i[debug info warn error].each do |severity|
64
+ define_method(severity) do |message, **data|
65
+ return if LogLevel::RANK.fetch(severity) < @rank
66
+
67
+ @sink.public_send(severity, message, **data)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Credential headers that retain a key suffix for identification.
73
+ module Redaction
74
+ KEY_HEADERS = %w[authorization proxy-authorization x-api-key].freeze
75
+ OPAQUE_HEADERS = %w[cookie set-cookie].freeze
76
+
77
+ module_function
78
+
79
+ # Mask a key, preserving its scheme and the last four characters of secrets longer than eight.
80
+ def redact_key(value)
81
+ scheme, secret = value.include?(" ") ? value.split(/\s+/, 2) : [nil, value]
82
+ tail = secret && secret.length > 8 ? secret[-4..] : ""
83
+ "#{"#{scheme} " if scheme}***#{tail}"
84
+ end
85
+
86
+ def redact(name, value)
87
+ lower = name.downcase
88
+ return redact_key(value) if KEY_HEADERS.include?(lower)
89
+ return "***" if OPAQUE_HEADERS.include?(lower)
90
+
91
+ value
92
+ end
93
+
94
+ # Copy headers with known credential values redacted.
95
+ def redact_headers(headers)
96
+ headers.to_h { |name, value| [name, redact(name, value)] }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Builders and validation for the three question types.
6
+ module Questions
7
+ module_function
8
+
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.
13
+ def noul(instructions = nil, criteria: nil)
14
+ { type: "noul", instructions: instructions, criteria: criteria }
15
+ end
16
+
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
+ 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
+
28
+ { type: "score", instructions: instructions, criteria: criteria }
29
+ end
30
+
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.
35
+ 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
+
40
+ { type: "choice", instructions: instructions, criteria: criteria }
41
+ end
42
+
43
+ # Reject empty question sets and score questions without a list of at least two criteria.
44
+ def validate!(questions)
45
+ raise TypeSafeError, "At least one question is required." if questions.empty?
46
+
47
+ questions.each do |name, question|
48
+ next unless question.is_a?(Hash) && question[:type] == "score"
49
+
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."
55
+ end
56
+ next unless criteria.length < 2
57
+
58
+ raise TypeSafeError,
59
+ "Score question \"#{name}\" has #{criteria.length} criteria; " \
60
+ "at least two scores are required."
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ module Resources
6
+ # Access to the Models API resource.
7
+ class Models
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # List the models available to the account.
13
+ #
14
+ # @param options [Hash] per-call `timeout`, `retry`, `headers`, and `signal` settings.
15
+ # @return [Array<ModelCard>]
16
+ # @raise [TypeSafeError] the response shape is unexpected.
17
+ # @raise [APIError] the server returns a non-2xx response after retries.
18
+ def list(**options)
19
+ response = @client.request(:get, "/v1/models", **options)
20
+ wire = response.body
21
+ unless wire.is_a?(Hash) && wire["models"].is_a?(Array)
22
+ raise TypeSafeError,
23
+ "Unexpected response shape from GET /v1/models; expected { models: [...] }."
24
+ end
25
+
26
+ wire["models"].map { |card| ModelCard.new(card) }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Request ID header returned by the API.
6
+ REQUEST_ID_HEADER = "x-typesafe-request-id"
7
+
8
+ # Retry defaults, delay calculation, and cancellable waits.
9
+ module Retry
10
+ # Default per-attempt timeout in seconds.
11
+ DEFAULT_TIMEOUT_S = 10
12
+
13
+ # Default SDK retry policy.
14
+ DEFAULT_RETRY_POLICY = {
15
+ max_retries: 2,
16
+ backoff_initial_ms: 500,
17
+ backoff_max_ms: 5_000,
18
+ backoff_jitter: 0.25,
19
+ # HTTP 408, 429, and 5xx responses.
20
+ http_statuses: [408, 429, *(500..599)].freeze,
21
+ respect_retry_after: true,
22
+ # Maximum server retry delay before falling back to backoff.
23
+ max_retry_after_ms: 60_000,
24
+ api_connection_error: true,
25
+ api_timeout_error: true
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ # Whether the policy retries an HTTP status code.
31
+ def retryable_status?(status, policy = DEFAULT_RETRY_POLICY)
32
+ policy[:http_statuses].include?(status)
33
+ end
34
+
35
+ # Parse `retry-after-ms` or `Retry-After` into milliseconds, preferring `retry-after-ms`.
36
+ # Returns `nil` when neither header contains a valid delay.
37
+ def parse_retry_after(headers, now: Time.now)
38
+ return nil unless headers.respond_to?(:[])
39
+
40
+ if (ms = headers["retry-after-ms"])
41
+ parsed = Integer(ms, exception: false)
42
+ return parsed if parsed && parsed >= 0
43
+ end
44
+
45
+ raw = headers["retry-after"]
46
+ return nil if raw.nil?
47
+
48
+ seconds = Float(raw, exception: false)
49
+ return (seconds * 1000).round if seconds && seconds >= 0
50
+
51
+ date = begin
52
+ Time.parse(raw)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ return nil if date.nil?
57
+
58
+ [(date - now) * 1000, 0].max.round
59
+ end
60
+
61
+ # Calculate the delay in milliseconds for a zero-based retry attempt.
62
+ # Uses an allowed server delay; otherwise capped exponential backoff with jitter.
63
+ def retry_delay_ms(attempt, headers = nil, policy = DEFAULT_RETRY_POLICY, random: Random)
64
+ if policy[:respect_retry_after] && headers
65
+ retry_after = parse_retry_after(headers)
66
+ return retry_after if retry_after && retry_after <= policy[:max_retry_after_ms]
67
+ end
68
+
69
+ exponential = [policy[:backoff_initial_ms] * (2**attempt), policy[:backoff_max_ms]].min
70
+ jitter = 1.0 - (random.rand * policy[:backoff_jitter])
71
+ (exponential * jitter).round
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Token usage for a request.
6
+ class Usage
7
+ attr_reader :input_tokens, :output_tokens
8
+
9
+ def initialize(data)
10
+ @input_tokens = data["input_tokens"]
11
+ @output_tokens = data["output_tokens"]
12
+ end
13
+ end
14
+
15
+ # A yes/no answer.
16
+ class NoulResponse
17
+ attr_reader :type, :noul
18
+
19
+ def initialize(data)
20
+ @type = data["type"]
21
+ @noul = data["noul"]
22
+ end
23
+ end
24
+
25
+ # A selected label with its probabilities.
26
+ class ChoiceResponse
27
+ attr_reader :type, :choice, :confidence, :probabilities
28
+
29
+ def initialize(data)
30
+ @type = data["type"]
31
+ @choice = data["choice"]
32
+ @confidence = data["confidence"]
33
+ @probabilities = data["probabilities"]
34
+ end
35
+ end
36
+
37
+ # An expected score with its rubric and probabilities.
38
+ class ScoreResponse
39
+ attr_reader :type, :score, :confidence, :legend, :probabilities
40
+
41
+ def initialize(data)
42
+ @type = data["type"]
43
+ @score = data["score"]
44
+ @confidence = data["confidence"]
45
+ @legend = data["legend"]
46
+ @probabilities = data["probabilities"]
47
+ end
48
+ end
49
+
50
+ # Builds the answer object matching the question type.
51
+ module ResponseFactory
52
+ CLASSES = {
53
+ "noul" => NoulResponse,
54
+ "choice" => ChoiceResponse,
55
+ "score" => ScoreResponse
56
+ }.freeze
57
+
58
+ module_function
59
+
60
+ def build(data)
61
+ klass = CLASSES[data["type"]]
62
+ raise TypeSafeError, "Unknown answer type #{data['type'].inspect}." unless klass
63
+
64
+ klass.new(data)
65
+ end
66
+ end
67
+
68
+ # Answers keyed by question name, with model and usage metadata.
69
+ class SystemOneResult
70
+ attr_reader :model, :answers, :usage
71
+
72
+ def initialize(data)
73
+ @model = data["model"]
74
+ @answers = (data["answers"] || {}).transform_values { |a| ResponseFactory.build(a) }
75
+ @usage = Usage.new(data["usage"] || {})
76
+ end
77
+
78
+ # Access an answer by question name (symbol or string).
79
+ def [](name)
80
+ @answers[name.to_s]
81
+ end
82
+ end
83
+
84
+ # Metadata for an available model.
85
+ class ModelCard
86
+ attr_reader :name, :description, :release_date
87
+
88
+ def initialize(data)
89
+ @name = data["name"]
90
+ @description = data["description"]
91
+ @release_date = data["release_date"]
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesafe
4
+ module SDK
5
+ # Keep in sync with the gemspec version. Checked by the version consistency spec.
6
+ VERSION = "0.6.0"
7
+ end
8
+ end