judgelint 0.1.0-x86_64-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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/judgelint +14 -0
- data/lib/judgelint/cache.rb +53 -0
- data/lib/judgelint/changes.rb +87 -0
- data/lib/judgelint/cli.rb +240 -0
- data/lib/judgelint/client.rb +182 -0
- data/lib/judgelint/config.rb +162 -0
- data/lib/judgelint/errors.rb +14 -0
- data/lib/judgelint/glob.rb +31 -0
- data/lib/judgelint/pool.rb +41 -0
- data/lib/judgelint/protocol.rb +88 -0
- data/lib/judgelint/providers.rb +78 -0
- data/lib/judgelint/question.rb +100 -0
- data/lib/judgelint/reporter.rb +147 -0
- data/lib/judgelint/rule.rb +147 -0
- data/lib/judgelint/runner.rb +274 -0
- data/lib/judgelint/scaffold.rb +67 -0
- data/lib/judgelint/source.rb +98 -0
- data/lib/judgelint/spec.rb +189 -0
- data/lib/judgelint/usage.rb +53 -0
- data/lib/judgelint/verdict.rb +61 -0
- data/lib/judgelint/version.rb +5 -0
- data/lib/judgelint.rb +57 -0
- data/lib/rubygems_plugin.rb +16 -0
- data/libexec/judgelint +0 -0
- metadata +76 -0
|
@@ -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
|