jevalyn 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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> asks Jev a fixed set of questions about a piece of state and
4
+ # gets typed answers back. It does not generate text -- every answer is a
5
+ # probability, one of your options, or a level on your rubric.
6
+ class <%= class_name %> < Jevalyn::Decision
7
+ <% parsed_questions.each do |name, type| -%>
8
+ <% case type
9
+ when :noul -%>
10
+ # A noul returns the probability that the answer is yes, 0.0 to 1.0. You pick the
11
+ # cutoff -- `result.<%= name %>?` uses 0.5, `result.<%= name %>?(0.9)` is stricter.
12
+ question :<%= name %>, type: :noul,
13
+ instructions: "TODO: a yes/no question about the state",
14
+ criteria: {
15
+ true: "TODO: what a yes looks like",
16
+ false: "TODO: what a no looks like"
17
+ }
18
+
19
+ <% when :choice -%>
20
+ # A choice picks one option and returns the full probability distribution plus a
21
+ # confidence. Give it every real option, not a shortlist -- Jev takes up to 255 and
22
+ # each one costs only a few tokens.
23
+ question :<%= name %>, type: :choice,
24
+ instructions: "TODO: what should the model decide?",
25
+ criteria: {
26
+ first_option: "TODO: when this option applies",
27
+ second_option: "TODO: when this one does instead"
28
+ },
29
+ # Optional. Without it this question uses the decision's floor below. Set it when
30
+ # this answer costs more to get wrong than the others.
31
+ confidence_threshold: <%= threshold %>
32
+
33
+ <% when :score -%>
34
+ # A score rates the state against ordered levels, lowest first, and returns a
35
+ # weighted number that can land between them: 1.6 means past the second level and
36
+ # not quite the third. Use `result.<%= name %>_label` for the nearest level's name.
37
+ question :<%= name %>, type: :score,
38
+ instructions: "TODO: what should the model rate?",
39
+ criteria: [
40
+ "TODO: lowest level",
41
+ "TODO: middle level",
42
+ "TODO: highest level"
43
+ ],
44
+ confidence_threshold: <%= threshold %>
45
+
46
+ <% end -%>
47
+ <% end -%>
48
+ # The default floor for any question above that did not declare its own. Answers
49
+ # below their floor are reported by `result.uncertain?` and named by
50
+ # `result.uncertain_questions`. Set these by what a wrong answer costs: a misrouted
51
+ # ticket is cheap, an auto-approved refund is not.
52
+ confidence_threshold <%= threshold %>
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe <%= class_name %> do
6
+ it "declares the questions it sends" do
7
+ expect(described_class.question_names).to contain_exactly(<%= parsed_questions.map { |n, _| ":#{n}" }.join(", ") %>)
8
+ end
9
+
10
+ <% first_name, first_type = parsed_questions.first -%>
11
+ it "answers <%= first_name %>" do
12
+ stub_jevalyn(described_class, <%= parsed_questions.map { |n, t| "#{n}: #{{ noul: "true", choice: ":first_option", score: "1" }[t]}" }.join(", ") %>)
13
+
14
+ result = described_class.evaluate("TODO: a realistic piece of state")
15
+
16
+ <% case first_type
17
+ when :noul -%>
18
+ expect(result.<%= first_name %>?).to be(true)
19
+ <% when :choice -%>
20
+ expect(result.<%= first_name %>).to eq(:first_option)
21
+ <% when :score -%>
22
+ expect(result.<%= first_name %>_level).to eq(1)
23
+ <% end -%>
24
+ expect(described_class).to have_been_evaluated.once
25
+ end
26
+
27
+ # Jev's answers move with the model and with your rubric wording. This is the test
28
+ # worth keeping honest: a handful of real inputs whose answer you are sure of, run
29
+ # against the live API. Tag it :jevalyn_live and run it deliberately, not in CI.
30
+ #
31
+ # it "routes real examples correctly", :jevalyn_live do
32
+ # result = described_class.evaluate(File.read("spec/fixtures/<%= file_name %>.txt"))
33
+ # expect(result.<%= first_name %>).to eq(...)
34
+ # end
35
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Jevalyn
6
+ module Generators
7
+ # rails g jevalyn:guardrail ToolCall
8
+ class GuardrailGenerator < ::Rails::Generators::NamedBase
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates a Jevalyn::Guardrail in app/decisions, and a spec for it."
12
+
13
+ class_option :question, type: :string, default: "safe",
14
+ desc: "Name of the single noul question"
15
+ class_option :allow_above, type: :numeric, default: 0.9,
16
+ desc: "Probability required to allow"
17
+ class_option :spec, type: :boolean, default: true
18
+
19
+ def create_guardrail
20
+ template "guardrail.rb.tt", File.join("app/decisions", class_path, "#{file_name}.rb")
21
+ end
22
+
23
+ def create_spec
24
+ return unless options[:spec]
25
+
26
+ template "guardrail_spec.rb.tt", File.join("spec/decisions", class_path, "#{file_name}_spec.rb")
27
+ end
28
+
29
+ private
30
+
31
+ def question_name = options[:question]
32
+
33
+ def allow_above = options[:allow_above]
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> is a one-question gate: it asks Jev whether something should be
4
+ # allowed through, and answers #allow? / #deny?.
5
+ #
6
+ # <%= class_name %>.check(payload).allow?
7
+ class <%= class_name %> < Jevalyn::Guardrail
8
+ question :<%= question_name %>, type: :noul,
9
+ instructions: "TODO: is this safe to allow without human review?",
10
+ criteria: {
11
+ true: "TODO: what clearly safe looks like",
12
+ false: "TODO: what needs a human"
13
+ }
14
+
15
+ # Jev returns no confidence for a noul -- the probability is the answer -- so this
16
+ # is the probability #allow? requires. The default is 0.5, a coin flip; set it by
17
+ # what letting a bad one through actually costs.
18
+ allow_above <%= allow_above %>
19
+
20
+ # If the API call itself fails, deny rather than allow. Set :raise to handle the
21
+ # outage yourself instead.
22
+ on_error :deny
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe <%= class_name %> do
6
+ it "allows when Jev is confident it is safe" do
7
+ stub_jevalyn(described_class, <%= question_name %>: 0.99)
8
+
9
+ expect(described_class.check("TODO: something safe")).to be_allow
10
+ end
11
+
12
+ it "denies when the probability is below the gate" do
13
+ stub_jevalyn(described_class, <%= question_name %>: <%= [allow_above.to_f - 0.1, 0.0].max.round(2) %>)
14
+
15
+ expect(described_class.check("TODO: something borderline")).to be_deny
16
+ end
17
+
18
+ it "denies rather than failing open when the API is unreachable" do
19
+ allow(Jevalyn.client).to receive(:evaluate).and_raise(Jevalyn::TimeoutError, "boom")
20
+
21
+ result = described_class.check("anything")
22
+
23
+ expect(result).to be_deny
24
+ expect(result).to be_failed
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Jevalyn
6
+ module Generators
7
+ # rails g jevalyn:install
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates config/initializers/jevalyn.rb and an app/decisions directory."
12
+
13
+ class_option :api_key_env, type: :string, default: "TYPESAFE_API_KEY",
14
+ desc: "Environment variable holding the TypeSafe API key"
15
+
16
+ def create_initializer
17
+ template "jevalyn.rb.tt", "config/initializers/jevalyn.rb"
18
+ end
19
+
20
+ def create_decisions_directory
21
+ empty_directory "app/decisions"
22
+ create_file "app/decisions/.keep" unless File.exist?(File.join(destination_root,
23
+ "app/decisions/.keep"))
24
+ end
25
+
26
+ def report
27
+ say ""
28
+ say "Jevalyn is installed.", :green
29
+ say ""
30
+ say " 1. Put your key in the environment: #{options[:api_key_env]}=ts_..."
31
+ say " 2. Generate a decision: rails g jevalyn:decision SupportTriage"
32
+ say " 3. Check the connection: rails jevalyn:ping"
33
+ say ""
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ Jevalyn.configure do |c|
4
+ # Your TypeSafe API key. Keep it out of the repo.
5
+ c.api_key = ENV["<%= options[:api_key_env] %>"]
6
+
7
+ # "jev-latest" tracks the newest stable release. An alias moves without telling
8
+ # you, so once you have tuned thresholds against a version, pin it here instead
9
+ # (e.g. "jev-1.13.0"). Every Result reports the version that actually answered.
10
+ c.default_model = "jev-latest"
11
+
12
+ # Jev answers in well under a second. A long timeout only means a slow request
13
+ # holds a Rails thread open for longer than it is worth.
14
+ c.timeout = 10
15
+ c.open_timeout = 5
16
+
17
+ # 429 (rate limited) and 529 (overloaded) are retried with exponential backoff,
18
+ # honouring the API's retry-after header when it sends one.
19
+ c.max_retries = 2
20
+
21
+ # No network in tests. Answers come from Jevalyn::Testing stubs instead; an
22
+ # unstubbed evaluation raises rather than silently passing.
23
+ c.mock_mode = Rails.env.test?
24
+
25
+ # Certainty floor a Decision uses when it does not declare its own. Leave nil to
26
+ # require each Decision to make its own call, which is usually the right default:
27
+ # the threshold for routing a support ticket is not the threshold for a refund.
28
+ # c.default_confidence_threshold = 0.75
29
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ # One typed answer from Jev, wrapped so callers read it as Ruby rather than as a
5
+ # Hash of strings. Every answer knows the Question it came from, which is what lets
6
+ # a Choice come back as a Symbol and a Score come back with its label attached.
7
+ class Answer
8
+ attr_reader :question, :raw
9
+
10
+ def initialize(question, raw)
11
+ @question = question
12
+ @raw = raw || {}
13
+ end
14
+
15
+ def name = question.name
16
+
17
+ def type = question.type
18
+
19
+ # The API's own certainty statistic, 0..1. Choice and Score carry one; a Noul
20
+ # does not, so this is nil for nouls. Use #certainty for a uniform number.
21
+ def confidence
22
+ value = raw["confidence"]
23
+ value&.to_f
24
+ end
25
+
26
+ # A 0..1 "how sure is this" usable across all three question types.
27
+ #
28
+ # For Choice and Score this is the API's own `confidence`. Jev returns no
29
+ # confidence for a Noul, so Jevalyn derives one from how far the probability sits
30
+ # from the 0.5 coin-flip: 0.92 and 0.08 are both decisive, 0.5 is not. This is
31
+ # Jevalyn's arithmetic, not TypeSafe's -- see Answer::Noul#decisiveness.
32
+ def certainty = confidence
33
+
34
+ # True when this answer clears the given confidence floor.
35
+ def certain?(threshold)
36
+ return true if threshold.nil?
37
+
38
+ value = certainty
39
+ return false if value.nil?
40
+
41
+ value >= threshold
42
+ end
43
+
44
+ def uncertain?(threshold) = !certain?(threshold)
45
+
46
+ # The full distribution the answer was derived from, keys left as the API sent them.
47
+ def probabilities
48
+ raw["probabilities"] || {}
49
+ end
50
+
51
+ def value = raise(NotImplementedError)
52
+
53
+ def to_h = raw
54
+
55
+ def inspect
56
+ "#<#{self.class.name} #{name}=#{value.inspect} certainty=#{certainty.inspect}>"
57
+ end
58
+
59
+ # Probability that a yes/no question is a yes.
60
+ class Noul < Answer
61
+ # The raw 0..1 value. Deliberately not rounded to a boolean -- the whole point
62
+ # of a noul is that you pick the cutoff your use case can afford.
63
+ def value
64
+ raw["noul"]&.to_f
65
+ end
66
+ alias noul value
67
+ alias probability value
68
+
69
+ # How far from a coin flip the answer sits, rescaled to 0..1.
70
+ # 0.92 -> 0.84, 0.5 -> 0.0, 0.08 -> 0.84.
71
+ def decisiveness
72
+ return nil if value.nil?
73
+
74
+ ((value - 0.5).abs * 2).round(10)
75
+ end
76
+
77
+ def certainty = decisiveness
78
+
79
+ # Jev returns no confidence for a noul; this stays nil on purpose.
80
+ def confidence = nil
81
+
82
+ def true?(threshold = 0.5)
83
+ return false if value.nil?
84
+
85
+ value >= threshold
86
+ end
87
+
88
+ def false?(threshold = 0.5) = !true?(threshold)
89
+
90
+ # A noul has no `probabilities` field; the value is the probability of yes.
91
+ def probabilities
92
+ return {} if value.nil?
93
+
94
+ { "true" => value, "false" => (1.0 - value).round(10) }
95
+ end
96
+ end
97
+
98
+ # One option out of the declared set.
99
+ class Choice < Answer
100
+ # The winning option, as a Symbol so it reads like the criteria keys you wrote.
101
+ def value
102
+ chosen = raw["choice"]
103
+ chosen&.to_sym
104
+ end
105
+ alias choice value
106
+
107
+ # The winning option as the API spelled it.
108
+ def value_s = raw["choice"]
109
+
110
+ def probability_of(option)
111
+ probabilities[option.to_s]&.to_f
112
+ end
113
+
114
+ # Options ordered most to least likely.
115
+ def ranked
116
+ probabilities.sort_by { |_, probability| -probability.to_f }
117
+ .map { |option, probability| [option.to_sym, probability.to_f] }
118
+ end
119
+
120
+ def runner_up
121
+ ranked[1]&.first
122
+ end
123
+ end
124
+
125
+ # A rating against the declared levels.
126
+ class Score < Answer
127
+ # The probability-weighted score. A Float, and it lands between levels on
128
+ # purpose -- 1.6 means "past Frustrated, not quite Very angry".
129
+ def value
130
+ raw["score"]&.to_f
131
+ end
132
+ alias score value
133
+
134
+ # Level index mapped back to its description, as the API returned it.
135
+ def legend
136
+ raw["legend"] || {}
137
+ end
138
+
139
+ # The nearest whole level.
140
+ def level
141
+ return nil if value.nil?
142
+
143
+ value.round
144
+ end
145
+
146
+ # The description of the nearest whole level, e.g. "Very angry".
147
+ def label
148
+ return nil if level.nil?
149
+
150
+ legend[level.to_s] || question.levels[level]
151
+ end
152
+
153
+ # Highest-probability level, which is not always the nearest to #value.
154
+ def modal_level
155
+ top = probabilities.max_by { |_, probability| probability.to_f }
156
+ top && Integer(top.first)
157
+ rescue ArgumentError, TypeError
158
+ nil
159
+ end
160
+
161
+ def probability_of(level_index)
162
+ probabilities[level_index.to_s]&.to_f
163
+ end
164
+
165
+ # Score normalised to 0..1 across the declared levels, for weighting in code.
166
+ def normalized
167
+ return nil if value.nil?
168
+
169
+ span = question.levels.length - 1
170
+ return nil if span <= 0
171
+
172
+ (value / span).round(10)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Jevalyn
7
+ # Thin, honest wrapper over POST /v1/systemone. It mirrors the HTTP API one-to-one:
8
+ # a state, a map of questions, one answer per question. Everything ergonomic lives
9
+ # a layer up in Decision -- this class stays boring on purpose.
10
+ class Client
11
+ USER_AGENT = "jevalyn/#{Jevalyn::VERSION} (ruby/#{RUBY_VERSION})".freeze
12
+
13
+ # Statuses worth trying again after a backoff. 429 and 529 are the API's own
14
+ # "slow down" and "come back later"; the rest are transient server faults.
15
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504, 529].freeze
16
+
17
+ attr_reader :config
18
+
19
+ def initialize(config = Jevalyn.config)
20
+ @config = config
21
+ end
22
+
23
+ # Evaluates a state against a map of questions.
24
+ #
25
+ # state -- String, Hash or Array. Anything responding to #jevalyn_state or
26
+ # #as_json is serialised first; see Jevalyn::State.
27
+ # questions -- Hash of name => Jevalyn::Question, or name => Hash payload.
28
+ #
29
+ # thresholds is Jevalyn's own, never sent: a Hash of question => confidence floor,
30
+ # or a single number for all of them.
31
+ #
32
+ # Returns a Jevalyn::Result.
33
+ def evaluate(state:, questions:, model: nil, confidence_threshold: nil, thresholds: nil,
34
+ result_class: Result, decision: nil)
35
+ questions = normalize_questions(questions)
36
+ body = {
37
+ "model" => model || config.default_model,
38
+ "state" => State.serialize(state),
39
+ "questions" => questions.each_with_object({}) { |(name, q), out| out[name.to_s] = q.to_payload }
40
+ }
41
+
42
+ raw = if config.mock_mode
43
+ Testing.answer(body: body, questions: questions, decision: decision)
44
+ else
45
+ Testing.through_cassette(body) { post(config.evaluation_url, body) }
46
+ end
47
+
48
+ result_class.new(questions: questions, raw: raw, thresholds: thresholds || confidence_threshold)
49
+ end
50
+
51
+ # GET /v1/models -- the names this account may send in the `model` field.
52
+ def models
53
+ return Testing.models if config.mock_mode
54
+
55
+ get(config.models_url).fetch("models", [])
56
+ end
57
+
58
+ # Cheap liveness check for `rails runner` or a health endpoint.
59
+ def reachable?
60
+ models
61
+ true
62
+ end
63
+
64
+ private
65
+
66
+ def normalize_questions(questions)
67
+ unless questions.is_a?(Hash) && !questions.empty?
68
+ raise ConfigurationError,
69
+ "evaluate needs a non-empty Hash of questions, got #{questions.inspect}."
70
+ end
71
+
72
+ questions.each_with_object({}) do |(name, question), out|
73
+ out[name.to_sym] =
74
+ case question
75
+ when Question then question
76
+ when Hash then Question.build(name, **symbolize(question))
77
+ else
78
+ raise ConfigurationError,
79
+ "Question #{name.inspect} must be a Jevalyn::Question or a Hash, " \
80
+ "got #{question.class}."
81
+ end
82
+ end
83
+ end
84
+
85
+ def symbolize(hash)
86
+ hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
87
+ end
88
+
89
+ def post(url, body)
90
+ request(:post, url) { |req| req.body = JSON.generate(body) }
91
+ end
92
+
93
+ def get(url)
94
+ request(:get, url)
95
+ end
96
+
97
+ def request(verb, url, &)
98
+ attempt = 0
99
+
100
+ begin
101
+ response = connection.public_send(verb, url, &)
102
+ handle(response)
103
+ rescue Faraday::TimeoutError => e
104
+ raise TimeoutError, timeout_message(e)
105
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
106
+ # Faraday reports an open timeout as a connection failure. From the caller's
107
+ # side it is still a timeout, and the difference matters when deciding whether
108
+ # to retry, so unwrap it rather than passing the label along.
109
+ raise TimeoutError, timeout_message(e) if timeout?(e)
110
+
111
+ raise ConnectionError, "Could not reach #{config.base_url}: #{e.message}"
112
+ rescue APIError => e
113
+ attempt += 1
114
+ raise unless e.retryable? && attempt <= config.max_retries.to_i
115
+
116
+ sleep(backoff_for(attempt, e))
117
+ retry
118
+ end
119
+ end
120
+
121
+ def timeout?(error)
122
+ wrapped = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
123
+
124
+ wrapped.is_a?(Timeout::Error) || error.message.to_s.match?(/timed? ?out|execution expired/i)
125
+ end
126
+
127
+ def timeout_message(error)
128
+ "TypeSafe request timed out after #{config.timeout}s (#{error.message})"
129
+ end
130
+
131
+ def handle(response)
132
+ body = parse(response.body)
133
+ return body if response.success?
134
+
135
+ raise APIError.from_response(status: response.status, body: body, headers: response.headers)
136
+ end
137
+
138
+ def parse(body)
139
+ return {} if body.nil? || body.to_s.strip.empty?
140
+ return body if body.is_a?(Hash) || body.is_a?(Array)
141
+
142
+ JSON.parse(body)
143
+ rescue JSON::ParserError
144
+ body.to_s
145
+ end
146
+
147
+ # Honour the API's own retry-after when it sends one, exponential backoff otherwise.
148
+ def backoff_for(attempt, error)
149
+ requested = error.respond_to?(:retry_after) ? error.retry_after : nil
150
+ return requested if requested&.positive?
151
+
152
+ config.retry_backoff.to_f * (2**(attempt - 1))
153
+ end
154
+
155
+ def connection
156
+ @connection ||= Faraday.new do |f|
157
+ f.headers["Authorization"] = "Bearer #{config.api_key!}"
158
+ f.headers["Content-Type"] = "application/json"
159
+ f.headers["Accept"] = "application/json"
160
+ f.headers["User-Agent"] = USER_AGENT
161
+ f.options.timeout = config.timeout
162
+ f.options.open_timeout = config.open_timeout
163
+ f.adapter Faraday.default_adapter
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ # Process-wide settings. Set these once in config/initializers/jevalyn.rb.
5
+ class Configuration
6
+ DEFAULT_BASE_URL = "https://api.typesafe.ai"
7
+ DEFAULT_MODEL = "jev-latest"
8
+
9
+ # The official SDKs default to 10s. Jev answers in well under a second, so a
10
+ # long timeout only means a slow request holds a Rails thread open.
11
+ DEFAULT_TIMEOUT = 10.0
12
+ DEFAULT_OPEN_TIMEOUT = 5.0
13
+
14
+ # 429 and 529 are expected under load; the API asks us to back off, not give up.
15
+ DEFAULT_MAX_RETRIES = 2
16
+ DEFAULT_RETRY_BACKOFF = 0.5
17
+
18
+ # Bearer token for the TypeSafe API.
19
+ attr_accessor :api_key
20
+
21
+ # Override to point at a proxy or a recorded fixture server.
22
+ attr_accessor :base_url
23
+
24
+ # Model name or alias sent when a call does not name one.
25
+ attr_accessor :default_model
26
+
27
+ # Per-request read timeout, in seconds.
28
+ attr_accessor :timeout
29
+
30
+ # Connection-open timeout, in seconds.
31
+ attr_accessor :open_timeout
32
+
33
+ # When true, no HTTP request is ever made -- answers come from Jevalyn::Testing.
34
+ attr_accessor :mock_mode
35
+
36
+ # How many times a retryable response (429 / 529 / 5xx) is retried.
37
+ attr_accessor :max_retries
38
+
39
+ # Base delay for exponential backoff between retries, in seconds.
40
+ attr_accessor :retry_backoff
41
+
42
+ # Anything responding to #info / #warn / #debug. Defaults to the Rails logger.
43
+ attr_accessor :logger
44
+
45
+ # Confidence floor a Decision uses when it does not declare its own.
46
+ attr_accessor :default_confidence_threshold
47
+
48
+ # ActiveJob queue used by .evaluate_later.
49
+ attr_accessor :job_queue_name
50
+
51
+ def initialize
52
+ @api_key = ENV.fetch("TYPESAFE_API_KEY", nil)
53
+ @base_url = ENV.fetch("TYPESAFE_BASE_URL", DEFAULT_BASE_URL)
54
+ @default_model = ENV.fetch("TYPESAFE_DEFAULT_MODEL", DEFAULT_MODEL)
55
+ @timeout = DEFAULT_TIMEOUT
56
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
57
+ @mock_mode = false
58
+ @max_retries = DEFAULT_MAX_RETRIES
59
+ @retry_backoff = DEFAULT_RETRY_BACKOFF
60
+ @logger = nil
61
+ @default_confidence_threshold = nil
62
+ @job_queue_name = :default
63
+ end
64
+
65
+ # Raises rather than letting a nil key turn into a confusing 401.
66
+ def api_key!
67
+ return @api_key if @api_key && !@api_key.to_s.strip.empty?
68
+
69
+ raise ConfigurationError, <<~MSG.strip
70
+ No TypeSafe API key configured. Set TYPESAFE_API_KEY in the environment, or
71
+ assign one in config/initializers/jevalyn.rb:
72
+
73
+ Jevalyn.configure do |c|
74
+ c.api_key = ENV["TYPESAFE_API_KEY"]
75
+ end
76
+
77
+ In tests, set `c.mock_mode = true` instead and stub with Jevalyn::Testing.
78
+ MSG
79
+ end
80
+
81
+ def evaluation_url = "#{base_url.to_s.chomp("/")}/v1/systemone"
82
+
83
+ def models_url = "#{base_url.to_s.chomp("/")}/v1/models"
84
+ end
85
+ end