judgelint 0.1.0-aarch64-linux

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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Asks one model (an Endpoint) the questions over Net::HTTP, in whichever
5
+ # protocol its provider speaks. Plain Net::HTTP rather than a gem keeps the
6
+ # same code compiling with Spinel.
7
+ class Client
8
+ RETRY_STATUSES = %w[408 429 500 502 503 504 529].freeze
9
+ # The native binary links OpenSSL statically, so it looks for certificates
10
+ # where OpenSSL was built. Unless SSL_CERT_FILE says otherwise, it uses the
11
+ # system's bundle: macOS and Alpine, Debian and Ubuntu, then Fedora and RHEL.
12
+ CA_BUNDLES = %w[/etc/ssl/cert.pem /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt].freeze
13
+
14
+ attr_reader :usage, :endpoint
15
+
16
+ def initialize(endpoint, usage: Usage.new, sleeper: ->(seconds) { sleep(seconds) })
17
+ if RUBY_ENGINE == "spinel" && ENV.fetch("SSL_CERT_FILE", "").empty?
18
+ bundle = CA_BUNDLES.find { |path| File.file?(path) }
19
+ ENV["SSL_CERT_FILE"] = bundle if bundle
20
+ end
21
+ @endpoint = endpoint
22
+ @protocol = Protocol::ALL.fetch(endpoint.protocol) do
23
+ raise ConfigError, "protocol is system_one or decisions, not #{endpoint.protocol}"
24
+ end
25
+ @usage = usage
26
+ @sleeper = sleeper
27
+ check_settings
28
+ end
29
+
30
+ # Returns the probability of "yes" for each question, keyed by its key,
31
+ # or nil for a question the model refused.
32
+ def judge(state, questions)
33
+ data = self.class.unwrap(post(JSON.generate(payload(state, questions))))
34
+ @usage.record(data, @endpoint.price_per_million)
35
+ @protocol.parse(data, questions)
36
+ end
37
+
38
+ def payload(state, questions)
39
+ @protocol.payload(@endpoint.model, state, questions).merge(@endpoint.provider_options)
40
+ end
41
+
42
+ # A conservative token count for code: three bytes a token.
43
+ def tokens(state, questions)
44
+ JSON.generate(payload(state, questions)).bytesize / 3
45
+ end
46
+
47
+ # What one more question adds to a request, without serializing the file again.
48
+ def question_tokens(question)
49
+ (JSON.generate(@protocol.render(question)).bytesize + question.key.bytesize + 6) / 3
50
+ end
51
+
52
+ def url
53
+ base = Config.expand(@endpoint.api_base)
54
+ path = Config.expand(@endpoint.path).gsub("{model}", @endpoint.model.to_s)
55
+ "#{base.delete_suffix('/')}/#{path.delete_prefix('/')}"
56
+ end
57
+
58
+ # Cloudflare wraps the answers as {result: {...}, success, errors}.
59
+ def self.unwrap(data)
60
+ raise ApiError, "Response is not a JSON object" unless data.is_a?(Hash)
61
+
62
+ result = data["result"]
63
+ !data.key?("answers") && result.is_a?(Hash) && result.key?("answers") ? result : data
64
+ end
65
+
66
+ # The error message, whichever of the shapes providers use: TypeSafe's
67
+ # detail (a string, {message}, or a validation list), OpenAI's
68
+ # {error: {message}}, Cloudflare's errors list, or a bare message.
69
+ def self.error_detail(body)
70
+ data = JSON.parse(body)
71
+ return body[0, 200] unless data.is_a?(Hash)
72
+
73
+ detail = data["detail"] || data["error"] || data["errors"] || data["message"]
74
+ message(detail) || body[0, 200]
75
+ rescue JSON::ParserError
76
+ body[0, 200]
77
+ end
78
+
79
+ def self.message(detail)
80
+ case detail
81
+ when String then detail.empty? ? nil : detail
82
+ when Hash then message(detail["message"] || detail["msg"])
83
+ when Array
84
+ messages = detail.map { |item| message(item) }.compact
85
+ messages.empty? ? nil : messages.join("; ")
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def check_settings
92
+ provider = @endpoint.provider
93
+ if @endpoint.api_base.to_s.empty?
94
+ raise ConfigError, "Provider #{provider} needs #{@endpoint.needs || 'api_base'} in .judgelint.yml"
95
+ end
96
+ raise ConfigError, "Provider #{provider} needs a model in .judgelint.yml" if model_needed?
97
+ raise ConfigError, "Set #{@endpoint.api_key_env} to your #{provider} API key" if key_missing?
98
+
99
+ unless %w[bearer basic none].include?(@endpoint.auth) || @endpoint.auth.start_with?("header:")
100
+ raise ConfigError, "auth is bearer, basic, none, or header:<name>, not #{@endpoint.auth}"
101
+ end
102
+ end
103
+
104
+ # llama.cpp serves one model and doesn't need its name; nothing else can guess it.
105
+ def model_needed?
106
+ @endpoint.model.to_s.empty? && @endpoint.provider != "llamacpp"
107
+ end
108
+
109
+ def key_missing?
110
+ @endpoint.auth != "none" && !@endpoint.api_key_env.to_s.empty? && @endpoint.api_key.to_s.empty?
111
+ end
112
+
113
+ def post(body)
114
+ attempt = 0
115
+ loop do
116
+ attempt += 1
117
+ response = request(body, attempt)
118
+ next if response.nil?
119
+
120
+ code = response.code
121
+ return parse_json(response.body.to_s) if code.start_with?("2")
122
+
123
+ detail = self.class.error_detail(response.body.to_s)
124
+ if RETRY_STATUSES.include?(code) && attempt <= Endpoint::RETRIES
125
+ @sleeper.call(retry_delay(response, attempt))
126
+ next
127
+ end
128
+ raise ApiError, "#{@endpoint.label} answered #{code}: #{detail}"
129
+ end
130
+ end
131
+
132
+ # Answers nil after a connection error that's worth another try.
133
+ def request(body, attempt)
134
+ uri = URI(url)
135
+ http = Net::HTTP.new(uri.host, uri.port)
136
+ http.use_ssl = uri.scheme == "https"
137
+ http.open_timeout = Endpoint::TIMEOUT
138
+ http.read_timeout = Endpoint::TIMEOUT
139
+
140
+ request = Net::HTTP::Post.new(uri.request_uri)
141
+ request["Content-Type"] = "application/json"
142
+ request["Accept"] = "application/json"
143
+ authorize(request)
144
+ @endpoint.headers.each { |name, value| request[name.to_s] = Config.expand(value.to_s) }
145
+ request.body = body
146
+ http.request(request)
147
+ rescue Judgelint::Error
148
+ raise
149
+ rescue StandardError => e
150
+ # Nothing listening isn't going to change in a few seconds; a dropped or
151
+ # stalled connection might.
152
+ refused = e.is_a?(Errno::ECONNREFUSED) || e.message.include?("Connection refused")
153
+ raise ApiError, "Couldn't reach #{@endpoint.label} at #{url}: #{e.message}" if refused || attempt > Endpoint::RETRIES
154
+
155
+ @sleeper.call(2.0**attempt)
156
+ nil
157
+ end
158
+
159
+ def authorize(request)
160
+ key = @endpoint.api_key
161
+ return if key.to_s.empty?
162
+
163
+ case @endpoint.auth
164
+ when "bearer" then request["Authorization"] = "Bearer #{key}"
165
+ when "basic" then request["Authorization"] = "Basic #{["token:#{key}"].pack("m0")}"
166
+ when "none" then nil
167
+ else request[@endpoint.auth.delete_prefix("header:")] = key
168
+ end
169
+ end
170
+
171
+ def retry_delay(response, attempt)
172
+ after = Judgelint.number(response["Retry-After"])
173
+ after ? [after, 30.0].min : 0.5 * (2**(attempt - 1))
174
+ end
175
+
176
+ def parse_json(body)
177
+ JSON.parse(body)
178
+ rescue JSON::ParserError
179
+ raise ApiError, "#{@endpoint.label} answered with something other than JSON: #{body[0, 200]}"
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # .judgelint.yml: which models to ask, the threshold, files to ignore, and a
5
+ # spending limit. Everything else has a fixed value.
6
+ #
7
+ # models: [typesafe, cloudflare:clef] # ask both and average
8
+ # threshold: 0.7
9
+ # ignore: ["vendor/**"]
10
+ # max_cost_usd: 1.00
11
+ class Config
12
+ FILE = ".judgelint.yml"
13
+ KEYS = %w[models threshold ignore max_cost_usd].freeze
14
+ RULES_DIR = ".judgelint/rules"
15
+ CACHE = ".judgelint/cache.json"
16
+ WINDOW_LINES = 40
17
+ THREADS = 8
18
+ # Answers this far below the threshold, or less, are uncertain: reported, never failing.
19
+ UNCERTAIN_BAND = 0.3
20
+
21
+ attr_reader :models, :threshold, :ignore, :max_cost_usd
22
+
23
+ # +overrides+ replaces the file's settings: --model replaces its models.
24
+ def self.load(path: nil, overrides: {})
25
+ new(Judgelint.read_yaml(path || FILE, required: !path.nil?).merge(overrides))
26
+ end
27
+
28
+ # ${VAR} reads the environment, so a URL can carry an account id. The
29
+ # client expands it when it sends, so commands that make no request don't
30
+ # need every variable set.
31
+ def self.expand(value)
32
+ value.gsub(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/) do |reference|
33
+ name = reference[2..-2]
34
+ found = ENV.fetch(name, "")
35
+ raise ConfigError, "Set #{name} (used in #{value})" if found.empty?
36
+
37
+ found
38
+ end
39
+ end
40
+
41
+ def initialize(data)
42
+ unknown = data.keys - KEYS
43
+ raise ConfigError, "#{FILE}: unknown keys #{unknown.join(', ')} (it takes #{KEYS.join(', ')})" unless unknown.empty?
44
+
45
+ entries = data.fetch("models", "typesafe")
46
+ entries = [entries] unless entries.is_a?(Array)
47
+ raise ConfigError, "models needs at least one model" if entries.empty?
48
+
49
+ @models = entries.map { |entry| Endpoint.from(entry) }
50
+ @threshold = Judgelint.number(data.key?("threshold") ? data["threshold"] : 0.7)
51
+ raise ConfigError, "threshold must be a number between 0 and 1" unless @threshold&.between?(0.01, 1)
52
+
53
+ @ignore = list(data["ignore"])
54
+ @max_cost_usd = Judgelint.number(data.key?("max_cost_usd") ? data["max_cost_usd"] : 1)
55
+ raise ConfigError, "max_cost_usd must be a number" if @max_cost_usd.nil?
56
+ end
57
+
58
+ def warn_at
59
+ [threshold - UNCERTAIN_BAND, 0.0].max
60
+ end
61
+
62
+ def label
63
+ models.map(&:label).join(" + ")
64
+ end
65
+
66
+ private
67
+
68
+ def list(value)
69
+ return [] if value.nil?
70
+
71
+ value.is_a?(Array) ? value.map(&:to_s) : [value.to_s]
72
+ end
73
+ end
74
+
75
+ # One model to ask, and how to reach it: a provider preset and model id
76
+ # ("typesafe", "cloudflare:clef", "openrouter:jaredpalmer/kev-4b"), or a
77
+ # mapping for an endpoint judgelint has no preset for.
78
+ class Endpoint
79
+ KEYS = %w[
80
+ provider model api_base path protocol auth api_key_env headers provider_options
81
+ price_per_million batch_size max_state_tokens max_request_tokens
82
+ ].freeze
83
+ TIMEOUT = 30
84
+ RETRIES = 3
85
+
86
+ attr_reader :provider, :model, :api_base, :path, :protocol, :auth, :api_key_env, :headers,
87
+ :provider_options, :price_per_million, :batch_size, :max_state_tokens, :max_request_tokens, :needs
88
+
89
+ def self.from(entry)
90
+ return new(entry) if entry.is_a?(Hash)
91
+
92
+ provider, model = entry.to_s.split(":", 2)
93
+ data = { "provider" => provider }
94
+ data["model"] = model if model && !model.empty?
95
+ new(data)
96
+ end
97
+
98
+ def initialize(data)
99
+ unknown = data.keys - KEYS
100
+ raise ConfigError, "models: unknown keys #{unknown.join(', ')}" unless unknown.empty?
101
+
102
+ @data = data
103
+ @provider = data.fetch("provider", "custom").to_s
104
+ @preset = PROVIDERS.fetch(@provider, {})
105
+ @model = setting("model")
106
+ @api_base = setting("api_base")
107
+ @path = setting("path") || "/v1/systemone"
108
+ @protocol = setting("protocol") || "system_one"
109
+ @auth = setting("auth") || "bearer"
110
+ @api_key_env = setting("api_key_env")
111
+ @headers = mapping("headers")
112
+ @provider_options = mapping("provider_options")
113
+ @price_per_million = price
114
+ @batch_size = count("batch_size", 32)
115
+ # A request's state plus its widest question, and a whole request: Jev's
116
+ # 32k and 64k token limits, with room for the estimate to be off.
117
+ @max_state_tokens = count("max_state_tokens", 30_000)
118
+ @max_request_tokens = count("max_request_tokens", 60_000)
119
+ @needs = @preset["needs"]
120
+ end
121
+
122
+ def label
123
+ "#{provider}:#{model}"
124
+ end
125
+
126
+ def api_key
127
+ api_key_env.to_s.empty? ? nil : ENV.fetch(api_key_env, nil)
128
+ end
129
+
130
+ private
131
+
132
+ def setting(key)
133
+ value = @data.key?(key) ? @data[key] : @preset[key]
134
+ value.nil? ? nil : value.to_s
135
+ end
136
+
137
+ def mapping(key)
138
+ value = @data.fetch(key, {})
139
+ raise ConfigError, "models: #{key} must be a mapping" unless value.is_a?(Hash)
140
+
141
+ value
142
+ end
143
+
144
+ # A preset's prices are per model; another model has no price unless the
145
+ # entry gives one, and its cost shows as unknown.
146
+ def price
147
+ return Judgelint.number(@data["price_per_million"]) if @data.key?("price_per_million")
148
+
149
+ prices = @preset["prices"] || {}
150
+ prices.key?(@model.to_s) ? Judgelint.number(prices[@model.to_s]) : nil
151
+ end
152
+
153
+ def count(key, default)
154
+ # Not fetch(key, default): Spinel miscompiles a Float default on a YAML hash.
155
+ value = @data.key?(key) ? @data[key] : @preset[key]
156
+ number = Judgelint.number(value.nil? ? default : value)
157
+ raise ConfigError, "models: #{key} must be a whole number of at least 1" if number.nil? || number < 1
158
+
159
+ number.to_i
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ class Error < StandardError; end
5
+
6
+ # The command line doesn't parse.
7
+ class UsageError < Error; end
8
+
9
+ # A rule file or the config file can't be used as written.
10
+ class ConfigError < Error; end
11
+
12
+ # The decision model couldn't be reached or answered with something unusable.
13
+ class ApiError < Error; end
14
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Path matching shared by rule targeting and file discovery. `**/` matches
5
+ # zero or more directories and `{a,b}` alternates, as in Dir.glob, and a
6
+ # trailing `/**` matches everything under a directory.
7
+ module Glob
8
+ FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
9
+
10
+ def self.match_any?(patterns, path)
11
+ patterns.any? do |pattern|
12
+ File.fnmatch?(pattern, path, FLAGS) || (pattern.end_with?("/**") && File.fnmatch?("#{pattern}/*", path, FLAGS))
13
+ end
14
+ end
15
+
16
+ def self.expand(patterns)
17
+ patterns.flat_map { |pattern| Dir.glob(pattern) }.select { |path| File.file?(path) }.uniq.sort
18
+ end
19
+
20
+ # Files named on the command line, with directories expanded.
21
+ def self.expand_arguments(arguments)
22
+ arguments.flat_map do |argument|
23
+ if File.directory?(argument)
24
+ expand([File.join(argument, "**", "*")])
25
+ else
26
+ [argument]
27
+ end
28
+ end.map { |path| path.delete_prefix("./") }.uniq
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Maps items over a few threads and returns results in the items' order, so
5
+ # output doesn't depend on which request came back first. The first error
6
+ # is raised once every thread has finished.
7
+ module Pool
8
+ def self.map(items, size)
9
+ results = Array.new(items.size)
10
+ queue = Queue.new
11
+ items.each_with_index { |item, index| queue << [item, index] }
12
+ errors = []
13
+ lock = Mutex.new
14
+ threads = Array.new([[size, items.size].min, 1].max) do
15
+ Thread.new do
16
+ Thread.current.report_on_exception = false
17
+ loop do
18
+ pair = next_item(queue)
19
+ break if pair.nil?
20
+
21
+ begin
22
+ results[pair[1]] = yield(pair[0])
23
+ rescue StandardError => e
24
+ lock.synchronize { errors << e }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ threads.each(&:join)
30
+ raise errors.first unless errors.empty?
31
+
32
+ results
33
+ end
34
+
35
+ def self.next_item(queue)
36
+ queue.pop(true)
37
+ rescue ThreadError
38
+ nil
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # The two request shapes decision models are served with. judgelint asks only
5
+ # yes/no questions, so each renders a Question as that protocol's yes/no
6
+ # type and reads back the probability of yes. A nil probability is a refusal.
7
+ module Protocol
8
+ # TypeSafe's System One: questions keyed by name, yes/no is "noul".
9
+ module SystemOne
10
+ def self.payload(model, state, questions)
11
+ rendered = {}
12
+ questions.each { |question| rendered[question.key] = render(question) }
13
+ body = { "state" => state, "questions" => rendered }
14
+ body["model"] = model unless model.to_s.empty?
15
+ body
16
+ end
17
+
18
+ def self.render(question)
19
+ rendered = { "type" => "noul", "instructions" => question.instructions }
20
+ criteria = {}
21
+ criteria["true"] = question.yes_means if question.yes_means
22
+ criteria["false"] = question.no_means if question.no_means
23
+ rendered["criteria"] = criteria unless criteria.empty?
24
+ rendered
25
+ end
26
+
27
+ def self.parse(data, questions)
28
+ answers = data["answers"]
29
+ raise ApiError, "Response has no answers: #{JSON.generate(data)[0, 200]}" unless answers.is_a?(Hash)
30
+
31
+ probabilities = {}
32
+ questions.each do |question|
33
+ probabilities[question.key] = Protocol.probability(answers[question.key], "noul", question)
34
+ end
35
+ probabilities
36
+ end
37
+ end
38
+
39
+ # OpenAI's Decisions API: questions are a list with names, yes/no is
40
+ # "predicate" with no criteria field, and answers come back as a list.
41
+ module Decisions
42
+ # Decisions' input is text, so a structured state goes as its JSON.
43
+ def self.payload(model, state, questions)
44
+ input = state.is_a?(String) ? state : JSON.generate(state)
45
+ { "model" => model, "input" => input, "questions" => questions.map { |question| render(question) } }
46
+ end
47
+
48
+ # Criteria become lines of the instructions, as RubyLLM and LiteLLM do.
49
+ def self.render(question)
50
+ lines = [question.instructions]
51
+ lines << "Yes: #{question.yes_means}" if question.yes_means
52
+ lines << "No: #{question.no_means}" if question.no_means
53
+ { "type" => "predicate", "name" => question.key, "instructions" => lines.join("\n") }
54
+ end
55
+
56
+ def self.parse(data, questions)
57
+ answers = data["answers"]
58
+ raise ApiError, "Response has no answers: #{JSON.generate(data)[0, 200]}" unless answers.is_a?(Array)
59
+
60
+ # Answers come back in question order; match by name when there is
61
+ # one, and give unnamed answers to the remaining questions in order.
62
+ by_name = {}
63
+ unnamed = []
64
+ answers.each do |answer|
65
+ name = answer.is_a?(Hash) ? answer["name"].to_s : ""
66
+ name.empty? ? unnamed << answer : by_name[name] = answer
67
+ end
68
+ probabilities = {}
69
+ questions.each do |question|
70
+ answer = by_name.key?(question.key) ? by_name[question.key] : unnamed.shift
71
+ probabilities[question.key] = Protocol.probability(answer, "probability", question)
72
+ end
73
+ probabilities
74
+ end
75
+ end
76
+
77
+ ALL = { "system_one" => SystemOne, "decisions" => Decisions }.freeze
78
+
79
+ def self.probability(answer, field, question)
80
+ return nil if answer.is_a?(Hash) && answer["type"] == "refusal"
81
+
82
+ value = answer.is_a?(Hash) ? answer[field] : nil
83
+ raise ApiError, "No yes/no answer for #{question.key}: #{answer.inspect}" unless value.is_a?(Numeric)
84
+
85
+ value.to_f
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Presets for the services and servers that answer System One questions.
5
+ # Any key can be overridden in .judgelint.yml, and a provider not listed here
6
+ # works by setting api_base (and model, path, protocol, auth as needed).
7
+ #
8
+ # protocol is "system_one" (POST {model, state, questions} keyed by name) or
9
+ # "decisions" (OpenAI's Decisions API: input, questions as a list). The path
10
+ # doesn't decide it: Perplexity and Hanzo take System One at /v1/decisions.
11
+ #
12
+ # auth is "bearer", "basic" (user "token", for Databricks), "none", or
13
+ # "header:<name>" (Microsoft Foundry's api-key). A preset with no
14
+ # api_key_env sends no credentials unless you set one.
15
+ #
16
+ # ${VAR} in api_base or path reads the environment; {model} in path is the
17
+ # model id. batch_size is the provider's most questions per request.
18
+ # prices are USD per million input tokens, by model (OpenRouter reports
19
+ # each request's cost instead). needs says what a preset can't fill in.
20
+ PROVIDERS = {
21
+ "typesafe" => {
22
+ "api_base" => "https://api.typesafe.ai", "api_key_env" => "TYPESAFE_API_KEY", "model" => "jev-latest",
23
+ "prices" => { "jev-latest" => "0.042", "jev-preview" => "0.042" }
24
+ },
25
+ "openrouter" => {
26
+ "api_base" => "https://openrouter.ai/api", "api_key_env" => "OPENROUTER_API_KEY", "model" => "typesafe/jev-1.13"
27
+ },
28
+ "celeris" => {
29
+ "api_base" => "https://inference.celeris.ai/celeris-1-decision", "api_key_env" => "CELERIS_API_KEY",
30
+ "model" => "celeris-1-decision", "prices" => { "celeris-1-decision" => "0.04" }
31
+ },
32
+ "cloudflare" => {
33
+ "api_base" => "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/run",
34
+ "path" => "/@cf/cloudflare/{model}", "api_key_env" => "CLOUDFLARE_API_TOKEN", "model" => "clef",
35
+ "batch_size" => "64", "prices" => { "clef" => "0.24" }
36
+ },
37
+ "openai" => {
38
+ "api_base" => "https://api.openai.com", "path" => "/v1/decisions", "protocol" => "decisions",
39
+ "api_key_env" => "OPENAI_API_KEY", "model" => "gpt-6-luna", "prices" => { "gpt-6-luna" => "0.10" }
40
+ },
41
+ "perplexity" => {
42
+ "api_base" => "https://api.perplexity.ai", "path" => "/v1/decisions", "api_key_env" => "PERPLEXITY_API_KEY",
43
+ "model" => "pplx-decider-latest", "batch_size" => "128",
44
+ "prices" => { "pplx-decider-latest" => "0.02", "pplx-decider-flash-latest" => "0.01" }
45
+ },
46
+ "vercel" => {
47
+ "api_base" => "https://ai-gateway.vercel.sh/typesafe", "api_key_env" => "AI_GATEWAY_API_KEY",
48
+ "model" => "typesafe-ai/jev"
49
+ },
50
+ "digitalocean" => {
51
+ "api_base" => "https://inference.do-ai.run", "api_key_env" => "MODEL_ACCESS_KEY", "model" => "typesafe-jev-1.13.0"
52
+ },
53
+ "upstage" => {
54
+ "api_base" => "https://api.upstage.ai", "api_key_env" => "UPSTAGE_API_KEY", "model" => "solar-decide"
55
+ },
56
+ "hanzo" => {
57
+ "api_base" => "https://api.hanzo.ai", "path" => "/v1/decisions", "api_key_env" => "HANZO_API_KEY",
58
+ "model" => "kai", "batch_size" => "100", "prices" => { "kai" => "0.021" }
59
+ },
60
+ "foundry" => {
61
+ "path" => "/providers/microsoft/v1/systemone", "auth" => "header:api-key", "api_key_env" => "AZURE_API_KEY",
62
+ "needs" => "api_base (your Foundry resource endpoint) and model (your deployment name)"
63
+ },
64
+ "databricks" => {
65
+ "path" => "/ai-gateway/typesafe/v1/systemone", "auth" => "basic", "api_key_env" => "DATABRICKS_TOKEN",
66
+ "needs" => "api_base (https://<workspace host>) and model (a Unity Catalog name)"
67
+ },
68
+ "ollama" => {
69
+ "api_base" => "http://localhost:11434", "batch_size" => "64", "needs" => "model (a decision model you've pulled)"
70
+ },
71
+ "llamacpp" => { "api_base" => "http://localhost:8080" },
72
+ "vllm" => { "api_base" => "http://localhost:8000", "batch_size" => "64", "needs" => "model (the model the server runs)" },
73
+ "litellm" => {
74
+ "api_base" => "http://localhost:4000", "api_key_env" => "LITELLM_API_KEY",
75
+ "needs" => "model (a decision model routed by your proxy)"
76
+ }
77
+ }.freeze
78
+ end