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
|
@@ -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
|