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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/lib/typesafe/client.rb +56 -0
- data/lib/typesafe/configuration.rb +129 -0
- data/lib/typesafe/constants.rb +49 -0
- data/lib/typesafe/errors.rb +117 -0
- data/lib/typesafe/http/connection_manager.rb +82 -0
- data/lib/typesafe/http/net_http_transport.rb +74 -0
- data/lib/typesafe/http/request.rb +30 -0
- data/lib/typesafe/http/requestor.rb +153 -0
- data/lib/typesafe/http/response.rb +48 -0
- data/lib/typesafe/http/retrier.rb +57 -0
- data/lib/typesafe/instrumentation.rb +84 -0
- data/lib/typesafe/logging.rb +37 -0
- data/lib/typesafe/questions/choice.rb +33 -0
- data/lib/typesafe/questions/normalizer.rb +65 -0
- data/lib/typesafe/questions/noul.rb +36 -0
- data/lib/typesafe/questions/question.rb +48 -0
- data/lib/typesafe/questions/score.rb +32 -0
- data/lib/typesafe/request_options.rb +56 -0
- data/lib/typesafe/resources/models.rb +18 -0
- data/lib/typesafe/responses/answer.rb +143 -0
- data/lib/typesafe/responses/list_models_response.rb +93 -0
- data/lib/typesafe/responses/reader.rb +75 -0
- data/lib/typesafe/responses/system_one_response.rb +89 -0
- data/lib/typesafe/responses/usage.rb +37 -0
- data/lib/typesafe/retry_policy.rb +133 -0
- data/lib/typesafe/util.rb +156 -0
- data/lib/typesafe/version.rb +5 -0
- data/lib/typesafe-ai-ruby.rb +4 -0
- data/lib/typesafe.rb +114 -0
- data/sig/typesafe/http.rbs +77 -0
- data/sig/typesafe/questions.rbs +48 -0
- data/sig/typesafe/responses.rbs +131 -0
- data/sig/typesafe.rbs +261 -0
- 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
|
+
[](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
|