typesafe-ai-ruby 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +176 -0
  5. data/lib/typesafe/client.rb +56 -0
  6. data/lib/typesafe/configuration.rb +129 -0
  7. data/lib/typesafe/constants.rb +49 -0
  8. data/lib/typesafe/errors.rb +117 -0
  9. data/lib/typesafe/http/connection_manager.rb +82 -0
  10. data/lib/typesafe/http/net_http_transport.rb +74 -0
  11. data/lib/typesafe/http/request.rb +30 -0
  12. data/lib/typesafe/http/requestor.rb +153 -0
  13. data/lib/typesafe/http/response.rb +48 -0
  14. data/lib/typesafe/http/retrier.rb +57 -0
  15. data/lib/typesafe/instrumentation.rb +84 -0
  16. data/lib/typesafe/logging.rb +37 -0
  17. data/lib/typesafe/questions/choice.rb +33 -0
  18. data/lib/typesafe/questions/normalizer.rb +65 -0
  19. data/lib/typesafe/questions/noul.rb +36 -0
  20. data/lib/typesafe/questions/question.rb +48 -0
  21. data/lib/typesafe/questions/score.rb +32 -0
  22. data/lib/typesafe/request_options.rb +56 -0
  23. data/lib/typesafe/resources/models.rb +18 -0
  24. data/lib/typesafe/responses/answer.rb +143 -0
  25. data/lib/typesafe/responses/list_models_response.rb +93 -0
  26. data/lib/typesafe/responses/reader.rb +75 -0
  27. data/lib/typesafe/responses/system_one_response.rb +89 -0
  28. data/lib/typesafe/responses/usage.rb +37 -0
  29. data/lib/typesafe/retry_policy.rb +133 -0
  30. data/lib/typesafe/util.rb +156 -0
  31. data/lib/typesafe/version.rb +5 -0
  32. data/lib/typesafe-ai-ruby.rb +4 -0
  33. data/lib/typesafe.rb +114 -0
  34. data/sig/typesafe/http.rbs +77 -0
  35. data/sig/typesafe/questions.rbs +48 -0
  36. data/sig/typesafe/responses.rbs +131 -0
  37. data/sig/typesafe.rbs +261 -0
  38. metadata +97 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 21d48754dd7b9582a87e2905f23d01de27c9d8e8f47a14c3eaf5e7f5bccf2e19
4
+ data.tar.gz: '0958ee9c8ef35da5b53b90dbacfdf3012ceaef7f79c27bf244ff50803deab56c'
5
+ SHA512:
6
+ metadata.gz: 6bb1ef123a99b204a3dae6e22ff9567494d328c2ec1bd5ed47061554aa2ac2d2c2b76f796f53cd40cae2f4821d9dee4f7f940b3695b3e34446ef176c922f968c
7
+ data.tar.gz: 95750fac853da2683a7c41b452518bb027ca2696552fc7312b8075377c6ac47668fc55242488d52ce26328d2e96f70f4140fe7aeb31ea4adc98aba5abc68a4c1
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-19
11
+
12
+ Initial release.
13
+
14
+ - `TypeSafe::Client#system_one` and `client.models.list` for the System One API.
15
+ - `TypeSafe::Noul`, `Choice` and `Score` questions with typed answers.
16
+ - Configuration from options or `TYPESAFE_*` environment variables.
17
+ - Retries with backoff and `Retry-After`, keep-alive connections, logging and instrumentation hooks.
18
+ - RBS signatures.
19
+
20
+ [Unreleased]: https://github.com/hnegishi/typesafe-ai-ruby/compare/v0.1.0...HEAD
21
+ [0.1.0]: https://github.com/hnegishi/typesafe-ai-ruby/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 hnegishi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # TypeSafe AI Ruby Library
2
+
3
+ [![CI](https://github.com/hnegishi/typesafe-ai-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/hnegishi/typesafe-ai-ruby/actions/workflows/ci.yml)
4
+
5
+ The TypeSafe AI Ruby library provides convenient access to the [TypeSafe](https://typesafe.ai) System One API from applications written in Ruby. Send state and typed questions to Jev, and get back structured answers with probabilities and confidence that your code can use directly.
6
+
7
+ See the [TypeSafe documentation](https://docs.typesafe.ai/) for the concepts behind the API.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem "typesafe-ai-ruby"
15
+ ```
16
+
17
+ Or install it yourself:
18
+
19
+ ```sh
20
+ gem install typesafe-ai-ruby
21
+ ```
22
+
23
+ ### Requirements
24
+
25
+ - Ruby 3.1 or newer.
26
+ - No runtime dependencies beyond the Ruby standard library.
27
+
28
+ ## Usage
29
+
30
+ Get an API key from the [TypeSafe console](https://console.typesafe.ai/settings/keys) and set it as `TYPESAFE_API_KEY`, or pass it to the client directly.
31
+
32
+ ```ruby
33
+ require "typesafe-ai-ruby"
34
+
35
+ client = TypeSafe::Client.new(api_key: ENV["TYPESAFE_API_KEY"])
36
+
37
+ response = client.system_one(
38
+ state: "I was charged twice. Please fix this ASAP.",
39
+ questions: {
40
+ department: TypeSafe::Choice.new(
41
+ instructions: "Which team should handle this?",
42
+ criteria: { billing: "Payment issues", technical: "Bugs or integrations", sales: "Pricing questions" }
43
+ ),
44
+ frustration: TypeSafe::Score.new(
45
+ instructions: "How frustrated is the customer?",
46
+ criteria: ["Calm", "Frustrated but civil", "Very angry"]
47
+ ),
48
+ is_urgent: TypeSafe::Noul.new(instructions: "Does the message convey urgency?")
49
+ }
50
+ )
51
+
52
+ response.choices[:department].choice # => "billing"
53
+ response.choices[:department].confidence # => 1.0
54
+ response.scores[:frustration].score # => 1.06
55
+ response.nouls[:is_urgent].noul # => 0.97
56
+ response.model # => "jev-1.13.0"
57
+ ```
58
+
59
+ ### Questions
60
+
61
+ There are three question types. `instructions` and every description can be a string, a Hash, or an Array when a sentence is not enough.
62
+
63
+ - `TypeSafe::Noul` asks a yes/no question and returns the probability of yes. Criteria are optional: `criteria: { true: "Spam", false: "A real conversation" }`.
64
+ - `TypeSafe::Choice` picks one option from a set. Criteria are required; use `nil` when the name speaks for itself.
65
+ - `TypeSafe::Score` rates the state against ordered levels. Criteria are an Array of at least two levels, and each level's index is its score.
66
+
67
+ The shorthand helpers take the instructions first:
68
+
69
+ ```ruby
70
+ TypeSafe.noul("Is this spam?")
71
+ TypeSafe.choice("What is the tone?", calm: nil, frustrated: nil, angry: nil)
72
+ TypeSafe.score("How urgent is this?", ["Can wait", "This week", "Today"])
73
+ ```
74
+
75
+ You can also pass a plain Hash with a `type` key. Extra keys are sent to the API untouched, so new API fields work before this library knows about them.
76
+
77
+ `state` is whatever the questions are about: a String, a Hash, or an Array. Symbols are converted to strings, and objects that respond to `as_json` are converted through it.
78
+
79
+ ### Answers
80
+
81
+ `system_one` returns a `TypeSafe::Responses::SystemOneResponse`. Answers are keyed by question name and accept String or Symbol keys.
82
+
83
+ ```ruby
84
+ response.answers # every answer
85
+ response.nouls # only Noul answers
86
+ response.choices # only Choice answers, with #choice, #probabilities and #confidence
87
+ response.scores # only Score answers, with #score, #legend, #probabilities and #confidence
88
+ response.usage.input_tokens
89
+ response.request_id
90
+ response.to_h # the raw JSON body
91
+ ```
92
+
93
+ Use `confidence` to decide whether to act on an answer automatically or hand it to a human. See `examples/` for confidence-gated routing and asking many questions in one request.
94
+
95
+ ### Errors
96
+
97
+ Errors inherit from `TypeSafe::Error`. HTTP failures raise a subclass of `TypeSafe::APIError` with `status`, `body`, `headers`, `endpoint` and `request_id`.
98
+
99
+ ```ruby
100
+ begin
101
+ client.system_one(state: ticket, questions: questions)
102
+ rescue TypeSafe::RateLimitError => e
103
+ sleep(e.retry_after || 1)
104
+ rescue TypeSafe::APIError => e
105
+ logger.error("TypeSafe #{e.status}: #{e.message} (request #{e.request_id})")
106
+ rescue TypeSafe::APIConnectionError => e
107
+ # no HTTP response; includes TypeSafe::APITimeoutError
108
+ end
109
+ ```
110
+
111
+ Malformed questions raise `TypeSafe::ValidationError` before anything is sent.
112
+
113
+ ## Configuration
114
+
115
+ Options can be passed to `TypeSafe::Client.new` or set once for the default client. Explicit options win over environment variables, which win over the defaults.
116
+
117
+ ```ruby
118
+ TypeSafe.configure do |c|
119
+ c.api_key = ENV.fetch("TYPESAFE_API_KEY") # TYPESAFE_API_KEY
120
+ c.model = "jev-1.13.0" # TYPESAFE_DEFAULT_MODEL, default jev-latest
121
+ c.base_url = "https://api.typesafe.ai" # TYPESAFE_BASE_URL
122
+ c.timeout = 10 # seconds per attempt
123
+ c.logger = Logger.new($stdout)
124
+ c.log_level = :info # TYPESAFE_LOG_LEVEL, default :warn
125
+ end
126
+
127
+ TypeSafe.client.system_one(state: "...", questions: { urgent: TypeSafe.noul("Is this urgent?") })
128
+ ```
129
+
130
+ Per-call overrides go in `request_options`:
131
+
132
+ ```ruby
133
+ client.system_one(state: "...", questions: questions, model: "jev-preview",
134
+ request_options: { timeout: 30, headers: { "X-Team" => "growth" } })
135
+ ```
136
+
137
+ ### Retries
138
+
139
+ Requests that fail with 408, 429, 5xx, a connection error or a timeout are retried twice with exponential backoff, honoring `Retry-After`. Adjust or disable this with a `retry_policy`:
140
+
141
+ ```ruby
142
+ client = TypeSafe::Client.new(retry_policy: { max_retries: 5, backoff_max: 10 })
143
+ client.models.list(request_options: { retry_policy: { max_retries: 0 } })
144
+ ```
145
+
146
+ ### Logging and instrumentation
147
+
148
+ At `:info` the client logs one line per request and each retry. At `:debug` it also logs headers and bodies, with credential headers redacted.
149
+
150
+ `TypeSafe::Instrumentation` reports every call once it finishes, including retries:
151
+
152
+ ```ruby
153
+ TypeSafe::Instrumentation.subscribe(:request_end) do |event|
154
+ StatsD.timing("typesafe.request", event.duration, tags: ["status:#{event.http_status}"])
155
+ end
156
+ ```
157
+
158
+ ### Rails
159
+
160
+ The library has no Rails specific code. Configure the default client in an initializer, keep question definitions in frozen constants, and layer ActiveJob retries on top of the built-in ones. `examples/rails/` shows each of these.
161
+
162
+ ## Development
163
+
164
+ After checking out the repo, run `bin/setup` to install dependencies. Then run `bundle exec rake` to run the tests, RuboCop and the RBS validation.
165
+
166
+ The live API tests are skipped unless `TYPESAFE_API_KEY` is set:
167
+
168
+ ```sh
169
+ TYPESAFE_API_KEY=... bundle exec rake test:integration
170
+ ```
171
+
172
+ To release a new version, update the version number in `lib/typesafe/version.rb` and the changelog, then push a matching `v*` tag. The release workflow publishes the gem to RubyGems through trusted publishing.
173
+
174
+ ## License
175
+
176
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Client for the TypeSafe AI API.
5
+ #
6
+ # Explicit options take precedence over environment variables, then SDK defaults.
7
+ # Instances are immutable and safe to share between threads.
8
+ #
9
+ # For example:
10
+ # client = TypeSafe::Client.new(api_key: "sk-...")
11
+ # response = client.system_one(
12
+ # state: "I was charged twice. Please help.",
13
+ # questions: { billing: TypeSafe::Noul.new(instructions: "Is this about billing?") }
14
+ # )
15
+ # response.nouls[:billing].noul # => 0.98
16
+ class Client
17
+ # The resolved, frozen configuration.
18
+ attr_reader :config
19
+
20
+ def initialize(**options)
21
+ @config = Configuration.new(**options).resolve
22
+ @requestor = HTTP::Requestor.new(@config)
23
+ @models = Resources::Models.new(@requestor)
24
+ end
25
+
26
+ # Answer named questions about text or structured state.
27
+ def system_one(state:, questions:, model: nil, request_options: {})
28
+ options = RequestOptions.from(request_options)
29
+ body = {
30
+ "state" => Questions::Normalizer.state(state),
31
+ "model" => resolve_model(model),
32
+ "questions" => Questions::Normalizer.questions(questions)
33
+ }
34
+ body.merge!(options.extra_body) if options.extra_body
35
+ response = @requestor.post(Constants::SYSTEM_ONE_PATH, body: body, options: options)
36
+ Responses::SystemOneResponse.from_http(response)
37
+ end
38
+
39
+ # The models resource.
40
+ attr_reader :models
41
+
42
+ # Release network resources held by the transport.
43
+ def close
44
+ @requestor.close
45
+ end
46
+
47
+ private
48
+
49
+ def resolve_model(model)
50
+ return config.model if model.nil?
51
+ raise ValidationError, "model must be a non-blank String" if Util.blank?(model)
52
+
53
+ model.to_s
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Client settings. Explicit values take precedence over environment variables, then SDK defaults.
5
+ #
6
+ # A Configuration starts mutable (used by TypeSafe.configure) and becomes a frozen, validated
7
+ # copy through #resolve, which is what TypeSafe::Client keeps.
8
+ class Configuration
9
+ LOG_LEVELS = {
10
+ debug: Logger::DEBUG, info: Logger::INFO, warn: Logger::WARN, error: Logger::ERROR, fatal: Logger::FATAL
11
+ }.freeze
12
+
13
+ # API key; falls back to TYPESAFE_API_KEY.
14
+ attr_accessor :api_key
15
+ # API root; falls back to TYPESAFE_BASE_URL, then https://api.typesafe.ai.
16
+ attr_accessor :base_url
17
+ # Default model; falls back to TYPESAFE_DEFAULT_MODEL, then jev-latest.
18
+ attr_accessor :model
19
+ # Timeout per HTTP attempt in seconds. Default: 10.
20
+ attr_accessor :timeout
21
+ # Additional headers sent with every request.
22
+ attr_accessor :headers
23
+ # Logger; defaults to a Logger on $stderr.
24
+ attr_accessor :logger
25
+ # Log level; falls back to TYPESAFE_LOG_LEVEL, then :warn.
26
+ attr_accessor :log_level
27
+ # Retry policy: a RetryPolicy, or a Hash of overrides on the SDK defaults.
28
+ attr_accessor :retry_policy
29
+ # Custom transport responding to #call(request); defaults to Net::HTTP.
30
+ attr_accessor :transport
31
+
32
+ def initialize(api_key: nil, base_url: nil, model: nil, timeout: nil, headers: nil, logger: nil,
33
+ log_level: nil, retry_policy: nil, transport: nil)
34
+ @api_key = api_key
35
+ @base_url = base_url
36
+ @model = model
37
+ @timeout = timeout
38
+ @headers = headers
39
+ @logger = logger
40
+ @log_level = log_level
41
+ @retry_policy = retry_policy
42
+ @transport = transport
43
+ end
44
+
45
+ # The explicitly set options, suitable for splatting into Client.new.
46
+ def to_h
47
+ {
48
+ api_key: api_key, base_url: base_url, model: model, timeout: timeout, headers: headers,
49
+ logger: logger, log_level: log_level, retry_policy: retry_policy, transport: transport
50
+ }.compact
51
+ end
52
+
53
+ # Apply environment fallbacks and defaults, validate, and return a frozen copy.
54
+ def resolve(env: ENV)
55
+ resolved = dup
56
+ resolved.api_key = resolve_api_key(env)
57
+ resolved.base_url = resolve_base_url(env)
58
+ resolved.model = resolve_model(env)
59
+ resolved.timeout = resolve_timeout
60
+ resolved.headers = resolve_headers
61
+ resolved.retry_policy = RetryPolicy.from(retry_policy)
62
+ resolve_logging(resolved, env)
63
+ resolved.freeze
64
+ end
65
+
66
+ # The Logger severity matching #log_level.
67
+ def logger_severity
68
+ LOG_LEVELS.fetch(log_level.to_s.downcase.to_sym, Logger::WARN)
69
+ end
70
+
71
+ private
72
+
73
+ def resolve_api_key(env)
74
+ value = api_key.nil? ? Util.env_value(env, Constants::API_KEY_ENV) : api_key.to_s.strip
75
+ return value unless Util.blank?(value)
76
+
77
+ raise ConfigurationError,
78
+ "No API key provided. Pass api_key: to TypeSafe::Client.new or set #{Constants::API_KEY_ENV}."
79
+ end
80
+
81
+ def resolve_base_url(env)
82
+ value = Util.blank?(base_url) ? Util.env_value(env, Constants::BASE_URL_ENV) : base_url.to_s.strip
83
+ value ||= Constants::DEFAULT_BASE_URL
84
+ uri = URI.parse(value)
85
+ raise ConfigurationError, "base_url must be an http(s) URL, got #{value.inspect}" unless uri.is_a?(URI::HTTP)
86
+
87
+ value.sub(%r{/+\z}, "")
88
+ rescue URI::InvalidURIError
89
+ raise ConfigurationError, "base_url must be an http(s) URL, got #{value.inspect}"
90
+ end
91
+
92
+ def resolve_model(env)
93
+ value = Util.blank?(model) ? Util.env_value(env, Constants::DEFAULT_MODEL_ENV) : model.to_s.strip
94
+ value || Constants::DEFAULT_MODEL
95
+ end
96
+
97
+ def resolve_timeout
98
+ value = timeout.nil? ? Constants::DEFAULT_TIMEOUT : timeout
99
+ unless value.is_a?(Numeric) && value.finite? && value.positive?
100
+ raise ConfigurationError, "timeout must be a positive number of seconds, got #{timeout.inspect}"
101
+ end
102
+
103
+ value
104
+ end
105
+
106
+ def resolve_headers
107
+ (headers || {}).each_with_object({}) { |(name, value), result| result[name.to_s] = value.to_s }
108
+ end
109
+
110
+ def resolve_logging(resolved, env)
111
+ resolved.log_level = resolve_log_level(env)
112
+ resolved.logger = logger || build_logger(resolved.log_level)
113
+ end
114
+
115
+ def resolve_log_level(env)
116
+ value = Util.blank?(log_level) ? Util.env_value(env, Constants::LOG_LEVEL_ENV) : log_level
117
+ value = (value || Constants::DEFAULT_LOG_LEVEL).to_s.downcase.to_sym
118
+ return value if LOG_LEVELS.key?(value)
119
+
120
+ raise ConfigurationError, "log_level must be one of #{LOG_LEVELS.keys.join(", ")}, got #{value.inspect}"
121
+ end
122
+
123
+ def build_logger(level)
124
+ logger = Logger.new($stderr, progname: "typesafe")
125
+ logger.level = LOG_LEVELS.fetch(level)
126
+ logger
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Environment variable names, client defaults, endpoint paths, and header names.
5
+ #
6
+ # Values mirror the official TypeSafe Python and JavaScript SDKs so that
7
+ # configuration behaves the same across languages.
8
+ module Constants
9
+ # Environment variable holding the API key.
10
+ API_KEY_ENV = "TYPESAFE_API_KEY"
11
+ # Environment variable overriding the API base URL.
12
+ BASE_URL_ENV = "TYPESAFE_BASE_URL"
13
+ # Environment variable overriding the default model.
14
+ DEFAULT_MODEL_ENV = "TYPESAFE_DEFAULT_MODEL"
15
+ # Environment variable overriding the log level.
16
+ LOG_LEVEL_ENV = "TYPESAFE_LOG_LEVEL"
17
+
18
+ # Default API base URL.
19
+ DEFAULT_BASE_URL = "https://api.typesafe.ai"
20
+ # Default model alias.
21
+ DEFAULT_MODEL = "jev-latest"
22
+ # Default timeout in seconds for each HTTP attempt.
23
+ DEFAULT_TIMEOUT = 10.0
24
+ # Default log level.
25
+ DEFAULT_LOG_LEVEL = :warn
26
+
27
+ # Path of the System One evaluation endpoint.
28
+ SYSTEM_ONE_PATH = "/v1/systemone"
29
+ # Path of the model listing endpoint.
30
+ MODELS_PATH = "/v1/models"
31
+
32
+ # Identifier sent in User-Agent and X-TypeSafe-SDK headers.
33
+ SDK_NAME = "typesafe-ruby"
34
+ # MIME type used for request and response bodies.
35
+ JSON_CONTENT_TYPE = "application/json"
36
+
37
+ # HTTP header names used by the client.
38
+ module Headers
39
+ AUTHORIZATION = "Authorization"
40
+ ACCEPT = "Accept"
41
+ CONTENT_TYPE = "Content-Type"
42
+ USER_AGENT = "User-Agent"
43
+ SDK = "X-TypeSafe-SDK"
44
+ RUNTIME = "X-TypeSafe-Runtime"
45
+ RETRY_COUNT = "X-TypeSafe-Retry-Count"
46
+ REQUEST_ID = "x-typesafe-request-id"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Base class for every error raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the client cannot be configured, for example when the API key is missing.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised before a request is sent when questions or state are malformed.
11
+ class ValidationError < Error; end
12
+
13
+ # An unsuccessful HTTP response, carrying the status, decoded body, headers and endpoint.
14
+ class APIError < Error
15
+ # HTTP status code.
16
+ attr_reader :status
17
+ # JSON body, plain text, or nil for an empty body.
18
+ attr_reader :body
19
+ # Response headers with lower-cased names.
20
+ attr_reader :headers
21
+ # Request method and URL without credentials, e.g. "POST https://api.typesafe.ai/v1/systemone".
22
+ attr_reader :endpoint
23
+
24
+ def initialize(message = nil, status: nil, body: nil, headers: {}, endpoint: nil)
25
+ @status = status
26
+ @body = body
27
+ @headers = Util.normalize_headers(headers)
28
+ @endpoint = endpoint
29
+ super(message || self.class.build_message(status: status, body: body, endpoint: endpoint, request_id: request_id))
30
+ end
31
+
32
+ # The x-typesafe-request-id response header.
33
+ def request_id
34
+ @headers[Constants::Headers::REQUEST_ID]
35
+ end
36
+
37
+ # Build the error subclass matching the response status.
38
+ def self.from_response(response)
39
+ class_for_status(response.status).new(
40
+ status: response.status,
41
+ body: response.json || (response.body.empty? ? nil : response.body),
42
+ headers: response.headers,
43
+ endpoint: response.request&.endpoint
44
+ )
45
+ end
46
+
47
+ def self.class_for_status(status)
48
+ STATUS_CLASSES.fetch(status) { (500..599).cover?(status) ? InternalServerError : APIError }
49
+ end
50
+
51
+ def self.build_message(status:, body:, endpoint:, request_id:)
52
+ head = [status && "[#{status}]", endpoint].compact.join(" ")
53
+ summary = Util.summarize_body(body)
54
+ message = [head, summary].reject(&:empty?).join(": ")
55
+ message = "API request failed" if message.empty?
56
+ request_id ? "#{message} (request_id: #{request_id})" : message
57
+ end
58
+ end
59
+
60
+ # The request was invalid (400).
61
+ class BadRequestError < APIError; end
62
+ # Authentication failed (401).
63
+ class AuthenticationError < APIError; end
64
+ # Access was denied (403).
65
+ class PermissionDeniedError < APIError; end
66
+ # The resource was not found (404).
67
+ class NotFoundError < APIError; end
68
+ # The request failed server-side validation (422).
69
+ class UnprocessableEntityError < APIError; end
70
+ # The server failed to process the request (5xx).
71
+ class InternalServerError < APIError; end
72
+ # TypeSafe is temporarily overloaded (529).
73
+ class OverloadedError < InternalServerError; end
74
+
75
+ # The rate limit was exceeded (429).
76
+ class RateLimitError < APIError
77
+ # The wait requested by the server in seconds, from retry-after-ms or Retry-After.
78
+ def retry_after
79
+ Util.parse_retry_after(headers)
80
+ end
81
+ end
82
+
83
+ # A successful HTTP response whose body was missing or structurally invalid required data.
84
+ class APIResponseValidationError < APIError
85
+ # Dotted path to the offending field, such as "answers.tone.confidence".
86
+ attr_reader :field_path
87
+
88
+ def initialize(message = nil, field_path:, **options)
89
+ @field_path = field_path
90
+ super(message || "Invalid response field #{field_path}", **options)
91
+ end
92
+ end
93
+
94
+ APIError::STATUS_CLASSES = {
95
+ 400 => BadRequestError,
96
+ 401 => AuthenticationError,
97
+ 403 => PermissionDeniedError,
98
+ 404 => NotFoundError,
99
+ 422 => UnprocessableEntityError,
100
+ 429 => RateLimitError,
101
+ 529 => OverloadedError
102
+ }.freeze
103
+
104
+ # A request failed without an HTTP response.
105
+ class APIConnectionError < Error; end
106
+
107
+ # A request exceeded its configured timeout.
108
+ class APITimeoutError < APIConnectionError
109
+ # The timeout applied to the request, in seconds.
110
+ attr_reader :timeout
111
+
112
+ def initialize(message = nil, timeout: nil)
113
+ @timeout = timeout
114
+ super(message || "Request timed out after #{timeout}s")
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module HTTP
5
+ # Keeps one open Net::HTTP connection per scheme, host and port so that consecutive
6
+ # requests reuse the TCP and TLS session. Net::HTTP is not thread safe, so the transport
7
+ # holds one manager per thread.
8
+ #
9
+ # Connections idle for longer than IDLE_TIMEOUT are closed before reuse, and every
10
+ # connection is dropped after a fork so child processes never share a parent's socket.
11
+ class ConnectionManager
12
+ IDLE_TIMEOUT = 120
13
+
14
+ def initialize(clock: nil)
15
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
16
+ @connections = {}
17
+ @touched = {}
18
+ @pid = Process.pid
19
+ end
20
+
21
+ # An open connection for the URI with the given timeout applied.
22
+ def connection_for(uri, timeout:)
23
+ clear if @pid != Process.pid
24
+ key = key_for(uri)
25
+ drop(key) if @connections.key?(key) && (now - @touched[key]) > IDLE_TIMEOUT
26
+
27
+ connection = (@connections[key] ||= start(uri, timeout))
28
+ connection.open_timeout = timeout
29
+ connection.read_timeout = timeout
30
+ connection.write_timeout = timeout
31
+ @touched[key] = now
32
+ connection
33
+ end
34
+
35
+ # Close and forget the connection for the URI, typically after a network error.
36
+ def discard(uri)
37
+ drop(key_for(uri))
38
+ end
39
+
40
+ # Close and forget every connection.
41
+ def clear
42
+ @connections.each_key.to_a.each { |key| drop(key) }
43
+ @pid = Process.pid
44
+ end
45
+
46
+ def size
47
+ @connections.size
48
+ end
49
+
50
+ private
51
+
52
+ def now
53
+ @clock.call
54
+ end
55
+
56
+ def key_for(uri)
57
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
58
+ end
59
+
60
+ def start(uri, timeout)
61
+ http = Net::HTTP.new(uri.host, uri.port)
62
+ http.use_ssl = uri.scheme == "https"
63
+ http.open_timeout = timeout
64
+ http.read_timeout = timeout
65
+ http.write_timeout = timeout
66
+ http.start
67
+ end
68
+
69
+ def drop(key)
70
+ connection = @connections.delete(key)
71
+ @touched.delete(key)
72
+ return unless connection
73
+
74
+ begin
75
+ connection.finish if connection.started?
76
+ rescue IOError
77
+ # Already closed by the server or the runtime; nothing to release.
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end